feat(mcp): support modern requests and legacy compatibility
This commit is contained in:
1 parent
3ff8bbe114
commit
8c785aefcd
9 files changed
+407
-26
No files matched your search
+1
-1
Submodule AgentKit updated: f78416b2b8...fc40c02791.
@@ -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.")
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -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<Data>.Continuation?
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user