diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f77fd0..4b65ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- **MCP client diagnostics and safe disconnects.** + `MCPSocketServer.clientConnections` reports a private connection id, the + kernel-reported peer pid when available, connection time, and last byte + activity — never a command line or path. `onConnectionsChange` publishes the + same snapshots, and `disconnectClient(id:)` closes exactly that socket + without signalling or killing its process. - **`SourceCursorStore.save(changed:all:)`** — the call the periodic cursor save makes. `changed` is what actually moved; `all` is the complete set, so a store that can only replace still has what it needs. The default writes @@ -19,6 +25,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). makes the claim below assertable rather than timed. ### Changed +- **A desktop-sized MCP client budget, optional idle reclamation, and explicit + refusal.** The default cap is 64 rather than 16 because desktop clients may + keep one stdio bridge per open task; hosts can tune it. Idle expiry is now an + opt-in timeout for hosts that know their clients reconnect after EOF. Closing + a socket makes `MCPStdioBridge` return immediately even when its stdin owner + forgot to close the pipe, while real EOF and transport errors still clean up + immediately. At the configured client cap the + listener now sends an explicit JSON-RPC capacity error before closing, and a + stdio bridge turns that private frame into an actionable stderr message and + exit code 2 instead of exiting 0 with empty stdout. - **The periodic save writes the cursors that moved, not all of them.** The save had one bit of state — "something moved" — and answered it by writing every cursor the coordinator held. One harness appending a transcript line a diff --git a/README.md b/README.md index b5bc4e8..cfc9c86 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,15 @@ a socket someone is *answering* on is not — the server reports `.socketOwnedByAnotherInstance` rather than cutting off the agents attached to the other copy. +The default client cap is 64; pass `maximumConnections:` to tune it. Hosts +whose stdio clients reconnect after EOF may also opt into idle reclamation +with `idleTimeout:`; it is disabled by default because the library cannot +assume every client respawns a deliberately closed child. `clientConnections` and +`onConnectionsChange` expose only a connection id, peer pid, connected time, +and last activity. A host can call `disconnectClient(id:)` to close one socket +safely. A client beyond the configured cap receives a framed JSON-RPC +capacity error instead of a silent close. + `MCPStdioBridge` runs the same binary as a plain stdio MCP server that pumps bytes to that socket, which is how MCP clients that spawn a command reach a running GUI app: @@ -163,6 +172,10 @@ if bridge.isRequested() { exit(bridge.run()) } The static functions this wraps (`MCPStdioBridge.isRequested`, `.socketPath`, `.run`, each taking `config` explicitly) are still there unchanged, for callers that would rather not hold an instance. +If the listener is full, the bridge writes the configured +`connectionLimitMessage` to stderr and exits with +`MCPStdioBridge.ExitCode.connectionLimit` (2); it does not report a successful +empty session. `MCPJSON`, `MCPRequest`, `MCPResponse`, `MCPTool`, `MCPResource`, and `MCPArguments` are here too, so a host writes its tool catalog and its diff --git a/Sources/AgentSessionKit/MCP/MCPSocketServer.swift b/Sources/AgentSessionKit/MCP/MCPSocketServer.swift index acda4f8..e56d4e5 100644 --- a/Sources/AgentSessionKit/MCP/MCPSocketServer.swift +++ b/Sources/AgentSessionKit/MCP/MCPSocketServer.swift @@ -53,6 +53,34 @@ public enum MCPSocketServerStatus: String, Sendable, Equatable { case conflict } +/// One live local client, suitable for a host diagnostics surface. +/// +/// The peer pid comes from the Unix socket itself (`LOCAL_PEERPID`), never a +/// process-list scrape. It may be nil when the kernel cannot provide it. No +/// command line or path is exposed: those can contain user or credential data. +public struct MCPClientConnectionInfo: Identifiable, Sendable, Equatable { + public let id: UUID + public let processID: Int32? + public let connectedAt: Date + public let lastActivityAt: Date + + init(id: UUID, processID: Int32?, connectedAt: Date, lastActivityAt: Date) { + self.id = id + self.processID = processID + self.connectedAt = connectedAt + self.lastActivityAt = lastActivityAt + } + + func withActivity(at date: Date) -> MCPClientConnectionInfo { + MCPClientConnectionInfo( + id: id, + processID: processID, + connectedAt: connectedAt, + lastActivityAt: date + ) + } +} + /// A newline-delimited JSON-RPC listener on a Unix domain socket. /// /// **No TCP, no port, no token.** The socket file is created with mode 0600, @@ -69,9 +97,22 @@ public final class MCPSocketServer: @unchecked Sendable { /// Framing cap. A single JSON-RPC line larger than this is a client bug, /// and buffering it would let one connection grow without bound. public static let maximumLineBytes = 4 * 1024 * 1024 - /// Concurrent clients. Two agents plus a stray bridge is the realistic - /// peak; the cap exists so a runaway client cannot exhaust descriptors. - public static let maximumConnections = 16 + /// Concurrent clients. Desktop clients may keep one stdio bridge per open + /// task, so sixteen is not a realistic ceiling. Sixty-four stays well below + /// the process descriptor budget while leaving room for a real task board. + public static let maximumConnections = 64 + /// Idle expiry is opt-in. A library cannot assume its client will respawn a + /// stdio child after an intentional EOF; hosts that own that lifecycle can + /// pass a timeout explicitly. + public static let defaultIdleTimeout: TimeInterval? = nil + static let connectionLimitError = MCPRPCError( + code: -32_098, + message: "The MCP server has reached its client connection limit." + ) + static let connectionLimitFrame = MCPResponse( + id: .null, + error: connectionLimitError + ).framed() public let socketPath: String @@ -81,12 +122,18 @@ public final class MCPSocketServer: @unchecked Sendable { /// server never creates a directory itself: chmod-ing a path it was /// merely handed is not its business. private let ensureDirectory: @Sendable () throws -> Void + private let maximumConnectionCount: Int + private let idleTimeout: TimeInterval? private let acceptQueue = DispatchQueue(label: "com.astroqore.AgentSessionKit.mcp.accept") private let stateLock = NSLock() private var listenFD: Int32 = -1 private var acceptSource: DispatchSourceRead? - private var connections: [ObjectIdentifier: Connection] = [:] + private struct ConnectionRecord { + let connection: Connection + var info: MCPClientConnectionInfo + } + private var connections: [UUID: ConnectionRecord] = [:] private var didBindSocketFile = false private var statusValue: MCPSocketServerStatus = .stopped @@ -94,6 +141,9 @@ public final class MCPSocketServer: @unchecked Sendable { /// with the live connection count. A settings surface uses it to show /// whether anything is attached without polling. public var onConnectionChange: (@Sendable (Int, Date) -> Void)? + /// Richer connection snapshots for hosts that show and manage clients. + /// Emitted on connect, activity, disconnect, idle expiry, and stop. + public var onConnectionsChange: (@Sendable ([MCPClientConnectionInfo]) -> Void)? /// - Parameters: /// - handler: answers each framed line. @@ -105,10 +155,14 @@ public final class MCPSocketServer: @unchecked Sendable { public init( handler: any MCPLineHandler, socketPath: String, + maximumConnections: Int = MCPSocketServer.maximumConnections, + idleTimeout: TimeInterval? = MCPSocketServer.defaultIdleTimeout, ensureDirectory: @escaping @Sendable () throws -> Void = {} ) { self.handler = handler self.socketPath = socketPath + self.maximumConnectionCount = max(1, maximumConnections) + self.idleTimeout = idleTimeout.flatMap { $0 > 0 ? $0 : nil } self.ensureDirectory = ensureDirectory } @@ -137,6 +191,29 @@ public final class MCPSocketServer: @unchecked Sendable { return connections.count } + public var clientConnections: [MCPClientConnectionInfo] { + stateLock.lock() + defer { stateLock.unlock() } + return connectionSnapshotLocked() + } + + /// Disconnect one exact accepted socket. The peer process is not killed; + /// its bridge simply observes EOF and exits, which is the same safe path as + /// the host app shutting down. + @discardableResult + public func disconnectClient(id: UUID) -> Bool { + stateLock.lock() + guard let record = connections.removeValue(forKey: id) else { + stateLock.unlock() + return false + } + let snapshot = connectionSnapshotLocked() + stateLock.unlock() + record.connection.close() + publishConnectionChange(snapshot, at: Date()) + return true + } + // MARK: - Lifecycle public func start() throws { @@ -226,7 +303,7 @@ public final class MCPSocketServer: @unchecked Sendable { public func stop() { stateLock.lock() let source = acceptSource - let openConnections = Array(connections.values) + let openConnections = connections.values.map(\.connection) let shouldUnlink = didBindSocketFile acceptSource = nil connections = [:] @@ -237,6 +314,7 @@ public final class MCPSocketServer: @unchecked Sendable { source?.cancel() for connection in openConnections { connection.close() } + publishConnectionChange([], at: Date()) // Only remove a socket file this instance actually created: a `stop` // during a failed start must not delete a healthy server's socket. if shouldUnlink { @@ -255,7 +333,8 @@ public final class MCPSocketServer: @unchecked Sendable { // EAGAIN/EWOULDBLOCK simply means the backlog drained. return } - guard connectionCount < Self.maximumConnections else { + guard connectionCount < maximumConnectionCount else { + Self.writeConnectionLimitFrame(to: clientFD) close(clientFD) KitLog.warn("MCP server refused a connection: too many clients.") continue @@ -272,23 +351,92 @@ public final class MCPSocketServer: @unchecked Sendable { } private func open(clientFD: Int32) { - let connection = Connection(fd: clientFD, handler: handler) - connection.onClose = { [weak self] closed in - guard let self else { return } - self.stateLock.lock() - self.connections.removeValue(forKey: ObjectIdentifier(closed)) - let remaining = self.connections.count - self.stateLock.unlock() - self.onConnectionChange?(remaining, Date()) + let id = UUID() + let now = Date() + let connection = Connection(fd: clientFD, handler: handler, idleTimeout: idleTimeout) + connection.onClose = { [weak self] in + self?.connectionClosed(id: id) + } + connection.onActivity = { [weak self] at in + self?.connectionActivity(id: id, at: at) } stateLock.lock() - connections[ObjectIdentifier(connection)] = connection - let count = connections.count + connections[id] = ConnectionRecord( + connection: connection, + info: MCPClientConnectionInfo( + id: id, + processID: Self.peerProcessID(clientFD), + connectedAt: now, + lastActivityAt: now + ) + ) + let snapshot = connectionSnapshotLocked() stateLock.unlock() connection.resume() - onConnectionChange?(count, Date()) + publishConnectionChange(snapshot, at: now) + } + + private func connectionClosed(id: UUID) { + stateLock.lock() + guard connections.removeValue(forKey: id) != nil else { + stateLock.unlock() + return + } + let snapshot = connectionSnapshotLocked() + stateLock.unlock() + publishConnectionChange(snapshot, at: Date()) + } + + private func connectionActivity(id: UUID, at: Date) { + stateLock.lock() + guard var record = connections[id] else { + stateLock.unlock() + return + } + record.info = record.info.withActivity(at: at) + connections[id] = record + let snapshot = connectionSnapshotLocked() + stateLock.unlock() + publishConnectionChange(snapshot, at: at) + } + + private func connectionSnapshotLocked() -> [MCPClientConnectionInfo] { + connections.values.map(\.info).sorted { + if $0.connectedAt != $1.connectedAt { return $0.connectedAt < $1.connectedAt } + return $0.id.uuidString < $1.id.uuidString + } + } + + private func publishConnectionChange(_ snapshot: [MCPClientConnectionInfo], at: Date) { + onConnectionChange?(snapshot.count, at) + onConnectionsChange?(snapshot) + } + + private static func peerProcessID(_ fd: Int32) -> Int32? { + var pid: pid_t = 0 + var length = socklen_t(MemoryLayout.size) + guard getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &length) == 0 else { + return nil + } + return Int32(pid) + } + + private static func writeConnectionLimitFrame(to fd: Int32) { + var remaining = connectionLimitFrame + while !remaining.isEmpty { + let written = remaining.withUnsafeBytes { raw -> Int in + guard let base = raw.baseAddress else { return 0 } + return Darwin.write(fd, base, raw.count) + } + if written > 0 { + remaining = remaining.dropFirst(written) + continue + } + if errno == EINTR { continue } + return + } } static func setNonBlocking(_ fd: Int32) { @@ -368,8 +516,10 @@ public final class MCPSocketServer: @unchecked Sendable { private final class Connection: @unchecked Sendable { private let fd: Int32 private let handler: any MCPLineHandler + private let idleTimeout: TimeInterval? private let queue: DispatchQueue private var source: DispatchSourceRead? + private var idleTimer: DispatchSourceTimer? private var buffer = Data() private var isClosed = false /// Requests handed to `handler.handle` whose reply has not been written yet. @@ -380,11 +530,13 @@ private final class Connection: @unchecked Sendable { /// `tools/list` and closes stdin must still get both answers. private var peerFinished = false - var onClose: (@Sendable (Connection) -> Void)? + var onClose: (@Sendable () -> Void)? + var onActivity: (@Sendable (Date) -> Void)? - init(fd: Int32, handler: any MCPLineHandler) { + init(fd: Int32, handler: any MCPLineHandler, idleTimeout: TimeInterval?) { self.fd = fd self.handler = handler + self.idleTimeout = idleTimeout self.queue = DispatchQueue(label: "com.astroqore.AgentSessionKit.mcp.connection.\(fd)") } @@ -393,6 +545,7 @@ private final class Connection: @unchecked Sendable { source.setEventHandler { [weak self] in self?.readAvailable() } self.source = source source.resume() + armIdleTimer() } func close() { @@ -418,9 +571,11 @@ private final class Connection: @unchecked Sendable { guard !isClosed else { return } isClosed = true stopReading() + idleTimer?.cancel() + idleTimer = nil Darwin.close(fd) buffer = Data() - onClose?(self) + onClose?() } /// Close once the peer is done *and* nothing is still being answered. @@ -434,6 +589,7 @@ private final class Connection: @unchecked Sendable { while true { let count = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) } if count > 0 { + noteActivity() buffer.append(contentsOf: chunk[0.. 0 { + self.armIdleTimer() + } else { + self.closeOnQueue() + } + } + idleTimer = timer + timer.resume() + } + timer.schedule(deadline: .now() + idleTimeout, leeway: .seconds(1)) + } } diff --git a/Sources/AgentSessionKit/MCP/MCPStdioBridge.swift b/Sources/AgentSessionKit/MCP/MCPStdioBridge.swift index d789c70..fc6033f 100644 --- a/Sources/AgentSessionKit/MCP/MCPStdioBridge.swift +++ b/Sources/AgentSessionKit/MCP/MCPStdioBridge.swift @@ -27,6 +27,11 @@ public struct MCPStdioBridgeConfig: Sendable { /// path; the host owns the wording because only it can name the app the /// user is supposed to launch. public let notRunningMessage: @Sendable (String) -> String + /// What to print when the app is alive but has no client slot left. The + /// socket listener sends a private transport control frame before closing; + /// the bridge consumes that frame and turns it into an actionable process + /// failure instead of an empty successful stdout stream. + public let connectionLimitMessage: @Sendable () -> String /// Primary initializer. `defaultSocketPath` is called each time /// ``MCPStdioBridge/socketPath(_:environment:)`` needs it — not cached. @@ -36,12 +41,16 @@ public struct MCPStdioBridgeConfig: Sendable { defaultSocketPath: @escaping @Sendable () -> String, notRunningMessage: @escaping @Sendable (String) -> String = { path in "No MCP server is listening on \(path). Start the app that serves it first." + }, + connectionLimitMessage: @escaping @Sendable () -> String = { + "The MCP server has no free client slots. Close stale MCP clients and try again." } ) { self.flag = flag self.envKey = envKey self.defaultSocketPath = defaultSocketPath self.notRunningMessage = notRunningMessage + self.connectionLimitMessage = connectionLimitMessage } /// Backward-compatible convenience initializer for a default socket path @@ -53,13 +62,17 @@ public struct MCPStdioBridgeConfig: Sendable { defaultSocketPath: String, notRunningMessage: @escaping @Sendable (String) -> String = { path in "No MCP server is listening on \(path). Start the app that serves it first." + }, + connectionLimitMessage: @escaping @Sendable () -> String = { + "The MCP server has no free client slots. Close stale MCP clients and try again." } ) { self.init( flag: flag, envKey: envKey, defaultSocketPath: { defaultSocketPath }, - notRunningMessage: notRunningMessage + notRunningMessage: notRunningMessage, + connectionLimitMessage: connectionLimitMessage ) } } @@ -94,6 +107,9 @@ public struct MCPStdioBridge: Sendable { public static let ok: Int32 = 0 /// Nothing was listening — almost always "the app is not running". public static let notRunning: Int32 = 1 + /// The app is running, but its listener deliberately refused this + /// bridge because every client slot is occupied. + public static let connectionLimit: Int32 = 2 } /// Whether `arguments` selects bridge mode. @@ -118,8 +134,8 @@ public struct MCPStdioBridge: Sendable { /// /// Returns when either side closes: stdin closing is the client shutting /// the server down, and the socket closing is the app quitting. Both are - /// ordinary ends of a session, so both exit zero — only a failure to - /// connect at all is an error worth a non-zero code. + /// ordinary ends of a session, so both exit zero. Failure to connect and + /// an explicit capacity rejection are non-zero and write to stderr. public static func run( _ config: MCPStdioBridgeConfig, socketPath path: String, @@ -147,11 +163,15 @@ public struct MCPStdioBridge: Sendable { thread.name = "com.astroqore.AgentSessionKit.mcp.stdio" thread.start() - pump(from: socketFD, to: output) + let downstream = pumpSocketToOutput(from: socketFD, to: output) // The socket closed. Do not wait on the stdin thread: it is parked in // a blocking read the client may never end, and the process exiting is // what the client is waiting for. close(socketFD) + if downstream == .connectionLimit { + standardError.write(Data((config.connectionLimitMessage() + "\n").utf8)) + return ExitCode.connectionLimit + } return ExitCode.ok } @@ -268,6 +288,49 @@ public struct MCPStdioBridge: Sendable { } } + private enum DownstreamResult { + case ended + case connectionLimit + } + + /// The listener's capacity rejection is the one transport event a stdio + /// client cannot otherwise distinguish from an orderly app shutdown: both + /// are an immediate socket EOF. Buffer only the first few bytes while they + /// still match the private rejection frame; every ordinary MCP response is + /// forwarded byte-for-byte as soon as its prefix differs. + private static func pumpSocketToOutput(from source: Int32, to destination: Int32) -> DownstreamResult { + let rejection = MCPSocketServer.connectionLimitFrame + var undecided = Data() + var isCheckingRejection = true + var chunk = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = chunk.withUnsafeMutableBytes { read(source, $0.baseAddress, $0.count) } + if count > 0 { + if isCheckingRejection { + undecided.append(contentsOf: chunk[0.. Bool { var offset = 0 while offset < count { diff --git a/Tests/AgentSessionKitTests/MCPSocketServerTests.swift b/Tests/AgentSessionKitTests/MCPSocketServerTests.swift index 93718a2..41eaf6f 100644 --- a/Tests/AgentSessionKitTests/MCPSocketServerTests.swift +++ b/Tests/AgentSessionKitTests/MCPSocketServerTests.swift @@ -29,8 +29,17 @@ final class MCPSocketServerTests: XCTestCase { try super.tearDownWithError() } - private func makeServer(path: String? = nil) -> MCPSocketServer { - MCPSocketServer(handler: EchoLineHandler(), socketPath: path ?? socketPath) + private func makeServer( + path: String? = nil, + maximumConnections: Int = MCPSocketServer.maximumConnections, + idleTimeout: TimeInterval? = MCPSocketServer.defaultIdleTimeout + ) -> MCPSocketServer { + MCPSocketServer( + handler: EchoLineHandler(), + socketPath: path ?? socketPath, + maximumConnections: maximumConnections, + idleTimeout: idleTimeout + ) } @discardableResult @@ -213,7 +222,68 @@ final class MCPSocketServerTests: XCTestCase { let client = try MCPSocketTestClient(path: socketPath) _ = try client.request(id: 1) XCTAssertEqual(socket.connectionCount, 1) + let disconnected = expectation(description: "server releases the disconnected client") + socket.onConnectionChange = { count, _ in + if count == 0 { disconnected.fulfill() } + } client.close() + wait(for: [disconnected], timeout: 5) + socket.onConnectionChange = nil + XCTAssertEqual(socket.connectionCount, 0) + } + + func testConnectionDiagnosticsExposePeerPIDAndAllowSafeDisconnect() throws { + let socket = try startServer() + let client = try MCPSocketTestClient(path: socketPath) + defer { client.close() } + _ = try client.request(id: 1) + + let info = try XCTUnwrap(socket.clientConnections.first) + XCTAssertEqual(info.processID, Int32(getpid())) + XCTAssertLessThanOrEqual(info.connectedAt, info.lastActivityAt) + XCTAssertTrue(socket.disconnectClient(id: info.id)) + XCTAssertEqual(socket.connectionCount, 0) + XCTAssertTrue(client.readUntilEOF(timeoutSeconds: 5)) + XCTAssertFalse(socket.disconnectClient(id: info.id)) + } + + func testIdleClientIsReclaimedWithoutKillingItsProcess() throws { + let socket = makeServer(idleTimeout: 0.05) + socketServer = socket + try socket.start() + let expired = expectation(description: "idle client expires") + socket.onConnectionChange = { count, _ in + if count == 0 { expired.fulfill() } + } + + let client = try MCPSocketTestClient(path: socketPath) + defer { client.close() } + _ = try client.request(id: 1) + wait(for: [expired], timeout: 5) + socket.onConnectionChange = nil + XCTAssertTrue(client.readUntilEOF(timeoutSeconds: 5)) + XCTAssertEqual(socket.connectionCount, 0) + } + + func testSeventeenthClientGetsAnExplicitCapacityError() throws { + let socket = makeServer(maximumConnections: 16) + socketServer = socket + try socket.start() + var clients: [MCPSocketTestClient] = [] + defer { clients.forEach { $0.close() } } + for id in 1...16 { + let client = try MCPSocketTestClient(path: socketPath) + _ = try client.request(id: id) + clients.append(client) + } + XCTAssertEqual(socket.connectionCount, 16) + + let refused = try MCPSocketTestClient(path: socketPath) + defer { refused.close() } + let error = try refused.readLine()["error"] + XCTAssertEqual(error?["code"]?.intValue, -32_098) + XCTAssertEqual(error?["message"]?.stringValue, MCPSocketServer.connectionLimitError.message) + XCTAssertTrue(refused.readUntilEOF(timeoutSeconds: 5)) } func testAPathTooLongForSockaddrUnFailsClearly() { diff --git a/Tests/AgentSessionKitTests/MCPStdioBridgeTests.swift b/Tests/AgentSessionKitTests/MCPStdioBridgeTests.swift index 4bd970f..99911ff 100644 --- a/Tests/AgentSessionKitTests/MCPStdioBridgeTests.swift +++ b/Tests/AgentSessionKitTests/MCPStdioBridgeTests.swift @@ -159,6 +159,73 @@ final class MCPStdioBridgeTests: XCTestCase { wait(for: [finished], timeout: 10) } + func testCapacityRejectionIsNonzeroAndActionable() throws { + let socket = MCPSocketServer( + handler: EchoBridgeHandler(), + socketPath: socketPath, + maximumConnections: 16 + ) + try socket.start() + socketServer = socket + var clients: [MCPSocketTestClient] = [] + defer { clients.forEach { $0.close() } } + for id in 1...16 { + let client = try MCPSocketTestClient(path: socketPath) + _ = try client.request(id: id) + clients.append(client) + } + + let input = Pipe() + let output = Pipe() + let errorFile = directory.appendingPathComponent("capacity-stderr.txt") + FileManager.default.createFile(atPath: errorFile.path, contents: Data()) + let errorHandle = try FileHandle(forWritingTo: errorFile) + let code = MCPStdioBridge.run( + config(), + socketPath: socketPath, + input: input.fileHandleForReading.fileDescriptor, + output: output.fileHandleForWriting.fileDescriptor, + standardError: errorHandle + ) + try input.fileHandleForWriting.close() + try output.fileHandleForWriting.close() + try errorHandle.close() + + XCTAssertEqual(code, MCPStdioBridge.ExitCode.connectionLimit) + XCTAssertEqual(output.fileHandleForReading.readDataToEndOfFile(), Data()) + let message = try String(contentsOf: errorFile, encoding: .utf8) + XCTAssertTrue(message.contains("no free client slots"), message) + } + + func testIdleServerReclaimMakesTheBridgeExit() throws { + let socket = MCPSocketServer( + handler: EchoBridgeHandler(), + socketPath: socketPath, + idleTimeout: 0.05 + ) + try socket.start() + socketServer = socket + let input = Pipe() + let output = Pipe() + let finished = expectation(description: "idle bridge exits") + let path = socketPath! + let config = config() + Thread.detachNewThread { + let code = MCPStdioBridge.run( + config, + socketPath: path, + input: input.fileHandleForReading.fileDescriptor, + output: output.fileHandleForWriting.fileDescriptor + ) + XCTAssertEqual(code, MCPStdioBridge.ExitCode.ok) + finished.fulfill() + } + + wait(for: [finished], timeout: 5) + try input.fileHandleForWriting.close() + XCTAssertEqual(socket.connectionCount, 0) + } + private func write(_ line: Data, to pipe: Pipe) throws { var framed = line framed.append(0x0A)