diff --git a/AgentKit b/AgentKit index f78416b2..fc40c027 160000 --- a/AgentKit +++ b/AgentKit @@ -1 +1 @@ -Subproject commit f78416b2b8523d09950872b96f0de841e2d704e8 +Subproject commit fc40c027917daef9ddcfe6c1ea3b0a03c9f7a2c5 diff --git a/Scripts/check-mcp-bridge.py b/Scripts/check-mcp-bridge.py new file mode 100644 index 00000000..614e72fc --- /dev/null +++ b/Scripts/check-mcp-bridge.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Exercise a built termind-mcp executable against a deterministic loopback server.""" + +import http.server +import json +import subprocess +import sys +import threading + + +def check(executable, modern): + observed = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + message = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + observed.append((message["method"], dict(self.headers))) + reply = {"jsonrpc": "2.0", "id": message.get("id", 1)} + if message["method"] == "initialize": + reply["result"] = {"protocolVersion": "2024-11-05"} + status = 200 + else: + reply["error"] = { + "code": -32022 if modern else -32602, + "message": "Test protocol error", + "data": {"supported": ["2026-07-28"]}, + } + status = 400 + data = json.dumps(reply).encode() + self.send_response(status) + if message["method"] == "initialize": + self.send_header("Mcp-Session-Id", "test-session") + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_DELETE(self): + observed.append(("DELETE", dict(self.headers))) + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "中文", "arguments": {}}} + messages = [] + if modern: + call["params"]["_meta"] = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + } + else: + messages.append({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}}) + messages.append(call) + try: + result = subprocess.run( + [executable, "--url", f"http://127.0.0.1:{server.server_port}/mcp", "--token", "test-only"], + input="".join(json.dumps(message) + "\n" for message in messages), + capture_output=True, text=True, timeout=10, check=True, + ) + replies = [json.loads(line) for line in result.stdout.splitlines()] + assert replies[-1]["error"]["code"] == (-32022 if modern else -32602), replies + call_headers = {key.lower(): value for method, headers in observed if method == "tools/call" for key, value in headers.items()} + if modern: + assert call_headers["mcp-method"] == "tools/call" + assert call_headers["mcp-name"] == "=?base64?5Lit5paH?=" + assert call_headers["mcp-protocol-version"] == "2026-07-28" + assert "mcp-session-id" not in call_headers + assert len(observed) == 1, observed + else: + assert call_headers["mcp-session-id"] == "test-session" + assert call_headers["mcp-protocol-version"] == "2024-11-05" + assert observed[-1][0] == "DELETE" + finally: + server.shutdown() + server.server_close() + thread.join() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: python3 Scripts/check-mcp-bridge.py /path/to/termind-mcp") + check(sys.argv[1], modern=True) + check(sys.argv[1], modern=False) + print("MCP bridge modern and legacy headers, lifecycle, and JSON-RPC error passthrough passed.") diff --git a/TFM/App/MCPServerController.swift b/TFM/App/MCPServerController.swift index 99f984bb..cced9c19 100644 --- a/TFM/App/MCPServerController.swift +++ b/TFM/App/MCPServerController.swift @@ -125,6 +125,9 @@ final class MCPServerController { disabledTools, hosts: hosts, metrics: metrics, connections: connections, authentication: authentication, background: background ) + if !enabled { + Task { [protocolServer] in await protocolServer.revokeModernOperations() } + } } func rotateToken() { diff --git a/TFM/Infrastructure/MCP/MCPServerProtocol.swift b/TFM/Infrastructure/MCP/MCPServerProtocol.swift index c2fa8d72..65d58cfe 100644 --- a/TFM/Infrastructure/MCP/MCPServerProtocol.swift +++ b/TFM/Infrastructure/MCP/MCPServerProtocol.swift @@ -6,7 +6,8 @@ actor MCPServerProtocol { static let versionHeader = "mcp-protocol-version" static let maximumSessions = 64 static let idleTimeout = Duration.seconds(30 * 60) - static let supportedStreamableVersions = [MCPClient.preferredVersion] + static let supportedInitializationVersions = ["2025-11-25", "2025-06-18", "2024-11-05"] + static let modernVersion = "2026-07-28" typealias ClientCountHandler = @Sendable (Int) -> Void @@ -64,6 +65,11 @@ actor MCPServerProtocol { clientCountChanged(0) } + func revokeModernOperations() async { + let ids = sessions.filter { $0.value.isModern }.map(\.key) + for id in ids { await removeSession(id) } + } + private func streamablePOST(_ request: HTTPRequest) async -> HTTPResponse { guard request.header("content-type")?.lowercased().contains("application/json") == true else { return .text(415, "MCP requests must use application/json.") @@ -73,6 +79,13 @@ actor MCPServerProtocol { return Self.jsonResponse(.error(id: .null, code: -32700, message: "Parse error"), status: 400) } + if message.method == "server/discover" + || message.params?["_meta"]?.objectValue?["io.modelcontextprotocol/protocolVersion"] != nil + || request.header(Self.versionHeader) == Self.modernVersion + { + return await modernPOST(request, message: message) + } + if message.method == "initialize" { guard sessions.count < Self.maximumSessions else { return .text(503, "Too many MCP sessions are active.") @@ -91,7 +104,7 @@ actor MCPServerProtocol { guard let sessionID = request.header(Self.sessionHeader) else { return .text(400, "Mcp-Session-Id is required after initialize.") } - guard var session = sessions[sessionID], !session.isLegacy else { + guard var session = sessions[sessionID], !session.isLegacy, !session.isModern else { return .text(404, "The MCP session is unknown or expired.") } guard let version = request.header(Self.versionHeader), version == session.protocolVersion else { @@ -104,11 +117,152 @@ actor MCPServerProtocol { return Self.jsonResponse(response) } + private func modernPOST(_ request: HTTPRequest, message: RPCMessage) async -> HTTPResponse { + let meta = message.params?["_meta"]?.objectValue + let version = meta?["io.modelcontextprotocol/protocolVersion"]?.stringValue + guard request.header(Self.versionHeader) == version, version != nil, + request.header("mcp-method") == message.method, + message.method != "tools/call" + || Self.decodeHeader(request.header("mcp-name")) == message.params?["name"]?.stringValue + else { + return Self.jsonResponse( + .error(id: message.id ?? .null, code: -32020, message: "Header mismatch"), status: 400) + } + guard version == Self.modernVersion else { + return Self.jsonResponse( + .error( + id: message.id ?? .null, code: -32022, + message: "Unsupported protocol version", + data: .object([ + "supported": .array( + ([Self.modernVersion] + Self.supportedInitializationVersions).map(AgentJSONValue.string)), + "requested": .string(version ?? ""), + ])), status: 400) + } + let info = meta?["io.modelcontextprotocol/clientInfo"]?.objectValue ?? [:] + guard meta?["io.modelcontextprotocol/clientCapabilities"]?.objectValue != nil, + meta?["io.modelcontextprotocol/clientInfo"] == nil + || (info["name"]?.stringValue != nil && info["version"]?.stringValue != nil), + let id = message.id + else { + return Self.jsonResponse( + .error(id: message.id ?? .null, code: -32602, message: "Missing request metadata"), status: 400) + } + let serverMeta: AgentJSONValue = .object([ + "io.modelcontextprotocol/serverInfo": .object([ + "name": .string("termind"), "version": .string(Bundle.termindDisplayVersion), + ]) + ]) + if message.method == "server/discover" { + return Self.jsonResponse( + .success( + id: id, + result: .object([ + "resultType": .string("complete"), + "supportedVersions": .array( + ([Self.modernVersion] + Self.supportedInitializationVersions).map(AgentJSONValue.string)), + "capabilities": .object(["tools": .object([:])]), "_meta": serverMeta, + "instructions": .string( + "For related tool calls, pass the returned operation_handle. Treat this opaque handle as a capability; it expires with the operation or server lifetime." + ), + ]))) + } + if message.method == "tools/list" { + let descriptors = await tools.descriptors + return Self.jsonResponse( + .success( + id: id, + result: .object([ + "resultType": .string("complete"), "_meta": serverMeta, + "tools": .array( + descriptors.map { descriptor in + var tool = Self.toolJSON(descriptor).objectValue ?? [:] + var schema = descriptor.inputSchema.objectValue ?? [:] + var properties = schema["properties"]?.objectValue ?? [:] + properties["operation_handle"] = .object([ + "type": .string("string"), + "description": .string( + "Opaque capability returned by an earlier related tool call. Required when using its secret handle, command, or transfer ticket." + ), + ]) + schema["properties"] = .object(properties) + tool["inputSchema"] = .object(schema) + tool["description"] = .string( + descriptor.summary + .replacingOccurrences( + of: "same live MCP session", with: "same live operation_handle" + ) + .replacingOccurrences( + of: "ticket/session/process", with: "ticket/operation/process")) + return .object(tool) + }), + ]))) + } + guard message.method == "tools/call" else { + return Self.jsonResponse(.error(id: id, code: -32601, message: "Method not found"), status: 404) + } + guard message.params?["arguments"] == nil || message.params?["arguments"]?.objectValue != nil else { + return Self.jsonResponse(.error(id: id, code: -32602, message: "Arguments must be an object")) + } + var arguments = message.params?["arguments"]?.objectValue ?? [:] + let suppliedHandle = arguments.removeValue(forKey: "operation_handle") + let operationID: String + if let suppliedHandle { + guard let handle = suppliedHandle.stringValue, sessions[handle]?.isModern == true else { + return Self.jsonResponse( + .error(id: id, code: -32602, message: "Operation handle is unknown or expired")) + } + operationID = handle + } else { + guard sessions.count < Self.maximumSessions else { return .text(503, "Too many active operations") } + operationID = UUID().uuidString + UUID().uuidString + sessions[operationID] = MCPServerSession( + id: operationID, runID: UUID(), secretBroker: SecretBroker(), protocolVersion: Self.modernVersion, + clientName: info["name"]?.stringValue ?? "MCP client", isModern: true, initialized: true, + isLegacy: false) + } + var call = message + call.params?["arguments"] = .object(arguments) + guard let response = await dispatch(call, sessionID: operationID) else { return HTTPResponse(status: 202) } + let successful = response.value.objectValue?["result"]?.objectValue?["isError"] == .bool(false) + let retainsState = + suppliedHandle != nil + || (successful + && ["request_user_secret", "sftp_upload", "sftp_download"].contains( + message.params?["name"]?.stringValue ?? "")) + if !retainsState { await removeSession(operationID) } + var envelope = response.value.objectValue ?? [:] + if var result = envelope["result"]?.objectValue { + result["resultType"] = .string("complete") + if retainsState { + var structured = result["structuredContent"]?.objectValue ?? [:] + structured["operation_handle"] = .string(operationID) + result["structuredContent"] = .object(structured) + var content = result["content"]?.arrayValue ?? [] + content.append(.object(["type": .string("text"), "text": .string("operation_handle: " + operationID)])) + result["content"] = .array(content) + } + envelope["result"] = .object(result) + } + return Self.jsonResponse(RPCResponse(value: .object(envelope))) + } + + private static func decodeHeader(_ value: String?) -> String? { + guard let value, value.utf8.allSatisfy({ $0 == 9 || (32...126).contains($0) }), + value == value.trimmingCharacters(in: .whitespacesAndNewlines) + else { return nil } + if value.hasPrefix("=?base64?"), value.hasSuffix("?=") { + guard let data = Data(base64Encoded: String(value.dropFirst(9).dropLast(2))) else { return nil } + return String(data: data, encoding: .utf8) + } + return value + } + private func deleteSession(_ request: HTTPRequest) async -> HTTPResponse { guard let id = request.header(Self.sessionHeader) else { return .text(400, "Mcp-Session-Id is required.") } - guard let removed = sessions.removeValue(forKey: id) else { + guard let existing = sessions[id], !existing.isModern, let removed = sessions.removeValue(forKey: id) else { return .text(404, "The MCP session is unknown or expired.") } removed.legacyContinuation?.finish() @@ -328,7 +482,7 @@ actor MCPServerProtocol { return MCPServerSession( id: id, runID: UUID(), secretBroker: SecretBroker(), protocolVersion: legacy - ? MCPClient.legacyVersion : MCPClient.preferredVersion, + ? MCPClient.legacyVersion : "2025-11-25", clientName: message.clientName ?? "MCP client", isLegacy: legacy ) } @@ -370,8 +524,8 @@ actor MCPServerProtocol { private func sessionCountDidChange() { clientCountChanged(sessions.count) } static func negotiatedStreamableVersion(_ requested: String?) -> String { - guard let requested, supportedStreamableVersions.contains(requested) else { - return MCPClient.preferredVersion + guard let requested, supportedInitializationVersions.contains(requested) else { + return "2025-11-25" } return requested } @@ -471,12 +625,12 @@ private nonisolated struct RPCResponse: Sendable { Self(value: .object(["jsonrpc": .string("2.0"), "id": id, "result": result])) } - static func error(id: AgentJSONValue, code: Int, message: String) -> Self { + static func error(id: AgentJSONValue, code: Int, message: String, data: AgentJSONValue = .null) -> Self { Self( value: .object([ "jsonrpc": .string("2.0"), "id": id, "error": .object([ - "code": .number(Double(code)), "message": .string(message), + "code": .number(Double(code)), "message": .string(message), "data": data, ]), ])) } diff --git a/TFM/Infrastructure/MCP/MCPServerSession.swift b/TFM/Infrastructure/MCP/MCPServerSession.swift index d665b043..fb8936f1 100644 --- a/TFM/Infrastructure/MCP/MCPServerSession.swift +++ b/TFM/Infrastructure/MCP/MCPServerSession.swift @@ -7,6 +7,7 @@ nonisolated struct MCPServerSession: Sendable { var secretBroker: SecretBroker var protocolVersion: String var clientName: String + var isModern = false var initialized = false var isLegacy: Bool var legacyContinuation: AsyncStream.Continuation? diff --git a/TFMTests/MCPServerSessionTests.swift b/TFMTests/MCPServerSessionTests.swift index 7ca7bd1f..58019a77 100644 --- a/TFMTests/MCPServerSessionTests.swift +++ b/TFMTests/MCPServerSessionTests.swift @@ -15,6 +15,112 @@ import struct TermindDomain.Host @Suite(.serialized) @MainActor struct MCPServerSessionTests { + @Test + func modernUploadHandleSupportsCrossRequestStatusAndIsRevokedOnStop() async throws { + let host = Host(name: "Upload", host: "upload.example") + let tools = makeMCPServerTools(hosts: [host]) + tools.uploads.configure(port: 7391) + let server = MCPServerProtocol(tools: tools) + func call(_ name: String, _ arguments: [String: AgentJSONValue]) async -> HTTPResponse { + let body: AgentJSONValue = .object([ + "jsonrpc": .string("2.0"), "id": .number(1), "method": .string("tools/call"), + "params": .object([ + "name": .string(name), "arguments": .object(arguments), + "_meta": .object([ + "io.modelcontextprotocol/protocolVersion": .string("2026-07-28"), + "io.modelcontextprotocol/clientInfo": .object([ + "name": .string("same-name"), "version": .string("1"), + ]), + "io.modelcontextprotocol/clientCapabilities": .object([:]), + ]), + ]), + ]) + return await server.handle( + HTTPRequest( + method: "POST", target: "/mcp", version: "HTTP/1.1", + headers: [ + "content-type": "application/json", "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", "mcp-name": name, + ], body: body.encodedData)) + } + // Invalid stateful calls must not exhaust the 64-operation capacity. + for _ in 0..<70 { + let failed = await call("sftp_upload", [:]) + #expect(failed.status == 200) + #expect(try responseObject(failed)["result"]?.objectValue?["isError"] == .bool(true)) + } + let ordinary = await call("list_hosts", [:]) + #expect(try responseObject(ordinary)["result"]?.objectValue?["isError"] == .bool(false)) + let created = await call( + "sftp_upload", + [ + "host_id": .string(host.id.uuidString), "remote_path": .string("/tmp/test"), + "byte_count": .number(0), "sha256": .string(String(repeating: "0", count: 64)), + ]) + let result = try #require(try responseObject(created)["result"]?.objectValue) + #expect(result["isError"] == .bool(false)) + let payload = try #require(result["structuredContent"]?.objectValue) + let handle = try #require(payload["operation_handle"]) + let uploadID = try #require(payload["upload_id"]) + let isolated = await call("sftp_upload_status", ["upload_id": uploadID]) + #expect(try responseObject(isolated)["result"]?.objectValue?["isError"] == .bool(true)) + let failedRelated = await call("sftp_upload", ["operation_handle": handle]) + #expect(try responseObject(failedRelated)["result"]?.objectValue?["isError"] == .bool(true)) + let status = await call("sftp_upload_status", ["upload_id": uploadID, "operation_handle": handle]) + #expect(try responseObject(status)["result"]?.objectValue?["isError"] == .bool(false)) + await server.stop() + let revoked = await call("sftp_upload_status", ["upload_id": uploadID, "operation_handle": handle]) + #expect(try responseObject(revoked)["error"]?.objectValue?["code"] == .number(-32602)) + } + + @Test + func modernRequestsAreIndependentAndValidateMirroredHeaders() async throws { + let server = MCPServerProtocol(tools: makeMCPServerTools()) + func request(_ method: String, mirrored: String? = nil) -> HTTPRequest { + let body: AgentJSONValue = .object([ + "jsonrpc": .string("2.0"), "id": .number(1), "method": .string(method), + "params": .object([ + "_meta": .object([ + "io.modelcontextprotocol/protocolVersion": .string("2026-07-28"), + "io.modelcontextprotocol/clientCapabilities": .object([:]), + ]) + ]), + ]) + return HTTPRequest( + method: "POST", target: "/mcp", version: "HTTP/1.1", + headers: [ + "content-type": "application/json", "mcp-protocol-version": "2026-07-28", + "mcp-method": mirrored ?? method, + ], body: body.encodedData) + } + let discover = await server.handle(request("server/discover")) + #expect(discover.status == 200) + #expect(discover.headers["Mcp-Session-Id"] == nil) + let result = try #require(try responseObject(discover)["result"]?.objectValue) + #expect(result["resultType"] == .string("complete")) + #expect(result["supportedVersions"]?.arrayValue?.count == 4) + let listed = await server.handle(request("tools/list")) + #expect(try responseObject(listed)["result"]?.objectValue?["tools"]?.arrayValue?.count == 18) + let mismatch = await server.handle(request("tools/list", mirrored: "tools/call")) + #expect(mismatch.status == 400) + #expect(try responseObject(mismatch)["error"]?.objectValue?["code"] == .number(-32020)) + await server.stop() + } + + @Test(arguments: ["2024-11-05", "2025-06-18", "2025-11-25"]) + func initializePreservesEachSupportedLegacyVersion(_ version: String) async throws { + let server = MCPServerProtocol(tools: makeMCPServerTools()) + let response = await server.handle( + jsonRequest( + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"\(version)","capabilities":{},"clientInfo":{"name":"test","version":"1"}}} + """)) + #expect(response.status == 200) + #expect(try responseObject(response)["result"]?.objectValue?["protocolVersion"] == .string(version)) + #expect(response.headers["Mcp-Session-Id"] != nil) + await server.stop() + } + @Test func streamableHandshakeMintsASessionAndPreservesStringIDs() async throws { let protocolServer = MCPServerProtocol(tools: makeMCPServerTools()) @@ -32,7 +138,7 @@ struct MCPServerSessionTests { #expect(initialized["id"]?.stringValue == "request-001") #expect( initialized["result"]?.objectValue?["protocolVersion"]?.stringValue - == MCPClient.preferredVersion + == "2025-11-25" ) let instructions = try #require( initialized["result"]?.objectValue?["instructions"]?.stringValue @@ -46,14 +152,14 @@ struct MCPServerSessionTests { let notification = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-11-25" )) #expect(notification.status == 202) let listed = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-11-25" )) let tools = try #require( try responseObject(listed)["result"]?.objectValue?["tools"]?.arrayValue @@ -101,13 +207,13 @@ struct MCPServerSessionTests { let initialized = await server.handle( jsonRequest( #"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-06-18" )) #expect(initialized.status == 202) let listed = await server.handle( jsonRequest( #"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion)) + sessionID: sessionID, version: "2025-06-18")) let tools = try #require(try responseObject(listed)["result"]?.objectValue?["tools"]?.arrayValue) let descriptor = try #require(tools.first { $0.objectValue?["name"]?.stringValue == "get_metrics" }) #expect(descriptor.objectValue?["annotations"]?.objectValue?["readOnlyHint"] == .bool(true)) @@ -115,7 +221,7 @@ struct MCPServerSessionTests { jsonRequest( """ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_metrics","arguments":{"host_id":"\(host.id.uuidString)","include":["gpu"]}}} - """, sessionID: sessionID, version: MCPClient.preferredVersion)) + """, sessionID: sessionID, version: "2025-06-18")) let result = try #require(try responseObject(response)["result"]?.objectValue) #expect(result["isError"] == .bool(false)) let data = try #require(result["structuredContent"]?.objectValue) @@ -167,13 +273,13 @@ struct MCPServerSessionTests { _ = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-06-18" )) let response = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"missing","arguments":{}}}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-06-18" )) let code = try responseObject(response)["error"]?.objectValue?["code"]?.integerValue #expect(code == -32602) @@ -227,7 +333,7 @@ struct MCPServerSessionTests { _ = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-06-18" )) let hostID = UUID() let call = Task { @@ -240,7 +346,7 @@ struct MCPServerSessionTests { "consumer_tool":"ssh_exec","purpose":"sudo password" } }} - """, sessionID: sessionID, version: MCPClient.preferredVersion)) + """, sessionID: sessionID, version: "2025-06-18")) } try await waitForProtocolCondition { if case .secret? = authentication.current?.request { return true } @@ -264,7 +370,7 @@ struct MCPServerSessionTests { let afterCancellation = await protocolServer.handle( jsonRequest( #"{"jsonrpc":"2.0","id":3,"method":"ping"}"#, - sessionID: sessionID, version: MCPClient.preferredVersion + sessionID: sessionID, version: "2025-06-18" )) #expect(afterCancellation.status == 404) } diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index ee56ca01..d213c2c6 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2866,7 +2866,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.16.0; + minimumVersion = 0.17.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5647e1fc..8cfb55d7 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "f78416b2b8523d09950872b96f0de841e2d704e8", - "version" : "0.16.0" + "revision" : "fc40c027917daef9ddcfe6c1ea3b0a03c9f7a2c5", + "version" : "0.17.0" } }, { diff --git a/TermindMCPBridge/main.swift b/TermindMCPBridge/main.swift index 3205ac31..5882f3de 100644 --- a/TermindMCPBridge/main.swift +++ b/TermindMCPBridge/main.swift @@ -29,8 +29,19 @@ struct TermindMCPBridge { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") request.setValue("Bearer \(configuration.token)", forHTTPHeaderField: "Authorization") - if let sessionID { request.setValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id") } - if let protocolVersion { + let params = object["params"] as? [String: Any] + let meta = params?["_meta"] as? [String: Any] + let modernVersion = meta?["io.modelcontextprotocol/protocolVersion"] as? String + if let modernVersion { + request.setValue(modernVersion, forHTTPHeaderField: "MCP-Protocol-Version") + request.setValue(method, forHTTPHeaderField: "Mcp-Method") + if let name = (params?["name"] ?? params?["uri"]) as? String { + request.setValue(headerValue(name), forHTTPHeaderField: "Mcp-Name") + } + } else if let sessionID { + request.setValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id") + } + if modernVersion == nil, let protocolVersion { request.setValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version") } @@ -39,12 +50,18 @@ struct TermindMCPBridge { guard let response = rawResponse as? HTTPURLResponse else { throw BridgeError.notHTTP } - if let assigned = response.value(forHTTPHeaderField: "Mcp-Session-Id"), + if modernVersion == nil, let assigned = response.value(forHTTPHeaderField: "Mcp-Session-Id"), !assigned.isEmpty { sessionID = assigned } guard (200..<300).contains(response.statusCode) else { + if let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + envelope["jsonrpc"] as? String == "2.0", envelope["error"] is [String: Any] + { + write(data) + continue + } if id != nil { write(errorResponse(id: id, message: "Termind HTTP server returned \(response.statusCode).")) } else { @@ -88,6 +105,16 @@ struct TermindMCPBridge { } } + private static func headerValue(_ value: String) -> String { + if value.utf8.allSatisfy({ $0 == 9 || (32...126).contains($0) }), + value == value.trimmingCharacters(in: .whitespacesAndNewlines), + !(value.hasPrefix("=?base64?") && value.hasSuffix("?=")) + { + return value + } + return "=?base64?" + Data(value.utf8).base64EncodedString() + "?=" + } + private static func errorResponse(id: Any?, message: String) -> Data { let object: [String: Any] = [ "jsonrpc": "2.0", "id": id ?? NSNull(),