From fa7868ad6b7049991f713ad6c176a96b2354c24e Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Tue, 14 Oct 2025 21:21:11 -0400 Subject: [PATCH 01/18] Support OpenAI _meta for Tool --- Sources/MCP/Server/Tools.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index fd10d934..6fe7a3fa 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -15,6 +15,8 @@ public struct Tool: Hashable, Codable, Sendable { public let description: String? /// The tool input schema public let inputSchema: Value + /// Additional properties for a tool for OpenAI use. Not part of spec. Encoded as `_meta`. + public let meta: [String: Value]? /// Annotations that provide display-facing and operational information for a Tool. /// @@ -177,6 +179,7 @@ public struct Tool: Hashable, Codable, Sendable { case description case inputSchema case annotations + case meta = "_meta" } public init(from decoder: Decoder) throws { @@ -186,6 +189,7 @@ public struct Tool: Hashable, Codable, Sendable { inputSchema = try container.decode(Value.self, forKey: .inputSchema) annotations = try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() + meta = try container.decodeIfPresent([String: Value].self, forKey: .meta) } public func encode(to encoder: Encoder) throws { @@ -196,6 +200,9 @@ public struct Tool: Hashable, Codable, Sendable { if !annotations.isEmpty { try container.encode(annotations, forKey: .annotations) } + if meta?.isEmpty == false { + try container.encode(meta, forKey: .meta) + } } } From 01b53092588e45bbfe54fa4a6265e642c1387d41 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Tue, 14 Oct 2025 21:24:30 -0400 Subject: [PATCH 02/18] Update init --- Sources/MCP/Server/Tools.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 6fe7a3fa..307a957b 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -89,12 +89,14 @@ public struct Tool: Hashable, Codable, Sendable { name: String, description: String?, inputSchema: Value, - annotations: Annotations = nil + annotations: Annotations = nil, + meta: [String: Value]? = nil ) { self.name = name self.description = description self.inputSchema = inputSchema self.annotations = annotations + self.meta = meta } /// Content types that can be returned by a tool From 7a2b6bd0866f5b724f32b74cfbf9c892dce47729 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Tue, 14 Oct 2025 22:03:36 -0400 Subject: [PATCH 03/18] Add title and outputSchema --- Package.resolved | 4 +- Sources/MCP/Server/Tools.swift | 440 +++++++++++++++++---------------- Tests/MCPTests/ToolTests.swift | 38 ++- 3 files changed, 266 insertions(+), 216 deletions(-) diff --git a/Package.resolved b/Package.resolved index 5e9023c5..fb776dd5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,10 +1,10 @@ { - "originHash" : "08de61941b7919a65e36c0e34f8c1c41995469b86a39122158b75b4a68c4527d", + "originHash" : "371f3dfcfa1201fc8d50e924ad31f9ebc4f90242924df1275958ac79df15dc12", "pins" : [ { "identity" : "eventsource", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/eventsource.git", + "location" : "https://github.com/mattt/eventsource.git", "state" : { "revision" : "e83f076811f32757305b8bf69ac92d05626ffdd7", "version" : "1.1.0" diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 307a957b..e53d8c4f 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -7,205 +7,219 @@ import Foundation /// Each tool is uniquely identified by a name and includes metadata /// describing its schema. /// -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/ +/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/server/tools public struct Tool: Hashable, Codable, Sendable { - /// The tool name - public let name: String - /// The tool description - public let description: String? - /// The tool input schema - public let inputSchema: Value - /// Additional properties for a tool for OpenAI use. Not part of spec. Encoded as `_meta`. - public let meta: [String: Value]? - - /// Annotations that provide display-facing and operational information for a Tool. + /// The tool name + public let name: String + /// The human-readable name of the tool for display purposes. + public let title: String? + /// The tool description + public let description: String? + /// The tool input schema + public let inputSchema: Value + /// Additional properties for a tool for OpenAI use. Not part of spec. Encoded as `_meta`. + public let meta: [String: Value]? + /// The tool output schema, defining expected output structure + public let outputSchema: Value? + + /// Annotations that provide display-facing and operational information for a Tool. + /// + /// - Note: All properties in `ToolAnnotations` are **hints**. + /// They are not guaranteed to provide a faithful description of + /// tool behavior (including descriptive properties like `title`). + /// + /// Clients should never make tool use decisions based on `ToolAnnotations` + /// received from untrusted servers. + public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral { + /// A human-readable title for the tool + public var title: String? + + /// If true, the tool may perform destructive updates to its environment. + /// If false, the tool performs only additive updates. + /// (This property is meaningful only when `readOnlyHint == false`) /// - /// - Note: All properties in `ToolAnnotations` are **hints**. - /// They are not guaranteed to provide a faithful description of - /// tool behavior (including descriptive properties like `title`). + /// When unspecified, the implicit default is `true`. + public var destructiveHint: Bool? + + /// If true, calling the tool repeatedly with the same arguments + /// will have no additional effect on its environment. + /// (This property is meaningful only when `readOnlyHint == false`) /// - /// Clients should never make tool use decisions based on `ToolAnnotations` - /// received from untrusted servers. - public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral { - /// A human-readable title for the tool - public var title: String? - - /// If true, the tool may perform destructive updates to its environment. - /// If false, the tool performs only additive updates. - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// When unspecified, the implicit default is `true`. - public var destructiveHint: Bool? - - /// If true, calling the tool repeatedly with the same arguments - /// will have no additional effect on its environment. - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// When unspecified, the implicit default is `false`. - public var idempotentHint: Bool? - - /// If true, this tool may interact with an "open world" of external - /// entities. If false, the tool's domain of interaction is closed. - /// For example, the world of a web search tool is open, whereas that - /// of a memory tool is not. - /// - /// When unspecified, the implicit default is `true`. - public var openWorldHint: Bool? - - /// If true, the tool does not modify its environment. - /// - /// When unspecified, the implicit default is `false`. - public var readOnlyHint: Bool? - - /// Returns true if all properties are nil - public var isEmpty: Bool { - title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil - && openWorldHint == nil - } - - public init( - title: String? = nil, - readOnlyHint: Bool? = nil, - destructiveHint: Bool? = nil, - idempotentHint: Bool? = nil, - openWorldHint: Bool? = nil - ) { - self.title = title - self.readOnlyHint = readOnlyHint - self.destructiveHint = destructiveHint - self.idempotentHint = idempotentHint - self.openWorldHint = openWorldHint - } - - /// Initialize an empty annotations object - public init(nilLiteral: ()) {} - } + /// When unspecified, the implicit default is `false`. + public var idempotentHint: Bool? - /// Annotations that provide display-facing and operational information - public var annotations: Annotations + /// If true, this tool may interact with an "open world" of external + /// entities. If false, the tool's domain of interaction is closed. + /// For example, the world of a web search tool is open, whereas that + /// of a memory tool is not. + /// + /// When unspecified, the implicit default is `true`. + public var openWorldHint: Bool? + + /// If true, the tool does not modify its environment. + /// + /// When unspecified, the implicit default is `false`. + public var readOnlyHint: Bool? + + /// Returns true if all properties are nil + public var isEmpty: Bool { + title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil + && openWorldHint == nil + } - /// Initialize a tool with a name, description, input schema, and annotations public init( - name: String, - description: String?, - inputSchema: Value, - annotations: Annotations = nil, - meta: [String: Value]? = nil + title: String? = nil, + readOnlyHint: Bool? = nil, + destructiveHint: Bool? = nil, + idempotentHint: Bool? = nil, + openWorldHint: Bool? = nil ) { - self.name = name - self.description = description - self.inputSchema = inputSchema - self.annotations = annotations - self.meta = meta + self.title = title + self.readOnlyHint = readOnlyHint + self.destructiveHint = destructiveHint + self.idempotentHint = idempotentHint + self.openWorldHint = openWorldHint } - /// Content types that can be returned by a tool - public enum Content: Hashable, Codable, Sendable { - /// Text content - case text(String) - /// Image content - case image(data: String, mimeType: String, metadata: [String: String]?) - /// Audio content - case audio(data: String, mimeType: String) - /// Embedded resource content - case resource(uri: String, mimeType: String, text: String?) - - private enum CodingKeys: String, CodingKey { - case type - case text - case image - case resource - case audio - case uri - case mimeType - case data - case metadata - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "text": - let text = try container.decode(String.self, forKey: .text) - self = .text(text) - case "image": - let data = try container.decode(String.self, forKey: .data) - let mimeType = try container.decode(String.self, forKey: .mimeType) - let metadata = try container.decodeIfPresent( - [String: String].self, forKey: .metadata) - self = .image(data: data, mimeType: mimeType, metadata: metadata) - case "audio": - let data = try container.decode(String.self, forKey: .data) - let mimeType = try container.decode(String.self, forKey: .mimeType) - self = .audio(data: data, mimeType: mimeType) - case "resource": - let uri = try container.decode(String.self, forKey: .uri) - let mimeType = try container.decode(String.self, forKey: .mimeType) - let text = try container.decodeIfPresent(String.self, forKey: .text) - self = .resource(uri: uri, mimeType: mimeType, text: text) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, in: container, debugDescription: "Unknown tool content type") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case .text(let text): - try container.encode("text", forKey: .type) - try container.encode(text, forKey: .text) - case .image(let data, let mimeType, let metadata): - try container.encode("image", forKey: .type) - try container.encode(data, forKey: .data) - try container.encode(mimeType, forKey: .mimeType) - try container.encodeIfPresent(metadata, forKey: .metadata) - case .audio(let data, let mimeType): - try container.encode("audio", forKey: .type) - try container.encode(data, forKey: .data) - try container.encode(mimeType, forKey: .mimeType) - case .resource(let uri, let mimeType, let text): - try container.encode("resource", forKey: .type) - try container.encode(uri, forKey: .uri) - try container.encode(mimeType, forKey: .mimeType) - try container.encodeIfPresent(text, forKey: .text) - } - } - } + /// Initialize an empty annotations object + public init(nilLiteral: ()) {} + } + + /// Annotations that provide display-facing and operational information + public var annotations: Annotations + + /// Initialize a tool with a name, description, input schema, and annotations + public init( + name: String, + title: String? = nil, + description: String?, + inputSchema: Value, + annotations: Annotations = nil, + meta: [String: Value]? = nil, + outputSchema: Value? = nil + ) { + self.name = name + self.title = title + self.description = description + self.inputSchema = inputSchema + self.outputSchema = outputSchema + self.annotations = annotations + self.meta = meta + } + + /// Content types that can be returned by a tool + public enum Content: Hashable, Codable, Sendable { + /// Text content + case text(String) + /// Image content + case image(data: String, mimeType: String, metadata: [String: String]?) + /// Audio content + case audio(data: String, mimeType: String) + /// Embedded resource content + case resource(uri: String, mimeType: String, text: String?) private enum CodingKeys: String, CodingKey { - case name - case description - case inputSchema - case annotations - case meta = "_meta" + case type + case text + case image + case resource + case audio + case uri + case mimeType + case data + case metadata } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - description = try container.decodeIfPresent(String.self, forKey: .description) - inputSchema = try container.decode(Value.self, forKey: .inputSchema) - annotations = - try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() - meta = try container.decodeIfPresent([String: Value].self, forKey: .meta) + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "text": + let text = try container.decode(String.self, forKey: .text) + self = .text(text) + case "image": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let metadata = try container.decodeIfPresent( + [String: String].self, forKey: .metadata) + self = .image(data: data, mimeType: mimeType, metadata: metadata) + case "audio": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + self = .audio(data: data, mimeType: mimeType) + case "resource": + let uri = try container.decode(String.self, forKey: .uri) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let text = try container.decodeIfPresent(String.self, forKey: .text) + self = .resource(uri: uri, mimeType: mimeType, text: text) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown tool content type") + } } public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - try container.encode(inputSchema, forKey: .inputSchema) - if !annotations.isEmpty { - try container.encode(annotations, forKey: .annotations) - } - if meta?.isEmpty == false { - try container.encode(meta, forKey: .meta) - } + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let data, let mimeType, let metadata): + try container.encode("image", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + case .audio(let data, let mimeType): + try container.encode("audio", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + case .resource(let uri, let mimeType, let text): + try container.encode("resource", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + } + } + } + + private enum CodingKeys: String, CodingKey { + case name + case title + case description + case inputSchema + case outputSchema + case annotations + case meta = "_meta" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + title = try container.decodeIfPresent(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + inputSchema = try container.decode(Value.self, forKey: .inputSchema) + outputSchema = try container.decodeIfPresent(Value.self, forKey: .outputSchema) + annotations = + try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() + meta = try container.decodeIfPresent([String: Value].self, forKey: .meta) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(title, forKey: .title) + try container.encode(description, forKey: .description) + try container.encode(inputSchema, forKey: .inputSchema) + try container.encodeIfPresent(outputSchema, forKey: .outputSchema) + if !annotations.isEmpty { + try container.encode(annotations, forKey: .annotations) } + if meta?.isEmpty == false { + try container.encode(meta, forKey: .meta) + } + } } // MARK: - @@ -213,59 +227,59 @@ public struct Tool: Hashable, Codable, Sendable { /// To discover available tools, clients send a `tools/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#listing-tools public enum ListTools: Method { - public static let name = "tools/list" + public static let name = "tools/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? - public init() { - self.cursor = nil - } + public init() { + self.cursor = nil + } - public init(cursor: String) { - self.cursor = cursor - } + public init(cursor: String) { + self.cursor = cursor } + } - public struct Result: Hashable, Codable, Sendable { - public let tools: [Tool] - public let nextCursor: String? + public struct Result: Hashable, Codable, Sendable { + public let tools: [Tool] + public let nextCursor: String? - public init(tools: [Tool], nextCursor: String? = nil) { - self.tools = tools - self.nextCursor = nextCursor - } + public init(tools: [Tool], nextCursor: String? = nil) { + self.tools = tools + self.nextCursor = nextCursor } + } } /// To call a tool, clients send a `tools/call` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#calling-tools public enum CallTool: Method { - public static let name = "tools/call" + public static let name = "tools/call" - public struct Parameters: Hashable, Codable, Sendable { - public let name: String - public let arguments: [String: Value]? + public struct Parameters: Hashable, Codable, Sendable { + public let name: String + public let arguments: [String: Value]? - public init(name: String, arguments: [String: Value]? = nil) { - self.name = name - self.arguments = arguments - } + public init(name: String, arguments: [String: Value]? = nil) { + self.name = name + self.arguments = arguments } + } - public struct Result: Hashable, Codable, Sendable { - public let content: [Tool.Content] - public let isError: Bool? + public struct Result: Hashable, Codable, Sendable { + public let content: [Tool.Content] + public let isError: Bool? - public init(content: [Tool.Content], isError: Bool? = nil) { - self.content = content - self.isError = isError - } + public init(content: [Tool.Content], isError: Bool? = nil) { + self.content = content + self.isError = isError } + } } /// When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification: /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification public struct ToolListChangedNotification: Notification { - public static let name: String = "notifications/tools/list_changed" + public static let name: String = "notifications/tools/list_changed" } diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index b08963b3..95adef60 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -20,6 +20,8 @@ struct ToolTests { #expect(tool.name == "test_tool") #expect(tool.description == "A test tool") #expect(tool.inputSchema != nil) + #expect(tool.title == nil) + #expect(tool.outputSchema == nil) } @Test("Tool Annotations initialization and properties") @@ -202,6 +204,40 @@ struct ToolTests { #expect(decoded.inputSchema == tool.inputSchema) } + @Test("Tool encoding and decoding with title and output schema") + func testToolEncodingDecodingWithTitleAndOutputSchema() throws { + let tool = Tool( + name: "test_tool", + title: "Readable Test Tool", + description: "Test tool description", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "param1": .string("String parameter") + ]) + ]), + outputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "result": .string("String result") + ]) + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.title == tool.title) + #expect(decoded.outputSchema == tool.outputSchema) + + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString.contains("\"title\":\"Readable Test Tool\"")) + #expect(jsonString.contains("\"outputSchema\"")) + } + @Test("Text content encoding and decoding") func testToolContentTextEncoding() throws { let content = Tool.Content.text("Hello, world!") @@ -439,4 +475,4 @@ struct ToolTests { #expect(tool.name == "test_tool") #expect(tool.description == nil) #expect(tool.inputSchema == [:]) - } \ No newline at end of file + } From 90e98f71844356388cb76a11aac4cec3f15fba7f Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Wed, 22 Oct 2025 17:26:23 -0400 Subject: [PATCH 04/18] Concurreny issue --- Tests/MCPTests/ClientTests.swift | 1413 ++++++++++---------- Tests/MCPTests/Helpers/TaskCollector.swift | 12 + 2 files changed, 722 insertions(+), 703 deletions(-) create mode 100644 Tests/MCPTests/Helpers/TaskCollector.swift diff --git a/Tests/MCPTests/ClientTests.swift b/Tests/MCPTests/ClientTests.swift index 6fcc87a4..a412c8d1 100644 --- a/Tests/MCPTests/ClientTests.swift +++ b/Tests/MCPTests/ClientTests.swift @@ -5,771 +5,778 @@ import Testing @Suite("Client Tests") struct ClientTests { - @Test("Client connect and disconnect") - func testClientConnectAndDisconnect() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - #expect(await transport.isConnected == false) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - let result = try await client.connect(transport: transport) - #expect(await transport.isConnected == true) - #expect(result.protocolVersion == Version.latest) - await client.disconnect() - #expect(await transport.isConnected == false) - initTask.cancel() + @Test("Client connect and disconnect") + func testClientConnectAndDisconnect() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + #expect(await transport.isConnected == false) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } } - @Test( - "Ping request", - .timeLimit(.minutes(1)) - ) - func testClientPing() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Queue a response for the initialize request - try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent - - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - // Create a valid initialize response - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - - try await transport.queue(response: response) - - // Now complete the connect call which will automatically initialize - let result = try await client.connect(transport: transport) - #expect(result.protocolVersion == Version.latest) - #expect(result.serverInfo.name == "TestServer") - #expect(result.serverInfo.version == "1.0") - - // Small delay to ensure message loop is started - try await Task.sleep(for: .milliseconds(10)) - - // Create a task for the ping - let pingTask = Task { - try await client.ping() - } - - // Give it a moment to send the request - try await Task.sleep(for: .milliseconds(10)) - - #expect(await transport.sentMessages.count == 2) // Initialize + Ping - #expect(await transport.sentMessages.last?.contains(Ping.name) == true) - - // Cancel the ping task - pingTask.cancel() - } - - // Disconnect client to clean up message loop and give time for continuation cleanup - await client.disconnect() - try await Task.sleep(for: .milliseconds(50)) + let result = try await client.connect(transport: transport) + #expect(await transport.isConnected == true) + #expect(result.protocolVersion == Version.latest) + await client.disconnect() + #expect(await transport.isConnected == false) + initTask.cancel() + } + + @Test( + "Ping request", + .timeLimit(.minutes(1)) + ) + func testClientPing() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Queue a response for the initialize request + try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent + + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + // Create a valid initialize response + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + + try await transport.queue(response: response) + + // Now complete the connect call which will automatically initialize + let result = try await client.connect(transport: transport) + #expect(result.protocolVersion == Version.latest) + #expect(result.serverInfo.name == "TestServer") + #expect(result.serverInfo.version == "1.0") + + // Small delay to ensure message loop is started + try await Task.sleep(for: .milliseconds(10)) + + // Create a task for the ping + let pingTask = Task { + try await client.ping() + } + + // Give it a moment to send the request + try await Task.sleep(for: .milliseconds(10)) + + #expect(await transport.sentMessages.count == 2) // Initialize + Ping + #expect(await transport.sentMessages.last?.contains(Ping.name) == true) + + // Cancel the ping task + pingTask.cancel() } - @Test("Connection failure handling") - func testClientConnectionFailure() async { - let transport = MockTransport() - await transport.setFailConnect(true) - let client = Client(name: "TestClient", version: "1.0") - - do { - try await client.connect(transport: transport) - #expect(Bool(false), "Expected connection to fail") - } catch let error as MCPError { - if case MCPError.transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } + // Disconnect client to clean up message loop and give time for continuation cleanup + await client.disconnect() + try await Task.sleep(for: .milliseconds(50)) + } + + @Test("Connection failure handling") + func testClientConnectionFailure() async { + let transport = MockTransport() + await transport.setFailConnect(true) + let client = Client(name: "TestClient", version: "1.0") + + do { + try await client.connect(transport: transport) + #expect(Bool(false), "Expected connection to fail") + } catch let error as MCPError { + if case MCPError.transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") } - - @Test("Send failure handling") - func testClientSendFailure() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - // Connect first without failure - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Now set the transport to fail sends - await transport.setFailSend(true) - - do { - try await client.ping() - #expect(Bool(false), "Expected ping to fail") - } catch let error as MCPError { - if case MCPError.transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } - - await client.disconnect() + } + + @Test("Send failure handling") + func testClientSendFailure() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } } - @Test("Strict configuration - capabilities check") - func testStrictConfiguration() async throws { - let transport = MockTransport() - let config = Client.Configuration.strict - let client = Client(name: "TestClient", version: "1.0", configuration: config) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - try await client.connect(transport: transport) - - // Create a task for listPrompts - let promptsTask = Task { - do { - _ = try await client.listPrompts() - #expect(Bool(false), "Expected listPrompts to fail in strict mode") - } catch let error as MCPError { - if case MCPError.methodNotFound = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected methodNotFound error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } - } - - // Give it a short time to execute the task - try await Task.sleep(for: .milliseconds(50)) - - // Cancel the task if it's still running - promptsTask.cancel() - initTask.cancel() - - // Disconnect client - await client.disconnect() - try await Task.sleep(for: .milliseconds(50)) + // Connect first without failure + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Now set the transport to fail sends + await transport.setFailSend(true) + + do { + try await client.ping() + #expect(Bool(false), "Expected ping to fail") + } catch let error as MCPError { + if case MCPError.transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") } - @Test("Non-strict configuration - capabilities check") - func testNonStrictConfiguration() async throws { - let transport = MockTransport() - let config = Client.Configuration.default - let client = Client(name: "TestClient", version: "1.0", configuration: config) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - try await client.connect(transport: transport) - - // Make sure init task is complete - initTask.cancel() + await client.disconnect() + } + + @Test("Strict configuration - capabilities check") + func testStrictConfiguration() async throws { + let transport = MockTransport() + let config = Client.Configuration.strict + let client = Client(name: "TestClient", version: "1.0", configuration: config) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - // Wait a bit for any setup to complete - try await Task.sleep(for: .milliseconds(10)) + try await client.connect(transport: transport) - // Send the listPrompts request and immediately provide an error response - let promptsTask = Task { - do { - // Start the request - try await Task.sleep(for: .seconds(1)) - - // Get the last sent message and extract the request ID - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let decodedRequest = try? JSONDecoder().decode( - Request.self, from: data) - { - - // Create an error response with the same ID - let errorResponse = Response( - id: decodedRequest.id, - error: MCPError.methodNotFound("Test: Prompts capability not available") - ) - try await transport.queue(response: errorResponse) - - // Try the request now that we have a response queued - do { - _ = try await client.listPrompts() - #expect(Bool(false), "Expected listPrompts to fail in non-strict mode") - } catch let error as MCPError { - if case MCPError.methodNotFound = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected methodNotFound error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } - } - } catch { - // Ignore task cancellation - if !(error is CancellationError) { - throw error - } - } - } - - // Wait for the task to complete or timeout - let timeoutTask = Task { - try await Task.sleep(for: .milliseconds(500)) - promptsTask.cancel() + // Create a task for listPrompts + let promptsTask = Task { + do { + _ = try await client.listPrompts() + #expect(Bool(false), "Expected listPrompts to fail in strict mode") + } catch let error as MCPError { + if case MCPError.methodNotFound = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected methodNotFound error, got \(error)") } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } - // Wait for the task to complete - _ = await promptsTask.result - - // Cancel the timeout task - timeoutTask.cancel() - - // Disconnect client - await client.disconnect() + // Give it a short time to execute the task + try await Task.sleep(for: .milliseconds(50)) + + // Cancel the task if it's still running + promptsTask.cancel() + initTask.cancel() + + // Disconnect client + await client.disconnect() + try await Task.sleep(for: .milliseconds(50)) + } + + @Test("Non-strict configuration - capabilities check") + func testNonStrictConfiguration() async throws { + let transport = MockTransport() + let config = Client.Configuration.default + let client = Client(name: "TestClient", version: "1.0", configuration: config) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } } - @Test("Batch request - success") - func testBatchRequestSuccess() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + try await client.connect(transport: transport) - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) // Allow connection tasks - initTask.cancel() + // Make sure init task is complete + initTask.cancel() - let request1 = Ping.request() - let request2 = Ping.request() - var resultTask1: Task? - var resultTask2: Task? + // Wait a bit for any setup to complete + try await Task.sleep(for: .milliseconds(10)) - try await client.withBatch { batch in - resultTask1 = try await batch.addRequest(request1) - resultTask2 = try await batch.addRequest(request2) - } + // Send the listPrompts request and immediately provide an error response + let promptsTask = Task { + do { + // Start the request + try await Task.sleep(for: .seconds(1)) - // Check if batch message was sent (after initialize and initialized notification) - let sentMessages = await transport.sentMessages - #expect(sentMessages.count == 3) // Initialize request + Initialized notification + Batch + // Get the last sent message and extract the request ID + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let decodedRequest = try? JSONDecoder().decode( + Request.self, from: data) + { - guard let batchData = sentMessages.last?.data(using: .utf8) else { - #expect(Bool(false), "Failed to get batch data") - return + // Create an error response with the same ID + let errorResponse = Response( + id: decodedRequest.id, + error: MCPError.methodNotFound("Test: Prompts capability not available") + ) + try await transport.queue(response: errorResponse) + + // Try the request now that we have a response queued + do { + _ = try await client.listPrompts() + #expect(Bool(false), "Expected listPrompts to fail in non-strict mode") + } catch let error as MCPError { + if case MCPError.methodNotFound = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected methodNotFound error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } } - - // Verify the sent batch contains the two requests - let decoder = JSONDecoder() - let sentRequests = try decoder.decode([AnyRequest].self, from: batchData) - #expect(sentRequests.count == 2) - #expect(sentRequests.first?.id == request1.id) - #expect(sentRequests.first?.method == Ping.name) - #expect(sentRequests.last?.id == request2.id) - #expect(sentRequests.last?.method == Ping.name) - - // Prepare batch response - let response1 = Response(id: request1.id, result: .init()) - let response2 = Response(id: request2.id, result: .init()) - let anyResponse1 = try AnyResponse(response1) - let anyResponse2 = try AnyResponse(response2) - - // Queue the batch response - try await transport.queue(batch: [anyResponse1, anyResponse2]) - - // Wait for results and verify - guard let task1 = resultTask1, let task2 = resultTask2 else { - #expect(Bool(false), "Result tasks not created") - return + } catch { + // Ignore task cancellation + if !(error is CancellationError) { + throw error } + } + } - _ = try await task1.value // Should succeed - _ = try await task2.value // Should succeed - - #expect(Bool(true)) // Reaching here means success + // Wait for the task to complete or timeout + let timeoutTask = Task { + try await Task.sleep(for: .milliseconds(500)) + promptsTask.cancel() + } - await client.disconnect() + // Wait for the task to complete + _ = await promptsTask.result + + // Cancel the timeout task + timeoutTask.cancel() + + // Disconnect client + await client.disconnect() + } + + @Test("Batch request - success") + func testBatchRequestSuccess() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } } - @Test("Batch request - mixed success/error") - func testBatchRequestMixed() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) // Allow connection tasks + initTask.cancel() - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() + let request1 = Ping.request() + let request2 = Ping.request() + let taskCollector = TaskCollector>() - let request1 = Ping.request() // Success - let request2 = Ping.request() // Error - - var resultTasks: [Task] = [] + let batchBody: @Sendable (Client.Batch) async throws -> Void = { batch in + await taskCollector.append(try await batch.addRequest(request1)) + await taskCollector.append(try await batch.addRequest(request2)) + } + try await client.withBatch(body: batchBody) - try await client.withBatch { batch in - resultTasks.append(try await batch.addRequest(request1)) - resultTasks.append(try await batch.addRequest(request2)) - } + // Check if batch message was sent (after initialize and initialized notification) + let sentMessages = await transport.sentMessages + #expect(sentMessages.count == 3) // Initialize request + Initialized notification + Batch - // Check if batch message was sent (after initialize and initialized notification) - #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Batch + guard let batchData = sentMessages.last?.data(using: .utf8) else { + #expect(Bool(false), "Failed to get batch data") + return + } - // Prepare batch response (success for 1, error for 2) - let response1 = Response(id: request1.id, result: .init()) - let error = MCPError.internalError("Simulated batch error") - let response2 = Response(id: request2.id, error: error) - let anyResponse1 = try AnyResponse(response1) - let anyResponse2 = try AnyResponse(response2) + // Verify the sent batch contains the two requests + let decoder = JSONDecoder() + let sentRequests = try decoder.decode([AnyRequest].self, from: batchData) + #expect(sentRequests.count == 2) + #expect(sentRequests.first?.id == request1.id) + #expect(sentRequests.first?.method == Ping.name) + #expect(sentRequests.last?.id == request2.id) + #expect(sentRequests.last?.method == Ping.name) + + // Prepare batch response + let response1 = Response(id: request1.id, result: .init()) + let response2 = Response(id: request2.id, result: .init()) + let anyResponse1 = try AnyResponse(response1) + let anyResponse2 = try AnyResponse(response2) + + // Queue the batch response + try await transport.queue(batch: [anyResponse1, anyResponse2]) + + let resultTasks = await taskCollector.snapshot() + #expect(resultTasks.count == 2) + guard resultTasks.count == 2 else { + #expect(Bool(false), "Result tasks not created") + return + } - // Queue the batch response - try await transport.queue(batch: [anyResponse1, anyResponse2]) + let task1 = resultTasks[0] + let task2 = resultTasks[1] + + _ = try await task1.value // Should succeed + _ = try await task2.value // Should succeed + + #expect(Bool(true)) // Reaching here means success + + await client.disconnect() + } + + @Test("Batch request - mixed success/error") + func testBatchRequestMixed() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - // Wait for results and verify - #expect(resultTasks.count == 2) - guard resultTasks.count == 2 else { - #expect(Bool(false), "Expected 2 result tasks") - return - } + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() - let task1 = resultTasks[0] - let task2 = resultTasks[1] + let request1 = Ping.request() // Success + let request2 = Ping.request() // Error - _ = try await task1.value // Task 1 should succeed + let taskCollector = TaskCollector>() - do { - _ = try await task2.value // Task 2 should fail - #expect(Bool(false), "Task 2 should have thrown an error") - } catch let mcpError as MCPError { - if case .internalError(let message) = mcpError { - #expect(message == "Simulated batch error") - } else { - #expect(Bool(false), "Expected internalError, got \(mcpError)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") - } - - await client.disconnect() + let mixedBody: @Sendable (Client.Batch) async throws -> Void = { batch in + await taskCollector.append(try await batch.addRequest(request1)) + await taskCollector.append(try await batch.addRequest(request2)) + } + try await client.withBatch(body: mixedBody) + + // Check if batch message was sent (after initialize and initialized notification) + #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Batch + + // Prepare batch response (success for 1, error for 2) + let response1 = Response(id: request1.id, result: .init()) + let error = MCPError.internalError("Simulated batch error") + let response2 = Response(id: request2.id, error: error) + let anyResponse1 = try AnyResponse(response1) + let anyResponse2 = try AnyResponse(response2) + + // Queue the batch response + try await transport.queue(batch: [anyResponse1, anyResponse2]) + + // Wait for results and verify + let resultTasks = await taskCollector.snapshot() + #expect(resultTasks.count == 2) + guard resultTasks.count == 2 else { + #expect(Bool(false), "Expected 2 result tasks") + return } - @Test("Batch request - empty") - func testBatchRequestEmpty() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() + let task1 = resultTasks[0] + let task2 = resultTasks[1] + + _ = try await task1.value // Task 1 should succeed + + do { + _ = try await task2.value // Task 2 should fail + #expect(Bool(false), "Task 2 should have thrown an error") + } catch let mcpError as MCPError { + if case .internalError(let message) = mcpError { + #expect(message == "Simulated batch error") + } else { + #expect(Bool(false), "Expected internalError, got \(mcpError)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") + } - // Call withBatch but don't add any requests - try await client.withBatch { _ in - // No requests added - } + await client.disconnect() + } + + @Test("Batch request - empty") + func testBatchRequestEmpty() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - // Check that only initialize message and initialized notification were sent - #expect(await transport.sentMessages.count == 2) // Initialize request + Initialized notification + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() - await client.disconnect() + // Call withBatch but don't add any requests + let emptyBody: @Sendable (Client.Batch) async throws -> Void = { _ in + // No requests added + } + try await client.withBatch(body: emptyBody) + + // Check that only initialize message and initialized notification were sent + #expect(await transport.sentMessages.count == 2) // Initialize request + Initialized notification + + await client.disconnect() + } + + @Test("Notify method sends notifications") + func testClientNotify() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } } - @Test("Notify method sends notifications") - func testClientNotify() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Create a test notification + let notification = InitializedNotification.message() + try await client.notify(notification) + + // Verify notification was sent (in addition to initialize and initialized notification) + #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Custom notification + + if let sentMessage = await transport.sentMessages.last, + let data = sentMessage.data(using: .utf8) + { + + // Decode as Message + let decoder = JSONDecoder() + do { + let decodedNotification = try decoder.decode( + Message.self, from: data) + #expect(decodedNotification.method == InitializedNotification.name) + } catch { + #expect(Bool(false), "Failed to decode notification: \(error)") + } + } else { + #expect(Bool(false), "No message was sent") + } + await client.disconnect() + } + + @Test("Initialize sends initialized notification") + func testClientInitializeNotification() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Create a task for initialize + let initTask = Task { + // Queue a response for the initialize request + try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent + + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + + // Create a valid initialize response + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + + try await transport.queue(response: response) + + // Now complete the initialize call try await client.connect(transport: transport) try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - // Create a test notification - let notification = InitializedNotification.message() - try await client.notify(notification) + // Verify that two messages were sent: initialize request and initialized notification + #expect(await transport.sentMessages.count == 2) - // Verify notification was sent (in addition to initialize and initialized notification) - #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Custom notification - - if let sentMessage = await transport.sentMessages.last, - let data = sentMessage.data(using: .utf8) - { - - // Decode as Message - let decoder = JSONDecoder() + // Check that the second message is the initialized notification + let notifications = await transport.sentMessages + if notifications.count >= 2 { + let notificationJson = notifications[1] + if let notificationData = notificationJson.data(using: .utf8) { do { - let decodedNotification = try decoder.decode( - Message.self, from: data) - #expect(decodedNotification.method == InitializedNotification.name) + let decoder = JSONDecoder() + let decodedNotification = try decoder.decode( + Message.self, from: notificationData) + #expect(decodedNotification.method == InitializedNotification.name) } catch { - #expect(Bool(false), "Failed to decode notification: \(error)") + #expect(Bool(false), "Failed to decode notification: \(error)") } + } else { + #expect(Bool(false), "Could not convert notification to data") + } } else { - #expect(Bool(false), "No message was sent") + #expect( + Bool(false), "Expected both initialize request and initialized notification" + ) } - - await client.disconnect() + } } - @Test("Initialize sends initialized notification") - func testClientInitializeNotification() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Create a task for initialize - let initTask = Task { - // Queue a response for the initialize request - try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent - - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - - // Create a valid initialize response - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - - try await transport.queue(response: response) - - // Now complete the initialize call - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - - // Verify that two messages were sent: initialize request and initialized notification - #expect(await transport.sentMessages.count == 2) - - // Check that the second message is the initialized notification - let notifications = await transport.sentMessages - if notifications.count >= 2 { - let notificationJson = notifications[1] - if let notificationData = notificationJson.data(using: .utf8) { - do { - let decoder = JSONDecoder() - let decodedNotification = try decoder.decode( - Message.self, from: notificationData) - #expect(decodedNotification.method == InitializedNotification.name) - } catch { - #expect(Bool(false), "Failed to decode notification: \(error)") - } - } else { - #expect(Bool(false), "Could not convert notification to data") - } - } else { - #expect( - Bool(false), "Expected both initialize request and initialized notification" - ) - } - } - } - - // Wait with timeout - let timeoutTask = Task { - try await Task.sleep(for: .seconds(1)) - initTask.cancel() - } - - // Wait for the task to complete - do { - _ = try await initTask.value - } catch is CancellationError { - #expect(Bool(false), "Test timed out") - } catch { - #expect(Bool(false), "Unexpected error: \(error)") - } - - timeoutTask.cancel() - - await client.disconnect() + // Wait with timeout + let timeoutTask = Task { + try await Task.sleep(for: .seconds(1)) + initTask.cancel() } - @Test("Race condition between send error and response") - func testSendErrorResponseRace() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } - - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Set up the transport to fail sends from the start - await transport.setFailSend(true) - - // Create a ping request to get the ID - let request = Ping.request() - - // Create a response for the request and queue it immediately - let response = Response(id: request.id, result: .init()) - let anyResponse = try AnyResponse(response) - try await transport.queue(response: anyResponse) - - // Now attempt to send the request - this should fail due to send error - // but the response handler might also try to process the queued response - do { - _ = try await client.ping() - #expect(Bool(false), "Expected send to fail") - } catch let error as MCPError { - if case .transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") - } + // Wait for the task to complete + do { + _ = try await initTask.value + } catch is CancellationError { + #expect(Bool(false), "Test timed out") + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } - // Verify no continuation misuse occurred - // (If it did, the test would have crashed) + timeoutTask.cancel() + + await client.disconnect() + } + + @Test("Race condition between send error and response") + func testSendErrorResponseRace() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - await client.disconnect() + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Set up the transport to fail sends from the start + await transport.setFailSend(true) + + // Create a ping request to get the ID + let request = Ping.request() + + // Create a response for the request and queue it immediately + let response = Response(id: request.id, result: .init()) + let anyResponse = try AnyResponse(response) + try await transport.queue(response: anyResponse) + + // Now attempt to send the request - this should fail due to send error + // but the response handler might also try to process the queued response + do { + _ = try await client.ping() + #expect(Bool(false), "Expected send to fail") + } catch let error as MCPError { + if case .transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") } - @Test("Race condition between response and send error") - func testResponseSendErrorRace() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + // Verify no continuation misuse occurred + // (If it did, the test would have crashed) + + await client.disconnect() + } + + @Test("Race condition between response and send error") + func testResponseSendErrorRace() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Create a ping request to get the ID - let request = Ping.request() - - // Create a response for the request and queue it immediately - let response = Response(id: request.id, result: .init()) - let anyResponse = try AnyResponse(response) - try await transport.queue(response: anyResponse) - - // Set up the transport to fail sends - await transport.setFailSend(true) - - // Now attempt to send the request - // The response might be processed before the send error occurs - do { - _ = try await client.ping() - // In this case, the response handler won the race and the request succeeded - #expect(Bool(true), "Response handler won the race - request succeeded") - } catch let error as MCPError { - if case .transportError = error { - // In this case, the send error handler won the race - #expect(Bool(true), "Send error handler won the race - request failed") - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") - } + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Create a ping request to get the ID + let request = Ping.request() + + // Create a response for the request and queue it immediately + let response = Response(id: request.id, result: .init()) + let anyResponse = try AnyResponse(response) + try await transport.queue(response: anyResponse) + + // Set up the transport to fail sends + await transport.setFailSend(true) + + // Now attempt to send the request + // The response might be processed before the send error occurs + do { + _ = try await client.ping() + // In this case, the response handler won the race and the request succeeded + #expect(Bool(true), "Response handler won the race - request succeeded") + } catch let error as MCPError { + if case .transportError = error { + // In this case, the send error handler won the race + #expect(Bool(true), "Send error handler won the race - request failed") + } else { + #expect(Bool(false), "Expected transport error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") + } - // Verify no continuation misuse occurred - // (If it did, the test would have crashed) + // Verify no continuation misuse occurred + // (If it did, the test would have crashed) - await client.disconnect() - } + await client.disconnect() + } } diff --git a/Tests/MCPTests/Helpers/TaskCollector.swift b/Tests/MCPTests/Helpers/TaskCollector.swift new file mode 100644 index 00000000..8204decd --- /dev/null +++ b/Tests/MCPTests/Helpers/TaskCollector.swift @@ -0,0 +1,12 @@ +// Helper actor to safely accumulate tasks across sendable closures. +actor TaskCollector { + private var storage: [Element] = [] + + func append(_ element: Element) { + storage.append(element) + } + + func snapshot() -> [Element] { + storage + } +} From 85528775f66302f6a94ae016e5743f3230f097da Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Wed, 22 Oct 2025 17:26:37 -0400 Subject: [PATCH 05/18] General fields --- Sources/MCP/Base/GeneralFields.swift | 195 ++++++++++++++++++++++++ Tests/MCPTests/GeneralFieldsTests.swift | 178 +++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 Sources/MCP/Base/GeneralFields.swift create mode 100644 Tests/MCPTests/GeneralFieldsTests.swift diff --git a/Sources/MCP/Base/GeneralFields.swift b/Sources/MCP/Base/GeneralFields.swift new file mode 100644 index 00000000..da5993f3 --- /dev/null +++ b/Sources/MCP/Base/GeneralFields.swift @@ -0,0 +1,195 @@ +import Foundation + +struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} + +public enum GeneralFieldError: Error, Equatable { + case invalidMetaKey(String) +} + +public struct GeneralFields: Hashable, Sendable { + public var meta: MetaFields? + public var additional: [String: Value] + + public init(meta: MetaFields? = nil, additional: [String: Value] = [:]) { + self.meta = meta + self.additional = additional.filter { $0.key != "_meta" } + } + + public var isEmpty: Bool { + (meta?.isEmpty ?? true) && additional.isEmpty + } + + public subscript(field name: String) -> Value? { + get { additional[name] } + set { additional[name] = newValue } + } + + mutating public func merge(additional fields: [String: Value]) { + for (key, value) in fields where key != "_meta" { + additional[key] = value + } + } + + func encode(into encoder: Encoder, reservedKeyNames: Set) throws { + guard !isEmpty else { return } + + var container = encoder.container(keyedBy: DynamicCodingKey.self) + + if let meta, !meta.isEmpty, let key = DynamicCodingKey(stringValue: "_meta") { + try container.encode(meta.dictionary, forKey: key) + } + + for (name, value) in additional where !reservedKeyNames.contains(name) { + guard let key = DynamicCodingKey(stringValue: name) else { continue } + try container.encode(value, forKey: key) + } + } + + static func decode( + from container: KeyedDecodingContainer, + reservedKeyNames: Set + ) throws -> GeneralFields { + var meta: MetaFields? + var additional: [String: Value] = [:] + + for key in container.allKeys { + let name = key.stringValue + if reservedKeyNames.contains(name) { + continue + } + + if name == "_meta" { + let raw = try container.decodeIfPresent([String: Value].self, forKey: key) + if let raw { + meta = try MetaFields(values: raw) + } + } else if let value = try? container.decode(Value.self, forKey: key) { + additional[name] = value + } + } + + return GeneralFields(meta: meta, additional: additional) + } +} + +public struct MetaFields: Hashable, Sendable { + private var storage: [String: Value] + + public init(values: [String: Value] = [:]) throws { + for key in values.keys { + guard MetaFields.isValidKeyName(key) else { + throw GeneralFieldError.invalidMetaKey(key) + } + } + self.storage = values + } + + public var dictionary: [String: Value] { + storage + } + + public var isEmpty: Bool { + storage.isEmpty + } + + public mutating func setValue(_ value: Value?, forKey key: String) throws { + guard MetaFields.isValidKeyName(key) else { + throw GeneralFieldError.invalidMetaKey(key) + } + storage[key] = value + } + + public subscript(key: String) -> Value? { + storage[key] + } + + public static func isValidKeyName(_ key: String) -> Bool { + let parts = key.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + + let prefix: Substring? + let name: Substring + + if parts.count == 2 { + prefix = parts[0] + name = parts[1] + } else { + prefix = nil + name = parts.first ?? "" + } + + if let prefix, !prefix.isEmpty { + let labels = prefix.split(separator: ".", omittingEmptySubsequences: false) + guard !labels.isEmpty else { return false } + for label in labels { + guard MetaFields.isValidPrefixLabel(label) else { return false } + } + } + + guard !name.isEmpty else { return false } + guard MetaFields.isValidName(name) else { return false } + + return true + } + + private static func isValidPrefixLabel(_ label: Substring) -> Bool { + guard let first = label.first, first.isLetter else { return false } + guard let last = label.last, last.isLetter || last.isNumber else { return false } + for character in label { + if character.isLetter || character.isNumber || character == "-" { + continue + } + return false + } + return true + } + + private static func isValidName(_ name: Substring) -> Bool { + guard let first = name.first, first.isLetter || first.isNumber else { return false } + guard let last = name.last, last.isLetter || last.isNumber else { return false } + + for character in name { + if character.isLetter || character.isNumber || character == "-" || character == "_" + || character == "." + { + continue + } + return false + } + + return true + } +} + +extension MetaFields: Codable { + public init(from decoder: Decoder) throws { + let values = try [String: Value](from: decoder) + try self.init(values: values) + } + + public func encode(to encoder: Encoder) throws { + try storage.encode(to: encoder) + } +} + +private extension Character { + var isLetter: Bool { + unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } + } + + var isNumber: Bool { + unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } + } +} diff --git a/Tests/MCPTests/GeneralFieldsTests.swift b/Tests/MCPTests/GeneralFieldsTests.swift new file mode 100644 index 00000000..4b17a5f5 --- /dev/null +++ b/Tests/MCPTests/GeneralFieldsTests.swift @@ -0,0 +1,178 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder +import class Foundation.JSONSerialization + +@testable import MCP + +@Suite("General Fields") +struct GeneralFieldsTests { + private struct Payload: Codable, Hashable, Sendable { + let message: String + } + + private enum TestMethod: Method { + static let name = "test.general" + typealias Parameters = Payload + typealias Result = Payload + } + + @Test("Encoding includes meta and custom fields") + func testEncodingGeneralFields() throws { + let meta = try MetaFields(values: ["vendor.example/request-id": .string("abc123")]) + let general = GeneralFields(meta: meta, additional: ["custom": .int(5)]) + + let request = Request( + id: 42, + method: TestMethod.name, + params: Payload(message: "hello"), + generalFields: general + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/request-id"] as? String == "abc123") + #expect(json?["custom"] as? Int == 5) + } + + @Test("Decoding restores general fields") + func testDecodingGeneralFields() throws { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": 7, + "method": TestMethod.name, + "params": ["message": "hi"], + "_meta": ["vendor.example/session": "s42"], + "custom-data": ["value": 1], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + let metaValue = decoded.generalFields.meta?.dictionary["vendor.example/session"] + #expect(metaValue == .string("s42")) + #expect(decoded.generalFields.additional["custom-data"] == .object(["value": .int(1)])) + } + + @Test("Reserved fields are ignored when encoding extras") + func testReservedFieldsIgnored() throws { + let general = GeneralFields( + meta: nil, + additional: ["method": .string("override"), "custom": .bool(true)] + ) + + let request = Request( + id: 1, + method: TestMethod.name, + params: Payload(message: "ping"), + generalFields: general + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + #expect(json?["method"] as? String == TestMethod.name) + #expect(json?["custom"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Request.self, from: data) + #expect(decoded.generalFields.additional["method"] == nil) + #expect(decoded.generalFields.additional["custom"] == .bool(true)) + } + + @Test("Invalid meta key is rejected") + func testInvalidMetaKey() { + #expect(throws: GeneralFieldError.invalidMetaKey("invalid key")) { + _ = try MetaFields(values: ["invalid key": .int(1)]) + } + } + + @Test("Response encoding includes general fields") + func testResponseGeneralFields() throws { + let meta = try MetaFields(values: ["vendor.example/status": .string("partial")]) + let general = GeneralFields(meta: meta, additional: ["progress": .int(50)]) + let response = Response( + id: 99, + result: .success(Payload(message: "ok")), + general: general + ) + + let data = try JSONEncoder().encode(response) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/status"] as? String == "partial") + #expect(json?["progress"] as? Int == 50) + + let decoded = try JSONDecoder().decode(Response.self, from: data) + #expect(decoded.general.additional["progress"] == .int(50)) + #expect( + decoded.general.meta?.dictionary["vendor.example/status"] + == .string("partial") + ) + } + + @Test("Tool encoding and decoding with general fields") + func testToolGeneralFields() throws { + let meta = try MetaFields(values: [ + "vendor.example/outputTemplate": .string("ui://widget/kanban-board.html") + ]) + let general = GeneralFields( + meta: meta, + additional: ["openai/toolInvocation/invoking": .string("Displaying the board")] + ) + + let tool = Tool( + name: "kanban-board", + title: "Kanban Board", + description: "Display kanban widget", + inputSchema: try Value(["type": "object"]), + general: general + ) + + let data = try JSONEncoder().encode(tool) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/outputTemplate"] as? String == "ui://widget/kanban-board.html") + #expect(json?["openai/toolInvocation/invoking"] as? String == "Displaying the board") + + let decoded = try JSONDecoder().decode(Tool.self, from: data) + #expect(decoded.general.additional["openai/toolInvocation/invoking"] == .string("Displaying the board")) + #expect( + decoded.general.meta?.dictionary["vendor.example/outputTemplate"] + == .string("ui://widget/kanban-board.html") + ) + } + + @Test("Resource content encodes meta") + func testResourceContentGeneralFields() throws { + let meta = try MetaFields(values: [ + "openai/widgetPrefersBorder": .bool(true) + ]) + let general = GeneralFields( + meta: meta, + additional: ["openai/widgetDomain": .string("https://chatgpt.com")] + ) + + let content = Resource.Content.text( + "
Widget
", + uri: "ui://widget/kanban-board.html", + mimeType: "text/html", + general: general + ) + + let data = try JSONEncoder().encode(content) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + + #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) + #expect(json?["openai/widgetDomain"] as? String == "https://chatgpt.com") + + let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) + #expect( + decoded.general.meta?.dictionary["openai/widgetPrefersBorder"] == .bool(true)) + #expect(decoded.general.additional["openai/widgetDomain"] == .string("https://chatgpt.com")) + } +} From 8843444c88274448343ad0448c9b82dbeaa04009 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Wed, 22 Oct 2025 17:27:31 -0400 Subject: [PATCH 06/18] Add Generate Fields to Server elements --- Sources/MCP/Base/Messages.swift | 100 +++- Sources/MCP/Server/Resources.swift | 404 ++++++++----- Sources/MCP/Server/Tools.swift | 98 +++- Tests/MCPTests/ToolTests.swift | 900 +++++++++++++++-------------- 4 files changed, 881 insertions(+), 621 deletions(-) diff --git a/Sources/MCP/Base/Messages.swift b/Sources/MCP/Base/Messages.swift index b9058f7e..e1914bc4 100644 --- a/Sources/MCP/Base/Messages.swift +++ b/Sources/MCP/Base/Messages.swift @@ -37,8 +37,11 @@ struct AnyMethod: Method, Sendable { } extension Method where Parameters == Empty { - public static func request(id: ID = .random) -> Request { - Request(id: id, method: name, params: Empty()) + public static func request( + id: ID = .random, + generalFields: GeneralFields = .init() + ) -> Request { + Request(id: id, method: name, params: Empty(), generalFields: generalFields) } } @@ -50,18 +53,30 @@ extension Method where Result == Empty { extension Method { /// Create a request with the given parameters. - public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request { - Request(id: id, method: name, params: parameters) + public static func request( + id: ID = .random, + _ parameters: Self.Parameters, + generalFields: GeneralFields = .init() + ) -> Request { + Request(id: id, method: name, params: parameters, generalFields: generalFields) } /// Create a response with the given result. - public static func response(id: ID, result: Self.Result) -> Response { - Response(id: id, result: result) + public static func response( + id: ID, + result: Self.Result, + general: GeneralFields = .init() + ) -> Response { + Response(id: id, result: result, general: general) } /// Create a response with the given error. - public static func response(id: ID, error: MCPError) -> Response { - Response(id: id, error: error) + public static func response( + id: ID, + error: MCPError, + general: GeneralFields = .init() + ) -> Response { + Response(id: id, error: error, general: general) } } @@ -75,11 +90,19 @@ public struct Request: Hashable, Identifiable, Codable, Sendable { public let method: String /// The request parameters. public let params: M.Parameters - - init(id: ID = .random, method: String, params: M.Parameters) { + /// General MCP fields like `_meta`. + public let generalFields: GeneralFields + + init( + id: ID = .random, + method: String, + params: M.Parameters, + generalFields: GeneralFields = .init() + ) { self.id = id self.method = method self.params = params + self.generalFields = generalFields } private enum CodingKeys: String, CodingKey { @@ -92,6 +115,11 @@ public struct Request: Hashable, Identifiable, Codable, Sendable { try container.encode(id, forKey: .id) try container.encode(method, forKey: .method) try container.encode(params, forKey: .params) + try generalFields.encode(into: encoder, reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["jsonrpc", "id", "method", "params"] } } @@ -133,6 +161,11 @@ extension Request { codingPath: container.codingPath, debugDescription: "Invalid params field")) } + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + generalFields = try GeneralFields.decode( + from: dynamicContainer, + reservedKeyNames: Self.reservedGeneralFieldNames) } } @@ -196,15 +229,25 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { public let id: ID /// The response result. public let result: Swift.Result - - public init(id: ID, result: M.Result) { + /// General MCP fields such as `_meta`. + public let general: GeneralFields + + public init( + id: ID, + result: Swift.Result, + general: GeneralFields = .init() + ) { self.id = id - self.result = .success(result) + self.result = result + self.general = general } - public init(id: ID, error: MCPError) { - self.id = id - self.result = .failure(error) + public init(id: ID, result: M.Result, general: GeneralFields = .init()) { + self.init(id: id, result: .success(result), general: general) + } + + public init(id: ID, error: MCPError, general: GeneralFields = .init()) { + self.init(id: id, result: .failure(error), general: general) } private enum CodingKeys: String, CodingKey { @@ -221,6 +264,7 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { case .failure(let error): try container.encode(error, forKey: .error) } + try general.encode(into: encoder, reservedKeyNames: Self.reservedGeneralFieldNames) } public init(from decoder: Decoder) throws { @@ -241,6 +285,15 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { codingPath: container.codingPath, debugDescription: "Invalid response")) } + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamicContainer, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["jsonrpc", "id", "result", "error"] } } @@ -249,18 +302,19 @@ typealias AnyResponse = Response extension AnyResponse { init(_ response: Response) throws { - // Instead of re-encoding/decoding which might double-wrap the error, - // directly transfer the properties - self.id = response.id switch response.result { case .success(let result): - // For success, we still need to convert the result to a Value let data = try JSONEncoder().encode(result) let resultValue = try JSONDecoder().decode(Value.self, from: data) - self.result = .success(resultValue) + self = Response( + id: response.id, + result: .success(resultValue), + general: response.general) case .failure(let error): - // Keep the original error without re-encoding/decoding - self.result = .failure(error) + self = Response( + id: response.id, + result: .failure(error), + general: response.general) } } } diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index 12f67335..c77f4b8b 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -8,88 +8,220 @@ import Foundation /// /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/ public struct Resource: Hashable, Codable, Sendable { - /// The resource name - public var name: String + /// The resource name + public var name: String + /// The resource URI + public var uri: String + /// The resource description + public var description: String? + /// The resource MIME type + public var mimeType: String? + /// The resource metadata + public var metadata: [String: String]? + /// General MCP fields such as `_meta`. + public var general: GeneralFields + + public init( + name: String, + uri: String, + description: String? = nil, + mimeType: String? = nil, + metadata: [String: String]? = nil, + general: GeneralFields = .init() + ) { + self.name = name + self.uri = uri + self.description = description + self.mimeType = mimeType + self.metadata = metadata + self.general = general + } + + private enum CodingKeys: String, CodingKey { + case name + case uri + case description + case mimeType + case metadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + uri = try container.decode(String.self, forKey: .uri) + description = try container.decodeIfPresent(String.self, forKey: .description) + mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + metadata = try container.decodeIfPresent([String: String].self, forKey: .metadata) + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(uri, forKey: .uri) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["name", "uri", "description", "mimeType", "metadata"] + } + + /// Content of a resource. + public struct Content: Hashable, Codable, Sendable { /// The resource URI - public var uri: String - /// The resource description + public let uri: String + /// The resource MIME type + public let mimeType: String? + /// The resource text content + public let text: String? + /// The resource binary content + public let blob: String? + /// General MCP fields such as `_meta`. + public var general: GeneralFields + + public static func text( + _ content: String, + uri: String, + mimeType: String? = nil, + general: GeneralFields = .init() + ) -> Self { + .init(uri: uri, mimeType: mimeType, text: content, general: general) + } + + public static func binary( + _ data: Data, + uri: String, + mimeType: String? = nil, + general: GeneralFields = .init() + ) -> Self { + .init( + uri: uri, + mimeType: mimeType, + blob: data.base64EncodedString(), + general: general + ) + } + + private init( + uri: String, + mimeType: String? = nil, + text: String? = nil, + general: GeneralFields = .init() + ) { + self.uri = uri + self.mimeType = mimeType + self.text = text + self.blob = nil + self.general = general + } + + private init( + uri: String, + mimeType: String? = nil, + blob: String, + general: GeneralFields = .init() + ) { + self.uri = uri + self.mimeType = mimeType + self.text = nil + self.blob = blob + self.general = general + } + + private enum CodingKeys: String, CodingKey { + case uri + case mimeType + case text + case blob + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + uri = try container.decode(String.self, forKey: .uri) + mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + text = try container.decodeIfPresent(String.self, forKey: .text) + blob = try container.decodeIfPresent(String.self, forKey: .blob) + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(uri, forKey: .uri) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(blob, forKey: .blob) + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["uri", "mimeType", "text", "blob"] + } + } + + /// A resource template. + public struct Template: Hashable, Codable, Sendable { + /// The URI template pattern + public var uriTemplate: String + /// The template name + public var name: String + /// The template description public var description: String? /// The resource MIME type public var mimeType: String? - /// The resource metadata - public var metadata: [String: String]? public init( - name: String, - uri: String, - description: String? = nil, - mimeType: String? = nil, - metadata: [String: String]? = nil + uriTemplate: String, + name: String, + description: String? = nil, + mimeType: String? = nil + ) { + self.uriTemplate = uriTemplate + self.name = name + self.description = description + self.mimeType = mimeType + } + } + + // A resource annotation. + public struct Annotations: Hashable, Codable, Sendable { + /// The intended audience for this resource. + public enum Audience: String, Hashable, Codable, Sendable { + /// Content intended for end users. + case user = "user" + /// Content intended for AI assistants. + case assistant = "assistant" + } + + /// An array indicating the intended audience(s) for this resource. For example, `[.user, .assistant]` indicates content useful for both. + public let audience: [Audience] + /// A number from 0.0 to 1.0 indicating the importance of this resource. A value of 1 means “most important” (effectively required), while 0 means “least important”. + public let priority: Double? + /// An ISO 8601 formatted timestamp indicating when the resource was last modified (e.g., "2025-01-12T15:00:58Z"). + public let lastModified: String + + public init( + audience: [Audience], + priority: Double? = nil, + lastModified: String ) { - self.name = name - self.uri = uri - self.description = description - self.mimeType = mimeType - self.metadata = metadata - } - - /// Content of a resource. - public struct Content: Hashable, Codable, Sendable { - /// The resource URI - public let uri: String - /// The resource MIME type - public let mimeType: String? - /// The resource text content - public let text: String? - /// The resource binary content - public let blob: String? - - public static func text(_ content: String, uri: String, mimeType: String? = nil) -> Self { - .init(uri: uri, mimeType: mimeType, text: content) - } - - public static func binary(_ data: Data, uri: String, mimeType: String? = nil) -> Self { - .init(uri: uri, mimeType: mimeType, blob: data.base64EncodedString()) - } - - private init(uri: String, mimeType: String? = nil, text: String? = nil) { - self.uri = uri - self.mimeType = mimeType - self.text = text - self.blob = nil - } - - private init(uri: String, mimeType: String? = nil, blob: String) { - self.uri = uri - self.mimeType = mimeType - self.text = nil - self.blob = blob - } - } - - /// A resource template. - public struct Template: Hashable, Codable, Sendable { - /// The URI template pattern - public var uriTemplate: String - /// The template name - public var name: String - /// The template description - public var description: String? - /// The resource MIME type - public var mimeType: String? - - public init( - uriTemplate: String, - name: String, - description: String? = nil, - mimeType: String? = nil - ) { - self.uriTemplate = uriTemplate - self.name = name - self.description = description - self.mimeType = mimeType - } + self.audience = audience + self.priority = priority + self.lastModified = lastModified } + } } // MARK: - @@ -97,116 +229,116 @@ public struct Resource: Hashable, Codable, Sendable { /// To discover available resources, clients send a `resources/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#listing-resources public enum ListResources: Method { - public static let name: String = "resources/list" + public static let name: String = "resources/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? + + public init() { + self.cursor = nil + } - public init() { - self.cursor = nil - } - - public init(cursor: String) { - self.cursor = cursor - } + public init(cursor: String) { + self.cursor = cursor } + } - public struct Result: Hashable, Codable, Sendable { - public let resources: [Resource] - public let nextCursor: String? + public struct Result: Hashable, Codable, Sendable { + public let resources: [Resource] + public let nextCursor: String? - public init(resources: [Resource], nextCursor: String? = nil) { - self.resources = resources - self.nextCursor = nextCursor - } + public init(resources: [Resource], nextCursor: String? = nil) { + self.resources = resources + self.nextCursor = nextCursor } + } } /// To retrieve resource contents, clients send a `resources/read` request: /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#reading-resources public enum ReadResource: Method { - public static let name: String = "resources/read" + public static let name: String = "resources/read" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String - public init(uri: String) { - self.uri = uri - } + public init(uri: String) { + self.uri = uri } + } - public struct Result: Hashable, Codable, Sendable { - public let contents: [Resource.Content] + public struct Result: Hashable, Codable, Sendable { + public let contents: [Resource.Content] - public init(contents: [Resource.Content]) { - self.contents = contents - } + public init(contents: [Resource.Content]) { + self.contents = contents } + } } /// To discover available resource templates, clients send a `resources/templates/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates public enum ListResourceTemplates: Method { - public static let name: String = "resources/templates/list" + public static let name: String = "resources/templates/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? - public init() { - self.cursor = nil - } - - public init(cursor: String) { - self.cursor = cursor - } + public init() { + self.cursor = nil } - public struct Result: Hashable, Codable, Sendable { - public let templates: [Resource.Template] - public let nextCursor: String? + public init(cursor: String) { + self.cursor = cursor + } + } + + public struct Result: Hashable, Codable, Sendable { + public let templates: [Resource.Template] + public let nextCursor: String? - public init(templates: [Resource.Template], nextCursor: String? = nil) { - self.templates = templates - self.nextCursor = nextCursor - } + public init(templates: [Resource.Template], nextCursor: String? = nil) { + self.templates = templates + self.nextCursor = nextCursor + } - private enum CodingKeys: String, CodingKey { - case templates = "resourceTemplates" - case nextCursor - } + private enum CodingKeys: String, CodingKey { + case templates = "resourceTemplates" + case nextCursor } + } } /// When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification public struct ResourceListChangedNotification: Notification { - public static let name: String = "notifications/resources/list_changed" + public static let name: String = "notifications/resources/list_changed" - public typealias Parameters = Empty + public typealias Parameters = Empty } /// Clients can subscribe to specific resources and receive notifications when they change. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions public enum ResourceSubscribe: Method { - public static let name: String = "resources/subscribe" + public static let name: String = "resources/subscribe" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String - } + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String + } - public typealias Result = Empty + public typealias Result = Empty } /// When a resource changes, servers that declared the updated capability SHOULD send a notification to subscribed clients. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions public struct ResourceUpdatedNotification: Notification { - public static let name: String = "notifications/resources/updated" + public static let name: String = "notifications/resources/updated" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String - public init(uri: String) { - self.uri = uri - } + public init(uri: String) { + self.uri = uri } + } } diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index e53d8c4f..60e60852 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -17,10 +17,10 @@ public struct Tool: Hashable, Codable, Sendable { public let description: String? /// The tool input schema public let inputSchema: Value - /// Additional properties for a tool for OpenAI use. Not part of spec. Encoded as `_meta`. - public let meta: [String: Value]? /// The tool output schema, defining expected output structure public let outputSchema: Value? + /// General MCP fields (e.g. `_meta`). + public var general: GeneralFields /// Annotations that provide display-facing and operational information for a Tool. /// @@ -95,7 +95,7 @@ public struct Tool: Hashable, Codable, Sendable { description: String?, inputSchema: Value, annotations: Annotations = nil, - meta: [String: Value]? = nil, + general: GeneralFields = .init(), outputSchema: Value? = nil ) { self.name = name @@ -104,7 +104,7 @@ public struct Tool: Hashable, Codable, Sendable { self.inputSchema = inputSchema self.outputSchema = outputSchema self.annotations = annotations - self.meta = meta + self.general = general } /// Content types that can be returned by a tool @@ -116,15 +116,28 @@ public struct Tool: Hashable, Codable, Sendable { /// Audio content case audio(data: String, mimeType: String) /// Embedded resource content - case resource(uri: String, mimeType: String, text: String?) + case resource( + uri: String, mimeType: String, text: String?, title: String? = nil, + annotations: Resource.Annotations? = nil + ) + /// Resource link + case resourceLink( + uri: String, name: String, description: String? = nil, mimeType: String? = nil, + annotations: Resource.Annotations? = nil + ) private enum CodingKeys: String, CodingKey { case type case text case image case resource + case resourceLink case audio case uri + case name + case title + case description + case annotations case mimeType case data case metadata @@ -150,9 +163,23 @@ public struct Tool: Hashable, Codable, Sendable { self = .audio(data: data, mimeType: mimeType) case "resource": let uri = try container.decode(String.self, forKey: .uri) + let title = try container.decodeIfPresent(String.self, forKey: .title) let mimeType = try container.decode(String.self, forKey: .mimeType) let text = try container.decodeIfPresent(String.self, forKey: .text) - self = .resource(uri: uri, mimeType: mimeType, text: text) + let annotations = try container.decodeIfPresent( + Resource.Annotations.self, forKey: .annotations) + self = .resource( + uri: uri, mimeType: mimeType, text: text, title: title, annotations: annotations) + case "resourceLink": + let uri = try container.decode(String.self, forKey: .uri) + let name = try container.decode(String.self, forKey: .name) + let description = try container.decodeIfPresent(String.self, forKey: .description) + let mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + let annotations = try container.decodeIfPresent( + Resource.Annotations.self, forKey: .annotations) + self = .resourceLink( + uri: uri, name: name, description: description, mimeType: mimeType, + annotations: annotations) default: throw DecodingError.dataCorruptedError( forKey: .type, in: container, debugDescription: "Unknown tool content type") @@ -175,11 +202,20 @@ public struct Tool: Hashable, Codable, Sendable { try container.encode("audio", forKey: .type) try container.encode(data, forKey: .data) try container.encode(mimeType, forKey: .mimeType) - case .resource(let uri, let mimeType, let text): + case .resource(let uri, let mimeType, let text, let title, let annotations): try container.encode("resource", forKey: .type) try container.encode(uri, forKey: .uri) try container.encode(mimeType, forKey: .mimeType) try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(annotations, forKey: .annotations) + case .resourceLink(let uri, let name, let description, let mimeType, let annotations): + try container.encode("resourceLink", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(annotations, forKey: .annotations) } } } @@ -191,7 +227,6 @@ public struct Tool: Hashable, Codable, Sendable { case inputSchema case outputSchema case annotations - case meta = "_meta" } public init(from decoder: Decoder) throws { @@ -203,7 +238,10 @@ public struct Tool: Hashable, Codable, Sendable { outputSchema = try container.decodeIfPresent(Value.self, forKey: .outputSchema) annotations = try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() - meta = try container.decodeIfPresent([String: Value].self, forKey: .meta) + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) } public func encode(to encoder: Encoder) throws { @@ -216,9 +254,13 @@ public struct Tool: Hashable, Codable, Sendable { if !annotations.isEmpty { try container.encode(annotations, forKey: .annotations) } - if meta?.isEmpty == false { - try container.encode(meta, forKey: .meta) - } + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["name", "title", "description", "inputSchema", "outputSchema", "annotations"] } } @@ -269,12 +311,42 @@ public enum CallTool: Method { public struct Result: Hashable, Codable, Sendable { public let content: [Tool.Content] + public let structuredContent: Value? public let isError: Bool? - public init(content: [Tool.Content], isError: Bool? = nil) { + public init( + content: [Tool.Content] = [], + structuredContent: Value? = nil, + isError: Bool? = nil + ) { self.content = content + self.structuredContent = structuredContent self.isError = isError } + + public init( + content: [Tool.Content] = [], + structuredContent: Output, + isError: Bool? = nil + ) throws { + let encoded = try Value(structuredContent) + self.init( + content: content, + structuredContent: Optional.some(encoded), + isError: isError + ) + } + + public init( + structuredContent: Output, + isError: Bool? = nil + ) throws { + try self.init( + content: [], + structuredContent: structuredContent, + isError: isError + ) + } } } diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index 95adef60..218e9a97 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -5,474 +5,476 @@ import Testing @Suite("Tool Tests") struct ToolTests { - @Test("Tool initialization with valid parameters") - func testToolInitialization() throws { - let tool = Tool( - name: "test_tool", - description: "A test tool", - inputSchema: .object([ - "properties": .object([ - "param1": .string("Test parameter") - ]) - ]) - ) - - #expect(tool.name == "test_tool") - #expect(tool.description == "A test tool") - #expect(tool.inputSchema != nil) - #expect(tool.title == nil) - #expect(tool.outputSchema == nil) + @Test("Tool initialization with valid parameters") + func testToolInitialization() throws { + let tool = Tool( + name: "test_tool", + description: "A test tool", + inputSchema: .object([ + "properties": .object([ + "param1": .string("Test parameter") + ]) + ]) + ) + + #expect(tool.name == "test_tool") + #expect(tool.description == "A test tool") + #expect(tool.inputSchema != nil) + #expect(tool.title == nil) + #expect(tool.outputSchema == nil) + } + + @Test("Tool Annotations initialization and properties") + func testToolAnnotationsInitialization() throws { + // Empty annotations + let emptyAnnotations = Tool.Annotations() + #expect(emptyAnnotations.isEmpty) + #expect(emptyAnnotations.title == nil) + #expect(emptyAnnotations.readOnlyHint == nil) + #expect(emptyAnnotations.destructiveHint == nil) + #expect(emptyAnnotations.idempotentHint == nil) + #expect(emptyAnnotations.openWorldHint == nil) + + // Full annotations + let fullAnnotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!fullAnnotations.isEmpty) + #expect(fullAnnotations.title == "Test Tool") + #expect(fullAnnotations.readOnlyHint == true) + #expect(fullAnnotations.destructiveHint == false) + #expect(fullAnnotations.idempotentHint == true) + #expect(fullAnnotations.openWorldHint == false) + + // Partial annotations - should not be empty + let partialAnnotations = Tool.Annotations(title: "Partial Test") + #expect(!partialAnnotations.isEmpty) + #expect(partialAnnotations.title == "Partial Test") + + // Initialize with nil literal + let nilAnnotations: Tool.Annotations = nil + #expect(nilAnnotations.isEmpty) + } + + @Test("Tool Annotations encoding and decoding") + func testToolAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!annotations.isEmpty) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(annotations) + let decoded = try decoder.decode(Tool.Annotations.self, from: data) + + #expect(decoded.title == annotations.title) + #expect(decoded.readOnlyHint == annotations.readOnlyHint) + #expect(decoded.destructiveHint == annotations.destructiveHint) + #expect(decoded.idempotentHint == annotations.idempotentHint) + #expect(decoded.openWorldHint == annotations.openWorldHint) + + // Test that empty annotations are encoded as expected + let emptyAnnotations = Tool.Annotations() + let emptyData = try encoder.encode(emptyAnnotations) + let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData) + + #expect(decodedEmpty.isEmpty) + } + + @Test("Tool with annotations encoding and decoding") + func testToolWithAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Calculator", + destructiveHint: false + ) + + let tool = Tool( + name: "calculate", + description: "Performs calculations", + inputSchema: .object([ + "properties": .object([ + "expression": .string("Mathematical expression to evaluate") + ]) + ]), + annotations: annotations + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.annotations.title == annotations.title) + #expect(decoded.annotations.destructiveHint == annotations.destructiveHint) + + // Verify that the annotations field is properly included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) + #expect(jsonString.contains("\"title\":\"Calculator\"")) + } + + @Test("Tool with empty annotations") + func testToolWithEmptyAnnotations() throws { + var tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: [:] + ) + + do { + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) } - @Test("Tool Annotations initialization and properties") - func testToolAnnotationsInitialization() throws { - // Empty annotations - let emptyAnnotations = Tool.Annotations() - #expect(emptyAnnotations.isEmpty) - #expect(emptyAnnotations.title == nil) - #expect(emptyAnnotations.readOnlyHint == nil) - #expect(emptyAnnotations.destructiveHint == nil) - #expect(emptyAnnotations.idempotentHint == nil) - #expect(emptyAnnotations.openWorldHint == nil) - - // Full annotations - let fullAnnotations = Tool.Annotations( - title: "Test Tool", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false - ) - - #expect(!fullAnnotations.isEmpty) - #expect(fullAnnotations.title == "Test Tool") - #expect(fullAnnotations.readOnlyHint == true) - #expect(fullAnnotations.destructiveHint == false) - #expect(fullAnnotations.idempotentHint == true) - #expect(fullAnnotations.openWorldHint == false) - - // Partial annotations - should not be empty - let partialAnnotations = Tool.Annotations(title: "Partial Test") - #expect(!partialAnnotations.isEmpty) - #expect(partialAnnotations.title == "Partial Test") - - // Initialize with nil literal - let nilAnnotations: Tool.Annotations = nil - #expect(nilAnnotations.isEmpty) - } - - @Test("Tool Annotations encoding and decoding") - func testToolAnnotationsEncodingDecoding() throws { - let annotations = Tool.Annotations( - title: "Test Tool", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false - ) - - #expect(!annotations.isEmpty) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(annotations) - let decoded = try decoder.decode(Tool.Annotations.self, from: data) - - #expect(decoded.title == annotations.title) - #expect(decoded.readOnlyHint == annotations.readOnlyHint) - #expect(decoded.destructiveHint == annotations.destructiveHint) - #expect(decoded.idempotentHint == annotations.idempotentHint) - #expect(decoded.openWorldHint == annotations.openWorldHint) - - // Test that empty annotations are encoded as expected - let emptyAnnotations = Tool.Annotations() - let emptyData = try encoder.encode(emptyAnnotations) - let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData) - - #expect(decodedEmpty.isEmpty) - } - - @Test("Tool with annotations encoding and decoding") - func testToolWithAnnotationsEncodingDecoding() throws { - let annotations = Tool.Annotations( - title: "Calculator", - destructiveHint: false - ) - - let tool = Tool( - name: "calculate", - description: "Performs calculations", - inputSchema: .object([ - "properties": .object([ - "expression": .string("Mathematical expression to evaluate") - ]) - ]), - annotations: annotations - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.name == tool.name) - #expect(decoded.description == tool.description) - #expect(decoded.annotations.title == annotations.title) - #expect(decoded.annotations.destructiveHint == annotations.destructiveHint) - - // Verify that the annotations field is properly included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(jsonString.contains("\"annotations\"")) - #expect(jsonString.contains("\"title\":\"Calculator\"")) - } - - @Test("Tool with empty annotations") - func testToolWithEmptyAnnotations() throws { - var tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: [:] - ) - - do { - #expect(tool.annotations.isEmpty) - - let encoder = JSONEncoder() - let data = try encoder.encode(tool) - - // Verify that empty annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(!jsonString.contains("\"annotations\"")) - } - - do { - tool.annotations.title = "Test" - - #expect(!tool.annotations.isEmpty) - - let encoder = JSONEncoder() - let data = try encoder.encode(tool) - - // Verify that empty annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(jsonString.contains("\"annotations\"")) - } - } - - @Test("Tool with nil literal annotations") - func testToolWithNilLiteralAnnotations() throws { - let tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: [:], - annotations: nil - ) - - #expect(tool.annotations.isEmpty) - - let encoder = JSONEncoder() - let data = try encoder.encode(tool) + do { + tool.annotations.title = "Test" - // Verify that nil literal annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(!jsonString.contains("\"annotations\"")) - } + #expect(!tool.annotations.isEmpty) - @Test("Tool encoding and decoding") - func testToolEncodingDecoding() throws { - let tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: .object([ - "properties": .object([ - "param1": .string("String parameter"), - "param2": .int(42), - ]) - ]) - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.name == tool.name) - #expect(decoded.description == tool.description) - #expect(decoded.inputSchema == tool.inputSchema) - } + let encoder = JSONEncoder() + let data = try encoder.encode(tool) - @Test("Tool encoding and decoding with title and output schema") - func testToolEncodingDecodingWithTitleAndOutputSchema() throws { - let tool = Tool( - name: "test_tool", - title: "Readable Test Tool", - description: "Test tool description", - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "param1": .string("String parameter") - ]) - ]), - outputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "result": .string("String result") - ]) - ]) - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.title == tool.title) - #expect(decoded.outputSchema == tool.outputSchema) - - let jsonString = String(decoding: data, as: UTF8.self) - #expect(jsonString.contains("\"title\":\"Readable Test Tool\"")) - #expect(jsonString.contains("\"outputSchema\"")) + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) } - - @Test("Text content encoding and decoding") - func testToolContentTextEncoding() throws { - let content = Tool.Content.text("Hello, world!") - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .text(let text) = decoded { - #expect(text == "Hello, world!") - } else { - #expect(Bool(false), "Expected text content") - } + } + + @Test("Tool with nil literal annotations") + func testToolWithNilLiteralAnnotations() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: [:], + annotations: nil + ) + + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that nil literal annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) + } + + @Test("Tool encoding and decoding") + func testToolEncodingDecoding() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: .object([ + "properties": .object([ + "param1": .string("String parameter"), + "param2": .int(42), + ]) + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.inputSchema == tool.inputSchema) + } + + @Test("Tool encoding and decoding with title and output schema") + func testToolEncodingDecodingWithTitleAndOutputSchema() throws { + let tool = Tool( + name: "test_tool", + title: "Readable Test Tool", + description: "Test tool description", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "param1": .string("String parameter") + ]), + ]), + outputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "result": .string("String result") + ]), + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.title == tool.title) + #expect(decoded.outputSchema == tool.outputSchema) + + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString.contains("\"title\":\"Readable Test Tool\"")) + #expect(jsonString.contains("\"outputSchema\"")) + } + + @Test("Text content encoding and decoding") + func testToolContentTextEncoding() throws { + let content = Tool.Content.text("Hello, world!") + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .text(let text) = decoded { + #expect(text == "Hello, world!") + } else { + #expect(Bool(false), "Expected text content") } - - @Test("Image content encoding and decoding") - func testToolContentImageEncoding() throws { - let content = Tool.Content.image( - data: "base64data", - mimeType: "image/png", - metadata: ["width": "100", "height": "100"] - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .image(let data, let mimeType, let metadata) = decoded { - #expect(data == "base64data") - #expect(mimeType == "image/png") - #expect(metadata?["width"] == "100") - #expect(metadata?["height"] == "100") - } else { - #expect(Bool(false), "Expected image content") - } + } + + @Test("Image content encoding and decoding") + func testToolContentImageEncoding() throws { + let content = Tool.Content.image( + data: "base64data", + mimeType: "image/png", + metadata: ["width": "100", "height": "100"] + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .image(let data, let mimeType, let metadata) = decoded { + #expect(data == "base64data") + #expect(mimeType == "image/png") + #expect(metadata?["width"] == "100") + #expect(metadata?["height"] == "100") + } else { + #expect(Bool(false), "Expected image content") } - - @Test("Resource content encoding and decoding") - func testToolContentResourceEncoding() throws { - let content = Tool.Content.resource( - uri: "file://test.txt", - mimeType: "text/plain", - text: "Sample text" - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .resource(let uri, let mimeType, let text) = decoded { - #expect(uri == "file://test.txt") - #expect(mimeType == "text/plain") - #expect(text == "Sample text") - } else { - #expect(Bool(false), "Expected resource content") - } + } + + @Test("Resource content encoding and decoding") + func testToolContentResourceEncoding() throws { + let content = Tool.Content.resource( + uri: "file://test.txt", + mimeType: "text/plain", + text: "Sample text" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .resource(let uri, let mimeType, let text, let title, let annotations) = decoded { + #expect(uri == "file://test.txt") + #expect(mimeType == "text/plain") + #expect(text == "Sample text") + #expect(title == nil) + #expect(annotations == nil) + } else { + #expect(Bool(false), "Expected resource content") } - - @Test("Audio content encoding and decoding") - func testToolContentAudioEncoding() throws { - let content = Tool.Content.audio( - data: "base64audiodata", - mimeType: "audio/wav" - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .audio(let data, let mimeType) = decoded { - #expect(data == "base64audiodata") - #expect(mimeType == "audio/wav") - } else { - #expect(Bool(false), "Expected audio content") - } + } + + @Test("Audio content encoding and decoding") + func testToolContentAudioEncoding() throws { + let content = Tool.Content.audio( + data: "base64audiodata", + mimeType: "audio/wav" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .audio(let data, let mimeType) = decoded { + #expect(data == "base64audiodata") + #expect(mimeType == "audio/wav") + } else { + #expect(Bool(false), "Expected audio content") } - - @Test("ListTools parameters validation") - func testListToolsParameters() throws { - let params = ListTools.Parameters(cursor: "next_page") - #expect(params.cursor == "next_page") - - let emptyParams = ListTools.Parameters() - #expect(emptyParams.cursor == nil) + } + + @Test("ListTools parameters validation") + func testListToolsParameters() throws { + let params = ListTools.Parameters(cursor: "next_page") + #expect(params.cursor == "next_page") + + let emptyParams = ListTools.Parameters() + #expect(emptyParams.cursor == nil) + } + + @Test("ListTools request decoding with omitted params") + func testListToolsRequestDecodingWithOmittedParams() throws { + // Test decoding when params field is omitted + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","method":"tools/list"} + """ + let data = jsonString.data(using: .utf8)! + + let decoder = JSONDecoder() + let decoded = try decoder.decode(Request.self, from: data) + + #expect(decoded.id == "test-id") + #expect(decoded.method == ListTools.name) + } + + @Test("ListTools request decoding with null params") + func testListToolsRequestDecodingWithNullParams() throws { + // Test decoding when params field is null + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","method":"tools/list","params":null} + """ + let data = jsonString.data(using: .utf8)! + + let decoder = JSONDecoder() + let decoded = try decoder.decode(Request.self, from: data) + + #expect(decoded.id == "test-id") + #expect(decoded.method == ListTools.name) + } + + @Test("ListTools result validation") + func testListToolsResult() throws { + let tools = [ + Tool(name: "tool1", description: "First tool", inputSchema: [:]), + Tool(name: "tool2", description: "Second tool", inputSchema: [:]), + ] + + let result = ListTools.Result(tools: tools, nextCursor: "next_page") + #expect(result.tools.count == 2) + #expect(result.tools[0].name == "tool1") + #expect(result.tools[1].name == "tool2") + #expect(result.nextCursor == "next_page") + } + + @Test("CallTool parameters validation") + func testCallToolParameters() throws { + let arguments: [String: Value] = [ + "param1": .string("value1"), + "param2": .int(42), + ] + + let params = CallTool.Parameters(name: "test_tool", arguments: arguments) + #expect(params.name == "test_tool") + #expect(params.arguments?["param1"] == .string("value1")) + #expect(params.arguments?["param2"] == .int(42)) + } + + @Test("CallTool success result validation") + func testCallToolResult() throws { + let content = [ + Tool.Content.text("Result 1"), + Tool.Content.text("Result 2"), + ] + + let result = CallTool.Result(content: content) + #expect(result.content.count == 2) + #expect(result.isError == nil) + + if case .text(let text) = result.content[0] { + #expect(text == "Result 1") + } else { + #expect(Bool(false), "Expected text content") } - - @Test("ListTools request decoding with omitted params") - func testListToolsRequestDecodingWithOmittedParams() throws { - // Test decoding when params field is omitted - let jsonString = """ - {"jsonrpc":"2.0","id":"test-id","method":"tools/list"} - """ - let data = jsonString.data(using: .utf8)! - - let decoder = JSONDecoder() - let decoded = try decoder.decode(Request.self, from: data) - - #expect(decoded.id == "test-id") - #expect(decoded.method == ListTools.name) + } + + @Test("CallTool error result validation") + func testCallToolErrorResult() throws { + let errorContent = [Tool.Content.text("Error message")] + let errorResult = CallTool.Result(content: errorContent, isError: true) + #expect(errorResult.content.count == 1) + #expect(errorResult.isError == true) + + if case .text(let text) = errorResult.content[0] { + #expect(text == "Error message") + } else { + #expect(Bool(false), "Expected error text content") } - - @Test("ListTools request decoding with null params") - func testListToolsRequestDecodingWithNullParams() throws { - // Test decoding when params field is null - let jsonString = """ - {"jsonrpc":"2.0","id":"test-id","method":"tools/list","params":null} - """ - let data = jsonString.data(using: .utf8)! - - let decoder = JSONDecoder() - let decoded = try decoder.decode(Request.self, from: data) - - #expect(decoded.id == "test-id") - #expect(decoded.method == ListTools.name) + } + + @Test("ToolListChanged notification name validation") + func testToolListChangedNotification() throws { + #expect(ToolListChangedNotification.name == "notifications/tools/list_changed") + } + + @Test("ListTools handler invocation without params") + func testListToolsHandlerWithoutParams() async throws { + let jsonString = """ + {"jsonrpc":"2.0","id":1,"method":"tools/list"} + """ + let jsonData = jsonString.data(using: .utf8)! + + let anyRequest = try JSONDecoder().decode(AnyRequest.self, from: jsonData) + + let handler = TypedRequestHandler { request in + #expect(request.method == ListTools.name) + #expect(request.id == 1) + #expect(request.params.cursor == nil) + + let testTool = Tool( + name: "test_tool", + description: "Test tool for verification", + inputSchema: [:] + ) + return ListTools.response(id: request.id, result: ListTools.Result(tools: [testTool])) } - @Test("ListTools result validation") - func testListToolsResult() throws { - let tools = [ - Tool(name: "tool1", description: "First tool", inputSchema: [:]), - Tool(name: "tool2", description: "Second tool", inputSchema: [:]), - ] - - let result = ListTools.Result(tools: tools, nextCursor: "next_page") - #expect(result.tools.count == 2) - #expect(result.tools[0].name == "tool1") - #expect(result.tools[1].name == "tool2") - #expect(result.nextCursor == "next_page") - } + let response = try await handler(anyRequest) - @Test("CallTool parameters validation") - func testCallToolParameters() throws { - let arguments: [String: Value] = [ - "param1": .string("value1"), - "param2": .int(42), - ] - - let params = CallTool.Parameters(name: "test_tool", arguments: arguments) - #expect(params.name == "test_tool") - #expect(params.arguments?["param1"] == .string("value1")) - #expect(params.arguments?["param2"] == .int(42)) - } + if case .success(let value) = response.result { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(value) + let result = try decoder.decode(ListTools.Result.self, from: data) - @Test("CallTool success result validation") - func testCallToolResult() throws { - let content = [ - Tool.Content.text("Result 1"), - Tool.Content.text("Result 2"), - ] - - let result = CallTool.Result(content: content) - #expect(result.content.count == 2) - #expect(result.isError == nil) - - if case .text(let text) = result.content[0] { - #expect(text == "Result 1") - } else { - #expect(Bool(false), "Expected text content") - } + #expect(result.tools.count == 1) + #expect(result.tools[0].name == "test_tool") + } else { + #expect(Bool(false), "Expected success result") } + } +} - @Test("CallTool error result validation") - func testCallToolErrorResult() throws { - let errorContent = [Tool.Content.text("Error message")] - let errorResult = CallTool.Result(content: errorContent, isError: true) - #expect(errorResult.content.count == 1) - #expect(errorResult.isError == true) - - if case .text(let text) = errorResult.content[0] { - #expect(text == "Error message") - } else { - #expect(Bool(false), "Expected error text content") - } +@Test("Tool with missing description") +func testToolWithMissingDescription() throws { + let jsonString = """ + { + "name": "test_tool", + "inputSchema": {} } + """ + let jsonData = jsonString.data(using: .utf8)! - @Test("ToolListChanged notification name validation") - func testToolListChangedNotification() throws { - #expect(ToolListChangedNotification.name == "notifications/tools/list_changed") - } + let tool = try JSONDecoder().decode(Tool.self, from: jsonData) - @Test("ListTools handler invocation without params") - func testListToolsHandlerWithoutParams() async throws { - let jsonString = """ - {"jsonrpc":"2.0","id":1,"method":"tools/list"} - """ - let jsonData = jsonString.data(using: .utf8)! - - let anyRequest = try JSONDecoder().decode(AnyRequest.self, from: jsonData) - - let handler = TypedRequestHandler { request in - #expect(request.method == ListTools.name) - #expect(request.id == 1) - #expect(request.params.cursor == nil) - - let testTool = Tool( - name: "test_tool", - description: "Test tool for verification", - inputSchema: [:] - ) - return ListTools.response(id: request.id, result: ListTools.Result(tools: [testTool])) - } - - let response = try await handler(anyRequest) - - if case .success(let value) = response.result { - let encoder = JSONEncoder() - let decoder = JSONDecoder() - let data = try encoder.encode(value) - let result = try decoder.decode(ListTools.Result.self, from: data) - - #expect(result.tools.count == 1) - #expect(result.tools[0].name == "test_tool") - } else { - #expect(Bool(false), "Expected success result") - } - } + #expect(tool.name == "test_tool") + #expect(tool.description == nil) + #expect(tool.inputSchema == [:]) } - - @Test("Tool with missing description") - func testToolWithMissingDescription() throws { - let jsonString = """ - { - "name": "test_tool", - "inputSchema": {} - } - """ - let jsonData = jsonString.data(using: .utf8)! - - let tool = try JSONDecoder().decode(Tool.self, from: jsonData) - - #expect(tool.name == "test_tool") - #expect(tool.description == nil) - #expect(tool.inputSchema == [:]) - } From dccffafa61441705bb3b2004f9d620eade521587 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Wed, 22 Oct 2025 17:35:15 -0400 Subject: [PATCH 07/18] 4 tab spacing --- .vscode/settings.json | 4 + Sources/MCP/Base/GeneralFields.swift | 6 +- Sources/MCP/Server/Resources.swift | 500 +++---- Sources/MCP/Server/Server.swift | 7 +- Sources/MCP/Server/Tools.swift | 595 ++++---- Tests/MCPTests/ClientTests.swift | 1420 ++++++++++---------- Tests/MCPTests/GeneralFieldsTests.swift | 336 ++--- Tests/MCPTests/Helpers/TaskCollector.swift | 14 +- Tests/MCPTests/ToolTests.swift | 874 ++++++------ 9 files changed, 1882 insertions(+), 1874 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e4ad6b15..fddc3420 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,9 @@ }, "[github-actions-workflow]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[swift]": { + "editor.insertSpaces": true, + "editor.tabSize": 4 } } diff --git a/Sources/MCP/Base/GeneralFields.swift b/Sources/MCP/Base/GeneralFields.swift index da5993f3..52cb110f 100644 --- a/Sources/MCP/Base/GeneralFields.swift +++ b/Sources/MCP/Base/GeneralFields.swift @@ -184,12 +184,12 @@ extension MetaFields: Codable { } } -private extension Character { - var isLetter: Bool { +extension Character { + fileprivate var isLetter: Bool { unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } } - var isNumber: Bool { + fileprivate var isNumber: Bool { unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } } } diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index c77f4b8b..d518a508 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -8,220 +8,220 @@ import Foundation /// /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/ public struct Resource: Hashable, Codable, Sendable { - /// The resource name - public var name: String - /// The resource URI - public var uri: String - /// The resource description - public var description: String? - /// The resource MIME type - public var mimeType: String? - /// The resource metadata - public var metadata: [String: String]? - /// General MCP fields such as `_meta`. - public var general: GeneralFields - - public init( - name: String, - uri: String, - description: String? = nil, - mimeType: String? = nil, - metadata: [String: String]? = nil, - general: GeneralFields = .init() - ) { - self.name = name - self.uri = uri - self.description = description - self.mimeType = mimeType - self.metadata = metadata - self.general = general - } - - private enum CodingKeys: String, CodingKey { - case name - case uri - case description - case mimeType - case metadata - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - uri = try container.decode(String.self, forKey: .uri) - description = try container.decodeIfPresent(String.self, forKey: .description) - mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) - metadata = try container.decodeIfPresent([String: String].self, forKey: .metadata) - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(uri, forKey: .uri) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(mimeType, forKey: .mimeType) - try container.encodeIfPresent(metadata, forKey: .metadata) - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["name", "uri", "description", "mimeType", "metadata"] - } - - /// Content of a resource. - public struct Content: Hashable, Codable, Sendable { + /// The resource name + public var name: String /// The resource URI - public let uri: String + public var uri: String + /// The resource description + public var description: String? /// The resource MIME type - public let mimeType: String? - /// The resource text content - public let text: String? - /// The resource binary content - public let blob: String? + public var mimeType: String? + /// The resource metadata + public var metadata: [String: String]? /// General MCP fields such as `_meta`. public var general: GeneralFields - public static func text( - _ content: String, - uri: String, - mimeType: String? = nil, - general: GeneralFields = .init() - ) -> Self { - .init(uri: uri, mimeType: mimeType, text: content, general: general) - } - - public static func binary( - _ data: Data, - uri: String, - mimeType: String? = nil, - general: GeneralFields = .init() - ) -> Self { - .init( - uri: uri, - mimeType: mimeType, - blob: data.base64EncodedString(), - general: general - ) - } - - private init( - uri: String, - mimeType: String? = nil, - text: String? = nil, - general: GeneralFields = .init() - ) { - self.uri = uri - self.mimeType = mimeType - self.text = text - self.blob = nil - self.general = general - } - - private init( - uri: String, - mimeType: String? = nil, - blob: String, - general: GeneralFields = .init() + public init( + name: String, + uri: String, + description: String? = nil, + mimeType: String? = nil, + metadata: [String: String]? = nil, + general: GeneralFields = .init() ) { - self.uri = uri - self.mimeType = mimeType - self.text = nil - self.blob = blob - self.general = general + self.name = name + self.uri = uri + self.description = description + self.mimeType = mimeType + self.metadata = metadata + self.general = general } private enum CodingKeys: String, CodingKey { - case uri - case mimeType - case text - case blob + case name + case uri + case description + case mimeType + case metadata } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - uri = try container.decode(String.self, forKey: .uri) - mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) - text = try container.decodeIfPresent(String.self, forKey: .text) - blob = try container.decodeIfPresent(String.self, forKey: .blob) - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + uri = try container.decode(String.self, forKey: .uri) + description = try container.decodeIfPresent(String.self, forKey: .description) + mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + metadata = try container.decodeIfPresent([String: String].self, forKey: .metadata) + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) } public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(uri, forKey: .uri) - try container.encodeIfPresent(mimeType, forKey: .mimeType) - try container.encodeIfPresent(text, forKey: .text) - try container.encodeIfPresent(blob, forKey: .blob) - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(uri, forKey: .uri) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) } private static var reservedGeneralFieldNames: Set { - ["uri", "mimeType", "text", "blob"] + ["name", "uri", "description", "mimeType", "metadata"] } - } - /// A resource template. - public struct Template: Hashable, Codable, Sendable { - /// The URI template pattern - public var uriTemplate: String - /// The template name - public var name: String - /// The template description - public var description: String? - /// The resource MIME type - public var mimeType: String? - - public init( - uriTemplate: String, - name: String, - description: String? = nil, - mimeType: String? = nil - ) { - self.uriTemplate = uriTemplate - self.name = name - self.description = description - self.mimeType = mimeType - } - } - - // A resource annotation. - public struct Annotations: Hashable, Codable, Sendable { - /// The intended audience for this resource. - public enum Audience: String, Hashable, Codable, Sendable { - /// Content intended for end users. - case user = "user" - /// Content intended for AI assistants. - case assistant = "assistant" + /// Content of a resource. + public struct Content: Hashable, Codable, Sendable { + /// The resource URI + public let uri: String + /// The resource MIME type + public let mimeType: String? + /// The resource text content + public let text: String? + /// The resource binary content + public let blob: String? + /// General MCP fields such as `_meta`. + public var general: GeneralFields + + public static func text( + _ content: String, + uri: String, + mimeType: String? = nil, + general: GeneralFields = .init() + ) -> Self { + .init(uri: uri, mimeType: mimeType, text: content, general: general) + } + + public static func binary( + _ data: Data, + uri: String, + mimeType: String? = nil, + general: GeneralFields = .init() + ) -> Self { + .init( + uri: uri, + mimeType: mimeType, + blob: data.base64EncodedString(), + general: general + ) + } + + private init( + uri: String, + mimeType: String? = nil, + text: String? = nil, + general: GeneralFields = .init() + ) { + self.uri = uri + self.mimeType = mimeType + self.text = text + self.blob = nil + self.general = general + } + + private init( + uri: String, + mimeType: String? = nil, + blob: String, + general: GeneralFields = .init() + ) { + self.uri = uri + self.mimeType = mimeType + self.text = nil + self.blob = blob + self.general = general + } + + private enum CodingKeys: String, CodingKey { + case uri + case mimeType + case text + case blob + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + uri = try container.decode(String.self, forKey: .uri) + mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + text = try container.decodeIfPresent(String.self, forKey: .text) + blob = try container.decodeIfPresent(String.self, forKey: .blob) + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(uri, forKey: .uri) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(blob, forKey: .blob) + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) + } + + private static var reservedGeneralFieldNames: Set { + ["uri", "mimeType", "text", "blob"] + } } - /// An array indicating the intended audience(s) for this resource. For example, `[.user, .assistant]` indicates content useful for both. - public let audience: [Audience] - /// A number from 0.0 to 1.0 indicating the importance of this resource. A value of 1 means “most important” (effectively required), while 0 means “least important”. - public let priority: Double? - /// An ISO 8601 formatted timestamp indicating when the resource was last modified (e.g., "2025-01-12T15:00:58Z"). - public let lastModified: String + /// A resource template. + public struct Template: Hashable, Codable, Sendable { + /// The URI template pattern + public var uriTemplate: String + /// The template name + public var name: String + /// The template description + public var description: String? + /// The resource MIME type + public var mimeType: String? + + public init( + uriTemplate: String, + name: String, + description: String? = nil, + mimeType: String? = nil + ) { + self.uriTemplate = uriTemplate + self.name = name + self.description = description + self.mimeType = mimeType + } + } - public init( - audience: [Audience], - priority: Double? = nil, - lastModified: String - ) { - self.audience = audience - self.priority = priority - self.lastModified = lastModified + // A resource annotation. + public struct Annotations: Hashable, Codable, Sendable { + /// The intended audience for this resource. + public enum Audience: String, Hashable, Codable, Sendable { + /// Content intended for end users. + case user = "user" + /// Content intended for AI assistants. + case assistant = "assistant" + } + + /// An array indicating the intended audience(s) for this resource. For example, `[.user, .assistant]` indicates content useful for both. + public let audience: [Audience] + /// A number from 0.0 to 1.0 indicating the importance of this resource. A value of 1 means “most important” (effectively required), while 0 means “least important”. + public let priority: Double? + /// An ISO 8601 formatted timestamp indicating when the resource was last modified (e.g., "2025-01-12T15:00:58Z"). + public let lastModified: String + + public init( + audience: [Audience], + priority: Double? = nil, + lastModified: String + ) { + self.audience = audience + self.priority = priority + self.lastModified = lastModified + } } - } } // MARK: - @@ -229,116 +229,116 @@ public struct Resource: Hashable, Codable, Sendable { /// To discover available resources, clients send a `resources/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#listing-resources public enum ListResources: Method { - public static let name: String = "resources/list" + public static let name: String = "resources/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? - public init() { - self.cursor = nil - } + public init() { + self.cursor = nil + } - public init(cursor: String) { - self.cursor = cursor + public init(cursor: String) { + self.cursor = cursor + } } - } - public struct Result: Hashable, Codable, Sendable { - public let resources: [Resource] - public let nextCursor: String? + public struct Result: Hashable, Codable, Sendable { + public let resources: [Resource] + public let nextCursor: String? - public init(resources: [Resource], nextCursor: String? = nil) { - self.resources = resources - self.nextCursor = nextCursor + public init(resources: [Resource], nextCursor: String? = nil) { + self.resources = resources + self.nextCursor = nextCursor + } } - } } /// To retrieve resource contents, clients send a `resources/read` request: /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#reading-resources public enum ReadResource: Method { - public static let name: String = "resources/read" + public static let name: String = "resources/read" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String - public init(uri: String) { - self.uri = uri + public init(uri: String) { + self.uri = uri + } } - } - public struct Result: Hashable, Codable, Sendable { - public let contents: [Resource.Content] + public struct Result: Hashable, Codable, Sendable { + public let contents: [Resource.Content] - public init(contents: [Resource.Content]) { - self.contents = contents + public init(contents: [Resource.Content]) { + self.contents = contents + } } - } } /// To discover available resource templates, clients send a `resources/templates/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates public enum ListResourceTemplates: Method { - public static let name: String = "resources/templates/list" + public static let name: String = "resources/templates/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? - public init() { - self.cursor = nil - } + public init() { + self.cursor = nil + } - public init(cursor: String) { - self.cursor = cursor + public init(cursor: String) { + self.cursor = cursor + } } - } - public struct Result: Hashable, Codable, Sendable { - public let templates: [Resource.Template] - public let nextCursor: String? + public struct Result: Hashable, Codable, Sendable { + public let templates: [Resource.Template] + public let nextCursor: String? - public init(templates: [Resource.Template], nextCursor: String? = nil) { - self.templates = templates - self.nextCursor = nextCursor - } + public init(templates: [Resource.Template], nextCursor: String? = nil) { + self.templates = templates + self.nextCursor = nextCursor + } - private enum CodingKeys: String, CodingKey { - case templates = "resourceTemplates" - case nextCursor + private enum CodingKeys: String, CodingKey { + case templates = "resourceTemplates" + case nextCursor + } } - } } /// When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification public struct ResourceListChangedNotification: Notification { - public static let name: String = "notifications/resources/list_changed" + public static let name: String = "notifications/resources/list_changed" - public typealias Parameters = Empty + public typealias Parameters = Empty } /// Clients can subscribe to specific resources and receive notifications when they change. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions public enum ResourceSubscribe: Method { - public static let name: String = "resources/subscribe" + public static let name: String = "resources/subscribe" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String - } + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String + } - public typealias Result = Empty + public typealias Result = Empty } /// When a resource changes, servers that declared the updated capability SHOULD send a notification to subscribed clients. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions public struct ResourceUpdatedNotification: Notification { - public static let name: String = "notifications/resources/updated" + public static let name: String = "notifications/resources/updated" - public struct Parameters: Hashable, Codable, Sendable { - public let uri: String + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String - public init(uri: String) { - self.uri = uri + public init(uri: String) { + self.uri = uri + } } - } } diff --git a/Sources/MCP/Server/Server.swift b/Sources/MCP/Server/Server.swift index 6ba1e27b..0fe625c7 100644 --- a/Sources/MCP/Server/Server.swift +++ b/Sources/MCP/Server/Server.swift @@ -130,16 +130,15 @@ public actor Server { public nonisolated var version: String { serverInfo.version } /// Instructions describing how to use the server and its features /// - /// This can be used by clients to improve the LLM's understanding of - /// available tools, resources, etc. - /// It can be thought of like a "hint" to the model. + /// This can be used by clients to improve the LLM's understanding of + /// available tools, resources, etc. + /// It can be thought of like a "hint" to the model. /// For example, this information MAY be added to the system prompt. public nonisolated let instructions: String? /// The server capabilities public var capabilities: Capabilities /// The server configuration public var configuration: Configuration - /// Request handlers private var methodHandlers: [String: RequestHandlerBox] = [:] diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 60e60852..4616d0fd 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -9,259 +9,260 @@ import Foundation /// /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/server/tools public struct Tool: Hashable, Codable, Sendable { - /// The tool name - public let name: String - /// The human-readable name of the tool for display purposes. - public let title: String? - /// The tool description - public let description: String? - /// The tool input schema - public let inputSchema: Value - /// The tool output schema, defining expected output structure - public let outputSchema: Value? - /// General MCP fields (e.g. `_meta`). - public var general: GeneralFields - - /// Annotations that provide display-facing and operational information for a Tool. - /// - /// - Note: All properties in `ToolAnnotations` are **hints**. - /// They are not guaranteed to provide a faithful description of - /// tool behavior (including descriptive properties like `title`). - /// - /// Clients should never make tool use decisions based on `ToolAnnotations` - /// received from untrusted servers. - public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral { - /// A human-readable title for the tool - public var title: String? - - /// If true, the tool may perform destructive updates to its environment. - /// If false, the tool performs only additive updates. - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// When unspecified, the implicit default is `true`. - public var destructiveHint: Bool? - - /// If true, calling the tool repeatedly with the same arguments - /// will have no additional effect on its environment. - /// (This property is meaningful only when `readOnlyHint == false`) - /// - /// When unspecified, the implicit default is `false`. - public var idempotentHint: Bool? - - /// If true, this tool may interact with an "open world" of external - /// entities. If false, the tool's domain of interaction is closed. - /// For example, the world of a web search tool is open, whereas that - /// of a memory tool is not. + /// The tool name + public let name: String + /// The human-readable name of the tool for display purposes. + public let title: String? + /// The tool description + public let description: String? + /// The tool input schema + public let inputSchema: Value + /// The tool output schema, defining expected output structure + public let outputSchema: Value? + /// General MCP fields (e.g. `_meta`). + public var general: GeneralFields + + /// Annotations that provide display-facing and operational information for a Tool. /// - /// When unspecified, the implicit default is `true`. - public var openWorldHint: Bool? - - /// If true, the tool does not modify its environment. + /// - Note: All properties in `ToolAnnotations` are **hints**. + /// They are not guaranteed to provide a faithful description of + /// tool behavior (including descriptive properties like `title`). /// - /// When unspecified, the implicit default is `false`. - public var readOnlyHint: Bool? - - /// Returns true if all properties are nil - public var isEmpty: Bool { - title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil - && openWorldHint == nil + /// Clients should never make tool use decisions based on `ToolAnnotations` + /// received from untrusted servers. + public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral { + /// A human-readable title for the tool + public var title: String? + + /// If true, the tool may perform destructive updates to its environment. + /// If false, the tool performs only additive updates. + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// When unspecified, the implicit default is `true`. + public var destructiveHint: Bool? + + /// If true, calling the tool repeatedly with the same arguments + /// will have no additional effect on its environment. + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// When unspecified, the implicit default is `false`. + public var idempotentHint: Bool? + + /// If true, this tool may interact with an "open world" of external + /// entities. If false, the tool's domain of interaction is closed. + /// For example, the world of a web search tool is open, whereas that + /// of a memory tool is not. + /// + /// When unspecified, the implicit default is `true`. + public var openWorldHint: Bool? + + /// If true, the tool does not modify its environment. + /// + /// When unspecified, the implicit default is `false`. + public var readOnlyHint: Bool? + + /// Returns true if all properties are nil + public var isEmpty: Bool { + title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil + && openWorldHint == nil + } + + public init( + title: String? = nil, + readOnlyHint: Bool? = nil, + destructiveHint: Bool? = nil, + idempotentHint: Bool? = nil, + openWorldHint: Bool? = nil + ) { + self.title = title + self.readOnlyHint = readOnlyHint + self.destructiveHint = destructiveHint + self.idempotentHint = idempotentHint + self.openWorldHint = openWorldHint + } + + /// Initialize an empty annotations object + public init(nilLiteral: ()) {} } + /// Annotations that provide display-facing and operational information + public var annotations: Annotations + + /// Initialize a tool with a name, description, input schema, and annotations public init( - title: String? = nil, - readOnlyHint: Bool? = nil, - destructiveHint: Bool? = nil, - idempotentHint: Bool? = nil, - openWorldHint: Bool? = nil + name: String, + title: String? = nil, + description: String?, + inputSchema: Value, + annotations: Annotations = nil, + general: GeneralFields = .init(), + outputSchema: Value? = nil ) { - self.title = title - self.readOnlyHint = readOnlyHint - self.destructiveHint = destructiveHint - self.idempotentHint = idempotentHint - self.openWorldHint = openWorldHint + self.name = name + self.title = title + self.description = description + self.inputSchema = inputSchema + self.outputSchema = outputSchema + self.annotations = annotations + self.general = general } - /// Initialize an empty annotations object - public init(nilLiteral: ()) {} - } - - /// Annotations that provide display-facing and operational information - public var annotations: Annotations - - /// Initialize a tool with a name, description, input schema, and annotations - public init( - name: String, - title: String? = nil, - description: String?, - inputSchema: Value, - annotations: Annotations = nil, - general: GeneralFields = .init(), - outputSchema: Value? = nil - ) { - self.name = name - self.title = title - self.description = description - self.inputSchema = inputSchema - self.outputSchema = outputSchema - self.annotations = annotations - self.general = general - } - - /// Content types that can be returned by a tool - public enum Content: Hashable, Codable, Sendable { - /// Text content - case text(String) - /// Image content - case image(data: String, mimeType: String, metadata: [String: String]?) - /// Audio content - case audio(data: String, mimeType: String) - /// Embedded resource content - case resource( - uri: String, mimeType: String, text: String?, title: String? = nil, - annotations: Resource.Annotations? = nil - ) - /// Resource link - case resourceLink( - uri: String, name: String, description: String? = nil, mimeType: String? = nil, - annotations: Resource.Annotations? = nil - ) + /// Content types that can be returned by a tool + public enum Content: Hashable, Codable, Sendable { + /// Text content + case text(String) + /// Image content + case image(data: String, mimeType: String, metadata: [String: String]?) + /// Audio content + case audio(data: String, mimeType: String) + /// Embedded resource content + case resource( + uri: String, mimeType: String, text: String?, title: String? = nil, + annotations: Resource.Annotations? = nil + ) + /// Resource link + case resourceLink( + uri: String, name: String, description: String? = nil, mimeType: String? = nil, + annotations: Resource.Annotations? = nil + ) + + private enum CodingKeys: String, CodingKey { + case type + case text + case image + case resource + case resourceLink + case audio + case uri + case name + case title + case description + case annotations + case mimeType + case data + case metadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "text": + let text = try container.decode(String.self, forKey: .text) + self = .text(text) + case "image": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let metadata = try container.decodeIfPresent( + [String: String].self, forKey: .metadata) + self = .image(data: data, mimeType: mimeType, metadata: metadata) + case "audio": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + self = .audio(data: data, mimeType: mimeType) + case "resource": + let uri = try container.decode(String.self, forKey: .uri) + let title = try container.decodeIfPresent(String.self, forKey: .title) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let text = try container.decodeIfPresent(String.self, forKey: .text) + let annotations = try container.decodeIfPresent( + Resource.Annotations.self, forKey: .annotations) + self = .resource( + uri: uri, mimeType: mimeType, text: text, title: title, annotations: annotations + ) + case "resourceLink": + let uri = try container.decode(String.self, forKey: .uri) + let name = try container.decode(String.self, forKey: .name) + let description = try container.decodeIfPresent(String.self, forKey: .description) + let mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + let annotations = try container.decodeIfPresent( + Resource.Annotations.self, forKey: .annotations) + self = .resourceLink( + uri: uri, name: name, description: description, mimeType: mimeType, + annotations: annotations) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown tool content type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let data, let mimeType, let metadata): + try container.encode("image", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + case .audio(let data, let mimeType): + try container.encode("audio", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + case .resource(let uri, let mimeType, let text, let title, let annotations): + try container.encode("resource", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(annotations, forKey: .annotations) + case .resourceLink(let uri, let name, let description, let mimeType, let annotations): + try container.encode("resourceLink", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(annotations, forKey: .annotations) + } + } + } private enum CodingKeys: String, CodingKey { - case type - case text - case image - case resource - case resourceLink - case audio - case uri - case name - case title - case description - case annotations - case mimeType - case data - case metadata + case name + case title + case description + case inputSchema + case outputSchema + case annotations } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "text": - let text = try container.decode(String.self, forKey: .text) - self = .text(text) - case "image": - let data = try container.decode(String.self, forKey: .data) - let mimeType = try container.decode(String.self, forKey: .mimeType) - let metadata = try container.decodeIfPresent( - [String: String].self, forKey: .metadata) - self = .image(data: data, mimeType: mimeType, metadata: metadata) - case "audio": - let data = try container.decode(String.self, forKey: .data) - let mimeType = try container.decode(String.self, forKey: .mimeType) - self = .audio(data: data, mimeType: mimeType) - case "resource": - let uri = try container.decode(String.self, forKey: .uri) - let title = try container.decodeIfPresent(String.self, forKey: .title) - let mimeType = try container.decode(String.self, forKey: .mimeType) - let text = try container.decodeIfPresent(String.self, forKey: .text) - let annotations = try container.decodeIfPresent( - Resource.Annotations.self, forKey: .annotations) - self = .resource( - uri: uri, mimeType: mimeType, text: text, title: title, annotations: annotations) - case "resourceLink": - let uri = try container.decode(String.self, forKey: .uri) - let name = try container.decode(String.self, forKey: .name) - let description = try container.decodeIfPresent(String.self, forKey: .description) - let mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) - let annotations = try container.decodeIfPresent( - Resource.Annotations.self, forKey: .annotations) - self = .resourceLink( - uri: uri, name: name, description: description, mimeType: mimeType, - annotations: annotations) - default: - throw DecodingError.dataCorruptedError( - forKey: .type, in: container, debugDescription: "Unknown tool content type") - } + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + title = try container.decodeIfPresent(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + inputSchema = try container.decode(Value.self, forKey: .inputSchema) + outputSchema = try container.decodeIfPresent(Value.self, forKey: .outputSchema) + annotations = + try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() + let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) + general = try GeneralFields.decode( + from: dynamic, + reservedKeyNames: Self.reservedGeneralFieldNames) } public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case .text(let text): - try container.encode("text", forKey: .type) - try container.encode(text, forKey: .text) - case .image(let data, let mimeType, let metadata): - try container.encode("image", forKey: .type) - try container.encode(data, forKey: .data) - try container.encode(mimeType, forKey: .mimeType) - try container.encodeIfPresent(metadata, forKey: .metadata) - case .audio(let data, let mimeType): - try container.encode("audio", forKey: .type) - try container.encode(data, forKey: .data) - try container.encode(mimeType, forKey: .mimeType) - case .resource(let uri, let mimeType, let text, let title, let annotations): - try container.encode("resource", forKey: .type) - try container.encode(uri, forKey: .uri) - try container.encode(mimeType, forKey: .mimeType) - try container.encodeIfPresent(text, forKey: .text) - try container.encodeIfPresent(title, forKey: .title) - try container.encodeIfPresent(annotations, forKey: .annotations) - case .resourceLink(let uri, let name, let description, let mimeType, let annotations): - try container.encode("resourceLink", forKey: .type) - try container.encode(uri, forKey: .uri) + var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(mimeType, forKey: .mimeType) - try container.encodeIfPresent(annotations, forKey: .annotations) - } + try container.encodeIfPresent(title, forKey: .title) + try container.encode(description, forKey: .description) + try container.encode(inputSchema, forKey: .inputSchema) + try container.encodeIfPresent(outputSchema, forKey: .outputSchema) + if !annotations.isEmpty { + try container.encode(annotations, forKey: .annotations) + } + try general.encode( + into: encoder, + reservedKeyNames: Self.reservedGeneralFieldNames) } - } - - private enum CodingKeys: String, CodingKey { - case name - case title - case description - case inputSchema - case outputSchema - case annotations - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - title = try container.decodeIfPresent(String.self, forKey: .title) - description = try container.decodeIfPresent(String.self, forKey: .description) - inputSchema = try container.decode(Value.self, forKey: .inputSchema) - outputSchema = try container.decodeIfPresent(Value.self, forKey: .outputSchema) - annotations = - try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encodeIfPresent(title, forKey: .title) - try container.encode(description, forKey: .description) - try container.encode(inputSchema, forKey: .inputSchema) - try container.encodeIfPresent(outputSchema, forKey: .outputSchema) - if !annotations.isEmpty { - try container.encode(annotations, forKey: .annotations) + + private static var reservedGeneralFieldNames: Set { + ["name", "title", "description", "inputSchema", "outputSchema", "annotations"] } - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["name", "title", "description", "inputSchema", "outputSchema", "annotations"] - } } // MARK: - @@ -269,89 +270,89 @@ public struct Tool: Hashable, Codable, Sendable { /// To discover available tools, clients send a `tools/list` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#listing-tools public enum ListTools: Method { - public static let name = "tools/list" + public static let name = "tools/list" - public struct Parameters: NotRequired, Hashable, Codable, Sendable { - public let cursor: String? + public struct Parameters: NotRequired, Hashable, Codable, Sendable { + public let cursor: String? - public init() { - self.cursor = nil - } + public init() { + self.cursor = nil + } - public init(cursor: String) { - self.cursor = cursor + public init(cursor: String) { + self.cursor = cursor + } } - } - public struct Result: Hashable, Codable, Sendable { - public let tools: [Tool] - public let nextCursor: String? + public struct Result: Hashable, Codable, Sendable { + public let tools: [Tool] + public let nextCursor: String? - public init(tools: [Tool], nextCursor: String? = nil) { - self.tools = tools - self.nextCursor = nextCursor + public init(tools: [Tool], nextCursor: String? = nil) { + self.tools = tools + self.nextCursor = nextCursor + } } - } } /// To call a tool, clients send a `tools/call` request. /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#calling-tools public enum CallTool: Method { - public static let name = "tools/call" - - public struct Parameters: Hashable, Codable, Sendable { - public let name: String - public let arguments: [String: Value]? - - public init(name: String, arguments: [String: Value]? = nil) { - self.name = name - self.arguments = arguments - } - } - - public struct Result: Hashable, Codable, Sendable { - public let content: [Tool.Content] - public let structuredContent: Value? - public let isError: Bool? + public static let name = "tools/call" - public init( - content: [Tool.Content] = [], - structuredContent: Value? = nil, - isError: Bool? = nil - ) { - self.content = content - self.structuredContent = structuredContent - self.isError = isError - } + public struct Parameters: Hashable, Codable, Sendable { + public let name: String + public let arguments: [String: Value]? - public init( - content: [Tool.Content] = [], - structuredContent: Output, - isError: Bool? = nil - ) throws { - let encoded = try Value(structuredContent) - self.init( - content: content, - structuredContent: Optional.some(encoded), - isError: isError - ) + public init(name: String, arguments: [String: Value]? = nil) { + self.name = name + self.arguments = arguments + } } - public init( - structuredContent: Output, - isError: Bool? = nil - ) throws { - try self.init( - content: [], - structuredContent: structuredContent, - isError: isError - ) + public struct Result: Hashable, Codable, Sendable { + public let content: [Tool.Content] + public let structuredContent: Value? + public let isError: Bool? + + public init( + content: [Tool.Content] = [], + structuredContent: Value? = nil, + isError: Bool? = nil + ) { + self.content = content + self.structuredContent = structuredContent + self.isError = isError + } + + public init( + content: [Tool.Content] = [], + structuredContent: Output, + isError: Bool? = nil + ) throws { + let encoded = try Value(structuredContent) + self.init( + content: content, + structuredContent: Optional.some(encoded), + isError: isError + ) + } + + public init( + structuredContent: Output, + isError: Bool? = nil + ) throws { + try self.init( + content: [], + structuredContent: structuredContent, + isError: isError + ) + } } - } } /// When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification: /// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification public struct ToolListChangedNotification: Notification { - public static let name: String = "notifications/tools/list_changed" + public static let name: String = "notifications/tools/list_changed" } diff --git a/Tests/MCPTests/ClientTests.swift b/Tests/MCPTests/ClientTests.swift index a412c8d1..60557dac 100644 --- a/Tests/MCPTests/ClientTests.swift +++ b/Tests/MCPTests/ClientTests.swift @@ -5,778 +5,778 @@ import Testing @Suite("Client Tests") struct ClientTests { - @Test("Client connect and disconnect") - func testClientConnectAndDisconnect() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - #expect(await transport.isConnected == false) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + @Test("Client connect and disconnect") + func testClientConnectAndDisconnect() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + #expect(await transport.isConnected == false) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - let result = try await client.connect(transport: transport) - #expect(await transport.isConnected == true) - #expect(result.protocolVersion == Version.latest) - await client.disconnect() - #expect(await transport.isConnected == false) - initTask.cancel() - } - - @Test( - "Ping request", - .timeLimit(.minutes(1)) - ) - func testClientPing() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Queue a response for the initialize request - try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent - - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - // Create a valid initialize response - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - - try await transport.queue(response: response) - - // Now complete the connect call which will automatically initialize - let result = try await client.connect(transport: transport) - #expect(result.protocolVersion == Version.latest) - #expect(result.serverInfo.name == "TestServer") - #expect(result.serverInfo.version == "1.0") - - // Small delay to ensure message loop is started - try await Task.sleep(for: .milliseconds(10)) - - // Create a task for the ping - let pingTask = Task { - try await client.ping() - } - - // Give it a moment to send the request - try await Task.sleep(for: .milliseconds(10)) - - #expect(await transport.sentMessages.count == 2) // Initialize + Ping - #expect(await transport.sentMessages.last?.contains(Ping.name) == true) - - // Cancel the ping task - pingTask.cancel() + let result = try await client.connect(transport: transport) + #expect(await transport.isConnected == true) + #expect(result.protocolVersion == Version.latest) + await client.disconnect() + #expect(await transport.isConnected == false) + initTask.cancel() } - // Disconnect client to clean up message loop and give time for continuation cleanup - await client.disconnect() - try await Task.sleep(for: .milliseconds(50)) - } - - @Test("Connection failure handling") - func testClientConnectionFailure() async { - let transport = MockTransport() - await transport.setFailConnect(true) - let client = Client(name: "TestClient", version: "1.0") - - do { - try await client.connect(transport: transport) - #expect(Bool(false), "Expected connection to fail") - } catch let error as MCPError { - if case MCPError.transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } - } - - @Test("Send failure handling") - func testClientSendFailure() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + @Test( + "Ping request", + .timeLimit(.minutes(1)) + ) + func testClientPing() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") - // Connect first without failure - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Now set the transport to fail sends - await transport.setFailSend(true) - - do { - try await client.ping() - #expect(Bool(false), "Expected ping to fail") - } catch let error as MCPError { - if case MCPError.transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } + // Queue a response for the initialize request + try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent - await client.disconnect() - } - - @Test("Strict configuration - capabilities check") - func testStrictConfiguration() async throws { - let transport = MockTransport() - let config = Client.Configuration.strict - let client = Client(name: "TestClient", version: "1.0", configuration: config) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + // Create a valid initialize response + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + + try await transport.queue(response: response) + + // Now complete the connect call which will automatically initialize + let result = try await client.connect(transport: transport) + #expect(result.protocolVersion == Version.latest) + #expect(result.serverInfo.name == "TestServer") + #expect(result.serverInfo.version == "1.0") + + // Small delay to ensure message loop is started + try await Task.sleep(for: .milliseconds(10)) + + // Create a task for the ping + let pingTask = Task { + try await client.ping() + } - try await client.connect(transport: transport) + // Give it a moment to send the request + try await Task.sleep(for: .milliseconds(10)) - // Create a task for listPrompts - let promptsTask = Task { - do { - _ = try await client.listPrompts() - #expect(Bool(false), "Expected listPrompts to fail in strict mode") - } catch let error as MCPError { - if case MCPError.methodNotFound = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected methodNotFound error, got \(error)") + #expect(await transport.sentMessages.count == 2) // Initialize + Ping + #expect(await transport.sentMessages.last?.contains(Ping.name) == true) + + // Cancel the ping task + pingTask.cancel() } - } catch { - #expect(Bool(false), "Expected MCP.Error") - } - } - // Give it a short time to execute the task - try await Task.sleep(for: .milliseconds(50)) - - // Cancel the task if it's still running - promptsTask.cancel() - initTask.cancel() - - // Disconnect client - await client.disconnect() - try await Task.sleep(for: .milliseconds(50)) - } - - @Test("Non-strict configuration - capabilities check") - func testNonStrictConfiguration() async throws { - let transport = MockTransport() - let config = Client.Configuration.default - let client = Client(name: "TestClient", version: "1.0", configuration: config) - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } + // Disconnect client to clean up message loop and give time for continuation cleanup + await client.disconnect() + try await Task.sleep(for: .milliseconds(50)) } - try await client.connect(transport: transport) - - // Make sure init task is complete - initTask.cancel() + @Test("Connection failure handling") + func testClientConnectionFailure() async { + let transport = MockTransport() + await transport.setFailConnect(true) + let client = Client(name: "TestClient", version: "1.0") + + do { + try await client.connect(transport: transport) + #expect(Bool(false), "Expected connection to fail") + } catch let error as MCPError { + if case MCPError.transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } - // Wait a bit for any setup to complete - try await Task.sleep(for: .milliseconds(10)) + @Test("Send failure handling") + func testClientSendFailure() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - // Send the listPrompts request and immediately provide an error response - let promptsTask = Task { - do { - // Start the request - try await Task.sleep(for: .seconds(1)) + // Connect first without failure + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() - // Get the last sent message and extract the request ID - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let decodedRequest = try? JSONDecoder().decode( - Request.self, from: data) - { + // Now set the transport to fail sends + await transport.setFailSend(true) - // Create an error response with the same ID - let errorResponse = Response( - id: decodedRequest.id, - error: MCPError.methodNotFound("Test: Prompts capability not available") - ) - try await transport.queue(response: errorResponse) - - // Try the request now that we have a response queued - do { - _ = try await client.listPrompts() - #expect(Bool(false), "Expected listPrompts to fail in non-strict mode") - } catch let error as MCPError { - if case MCPError.methodNotFound = error { - #expect(Bool(true)) + do { + try await client.ping() + #expect(Bool(false), "Expected ping to fail") + } catch let error as MCPError { + if case MCPError.transportError = error { + #expect(Bool(true)) } else { - #expect(Bool(false), "Expected methodNotFound error, got \(error)") + #expect(Bool(false), "Expected transport error, got \(error)") } - } catch { + } catch { #expect(Bool(false), "Expected MCP.Error") - } - } - } catch { - // Ignore task cancellation - if !(error is CancellationError) { - throw error } - } - } - // Wait for the task to complete or timeout - let timeoutTask = Task { - try await Task.sleep(for: .milliseconds(500)) - promptsTask.cancel() + await client.disconnect() } - // Wait for the task to complete - _ = await promptsTask.result - - // Cancel the timeout task - timeoutTask.cancel() - - // Disconnect client - await client.disconnect() - } - - @Test("Batch request - success") - func testBatchRequestSuccess() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + @Test("Strict configuration - capabilities check") + func testStrictConfiguration() async throws { + let transport = MockTransport() + let config = Client.Configuration.strict + let client = Client(name: "TestClient", version: "1.0", configuration: config) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + + try await client.connect(transport: transport) - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) // Allow connection tasks - initTask.cancel() + // Create a task for listPrompts + let promptsTask = Task { + do { + _ = try await client.listPrompts() + #expect(Bool(false), "Expected listPrompts to fail in strict mode") + } catch let error as MCPError { + if case MCPError.methodNotFound = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected methodNotFound error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } - let request1 = Ping.request() - let request2 = Ping.request() - let taskCollector = TaskCollector>() + // Give it a short time to execute the task + try await Task.sleep(for: .milliseconds(50)) - let batchBody: @Sendable (Client.Batch) async throws -> Void = { batch in - await taskCollector.append(try await batch.addRequest(request1)) - await taskCollector.append(try await batch.addRequest(request2)) + // Cancel the task if it's still running + promptsTask.cancel() + initTask.cancel() + + // Disconnect client + await client.disconnect() + try await Task.sleep(for: .milliseconds(50)) } - try await client.withBatch(body: batchBody) - // Check if batch message was sent (after initialize and initialized notification) - let sentMessages = await transport.sentMessages - #expect(sentMessages.count == 3) // Initialize request + Initialized notification + Batch + @Test("Non-strict configuration - capabilities check") + func testNonStrictConfiguration() async throws { + let transport = MockTransport() + let config = Client.Configuration.default + let client = Client(name: "TestClient", version: "1.0", configuration: config) + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - guard let batchData = sentMessages.last?.data(using: .utf8) else { - #expect(Bool(false), "Failed to get batch data") - return - } + try await client.connect(transport: transport) - // Verify the sent batch contains the two requests - let decoder = JSONDecoder() - let sentRequests = try decoder.decode([AnyRequest].self, from: batchData) - #expect(sentRequests.count == 2) - #expect(sentRequests.first?.id == request1.id) - #expect(sentRequests.first?.method == Ping.name) - #expect(sentRequests.last?.id == request2.id) - #expect(sentRequests.last?.method == Ping.name) - - // Prepare batch response - let response1 = Response(id: request1.id, result: .init()) - let response2 = Response(id: request2.id, result: .init()) - let anyResponse1 = try AnyResponse(response1) - let anyResponse2 = try AnyResponse(response2) - - // Queue the batch response - try await transport.queue(batch: [anyResponse1, anyResponse2]) - - let resultTasks = await taskCollector.snapshot() - #expect(resultTasks.count == 2) - guard resultTasks.count == 2 else { - #expect(Bool(false), "Result tasks not created") - return - } + // Make sure init task is complete + initTask.cancel() - let task1 = resultTasks[0] - let task2 = resultTasks[1] - - _ = try await task1.value // Should succeed - _ = try await task2.value // Should succeed - - #expect(Bool(true)) // Reaching here means success - - await client.disconnect() - } - - @Test("Batch request - mixed success/error") - func testBatchRequestMixed() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + // Wait a bit for any setup to complete + try await Task.sleep(for: .milliseconds(10)) + + // Send the listPrompts request and immediately provide an error response + let promptsTask = Task { + do { + // Start the request + try await Task.sleep(for: .seconds(1)) + + // Get the last sent message and extract the request ID + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let decodedRequest = try? JSONDecoder().decode( + Request.self, from: data) + { + + // Create an error response with the same ID + let errorResponse = Response( + id: decodedRequest.id, + error: MCPError.methodNotFound("Test: Prompts capability not available") + ) + try await transport.queue(response: errorResponse) + + // Try the request now that we have a response queued + do { + _ = try await client.listPrompts() + #expect(Bool(false), "Expected listPrompts to fail in non-strict mode") + } catch let error as MCPError { + if case MCPError.methodNotFound = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected methodNotFound error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } + } catch { + // Ignore task cancellation + if !(error is CancellationError) { + throw error + } + } + } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() + // Wait for the task to complete or timeout + let timeoutTask = Task { + try await Task.sleep(for: .milliseconds(500)) + promptsTask.cancel() + } - let request1 = Ping.request() // Success - let request2 = Ping.request() // Error + // Wait for the task to complete + _ = await promptsTask.result - let taskCollector = TaskCollector>() + // Cancel the timeout task + timeoutTask.cancel() - let mixedBody: @Sendable (Client.Batch) async throws -> Void = { batch in - await taskCollector.append(try await batch.addRequest(request1)) - await taskCollector.append(try await batch.addRequest(request2)) - } - try await client.withBatch(body: mixedBody) - - // Check if batch message was sent (after initialize and initialized notification) - #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Batch - - // Prepare batch response (success for 1, error for 2) - let response1 = Response(id: request1.id, result: .init()) - let error = MCPError.internalError("Simulated batch error") - let response2 = Response(id: request2.id, error: error) - let anyResponse1 = try AnyResponse(response1) - let anyResponse2 = try AnyResponse(response2) - - // Queue the batch response - try await transport.queue(batch: [anyResponse1, anyResponse2]) - - // Wait for results and verify - let resultTasks = await taskCollector.snapshot() - #expect(resultTasks.count == 2) - guard resultTasks.count == 2 else { - #expect(Bool(false), "Expected 2 result tasks") - return + // Disconnect client + await client.disconnect() } - let task1 = resultTasks[0] - let task2 = resultTasks[1] - - _ = try await task1.value // Task 1 should succeed - - do { - _ = try await task2.value // Task 2 should fail - #expect(Bool(false), "Task 2 should have thrown an error") - } catch let mcpError as MCPError { - if case .internalError(let message) = mcpError { - #expect(message == "Simulated batch error") - } else { - #expect(Bool(false), "Expected internalError, got \(mcpError)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") - } + @Test("Batch request - success") + func testBatchRequestSuccess() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } - await client.disconnect() - } - - @Test("Batch request - empty") - func testBatchRequestEmpty() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) // Allow connection tasks + initTask.cancel() + + let request1 = Ping.request() + let request2 = Ping.request() + let taskCollector = TaskCollector>() + + let batchBody: @Sendable (Client.Batch) async throws -> Void = { batch in + await taskCollector.append(try await batch.addRequest(request1)) + await taskCollector.append(try await batch.addRequest(request2)) + } + try await client.withBatch(body: batchBody) + + // Check if batch message was sent (after initialize and initialized notification) + let sentMessages = await transport.sentMessages + #expect(sentMessages.count == 3) // Initialize request + Initialized notification + Batch + + guard let batchData = sentMessages.last?.data(using: .utf8) else { + #expect(Bool(false), "Failed to get batch data") + return + } + + // Verify the sent batch contains the two requests + let decoder = JSONDecoder() + let sentRequests = try decoder.decode([AnyRequest].self, from: batchData) + #expect(sentRequests.count == 2) + #expect(sentRequests.first?.id == request1.id) + #expect(sentRequests.first?.method == Ping.name) + #expect(sentRequests.last?.id == request2.id) + #expect(sentRequests.last?.method == Ping.name) + + // Prepare batch response + let response1 = Response(id: request1.id, result: .init()) + let response2 = Response(id: request2.id, result: .init()) + let anyResponse1 = try AnyResponse(response1) + let anyResponse2 = try AnyResponse(response2) + + // Queue the batch response + try await transport.queue(batch: [anyResponse1, anyResponse2]) + + let resultTasks = await taskCollector.snapshot() + #expect(resultTasks.count == 2) + guard resultTasks.count == 2 else { + #expect(Bool(false), "Result tasks not created") + return + } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() + let task1 = resultTasks[0] + let task2 = resultTasks[1] - // Call withBatch but don't add any requests - let emptyBody: @Sendable (Client.Batch) async throws -> Void = { _ in - // No requests added + _ = try await task1.value // Should succeed + _ = try await task2.value // Should succeed + + #expect(Bool(true)) // Reaching here means success + + await client.disconnect() } - try await client.withBatch(body: emptyBody) - - // Check that only initialize message and initialized notification were sent - #expect(await transport.sentMessages.count == 2) // Initialize request + Initialized notification - - await client.disconnect() - } - - @Test("Notify method sends notifications") - func testClientNotify() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } + + @Test("Batch request - mixed success/error") + func testBatchRequestMixed() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + let request1 = Ping.request() // Success + let request2 = Ping.request() // Error + + let taskCollector = TaskCollector>() + + let mixedBody: @Sendable (Client.Batch) async throws -> Void = { batch in + await taskCollector.append(try await batch.addRequest(request1)) + await taskCollector.append(try await batch.addRequest(request2)) + } + try await client.withBatch(body: mixedBody) + + // Check if batch message was sent (after initialize and initialized notification) + #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Batch + + // Prepare batch response (success for 1, error for 2) + let response1 = Response(id: request1.id, result: .init()) + let error = MCPError.internalError("Simulated batch error") + let response2 = Response(id: request2.id, error: error) + let anyResponse1 = try AnyResponse(response1) + let anyResponse2 = try AnyResponse(response2) + + // Queue the batch response + try await transport.queue(batch: [anyResponse1, anyResponse2]) + + // Wait for results and verify + let resultTasks = await taskCollector.snapshot() + #expect(resultTasks.count == 2) + guard resultTasks.count == 2 else { + #expect(Bool(false), "Expected 2 result tasks") + return + } + + let task1 = resultTasks[0] + let task2 = resultTasks[1] + + _ = try await task1.value // Task 1 should succeed + + do { + _ = try await task2.value // Task 2 should fail + #expect(Bool(false), "Task 2 should have thrown an error") + } catch let mcpError as MCPError { + if case .internalError(let message) = mcpError { + #expect(message == "Simulated batch error") + } else { + #expect(Bool(false), "Expected internalError, got \(mcpError)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") + } + + await client.disconnect() } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Create a test notification - let notification = InitializedNotification.message() - try await client.notify(notification) - - // Verify notification was sent (in addition to initialize and initialized notification) - #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Custom notification - - if let sentMessage = await transport.sentMessages.last, - let data = sentMessage.data(using: .utf8) - { - - // Decode as Message - let decoder = JSONDecoder() - do { - let decodedNotification = try decoder.decode( - Message.self, from: data) - #expect(decodedNotification.method == InitializedNotification.name) - } catch { - #expect(Bool(false), "Failed to decode notification: \(error)") - } - } else { - #expect(Bool(false), "No message was sent") + @Test("Batch request - empty") + func testBatchRequestEmpty() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Call withBatch but don't add any requests + let emptyBody: @Sendable (Client.Batch) async throws -> Void = { _ in + // No requests added + } + try await client.withBatch(body: emptyBody) + + // Check that only initialize message and initialized notification were sent + #expect(await transport.sentMessages.count == 2) // Initialize request + Initialized notification + + await client.disconnect() } - await client.disconnect() - } - - @Test("Initialize sends initialized notification") - func testClientInitializeNotification() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Create a task for initialize - let initTask = Task { - // Queue a response for the initialize request - try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent - - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - - // Create a valid initialize response - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - - try await transport.queue(response: response) - - // Now complete the initialize call + @Test("Notify method sends notifications") + func testClientNotify() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + try await client.connect(transport: transport) try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() - // Verify that two messages were sent: initialize request and initialized notification - #expect(await transport.sentMessages.count == 2) + // Create a test notification + let notification = InitializedNotification.message() + try await client.notify(notification) - // Check that the second message is the initialized notification - let notifications = await transport.sentMessages - if notifications.count >= 2 { - let notificationJson = notifications[1] - if let notificationData = notificationJson.data(using: .utf8) { + // Verify notification was sent (in addition to initialize and initialized notification) + #expect(await transport.sentMessages.count == 3) // Initialize request + Initialized notification + Custom notification + + if let sentMessage = await transport.sentMessages.last, + let data = sentMessage.data(using: .utf8) + { + + // Decode as Message + let decoder = JSONDecoder() do { - let decoder = JSONDecoder() - let decodedNotification = try decoder.decode( - Message.self, from: notificationData) - #expect(decodedNotification.method == InitializedNotification.name) + let decodedNotification = try decoder.decode( + Message.self, from: data) + #expect(decodedNotification.method == InitializedNotification.name) } catch { - #expect(Bool(false), "Failed to decode notification: \(error)") + #expect(Bool(false), "Failed to decode notification: \(error)") } - } else { - #expect(Bool(false), "Could not convert notification to data") - } } else { - #expect( - Bool(false), "Expected both initialize request and initialized notification" - ) + #expect(Bool(false), "No message was sent") } - } - } - // Wait with timeout - let timeoutTask = Task { - try await Task.sleep(for: .seconds(1)) - initTask.cancel() + await client.disconnect() } - // Wait for the task to complete - do { - _ = try await initTask.value - } catch is CancellationError { - #expect(Bool(false), "Test timed out") - } catch { - #expect(Bool(false), "Unexpected error: \(error)") - } + @Test("Initialize sends initialized notification") + func testClientInitializeNotification() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Create a task for initialize + let initTask = Task { + // Queue a response for the initialize request + try await Task.sleep(for: .milliseconds(10)) // Wait for request to be sent + + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + + // Create a valid initialize response + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + + try await transport.queue(response: response) + + // Now complete the initialize call + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + + // Verify that two messages were sent: initialize request and initialized notification + #expect(await transport.sentMessages.count == 2) + + // Check that the second message is the initialized notification + let notifications = await transport.sentMessages + if notifications.count >= 2 { + let notificationJson = notifications[1] + if let notificationData = notificationJson.data(using: .utf8) { + do { + let decoder = JSONDecoder() + let decodedNotification = try decoder.decode( + Message.self, from: notificationData) + #expect(decodedNotification.method == InitializedNotification.name) + } catch { + #expect(Bool(false), "Failed to decode notification: \(error)") + } + } else { + #expect(Bool(false), "Could not convert notification to data") + } + } else { + #expect( + Bool(false), "Expected both initialize request and initialized notification" + ) + } + } + } - timeoutTask.cancel() - - await client.disconnect() - } - - @Test("Race condition between send error and response") - func testSendErrorResponseRace() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } - } + // Wait with timeout + let timeoutTask = Task { + try await Task.sleep(for: .seconds(1)) + initTask.cancel() + } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Set up the transport to fail sends from the start - await transport.setFailSend(true) - - // Create a ping request to get the ID - let request = Ping.request() - - // Create a response for the request and queue it immediately - let response = Response(id: request.id, result: .init()) - let anyResponse = try AnyResponse(response) - try await transport.queue(response: anyResponse) - - // Now attempt to send the request - this should fail due to send error - // but the response handler might also try to process the queued response - do { - _ = try await client.ping() - #expect(Bool(false), "Expected send to fail") - } catch let error as MCPError { - if case .transportError = error { - #expect(Bool(true)) - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") - } + // Wait for the task to complete + do { + _ = try await initTask.value + } catch is CancellationError { + #expect(Bool(false), "Test timed out") + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + + timeoutTask.cancel() - // Verify no continuation misuse occurred - // (If it did, the test would have crashed) - - await client.disconnect() - } - - @Test("Race condition between response and send error") - func testResponseSendErrorRace() async throws { - let transport = MockTransport() - let client = Client(name: "TestClient", version: "1.0") - - // Set up a task to handle the initialize response - let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) - { - let response = Initialize.response( - id: request.id, - result: .init( - protocolVersion: Version.latest, - capabilities: .init(), - serverInfo: .init(name: "TestServer", version: "1.0"), - instructions: nil - ) - ) - try await transport.queue(response: response) - } + await client.disconnect() } - try await client.connect(transport: transport) - try await Task.sleep(for: .milliseconds(10)) - initTask.cancel() - - // Create a ping request to get the ID - let request = Ping.request() - - // Create a response for the request and queue it immediately - let response = Response(id: request.id, result: .init()) - let anyResponse = try AnyResponse(response) - try await transport.queue(response: anyResponse) - - // Set up the transport to fail sends - await transport.setFailSend(true) - - // Now attempt to send the request - // The response might be processed before the send error occurs - do { - _ = try await client.ping() - // In this case, the response handler won the race and the request succeeded - #expect(Bool(true), "Response handler won the race - request succeeded") - } catch let error as MCPError { - if case .transportError = error { - // In this case, the send error handler won the race - #expect(Bool(true), "Send error handler won the race - request failed") - } else { - #expect(Bool(false), "Expected transport error, got \(error)") - } - } catch { - #expect(Bool(false), "Expected MCPError, got \(error)") + @Test("Race condition between send error and response") + func testSendErrorResponseRace() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Set up the transport to fail sends from the start + await transport.setFailSend(true) + + // Create a ping request to get the ID + let request = Ping.request() + + // Create a response for the request and queue it immediately + let response = Response(id: request.id, result: .init()) + let anyResponse = try AnyResponse(response) + try await transport.queue(response: anyResponse) + + // Now attempt to send the request - this should fail due to send error + // but the response handler might also try to process the queued response + do { + _ = try await client.ping() + #expect(Bool(false), "Expected send to fail") + } catch let error as MCPError { + if case .transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") + } + + // Verify no continuation misuse occurred + // (If it did, the test would have crashed) + + await client.disconnect() } - // Verify no continuation misuse occurred - // (If it did, the test would have crashed) + @Test("Race condition between response and send error") + func testResponseSendErrorRace() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + // Set up a task to handle the initialize response + let initTask = Task { + try await Task.sleep(for: .milliseconds(10)) + if let lastMessage = await transport.sentMessages.last, + let data = lastMessage.data(using: .utf8), + let request = try? JSONDecoder().decode(Request.self, from: data) + { + let response = Initialize.response( + id: request.id, + result: .init( + protocolVersion: Version.latest, + capabilities: .init(), + serverInfo: .init(name: "TestServer", version: "1.0"), + instructions: nil + ) + ) + try await transport.queue(response: response) + } + } + + try await client.connect(transport: transport) + try await Task.sleep(for: .milliseconds(10)) + initTask.cancel() + + // Create a ping request to get the ID + let request = Ping.request() + + // Create a response for the request and queue it immediately + let response = Response(id: request.id, result: .init()) + let anyResponse = try AnyResponse(response) + try await transport.queue(response: anyResponse) + + // Set up the transport to fail sends + await transport.setFailSend(true) + + // Now attempt to send the request + // The response might be processed before the send error occurs + do { + _ = try await client.ping() + // In this case, the response handler won the race and the request succeeded + #expect(Bool(true), "Response handler won the race - request succeeded") + } catch let error as MCPError { + if case .transportError = error { + // In this case, the send error handler won the race + #expect(Bool(true), "Send error handler won the race - request failed") + } else { + #expect(Bool(false), "Expected transport error, got \(error)") + } + } catch { + #expect(Bool(false), "Expected MCPError, got \(error)") + } + + // Verify no continuation misuse occurred + // (If it did, the test would have crashed) - await client.disconnect() - } + await client.disconnect() + } } diff --git a/Tests/MCPTests/GeneralFieldsTests.swift b/Tests/MCPTests/GeneralFieldsTests.swift index 4b17a5f5..979bd5f5 100644 --- a/Tests/MCPTests/GeneralFieldsTests.swift +++ b/Tests/MCPTests/GeneralFieldsTests.swift @@ -8,171 +8,175 @@ import class Foundation.JSONSerialization @Suite("General Fields") struct GeneralFieldsTests { - private struct Payload: Codable, Hashable, Sendable { - let message: String - } - - private enum TestMethod: Method { - static let name = "test.general" - typealias Parameters = Payload - typealias Result = Payload - } - - @Test("Encoding includes meta and custom fields") - func testEncodingGeneralFields() throws { - let meta = try MetaFields(values: ["vendor.example/request-id": .string("abc123")]) - let general = GeneralFields(meta: meta, additional: ["custom": .int(5)]) - - let request = Request( - id: 42, - method: TestMethod.name, - params: Payload(message: "hello"), - generalFields: general - ) - - let data = try JSONEncoder().encode(request) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["vendor.example/request-id"] as? String == "abc123") - #expect(json?["custom"] as? Int == 5) - } - - @Test("Decoding restores general fields") - func testDecodingGeneralFields() throws { - let payload: [String: Any] = [ - "jsonrpc": "2.0", - "id": 7, - "method": TestMethod.name, - "params": ["message": "hi"], - "_meta": ["vendor.example/session": "s42"], - "custom-data": ["value": 1], - ] - - let data = try JSONSerialization.data(withJSONObject: payload) - let decoded = try JSONDecoder().decode(Request.self, from: data) - - let metaValue = decoded.generalFields.meta?.dictionary["vendor.example/session"] - #expect(metaValue == .string("s42")) - #expect(decoded.generalFields.additional["custom-data"] == .object(["value": .int(1)])) - } - - @Test("Reserved fields are ignored when encoding extras") - func testReservedFieldsIgnored() throws { - let general = GeneralFields( - meta: nil, - additional: ["method": .string("override"), "custom": .bool(true)] - ) - - let request = Request( - id: 1, - method: TestMethod.name, - params: Payload(message: "ping"), - generalFields: general - ) - - let data = try JSONEncoder().encode(request) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - #expect(json?["method"] as? String == TestMethod.name) - #expect(json?["custom"] as? Bool == true) - - let decoded = try JSONDecoder().decode(Request.self, from: data) - #expect(decoded.generalFields.additional["method"] == nil) - #expect(decoded.generalFields.additional["custom"] == .bool(true)) - } - - @Test("Invalid meta key is rejected") - func testInvalidMetaKey() { - #expect(throws: GeneralFieldError.invalidMetaKey("invalid key")) { - _ = try MetaFields(values: ["invalid key": .int(1)]) + private struct Payload: Codable, Hashable, Sendable { + let message: String + } + + private enum TestMethod: Method { + static let name = "test.general" + typealias Parameters = Payload + typealias Result = Payload + } + + @Test("Encoding includes meta and custom fields") + func testEncodingGeneralFields() throws { + let meta = try MetaFields(values: ["vendor.example/request-id": .string("abc123")]) + let general = GeneralFields(meta: meta, additional: ["custom": .int(5)]) + + let request = Request( + id: 42, + method: TestMethod.name, + params: Payload(message: "hello"), + generalFields: general + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/request-id"] as? String == "abc123") + #expect(json?["custom"] as? Int == 5) + } + + @Test("Decoding restores general fields") + func testDecodingGeneralFields() throws { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": 7, + "method": TestMethod.name, + "params": ["message": "hi"], + "_meta": ["vendor.example/session": "s42"], + "custom-data": ["value": 1], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + let metaValue = decoded.generalFields.meta?.dictionary["vendor.example/session"] + #expect(metaValue == .string("s42")) + #expect(decoded.generalFields.additional["custom-data"] == .object(["value": .int(1)])) + } + + @Test("Reserved fields are ignored when encoding extras") + func testReservedFieldsIgnored() throws { + let general = GeneralFields( + meta: nil, + additional: ["method": .string("override"), "custom": .bool(true)] + ) + + let request = Request( + id: 1, + method: TestMethod.name, + params: Payload(message: "ping"), + generalFields: general + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + #expect(json?["method"] as? String == TestMethod.name) + #expect(json?["custom"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Request.self, from: data) + #expect(decoded.generalFields.additional["method"] == nil) + #expect(decoded.generalFields.additional["custom"] == .bool(true)) + } + + @Test("Invalid meta key is rejected") + func testInvalidMetaKey() { + #expect(throws: GeneralFieldError.invalidMetaKey("invalid key")) { + _ = try MetaFields(values: ["invalid key": .int(1)]) + } + } + + @Test("Response encoding includes general fields") + func testResponseGeneralFields() throws { + let meta = try MetaFields(values: ["vendor.example/status": .string("partial")]) + let general = GeneralFields(meta: meta, additional: ["progress": .int(50)]) + let response = Response( + id: 99, + result: .success(Payload(message: "ok")), + general: general + ) + + let data = try JSONEncoder().encode(response) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/status"] as? String == "partial") + #expect(json?["progress"] as? Int == 50) + + let decoded = try JSONDecoder().decode(Response.self, from: data) + #expect(decoded.general.additional["progress"] == .int(50)) + #expect( + decoded.general.meta?.dictionary["vendor.example/status"] + == .string("partial") + ) + } + + @Test("Tool encoding and decoding with general fields") + func testToolGeneralFields() throws { + let meta = try MetaFields(values: [ + "vendor.example/outputTemplate": .string("ui://widget/kanban-board.html") + ]) + let general = GeneralFields( + meta: meta, + additional: ["openai/toolInvocation/invoking": .string("Displaying the board")] + ) + + let tool = Tool( + name: "kanban-board", + title: "Kanban Board", + description: "Display kanban widget", + inputSchema: try Value(["type": "object"]), + general: general + ) + + let data = try JSONEncoder().encode(tool) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect( + metaObject?["vendor.example/outputTemplate"] as? String + == "ui://widget/kanban-board.html") + #expect(json?["openai/toolInvocation/invoking"] as? String == "Displaying the board") + + let decoded = try JSONDecoder().decode(Tool.self, from: data) + #expect( + decoded.general.additional["openai/toolInvocation/invoking"] + == .string("Displaying the board")) + #expect( + decoded.general.meta?.dictionary["vendor.example/outputTemplate"] + == .string("ui://widget/kanban-board.html") + ) + } + + @Test("Resource content encodes meta") + func testResourceContentGeneralFields() throws { + let meta = try MetaFields(values: [ + "openai/widgetPrefersBorder": .bool(true) + ]) + let general = GeneralFields( + meta: meta, + additional: ["openai/widgetDomain": .string("https://chatgpt.com")] + ) + + let content = Resource.Content.text( + "
Widget
", + uri: "ui://widget/kanban-board.html", + mimeType: "text/html", + general: general + ) + + let data = try JSONEncoder().encode(content) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + + #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) + #expect(json?["openai/widgetDomain"] as? String == "https://chatgpt.com") + + let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) + #expect( + decoded.general.meta?.dictionary["openai/widgetPrefersBorder"] == .bool(true)) + #expect(decoded.general.additional["openai/widgetDomain"] == .string("https://chatgpt.com")) } - } - - @Test("Response encoding includes general fields") - func testResponseGeneralFields() throws { - let meta = try MetaFields(values: ["vendor.example/status": .string("partial")]) - let general = GeneralFields(meta: meta, additional: ["progress": .int(50)]) - let response = Response( - id: 99, - result: .success(Payload(message: "ok")), - general: general - ) - - let data = try JSONEncoder().encode(response) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["vendor.example/status"] as? String == "partial") - #expect(json?["progress"] as? Int == 50) - - let decoded = try JSONDecoder().decode(Response.self, from: data) - #expect(decoded.general.additional["progress"] == .int(50)) - #expect( - decoded.general.meta?.dictionary["vendor.example/status"] - == .string("partial") - ) - } - - @Test("Tool encoding and decoding with general fields") - func testToolGeneralFields() throws { - let meta = try MetaFields(values: [ - "vendor.example/outputTemplate": .string("ui://widget/kanban-board.html") - ]) - let general = GeneralFields( - meta: meta, - additional: ["openai/toolInvocation/invoking": .string("Displaying the board")] - ) - - let tool = Tool( - name: "kanban-board", - title: "Kanban Board", - description: "Display kanban widget", - inputSchema: try Value(["type": "object"]), - general: general - ) - - let data = try JSONEncoder().encode(tool) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["vendor.example/outputTemplate"] as? String == "ui://widget/kanban-board.html") - #expect(json?["openai/toolInvocation/invoking"] as? String == "Displaying the board") - - let decoded = try JSONDecoder().decode(Tool.self, from: data) - #expect(decoded.general.additional["openai/toolInvocation/invoking"] == .string("Displaying the board")) - #expect( - decoded.general.meta?.dictionary["vendor.example/outputTemplate"] - == .string("ui://widget/kanban-board.html") - ) - } - - @Test("Resource content encodes meta") - func testResourceContentGeneralFields() throws { - let meta = try MetaFields(values: [ - "openai/widgetPrefersBorder": .bool(true) - ]) - let general = GeneralFields( - meta: meta, - additional: ["openai/widgetDomain": .string("https://chatgpt.com")] - ) - - let content = Resource.Content.text( - "
Widget
", - uri: "ui://widget/kanban-board.html", - mimeType: "text/html", - general: general - ) - - let data = try JSONEncoder().encode(content) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let metaObject = json?["_meta"] as? [String: Any] - - #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) - #expect(json?["openai/widgetDomain"] as? String == "https://chatgpt.com") - - let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) - #expect( - decoded.general.meta?.dictionary["openai/widgetPrefersBorder"] == .bool(true)) - #expect(decoded.general.additional["openai/widgetDomain"] == .string("https://chatgpt.com")) - } } diff --git a/Tests/MCPTests/Helpers/TaskCollector.swift b/Tests/MCPTests/Helpers/TaskCollector.swift index 8204decd..f09f8781 100644 --- a/Tests/MCPTests/Helpers/TaskCollector.swift +++ b/Tests/MCPTests/Helpers/TaskCollector.swift @@ -1,12 +1,12 @@ // Helper actor to safely accumulate tasks across sendable closures. actor TaskCollector { - private var storage: [Element] = [] + private var storage: [Element] = [] - func append(_ element: Element) { - storage.append(element) - } + func append(_ element: Element) { + storage.append(element) + } - func snapshot() -> [Element] { - storage - } + func snapshot() -> [Element] { + storage + } } diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index 218e9a97..56c6ce69 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -5,476 +5,476 @@ import Testing @Suite("Tool Tests") struct ToolTests { - @Test("Tool initialization with valid parameters") - func testToolInitialization() throws { - let tool = Tool( - name: "test_tool", - description: "A test tool", - inputSchema: .object([ - "properties": .object([ - "param1": .string("Test parameter") - ]) - ]) - ) + @Test("Tool initialization with valid parameters") + func testToolInitialization() throws { + let tool = Tool( + name: "test_tool", + description: "A test tool", + inputSchema: .object([ + "properties": .object([ + "param1": .string("Test parameter") + ]) + ]) + ) + + #expect(tool.name == "test_tool") + #expect(tool.description == "A test tool") + #expect(tool.inputSchema != nil) + #expect(tool.title == nil) + #expect(tool.outputSchema == nil) + } - #expect(tool.name == "test_tool") - #expect(tool.description == "A test tool") - #expect(tool.inputSchema != nil) - #expect(tool.title == nil) - #expect(tool.outputSchema == nil) - } - - @Test("Tool Annotations initialization and properties") - func testToolAnnotationsInitialization() throws { - // Empty annotations - let emptyAnnotations = Tool.Annotations() - #expect(emptyAnnotations.isEmpty) - #expect(emptyAnnotations.title == nil) - #expect(emptyAnnotations.readOnlyHint == nil) - #expect(emptyAnnotations.destructiveHint == nil) - #expect(emptyAnnotations.idempotentHint == nil) - #expect(emptyAnnotations.openWorldHint == nil) - - // Full annotations - let fullAnnotations = Tool.Annotations( - title: "Test Tool", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false - ) - - #expect(!fullAnnotations.isEmpty) - #expect(fullAnnotations.title == "Test Tool") - #expect(fullAnnotations.readOnlyHint == true) - #expect(fullAnnotations.destructiveHint == false) - #expect(fullAnnotations.idempotentHint == true) - #expect(fullAnnotations.openWorldHint == false) - - // Partial annotations - should not be empty - let partialAnnotations = Tool.Annotations(title: "Partial Test") - #expect(!partialAnnotations.isEmpty) - #expect(partialAnnotations.title == "Partial Test") - - // Initialize with nil literal - let nilAnnotations: Tool.Annotations = nil - #expect(nilAnnotations.isEmpty) - } - - @Test("Tool Annotations encoding and decoding") - func testToolAnnotationsEncodingDecoding() throws { - let annotations = Tool.Annotations( - title: "Test Tool", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false - ) - - #expect(!annotations.isEmpty) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(annotations) - let decoded = try decoder.decode(Tool.Annotations.self, from: data) - - #expect(decoded.title == annotations.title) - #expect(decoded.readOnlyHint == annotations.readOnlyHint) - #expect(decoded.destructiveHint == annotations.destructiveHint) - #expect(decoded.idempotentHint == annotations.idempotentHint) - #expect(decoded.openWorldHint == annotations.openWorldHint) - - // Test that empty annotations are encoded as expected - let emptyAnnotations = Tool.Annotations() - let emptyData = try encoder.encode(emptyAnnotations) - let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData) - - #expect(decodedEmpty.isEmpty) - } - - @Test("Tool with annotations encoding and decoding") - func testToolWithAnnotationsEncodingDecoding() throws { - let annotations = Tool.Annotations( - title: "Calculator", - destructiveHint: false - ) - - let tool = Tool( - name: "calculate", - description: "Performs calculations", - inputSchema: .object([ - "properties": .object([ - "expression": .string("Mathematical expression to evaluate") - ]) - ]), - annotations: annotations - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.name == tool.name) - #expect(decoded.description == tool.description) - #expect(decoded.annotations.title == annotations.title) - #expect(decoded.annotations.destructiveHint == annotations.destructiveHint) - - // Verify that the annotations field is properly included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(jsonString.contains("\"annotations\"")) - #expect(jsonString.contains("\"title\":\"Calculator\"")) - } - - @Test("Tool with empty annotations") - func testToolWithEmptyAnnotations() throws { - var tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: [:] - ) - - do { - #expect(tool.annotations.isEmpty) - - let encoder = JSONEncoder() - let data = try encoder.encode(tool) - - // Verify that empty annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(!jsonString.contains("\"annotations\"")) + @Test("Tool Annotations initialization and properties") + func testToolAnnotationsInitialization() throws { + // Empty annotations + let emptyAnnotations = Tool.Annotations() + #expect(emptyAnnotations.isEmpty) + #expect(emptyAnnotations.title == nil) + #expect(emptyAnnotations.readOnlyHint == nil) + #expect(emptyAnnotations.destructiveHint == nil) + #expect(emptyAnnotations.idempotentHint == nil) + #expect(emptyAnnotations.openWorldHint == nil) + + // Full annotations + let fullAnnotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!fullAnnotations.isEmpty) + #expect(fullAnnotations.title == "Test Tool") + #expect(fullAnnotations.readOnlyHint == true) + #expect(fullAnnotations.destructiveHint == false) + #expect(fullAnnotations.idempotentHint == true) + #expect(fullAnnotations.openWorldHint == false) + + // Partial annotations - should not be empty + let partialAnnotations = Tool.Annotations(title: "Partial Test") + #expect(!partialAnnotations.isEmpty) + #expect(partialAnnotations.title == "Partial Test") + + // Initialize with nil literal + let nilAnnotations: Tool.Annotations = nil + #expect(nilAnnotations.isEmpty) } - do { - tool.annotations.title = "Test" + @Test("Tool Annotations encoding and decoding") + func testToolAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!annotations.isEmpty) - #expect(!tool.annotations.isEmpty) + let encoder = JSONEncoder() + let decoder = JSONDecoder() - let encoder = JSONEncoder() - let data = try encoder.encode(tool) + let data = try encoder.encode(annotations) + let decoded = try decoder.decode(Tool.Annotations.self, from: data) - // Verify that empty annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(jsonString.contains("\"annotations\"")) + #expect(decoded.title == annotations.title) + #expect(decoded.readOnlyHint == annotations.readOnlyHint) + #expect(decoded.destructiveHint == annotations.destructiveHint) + #expect(decoded.idempotentHint == annotations.idempotentHint) + #expect(decoded.openWorldHint == annotations.openWorldHint) + + // Test that empty annotations are encoded as expected + let emptyAnnotations = Tool.Annotations() + let emptyData = try encoder.encode(emptyAnnotations) + let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData) + + #expect(decodedEmpty.isEmpty) } - } - - @Test("Tool with nil literal annotations") - func testToolWithNilLiteralAnnotations() throws { - let tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: [:], - annotations: nil - ) - - #expect(tool.annotations.isEmpty) - - let encoder = JSONEncoder() - let data = try encoder.encode(tool) - - // Verify that nil literal annotations are not included in the JSON - let jsonString = String(data: data, encoding: .utf8)! - #expect(!jsonString.contains("\"annotations\"")) - } - - @Test("Tool encoding and decoding") - func testToolEncodingDecoding() throws { - let tool = Tool( - name: "test_tool", - description: "Test tool description", - inputSchema: .object([ - "properties": .object([ - "param1": .string("String parameter"), - "param2": .int(42), - ]) - ]) - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.name == tool.name) - #expect(decoded.description == tool.description) - #expect(decoded.inputSchema == tool.inputSchema) - } - - @Test("Tool encoding and decoding with title and output schema") - func testToolEncodingDecodingWithTitleAndOutputSchema() throws { - let tool = Tool( - name: "test_tool", - title: "Readable Test Tool", - description: "Test tool description", - inputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "param1": .string("String parameter") - ]), - ]), - outputSchema: .object([ - "type": .string("object"), - "properties": .object([ - "result": .string("String result") - ]), - ]) - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(tool) - let decoded = try decoder.decode(Tool.self, from: data) - - #expect(decoded.title == tool.title) - #expect(decoded.outputSchema == tool.outputSchema) - - let jsonString = String(decoding: data, as: UTF8.self) - #expect(jsonString.contains("\"title\":\"Readable Test Tool\"")) - #expect(jsonString.contains("\"outputSchema\"")) - } - - @Test("Text content encoding and decoding") - func testToolContentTextEncoding() throws { - let content = Tool.Content.text("Hello, world!") - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .text(let text) = decoded { - #expect(text == "Hello, world!") - } else { - #expect(Bool(false), "Expected text content") + + @Test("Tool with annotations encoding and decoding") + func testToolWithAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Calculator", + destructiveHint: false + ) + + let tool = Tool( + name: "calculate", + description: "Performs calculations", + inputSchema: .object([ + "properties": .object([ + "expression": .string("Mathematical expression to evaluate") + ]) + ]), + annotations: annotations + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.annotations.title == annotations.title) + #expect(decoded.annotations.destructiveHint == annotations.destructiveHint) + + // Verify that the annotations field is properly included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) + #expect(jsonString.contains("\"title\":\"Calculator\"")) + } + + @Test("Tool with empty annotations") + func testToolWithEmptyAnnotations() throws { + var tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: [:] + ) + + do { + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) + } + + do { + tool.annotations.title = "Test" + + #expect(!tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) + } } - } - - @Test("Image content encoding and decoding") - func testToolContentImageEncoding() throws { - let content = Tool.Content.image( - data: "base64data", - mimeType: "image/png", - metadata: ["width": "100", "height": "100"] - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .image(let data, let mimeType, let metadata) = decoded { - #expect(data == "base64data") - #expect(mimeType == "image/png") - #expect(metadata?["width"] == "100") - #expect(metadata?["height"] == "100") - } else { - #expect(Bool(false), "Expected image content") + + @Test("Tool with nil literal annotations") + func testToolWithNilLiteralAnnotations() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: [:], + annotations: nil + ) + + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that nil literal annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) } - } - - @Test("Resource content encoding and decoding") - func testToolContentResourceEncoding() throws { - let content = Tool.Content.resource( - uri: "file://test.txt", - mimeType: "text/plain", - text: "Sample text" - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .resource(let uri, let mimeType, let text, let title, let annotations) = decoded { - #expect(uri == "file://test.txt") - #expect(mimeType == "text/plain") - #expect(text == "Sample text") - #expect(title == nil) - #expect(annotations == nil) - } else { - #expect(Bool(false), "Expected resource content") + + @Test("Tool encoding and decoding") + func testToolEncodingDecoding() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: .object([ + "properties": .object([ + "param1": .string("String parameter"), + "param2": .int(42), + ]) + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.inputSchema == tool.inputSchema) } - } - - @Test("Audio content encoding and decoding") - func testToolContentAudioEncoding() throws { - let content = Tool.Content.audio( - data: "base64audiodata", - mimeType: "audio/wav" - ) - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let data = try encoder.encode(content) - let decoded = try decoder.decode(Tool.Content.self, from: data) - - if case .audio(let data, let mimeType) = decoded { - #expect(data == "base64audiodata") - #expect(mimeType == "audio/wav") - } else { - #expect(Bool(false), "Expected audio content") + + @Test("Tool encoding and decoding with title and output schema") + func testToolEncodingDecodingWithTitleAndOutputSchema() throws { + let tool = Tool( + name: "test_tool", + title: "Readable Test Tool", + description: "Test tool description", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "param1": .string("String parameter") + ]), + ]), + outputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "result": .string("String result") + ]), + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.title == tool.title) + #expect(decoded.outputSchema == tool.outputSchema) + + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString.contains("\"title\":\"Readable Test Tool\"")) + #expect(jsonString.contains("\"outputSchema\"")) } - } - @Test("ListTools parameters validation") - func testListToolsParameters() throws { - let params = ListTools.Parameters(cursor: "next_page") - #expect(params.cursor == "next_page") + @Test("Text content encoding and decoding") + func testToolContentTextEncoding() throws { + let content = Tool.Content.text("Hello, world!") + let encoder = JSONEncoder() + let decoder = JSONDecoder() - let emptyParams = ListTools.Parameters() - #expect(emptyParams.cursor == nil) - } + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) - @Test("ListTools request decoding with omitted params") - func testListToolsRequestDecodingWithOmittedParams() throws { - // Test decoding when params field is omitted - let jsonString = """ - {"jsonrpc":"2.0","id":"test-id","method":"tools/list"} - """ - let data = jsonString.data(using: .utf8)! + if case .text(let text) = decoded { + #expect(text == "Hello, world!") + } else { + #expect(Bool(false), "Expected text content") + } + } - let decoder = JSONDecoder() - let decoded = try decoder.decode(Request.self, from: data) + @Test("Image content encoding and decoding") + func testToolContentImageEncoding() throws { + let content = Tool.Content.image( + data: "base64data", + mimeType: "image/png", + metadata: ["width": "100", "height": "100"] + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .image(let data, let mimeType, let metadata) = decoded { + #expect(data == "base64data") + #expect(mimeType == "image/png") + #expect(metadata?["width"] == "100") + #expect(metadata?["height"] == "100") + } else { + #expect(Bool(false), "Expected image content") + } + } - #expect(decoded.id == "test-id") - #expect(decoded.method == ListTools.name) - } + @Test("Resource content encoding and decoding") + func testToolContentResourceEncoding() throws { + let content = Tool.Content.resource( + uri: "file://test.txt", + mimeType: "text/plain", + text: "Sample text" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .resource(let uri, let mimeType, let text, let title, let annotations) = decoded { + #expect(uri == "file://test.txt") + #expect(mimeType == "text/plain") + #expect(text == "Sample text") + #expect(title == nil) + #expect(annotations == nil) + } else { + #expect(Bool(false), "Expected resource content") + } + } - @Test("ListTools request decoding with null params") - func testListToolsRequestDecodingWithNullParams() throws { - // Test decoding when params field is null - let jsonString = """ - {"jsonrpc":"2.0","id":"test-id","method":"tools/list","params":null} - """ - let data = jsonString.data(using: .utf8)! - - let decoder = JSONDecoder() - let decoded = try decoder.decode(Request.self, from: data) - - #expect(decoded.id == "test-id") - #expect(decoded.method == ListTools.name) - } - - @Test("ListTools result validation") - func testListToolsResult() throws { - let tools = [ - Tool(name: "tool1", description: "First tool", inputSchema: [:]), - Tool(name: "tool2", description: "Second tool", inputSchema: [:]), - ] - - let result = ListTools.Result(tools: tools, nextCursor: "next_page") - #expect(result.tools.count == 2) - #expect(result.tools[0].name == "tool1") - #expect(result.tools[1].name == "tool2") - #expect(result.nextCursor == "next_page") - } - - @Test("CallTool parameters validation") - func testCallToolParameters() throws { - let arguments: [String: Value] = [ - "param1": .string("value1"), - "param2": .int(42), - ] - - let params = CallTool.Parameters(name: "test_tool", arguments: arguments) - #expect(params.name == "test_tool") - #expect(params.arguments?["param1"] == .string("value1")) - #expect(params.arguments?["param2"] == .int(42)) - } - - @Test("CallTool success result validation") - func testCallToolResult() throws { - let content = [ - Tool.Content.text("Result 1"), - Tool.Content.text("Result 2"), - ] - - let result = CallTool.Result(content: content) - #expect(result.content.count == 2) - #expect(result.isError == nil) - - if case .text(let text) = result.content[0] { - #expect(text == "Result 1") - } else { - #expect(Bool(false), "Expected text content") + @Test("Audio content encoding and decoding") + func testToolContentAudioEncoding() throws { + let content = Tool.Content.audio( + data: "base64audiodata", + mimeType: "audio/wav" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .audio(let data, let mimeType) = decoded { + #expect(data == "base64audiodata") + #expect(mimeType == "audio/wav") + } else { + #expect(Bool(false), "Expected audio content") + } } - } - - @Test("CallTool error result validation") - func testCallToolErrorResult() throws { - let errorContent = [Tool.Content.text("Error message")] - let errorResult = CallTool.Result(content: errorContent, isError: true) - #expect(errorResult.content.count == 1) - #expect(errorResult.isError == true) - - if case .text(let text) = errorResult.content[0] { - #expect(text == "Error message") - } else { - #expect(Bool(false), "Expected error text content") + + @Test("ListTools parameters validation") + func testListToolsParameters() throws { + let params = ListTools.Parameters(cursor: "next_page") + #expect(params.cursor == "next_page") + + let emptyParams = ListTools.Parameters() + #expect(emptyParams.cursor == nil) } - } - @Test("ToolListChanged notification name validation") - func testToolListChangedNotification() throws { - #expect(ToolListChangedNotification.name == "notifications/tools/list_changed") - } + @Test("ListTools request decoding with omitted params") + func testListToolsRequestDecodingWithOmittedParams() throws { + // Test decoding when params field is omitted + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","method":"tools/list"} + """ + let data = jsonString.data(using: .utf8)! - @Test("ListTools handler invocation without params") - func testListToolsHandlerWithoutParams() async throws { - let jsonString = """ - {"jsonrpc":"2.0","id":1,"method":"tools/list"} - """ - let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let decoded = try decoder.decode(Request.self, from: data) + + #expect(decoded.id == "test-id") + #expect(decoded.method == ListTools.name) + } + + @Test("ListTools request decoding with null params") + func testListToolsRequestDecodingWithNullParams() throws { + // Test decoding when params field is null + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","method":"tools/list","params":null} + """ + let data = jsonString.data(using: .utf8)! - let anyRequest = try JSONDecoder().decode(AnyRequest.self, from: jsonData) + let decoder = JSONDecoder() + let decoded = try decoder.decode(Request.self, from: data) - let handler = TypedRequestHandler { request in - #expect(request.method == ListTools.name) - #expect(request.id == 1) - #expect(request.params.cursor == nil) + #expect(decoded.id == "test-id") + #expect(decoded.method == ListTools.name) + } + + @Test("ListTools result validation") + func testListToolsResult() throws { + let tools = [ + Tool(name: "tool1", description: "First tool", inputSchema: [:]), + Tool(name: "tool2", description: "Second tool", inputSchema: [:]), + ] + + let result = ListTools.Result(tools: tools, nextCursor: "next_page") + #expect(result.tools.count == 2) + #expect(result.tools[0].name == "tool1") + #expect(result.tools[1].name == "tool2") + #expect(result.nextCursor == "next_page") + } - let testTool = Tool( - name: "test_tool", - description: "Test tool for verification", - inputSchema: [:] - ) - return ListTools.response(id: request.id, result: ListTools.Result(tools: [testTool])) + @Test("CallTool parameters validation") + func testCallToolParameters() throws { + let arguments: [String: Value] = [ + "param1": .string("value1"), + "param2": .int(42), + ] + + let params = CallTool.Parameters(name: "test_tool", arguments: arguments) + #expect(params.name == "test_tool") + #expect(params.arguments?["param1"] == .string("value1")) + #expect(params.arguments?["param2"] == .int(42)) } - let response = try await handler(anyRequest) + @Test("CallTool success result validation") + func testCallToolResult() throws { + let content = [ + Tool.Content.text("Result 1"), + Tool.Content.text("Result 2"), + ] + + let result = CallTool.Result(content: content) + #expect(result.content.count == 2) + #expect(result.isError == nil) + + if case .text(let text) = result.content[0] { + #expect(text == "Result 1") + } else { + #expect(Bool(false), "Expected text content") + } + } - if case .success(let value) = response.result { - let encoder = JSONEncoder() - let decoder = JSONDecoder() - let data = try encoder.encode(value) - let result = try decoder.decode(ListTools.Result.self, from: data) + @Test("CallTool error result validation") + func testCallToolErrorResult() throws { + let errorContent = [Tool.Content.text("Error message")] + let errorResult = CallTool.Result(content: errorContent, isError: true) + #expect(errorResult.content.count == 1) + #expect(errorResult.isError == true) + + if case .text(let text) = errorResult.content[0] { + #expect(text == "Error message") + } else { + #expect(Bool(false), "Expected error text content") + } + } - #expect(result.tools.count == 1) - #expect(result.tools[0].name == "test_tool") - } else { - #expect(Bool(false), "Expected success result") + @Test("ToolListChanged notification name validation") + func testToolListChangedNotification() throws { + #expect(ToolListChangedNotification.name == "notifications/tools/list_changed") + } + + @Test("ListTools handler invocation without params") + func testListToolsHandlerWithoutParams() async throws { + let jsonString = """ + {"jsonrpc":"2.0","id":1,"method":"tools/list"} + """ + let jsonData = jsonString.data(using: .utf8)! + + let anyRequest = try JSONDecoder().decode(AnyRequest.self, from: jsonData) + + let handler = TypedRequestHandler { request in + #expect(request.method == ListTools.name) + #expect(request.id == 1) + #expect(request.params.cursor == nil) + + let testTool = Tool( + name: "test_tool", + description: "Test tool for verification", + inputSchema: [:] + ) + return ListTools.response(id: request.id, result: ListTools.Result(tools: [testTool])) + } + + let response = try await handler(anyRequest) + + if case .success(let value) = response.result { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(value) + let result = try decoder.decode(ListTools.Result.self, from: data) + + #expect(result.tools.count == 1) + #expect(result.tools[0].name == "test_tool") + } else { + #expect(Bool(false), "Expected success result") + } } - } } @Test("Tool with missing description") func testToolWithMissingDescription() throws { - let jsonString = """ - { - "name": "test_tool", - "inputSchema": {} - } - """ - let jsonData = jsonString.data(using: .utf8)! + let jsonString = """ + { + "name": "test_tool", + "inputSchema": {} + } + """ + let jsonData = jsonString.data(using: .utf8)! - let tool = try JSONDecoder().decode(Tool.self, from: jsonData) + let tool = try JSONDecoder().decode(Tool.self, from: jsonData) - #expect(tool.name == "test_tool") - #expect(tool.description == nil) - #expect(tool.inputSchema == [:]) + #expect(tool.name == "test_tool") + #expect(tool.description == nil) + #expect(tool.inputSchema == [:]) } From 26ea5a248e49bc850a6118c3cbbfd708bdd74909 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Thu, 23 Oct 2025 22:11:54 -0400 Subject: [PATCH 08/18] Update Version --- README.md | 10 +++++----- .../MCP/Base/Transports/HTTPClientTransport.swift | 2 +- Sources/MCP/Base/Transports/StdioTransport.swift | 2 +- Sources/MCP/Base/Versioning.swift | 3 ++- Sources/MCP/Server/Resources.swift | 14 +++++++------- Sources/MCP/Server/Tools.swift | 6 +++--- Tests/MCPTests/VersioningTests.swift | 5 +++-- 7 files changed, 22 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ac23a4c3..ffb881f3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Official Swift SDK for the [Model Context Protocol][mcp] (MCP). The Model Context Protocol (MCP) defines a standardized way for applications to communicate with AI and ML models. This Swift SDK implements both client and server components -according to the [2025-03-26][mcp-spec-2025-03-26] (latest) version +according to the [2025-06-18][mcp-spec-2025-06-18] (latest) version of the MCP specification. ## Requirements @@ -774,8 +774,8 @@ The Swift SDK provides multiple built-in transports: | Transport | Description | Platforms | Best for | |-----------|-------------|-----------|----------| -| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools | -| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications | +| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools | +| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications | | [`InMemoryTransport`](/Sources/MCP/Base/Transports/InMemoryTransport.swift) | Custom in-memory transport for direct communication within the same process | All platforms | Testing, debugging, same-process client-server communication | | [`NetworkTransport`](/Sources/MCP/Base/Transports/NetworkTransport.swift) | Custom transport using Apple's Network framework for TCP/UDP connections | Apple platforms only | Low-level networking, custom protocols | @@ -868,7 +868,7 @@ let transport = StdioTransport(logger: logger) ## Additional Resources -- [MCP Specification](https://modelcontextprotocol.io/specification/2025-03-26/) +- [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18) - [Protocol Documentation](https://modelcontextprotocol.io) - [GitHub Repository](https://github.com/modelcontextprotocol/swift-sdk) @@ -886,4 +886,4 @@ see the [GitHub Releases page](https://github.com/modelcontextprotocol/swift-sdk This project is licensed under the MIT License. [mcp]: https://modelcontextprotocol.io -[mcp-spec-2025-03-26]: https://modelcontextprotocol.io/specification/2025-03-26 \ No newline at end of file +[mcp-spec-2025-06-18]: https://modelcontextprotocol.io/specification/2025-06-18 diff --git a/Sources/MCP/Base/Transports/HTTPClientTransport.swift b/Sources/MCP/Base/Transports/HTTPClientTransport.swift index 11a4455e..982cd505 100644 --- a/Sources/MCP/Base/Transports/HTTPClientTransport.swift +++ b/Sources/MCP/Base/Transports/HTTPClientTransport.swift @@ -11,7 +11,7 @@ import Logging /// An implementation of the MCP Streamable HTTP transport protocol for clients. /// -/// This transport implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) +/// This transport implements the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) /// specification from the Model Context Protocol. /// /// It supports: diff --git a/Sources/MCP/Base/Transports/StdioTransport.swift b/Sources/MCP/Base/Transports/StdioTransport.swift index 84bfd93a..45522fac 100644 --- a/Sources/MCP/Base/Transports/StdioTransport.swift +++ b/Sources/MCP/Base/Transports/StdioTransport.swift @@ -20,7 +20,7 @@ import struct Foundation.Data #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) /// An implementation of the MCP stdio transport protocol. /// - /// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) + /// This transport implements the [stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) /// specification from the Model Context Protocol. /// /// The stdio transport works by: diff --git a/Sources/MCP/Base/Versioning.swift b/Sources/MCP/Base/Versioning.swift index 05c77a00..b916b2d7 100644 --- a/Sources/MCP/Base/Versioning.swift +++ b/Sources/MCP/Base/Versioning.swift @@ -4,10 +4,11 @@ import Foundation /// following the format YYYY-MM-DD, to indicate /// the last date backwards incompatible changes were made. /// -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-03-26/ +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/ public enum Version { /// All protocol versions supported by this implementation, ordered from newest to oldest. static let supported: Set = [ + "2025-06-18", "2025-03-26", "2024-11-05", ] diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index d518a508..efdaa42f 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -6,7 +6,7 @@ import Foundation /// such as files, database schemas, or application-specific information. /// Each resource is uniquely identified by a URI. /// -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/ +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/ public struct Resource: Hashable, Codable, Sendable { /// The resource name public var name: String @@ -227,7 +227,7 @@ public struct Resource: Hashable, Codable, Sendable { // MARK: - /// To discover available resources, clients send a `resources/list` request. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#listing-resources +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#listing-resources public enum ListResources: Method { public static let name: String = "resources/list" @@ -255,7 +255,7 @@ public enum ListResources: Method { } /// To retrieve resource contents, clients send a `resources/read` request: -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#reading-resources +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#reading-resources public enum ReadResource: Method { public static let name: String = "resources/read" @@ -277,7 +277,7 @@ public enum ReadResource: Method { } /// To discover available resource templates, clients send a `resources/templates/list` request. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#resource-templates public enum ListResourceTemplates: Method { public static let name: String = "resources/templates/list" @@ -310,7 +310,7 @@ public enum ListResourceTemplates: Method { } /// When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#list-changed-notification public struct ResourceListChangedNotification: Notification { public static let name: String = "notifications/resources/list_changed" @@ -318,7 +318,7 @@ public struct ResourceListChangedNotification: Notification { } /// Clients can subscribe to specific resources and receive notifications when they change. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#subscriptions public enum ResourceSubscribe: Method { public static let name: String = "resources/subscribe" @@ -330,7 +330,7 @@ public enum ResourceSubscribe: Method { } /// When a resource changes, servers that declared the updated capability SHOULD send a notification to subscribed clients. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/resources/#subscriptions public struct ResourceUpdatedNotification: Notification { public static let name: String = "notifications/resources/updated" diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 4616d0fd..57bff742 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -268,7 +268,7 @@ public struct Tool: Hashable, Codable, Sendable { // MARK: - /// To discover available tools, clients send a `tools/list` request. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#listing-tools +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/tools/#listing-tools public enum ListTools: Method { public static let name = "tools/list" @@ -296,7 +296,7 @@ public enum ListTools: Method { } /// To call a tool, clients send a `tools/call` request. -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#calling-tools +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/tools/#calling-tools public enum CallTool: Method { public static let name = "tools/call" @@ -352,7 +352,7 @@ public enum CallTool: Method { } /// When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification: -/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2025-06-18/server/tools/#list-changed-notification public struct ToolListChangedNotification: Notification { public static let name: String = "notifications/tools/list_changed" } diff --git a/Tests/MCPTests/VersioningTests.swift b/Tests/MCPTests/VersioningTests.swift index d1896b53..de26e80a 100644 --- a/Tests/MCPTests/VersioningTests.swift +++ b/Tests/MCPTests/VersioningTests.swift @@ -41,13 +41,14 @@ struct VersioningTests { @Test("Server's supported versions correctly defined") func testServerSupportedVersions() { + #expect(Version.supported.contains("2025-06-18")) #expect(Version.supported.contains("2025-03-26")) #expect(Version.supported.contains("2024-11-05")) - #expect(Version.supported.count == 2) + #expect(Version.supported.count == 3) } @Test("Server's latest version is correct") func testServerLatestVersion() { - #expect(Version.latest == "2025-03-26") + #expect(Version.latest == "2025-06-18") } } From 974c1cb10c29eb1cfe1dbdc725fb1f7bfbc1d22e Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Fri, 24 Oct 2025 00:23:38 -0400 Subject: [PATCH 09/18] Elicitation --- Sources/MCP/Client/Client.swift | 30 +++++ Sources/MCP/Client/Elicitation.swift | 88 ++++++++++++++ Tests/MCPTests/ElicitationTests.swift | 161 ++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 Sources/MCP/Client/Elicitation.swift create mode 100644 Tests/MCPTests/ElicitationTests.swift diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift index 696ffd14..862d6738 100644 --- a/Sources/MCP/Client/Client.swift +++ b/Sources/MCP/Client/Client.swift @@ -60,8 +60,15 @@ public actor Client { public init() {} } + /// The elicitation capabilities + public struct Elicitation: Hashable, Codable, Sendable { + public init() {} + } + /// Whether the client supports sampling public var sampling: Sampling? + /// Whether the client supports elicitation + public var elicitation: Elicitation? /// Experimental features supported by the client public var experimental: [String: String]? /// Whether the client supports roots @@ -69,10 +76,12 @@ public actor Client { public init( sampling: Sampling? = nil, + elicitation: Elicitation? = nil, experimental: [String: String]? = nil, roots: Capabilities.Roots? = nil ) { self.sampling = sampling + self.elicitation = elicitation self.experimental = experimental self.roots = roots } @@ -656,6 +665,27 @@ public actor Client { return self } + // MARK: - Elicitation + + /// Register a handler for elicitation requests from servers + /// + /// The elicitation flow lets servers collect structured input from users during + /// ongoing interactions. Clients remain in control by mediating the prompt, + /// collecting the response, and returning the chosen action to the server. + /// + /// - Parameter handler: A closure that processes elicitation requests and returns user actions + /// - Returns: Self for method chaining + /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation + @discardableResult + public func withElicitationHandler( + _ handler: @escaping @Sendable (CreateElicitation.Parameters) async throws -> + CreateElicitation.Result + ) -> Self { + // Supporting server-initiated requests requires bidirectional transports. + // Once available, this handler will be wired into the request routing path. + return self + } + // MARK: - private func handleResponse(_ response: Response) async { diff --git a/Sources/MCP/Client/Elicitation.swift b/Sources/MCP/Client/Elicitation.swift new file mode 100644 index 00000000..8cb04fac --- /dev/null +++ b/Sources/MCP/Client/Elicitation.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Types supporting the MCP elicitation flow. +/// +/// Servers use elicitation to collect structured input from users via the client. +/// The schema subset mirrors the 2025-06-18 revision of the specification. +public enum Elicitation { + /// Schema describing the expected response content. + public struct RequestSchema: Hashable, Codable, Sendable { + /// Supported top-level types. Currently limited to objects. + public enum SchemaType: String, Hashable, Codable, Sendable { + case object + } + + /// Schema title presented to users. + public var title: String? + /// Schema description providing additional guidance. + public var description: String? + /// Raw JSON Schema fragments describing the requested fields. + public var properties: [String: Value] + /// List of required field keys. + public var required: [String]? + /// Top-level schema type. Defaults to `object`. + public var type: SchemaType + + public init( + title: String? = nil, + description: String? = nil, + properties: [String: Value] = [:], + required: [String]? = nil, + type: SchemaType = .object + ) { + self.title = title + self.description = description + self.properties = properties + self.required = required + self.type = type + } + + private enum CodingKeys: String, CodingKey { + case title, description, properties, required, type + } + } +} + +/// To request information from a user, servers send an `elicitation/create` request. +/// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation +public enum CreateElicitation: Method { + public static let name = "elicitation/create" + + public struct Parameters: Hashable, Codable, Sendable { + /// Message displayed to the user describing the request. + public var message: String + /// Optional schema describing the expected response content. + public var requestedSchema: Elicitation.RequestSchema? + /// Optional provider-specific metadata. + public var metadata: [String: Value]? + + public init( + message: String, + requestedSchema: Elicitation.RequestSchema? = nil, + metadata: [String: Value]? = nil + ) { + self.message = message + self.requestedSchema = requestedSchema + self.metadata = metadata + } + } + + public struct Result: Hashable, Codable, Sendable { + /// Indicates how the user responded to the request. + public enum Action: String, Hashable, Codable, Sendable { + case accept + case decline + case cancel + } + + /// Selected action. + public var action: Action + /// Submitted content when action is `.accept`. + public var content: [String: Value]? + + public init(action: Action, content: [String: Value]? = nil) { + self.action = action + self.content = content + } + } +} diff --git a/Tests/MCPTests/ElicitationTests.swift b/Tests/MCPTests/ElicitationTests.swift new file mode 100644 index 00000000..6d6fa619 --- /dev/null +++ b/Tests/MCPTests/ElicitationTests.swift @@ -0,0 +1,161 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Elicitation Tests") +struct ElicitationTests { + @Test("Request schema encoding and decoding") + func testSchemaCoding() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let decoder = JSONDecoder() + + let schema = Elicitation.RequestSchema( + title: "Contact Information", + description: "Used to follow up after onboarding", + properties: [ + "name": [ + "type": "string", + "title": "Full Name", + "description": "Enter your legal name", + "minLength": 2, + "maxLength": 120, + ], + "email": [ + "type": "string", + "title": "Email Address", + "description": "Where we can reach you", + "format": "email", + ], + "age": [ + "type": "integer", + "minimum": 18, + "maximum": 110, + ], + "marketingOptIn": [ + "type": "boolean", + "title": "Marketing opt-in", + "default": false, + ], + ], + required: ["name", "email"] + ) + + let data = try encoder.encode(schema) + let decoded = try decoder.decode(Elicitation.RequestSchema.self, from: data) + + #expect(decoded.title == "Contact Information") + let emailSchema = decoded.properties["email"]?.objectValue + #expect(emailSchema?["format"]?.stringValue == "email") + + let ageSchema = decoded.properties["age"]?.objectValue + #expect(ageSchema?["minimum"]?.intValue == 18) + + let marketingSchema = decoded.properties["marketingOptIn"]?.objectValue + #expect(marketingSchema?["default"]?.boolValue == false) + #expect(decoded.required == ["name", "email"]) + } + + @Test("Enumeration support") + func testEnumerationSupport() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let property: Value = [ + "type": "string", + "title": "Department", + "enum": ["engineering", "design", "product"], + "enumNames": ["Engineering", "Design", "Product"], + ] + + let data = try encoder.encode(property) + let decoded = try decoder.decode(Value.self, from: data) + + let object = decoded.objectValue + let enumeration = object?["enum"]?.arrayValue?.compactMap { $0.stringValue } + let enumNames = object?["enumNames"]?.arrayValue?.compactMap { $0.stringValue } + + #expect(enumeration == ["engineering", "design", "product"]) + #expect(enumNames == ["Engineering", "Design", "Product"]) + } + + @Test("CreateElicitation.Parameters coding") + func testParametersCoding() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let schema = Elicitation.RequestSchema( + properties: [ + "username": [ + "type": "string", + "minLength": 2, + "maxLength": 39, + ] + ], + required: ["username"] + ) + + let parameters = CreateElicitation.Parameters( + message: "Please share your GitHub username", + requestedSchema: schema, + metadata: ["flow": "onboarding"] + ) + + let data = try encoder.encode(parameters) + let decoded = try decoder.decode(CreateElicitation.Parameters.self, from: data) + + #expect(decoded.message == "Please share your GitHub username") + #expect(decoded.requestedSchema?.properties.keys.contains("username") == true) + #expect(decoded.metadata?["flow"]?.stringValue == "onboarding") + } + + @Test("CreateElicitation.Result coding") + func testResultCoding() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let result = CreateElicitation.Result( + action: .accept, + content: ["username": "octocat", "age": 30] + ) + + let data = try encoder.encode(result) + let decoded = try decoder.decode(CreateElicitation.Result.self, from: data) + + #expect(decoded.action == .accept) + #expect(decoded.content?["username"]?.stringValue == "octocat") + #expect(decoded.content?["age"]?.intValue == 30) + } + + @Test("Client capabilities include elicitation") + func testClientCapabilitiesIncludeElicitation() throws { + let capabilities = Client.Capabilities( + elicitation: .init() + ) + + #expect(capabilities.elicitation != nil) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(capabilities) + let decoded = try decoder.decode(Client.Capabilities.self, from: data) + + #expect(decoded.elicitation != nil) + } + + @Test("Client elicitation handler registration") + func testClientElicitationHandlerRegistration() async throws { + let client = Client(name: "TestClient", version: "1.0") + + let handlerClient = await client.withElicitationHandler { parameters in + #expect(parameters.message == "Collect input") + return CreateElicitation.Result(action: .decline) + } + + #expect(handlerClient === client) + } +} From f495cb11c7060a9f5ae2c7392007bfc9a188e3ce Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Fri, 24 Oct 2025 00:28:51 -0400 Subject: [PATCH 10/18] Format --- Sources/MCP/Client/Client.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift index 862d6738..a2d749c9 100644 --- a/Sources/MCP/Client/Client.swift +++ b/Sources/MCP/Client/Client.swift @@ -645,7 +645,8 @@ public actor Client { /// - SeeAlso: https://modelcontextprotocol.io/docs/concepts/sampling#how-sampling-works @discardableResult public func withSamplingHandler( - _ handler: @escaping @Sendable (CreateSamplingMessage.Parameters) async throws -> + _ handler: + @escaping @Sendable (CreateSamplingMessage.Parameters) async throws -> CreateSamplingMessage.Result ) -> Self { // Note: This would require extending the client architecture to handle incoming requests from servers. @@ -678,7 +679,8 @@ public actor Client { /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation @discardableResult public func withElicitationHandler( - _ handler: @escaping @Sendable (CreateElicitation.Parameters) async throws -> + _ handler: + @escaping @Sendable (CreateElicitation.Parameters) async throws -> CreateElicitation.Result ) -> Self { // Supporting server-initiated requests requires bidirectional transports. From 6710742cf7053e1488b8a7f7ecc66b817416a97c Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 19:58:56 -0400 Subject: [PATCH 11/18] More tests and inits --- Sources/MCP/Base/GeneralFields.swift | 195 ----------- Sources/MCP/Base/Lifecycle.swift | 54 +++- Sources/MCP/Base/Messages.swift | 102 +++--- Sources/MCP/Base/MetaHelpers.swift | 185 +++++++++++ Sources/MCP/Client/Elicitation.swift | 37 ++- Sources/MCP/Client/Sampling.swift | 38 ++- Sources/MCP/Server/Prompts.swift | 109 ++++++- Sources/MCP/Server/Resources.swift | 170 +++++++--- Sources/MCP/Server/Tools.swift | 115 +++++-- Tests/MCPTests/GeneralFieldsTests.swift | 182 ----------- Tests/MCPTests/MetaFieldsTests.swift | 411 ++++++++++++++++++++++++ 11 files changed, 1094 insertions(+), 504 deletions(-) delete mode 100644 Sources/MCP/Base/GeneralFields.swift create mode 100644 Sources/MCP/Base/MetaHelpers.swift delete mode 100644 Tests/MCPTests/GeneralFieldsTests.swift create mode 100644 Tests/MCPTests/MetaFieldsTests.swift diff --git a/Sources/MCP/Base/GeneralFields.swift b/Sources/MCP/Base/GeneralFields.swift deleted file mode 100644 index 52cb110f..00000000 --- a/Sources/MCP/Base/GeneralFields.swift +++ /dev/null @@ -1,195 +0,0 @@ -import Foundation - -struct DynamicCodingKey: CodingKey { - let stringValue: String - let intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } -} - -public enum GeneralFieldError: Error, Equatable { - case invalidMetaKey(String) -} - -public struct GeneralFields: Hashable, Sendable { - public var meta: MetaFields? - public var additional: [String: Value] - - public init(meta: MetaFields? = nil, additional: [String: Value] = [:]) { - self.meta = meta - self.additional = additional.filter { $0.key != "_meta" } - } - - public var isEmpty: Bool { - (meta?.isEmpty ?? true) && additional.isEmpty - } - - public subscript(field name: String) -> Value? { - get { additional[name] } - set { additional[name] = newValue } - } - - mutating public func merge(additional fields: [String: Value]) { - for (key, value) in fields where key != "_meta" { - additional[key] = value - } - } - - func encode(into encoder: Encoder, reservedKeyNames: Set) throws { - guard !isEmpty else { return } - - var container = encoder.container(keyedBy: DynamicCodingKey.self) - - if let meta, !meta.isEmpty, let key = DynamicCodingKey(stringValue: "_meta") { - try container.encode(meta.dictionary, forKey: key) - } - - for (name, value) in additional where !reservedKeyNames.contains(name) { - guard let key = DynamicCodingKey(stringValue: name) else { continue } - try container.encode(value, forKey: key) - } - } - - static func decode( - from container: KeyedDecodingContainer, - reservedKeyNames: Set - ) throws -> GeneralFields { - var meta: MetaFields? - var additional: [String: Value] = [:] - - for key in container.allKeys { - let name = key.stringValue - if reservedKeyNames.contains(name) { - continue - } - - if name == "_meta" { - let raw = try container.decodeIfPresent([String: Value].self, forKey: key) - if let raw { - meta = try MetaFields(values: raw) - } - } else if let value = try? container.decode(Value.self, forKey: key) { - additional[name] = value - } - } - - return GeneralFields(meta: meta, additional: additional) - } -} - -public struct MetaFields: Hashable, Sendable { - private var storage: [String: Value] - - public init(values: [String: Value] = [:]) throws { - for key in values.keys { - guard MetaFields.isValidKeyName(key) else { - throw GeneralFieldError.invalidMetaKey(key) - } - } - self.storage = values - } - - public var dictionary: [String: Value] { - storage - } - - public var isEmpty: Bool { - storage.isEmpty - } - - public mutating func setValue(_ value: Value?, forKey key: String) throws { - guard MetaFields.isValidKeyName(key) else { - throw GeneralFieldError.invalidMetaKey(key) - } - storage[key] = value - } - - public subscript(key: String) -> Value? { - storage[key] - } - - public static func isValidKeyName(_ key: String) -> Bool { - let parts = key.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) - - let prefix: Substring? - let name: Substring - - if parts.count == 2 { - prefix = parts[0] - name = parts[1] - } else { - prefix = nil - name = parts.first ?? "" - } - - if let prefix, !prefix.isEmpty { - let labels = prefix.split(separator: ".", omittingEmptySubsequences: false) - guard !labels.isEmpty else { return false } - for label in labels { - guard MetaFields.isValidPrefixLabel(label) else { return false } - } - } - - guard !name.isEmpty else { return false } - guard MetaFields.isValidName(name) else { return false } - - return true - } - - private static func isValidPrefixLabel(_ label: Substring) -> Bool { - guard let first = label.first, first.isLetter else { return false } - guard let last = label.last, last.isLetter || last.isNumber else { return false } - for character in label { - if character.isLetter || character.isNumber || character == "-" { - continue - } - return false - } - return true - } - - private static func isValidName(_ name: Substring) -> Bool { - guard let first = name.first, first.isLetter || first.isNumber else { return false } - guard let last = name.last, last.isLetter || last.isNumber else { return false } - - for character in name { - if character.isLetter || character.isNumber || character == "-" || character == "_" - || character == "." - { - continue - } - return false - } - - return true - } -} - -extension MetaFields: Codable { - public init(from decoder: Decoder) throws { - let values = try [String: Value](from: decoder) - try self.init(values: values) - } - - public func encode(to encoder: Encoder) throws { - try storage.encode(to: encoder) - } -} - -extension Character { - fileprivate var isLetter: Bool { - unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } - } - - fileprivate var isNumber: Bool { - unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } - } -} diff --git a/Sources/MCP/Base/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift index 7d3e7119..495b71ca 100644 --- a/Sources/MCP/Base/Lifecycle.swift +++ b/Sources/MCP/Base/Lifecycle.swift @@ -42,10 +42,56 @@ public enum Initialize: Method { } public struct Result: Hashable, Codable, Sendable { - public let protocolVersion: String - public let capabilities: Server.Capabilities - public let serverInfo: Server.Info - public let instructions: String? + let protocolVersion: String + let capabilities: Server.Capabilities + let serverInfo: Server.Info + let instructions: String? + var _meta: [String: Value]? + var extraFields: [String: Value]? + + public init( + protocolVersion: String, + capabilities: Server.Capabilities, + serverInfo: Server.Info, + instructions: String? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { + self.protocolVersion = protocolVersion + self.capabilities = capabilities + self.serverInfo = serverInfo + self.instructions = instructions + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case protocolVersion, capabilities, serverInfo, instructions + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(capabilities, forKey: .capabilities) + try container.encode(serverInfo, forKey: .serverInfo) + try container.encodeIfPresent(instructions, forKey: .instructions) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try container.decode(String.self, forKey: .protocolVersion) + capabilities = try container.decode(Server.Capabilities.self, forKey: .capabilities) + serverInfo = try container.decode(Server.Info.self, forKey: .serverInfo) + instructions = try container.decodeIfPresent(String.self, forKey: .instructions) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } } } diff --git a/Sources/MCP/Base/Messages.swift b/Sources/MCP/Base/Messages.swift index e1914bc4..19e61379 100644 --- a/Sources/MCP/Base/Messages.swift +++ b/Sources/MCP/Base/Messages.swift @@ -39,9 +39,10 @@ struct AnyMethod: Method, Sendable { extension Method where Parameters == Empty { public static func request( id: ID = .random, - generalFields: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) -> Request { - Request(id: id, method: name, params: Empty(), generalFields: generalFields) + Request(id: id, method: name, params: Empty(), _meta: _meta, extraFields: extraFields) } } @@ -56,27 +57,30 @@ extension Method { public static func request( id: ID = .random, _ parameters: Self.Parameters, - generalFields: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) -> Request { - Request(id: id, method: name, params: parameters, generalFields: generalFields) + Request(id: id, method: name, params: parameters, _meta: _meta, extraFields: extraFields) } /// Create a response with the given result. public static func response( id: ID, result: Self.Result, - general: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) -> Response { - Response(id: id, result: result, general: general) + Response(id: id, result: result, _meta: _meta, extraFields: extraFields) } /// Create a response with the given error. public static func response( id: ID, error: MCPError, - general: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) -> Response { - Response(id: id, error: error, general: general) + Response(id: id, error: error, _meta: _meta, extraFields: extraFields) } } @@ -90,22 +94,26 @@ public struct Request: Hashable, Identifiable, Codable, Sendable { public let method: String /// The request parameters. public let params: M.Parameters - /// General MCP fields like `_meta`. - public let generalFields: GeneralFields + /// Metadata for this request (see spec for _meta usage, includes progressToken) + public let _meta: [String: Value]? + /// Extra fields for this request (index signature) + public let extraFields: [String: Value]? init( id: ID = .random, method: String, params: M.Parameters, - generalFields: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) { self.id = id self.method = method self.params = params - self.generalFields = generalFields + self._meta = _meta + self.extraFields = extraFields } - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case jsonrpc, id, method, params } @@ -115,11 +123,11 @@ public struct Request: Hashable, Identifiable, Codable, Sendable { try container.encode(id, forKey: .id) try container.encode(method, forKey: .method) try container.encode(params, forKey: .params) - try generalFields.encode(into: encoder, reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["jsonrpc", "id", "method", "params"] + + // Encode _meta and extra fields, excluding JSON-RPC protocol fields + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -163,9 +171,8 @@ extension Request { } let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) - generalFields = try GeneralFields.decode( - from: dynamicContainer, - reservedKeyNames: Self.reservedGeneralFieldNames) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -229,28 +236,42 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { public let id: ID /// The response result. public let result: Swift.Result - /// General MCP fields such as `_meta`. - public let general: GeneralFields + /// Metadata for this response (see spec for _meta usage) + public let _meta: [String: Value]? + /// Extra fields for this response (index signature) + public let extraFields: [String: Value]? public init( id: ID, result: Swift.Result, - general: GeneralFields = .init() + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) { self.id = id self.result = result - self.general = general + self._meta = _meta + self.extraFields = extraFields } - public init(id: ID, result: M.Result, general: GeneralFields = .init()) { - self.init(id: id, result: .success(result), general: general) + public init( + id: ID, + result: M.Result, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { + self.init(id: id, result: .success(result), _meta: _meta, extraFields: extraFields) } - public init(id: ID, error: MCPError, general: GeneralFields = .init()) { - self.init(id: id, result: .failure(error), general: general) + public init( + id: ID, + error: MCPError, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { + self.init(id: id, result: .failure(error), _meta: _meta, extraFields: extraFields) } - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case jsonrpc, id, result, error } @@ -264,7 +285,11 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { case .failure(let error): try container.encode(error, forKey: .error) } - try general.encode(into: encoder, reservedKeyNames: Self.reservedGeneralFieldNames) + + // Encode _meta and extra fields, excluding JSON-RPC protocol fields + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -287,13 +312,8 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { } let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamicContainer, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["jsonrpc", "id", "result", "error"] + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -309,12 +329,14 @@ extension AnyResponse { self = Response( id: response.id, result: .success(resultValue), - general: response.general) + _meta: response._meta, + extraFields: response.extraFields) case .failure(let error): self = Response( id: response.id, result: .failure(error), - general: response.general) + _meta: response._meta, + extraFields: response.extraFields) } } } diff --git a/Sources/MCP/Base/MetaHelpers.swift b/Sources/MCP/Base/MetaHelpers.swift new file mode 100644 index 00000000..46c2c0c7 --- /dev/null +++ b/Sources/MCP/Base/MetaHelpers.swift @@ -0,0 +1,185 @@ +// Internal helpers for encoding and decoding _meta fields per MCP spec + +import Foundation + +/// The reserved key name for metadata fields +let metaKey = "_meta" + +/// A dynamic coding key for encoding/decoding arbitrary string keys +struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} + +/// Error thrown when meta field validation fails +enum MetaFieldError: Error, LocalizedError, Equatable { + case invalidMetaKey(String) + + var errorDescription: String? { + switch self { + case .invalidMetaKey(let key): + return "Invalid _meta key: '\(key)'. Keys must follow the format: [prefix/]name where prefix is dot-separated labels and name is alphanumeric with hyphens, underscores, or dots." + } + } +} + +/// Validates that a key name follows the spec-defined format for _meta fields +/// Keys must follow the format: [prefix/]name where: +/// - prefix (optional): dot-separated labels, each starting with a letter, ending with letter or digit, containing letters, digits, or hyphens +/// - name: starts and ends with alphanumeric, may contain hyphens, underscores, dots, and alphanumerics +func validateMetaKey(_ key: String) throws { + guard isValidMetaKey(key) else { + throw MetaFieldError.invalidMetaKey(key) + } +} + +/// Checks if a key is valid for _meta without throwing +func isValidMetaKey(_ key: String) -> Bool { + // Empty keys are invalid + guard !key.isEmpty else { return false } + + let parts = key.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + + let prefix: Substring? + let name: Substring + + if parts.count == 2 { + prefix = parts[0] + name = parts[1] + } else { + prefix = nil + name = parts.first ?? "" + } + + // Validate prefix if present + if let prefix, !prefix.isEmpty { + let labels = prefix.split(separator: ".", omittingEmptySubsequences: false) + guard !labels.isEmpty else { return false } + for label in labels { + guard isValidPrefixLabel(label) else { return false } + } + } + + // Validate name + guard !name.isEmpty else { return false } + guard isValidName(name) else { return false } + + return true +} + +/// Validates that a prefix label follows the format: +/// - Starts with a letter +/// - Ends with a letter or digit +/// - Contains only letters, digits, or hyphens +private func isValidPrefixLabel(_ label: Substring) -> Bool { + guard let first = label.first, first.isLetter else { return false } + guard let last = label.last, last.isLetter || last.isNumber else { return false } + for character in label { + if character.isLetter || character.isNumber || character == "-" { + continue + } + return false + } + return true +} + +/// Validates that a name follows the format: +/// - Starts with a letter or digit +/// - Ends with a letter or digit +/// - Contains only letters, digits, hyphens, underscores, or dots +private func isValidName(_ name: Substring) -> Bool { + guard let first = name.first, first.isLetter || first.isNumber else { return false } + guard let last = name.last, last.isLetter || last.isNumber else { return false } + + for character in name { + if character.isLetter || character.isNumber || character == "-" || character == "_" || character == "." { + continue + } + return false + } + + return true +} + +// Character extensions for validation +private extension Character { + var isLetter: Bool { + unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } + } + + var isNumber: Bool { + unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } + } +} + +/// Encodes a _meta dictionary into a container, validating keys +func encodeMeta(_ meta: [String: Value]?, to container: inout KeyedEncodingContainer) throws { + guard let meta = meta, !meta.isEmpty else { return } + + // Validate all keys before encoding + for key in meta.keys { + try validateMetaKey(key) + } + + // Encode the _meta object + let metaCodingKey = DynamicCodingKey(stringValue: metaKey)! + var metaContainer = container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) + + for (key, value) in meta { + let dynamicKey = DynamicCodingKey(stringValue: key)! + try metaContainer.encode(value, forKey: dynamicKey) + } +} + +/// Encodes extra fields (index signature) into a container +func encodeExtraFields(_ extraFields: [String: Value]?, to container: inout KeyedEncodingContainer, excluding excludedKeys: Set = []) throws { + guard let extraFields = extraFields, !extraFields.isEmpty else { return } + + for (key, value) in extraFields where key != metaKey && !excludedKeys.contains(key) { + let dynamicKey = DynamicCodingKey(stringValue: key)! + try container.encode(value, forKey: dynamicKey) + } +} + +/// Decodes a _meta dictionary from a container +func decodeMeta(from container: KeyedDecodingContainer) throws -> [String: Value]? { + let metaCodingKey = DynamicCodingKey(stringValue: metaKey)! + + guard container.contains(metaCodingKey) else { + return nil + } + + let metaContainer = try container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) + var meta: [String: Value] = [:] + + for key in metaContainer.allKeys { + // Validate each key as we decode + try validateMetaKey(key.stringValue) + let value = try metaContainer.decode(Value.self, forKey: key) + meta[key.stringValue] = value + } + + return meta.isEmpty ? nil : meta +} + +/// Decodes extra fields (index signature) from a container +func decodeExtraFields(from container: KeyedDecodingContainer, excluding excludedKeys: Set = []) throws -> [String: Value]? { + var extraFields: [String: Value] = [:] + + for key in container.allKeys where key.stringValue != metaKey && !excludedKeys.contains(key.stringValue) { + let value = try container.decode(Value.self, forKey: key) + extraFields[key.stringValue] = value + } + + return extraFields.isEmpty ? nil : extraFields +} diff --git a/Sources/MCP/Client/Elicitation.swift b/Sources/MCP/Client/Elicitation.swift index 8cb04fac..3597afad 100644 --- a/Sources/MCP/Client/Elicitation.swift +++ b/Sources/MCP/Client/Elicitation.swift @@ -79,10 +79,45 @@ public enum CreateElicitation: Method { public var action: Action /// Submitted content when action is `.accept`. public var content: [String: Value]? + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? - public init(action: Action, content: [String: Value]? = nil) { + public init( + action: Action, + content: [String: Value]? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.action = action self.content = content + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case action, content + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(action, forKey: .action) + try container.encodeIfPresent(content, forKey: .content) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + action = try container.decode(Action.self, forKey: .action) + content = try container.decodeIfPresent([String: Value].self, forKey: .content) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Client/Sampling.swift b/Sources/MCP/Client/Sampling.swift index 46563985..886b80a2 100644 --- a/Sources/MCP/Client/Sampling.swift +++ b/Sources/MCP/Client/Sampling.swift @@ -222,17 +222,53 @@ public enum CreateSamplingMessage: Method { public let role: Sampling.Message.Role /// The completion content public let content: Sampling.Message.Content + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? public init( model: String, stopReason: Sampling.StopReason? = nil, role: Sampling.Message.Role, - content: Sampling.Message.Content + content: Sampling.Message.Content, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) { self.model = model self.stopReason = stopReason self.role = role self.content = content + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case model, stopReason, role, content + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(model, forKey: .model) + try container.encodeIfPresent(stopReason, forKey: .stopReason) + try container.encode(role, forKey: .role) + try container.encode(content, forKey: .content) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + model = try container.decode(String.self, forKey: .model) + stopReason = try container.decodeIfPresent(Sampling.StopReason.self, forKey: .stopReason) + role = try container.decode(Sampling.Message.Role.self, forKey: .role) + content = try container.decode(Sampling.Message.Content.self, forKey: .content) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Server/Prompts.swift b/Sources/MCP/Server/Prompts.swift index c194b28a..8868937f 100644 --- a/Sources/MCP/Server/Prompts.swift +++ b/Sources/MCP/Server/Prompts.swift @@ -15,11 +15,38 @@ public struct Prompt: Hashable, Codable, Sendable { public let description: String? /// The prompt arguments public let arguments: [Argument]? + /// Optional metadata about this prompt + public var _meta: [String: Value]? - public init(name: String, description: String? = nil, arguments: [Argument]? = nil) { + public init(name: String, description: String? = nil, arguments: [Argument]? = nil, meta: [String: Value]? = nil) { self.name = name self.description = description self.arguments = arguments + self._meta = meta + } + + private enum CodingKeys: String, CodingKey { + case name, description, arguments + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(arguments, forKey: .arguments) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + description = try container.decodeIfPresent(String.self, forKey: .description) + arguments = try container.decodeIfPresent([Argument].self, forKey: .arguments) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) } /// An argument for a prompt @@ -216,12 +243,45 @@ public enum ListPrompts: Method { } public struct Result: Hashable, Codable, Sendable { - public let prompts: [Prompt] - public let nextCursor: String? - - public init(prompts: [Prompt], nextCursor: String? = nil) { + let prompts: [Prompt] + let nextCursor: String? + var _meta: [String: Value]? + var extraFields: [String: Value]? + + public init( + prompts: [Prompt], + nextCursor: String? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.prompts = prompts self.nextCursor = nextCursor + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case prompts, nextCursor + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(prompts, forKey: .prompts) + try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + prompts = try container.decode([Prompt].self, forKey: .prompts) + nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -245,10 +305,45 @@ public enum GetPrompt: Method { public struct Result: Hashable, Codable, Sendable { public let description: String? public let messages: [Prompt.Message] - - public init(description: String?, messages: [Prompt.Message]) { + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? + + public init( + description: String? = nil, + messages: [Prompt.Message], + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.description = description self.messages = messages + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case description, messages + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(description, forKey: .description) + try container.encode(messages, forKey: .messages) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + description = try container.decodeIfPresent(String.self, forKey: .description) + messages = try container.decode([Prompt.Message].self, forKey: .messages) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index efdaa42f..f0d50d00 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -18,8 +18,8 @@ public struct Resource: Hashable, Codable, Sendable { public var mimeType: String? /// The resource metadata public var metadata: [String: String]? - /// General MCP fields such as `_meta`. - public var general: GeneralFields + /// Metadata fields for the resource (see spec for _meta usage) + public var _meta: [String: Value]? public init( name: String, @@ -27,14 +27,14 @@ public struct Resource: Hashable, Codable, Sendable { description: String? = nil, mimeType: String? = nil, metadata: [String: String]? = nil, - general: GeneralFields = .init() + _meta: [String: Value]? = nil ) { self.name = name self.uri = uri self.description = description self.mimeType = mimeType self.metadata = metadata - self.general = general + self._meta = _meta } private enum CodingKeys: String, CodingKey { @@ -52,10 +52,8 @@ public struct Resource: Hashable, Codable, Sendable { description = try container.decodeIfPresent(String.self, forKey: .description) mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) metadata = try container.decodeIfPresent([String: String].self, forKey: .metadata) - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) + let metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) } public func encode(to encoder: Encoder) throws { @@ -65,13 +63,8 @@ public struct Resource: Hashable, Codable, Sendable { try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(mimeType, forKey: .mimeType) try container.encodeIfPresent(metadata, forKey: .metadata) - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["name", "uri", "description", "mimeType", "metadata"] + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } /// Content of a resource. @@ -84,29 +77,29 @@ public struct Resource: Hashable, Codable, Sendable { public let text: String? /// The resource binary content public let blob: String? - /// General MCP fields such as `_meta`. - public var general: GeneralFields + /// Metadata fields (see spec for _meta usage) + public var _meta: [String: Value]? public static func text( _ content: String, uri: String, mimeType: String? = nil, - general: GeneralFields = .init() + _meta: [String: Value]? = nil ) -> Self { - .init(uri: uri, mimeType: mimeType, text: content, general: general) + .init(uri: uri, mimeType: mimeType, text: content, _meta: _meta) } public static func binary( _ data: Data, uri: String, mimeType: String? = nil, - general: GeneralFields = .init() + _meta: [String: Value]? = nil ) -> Self { .init( uri: uri, mimeType: mimeType, blob: data.base64EncodedString(), - general: general + _meta: _meta ) } @@ -114,26 +107,26 @@ public struct Resource: Hashable, Codable, Sendable { uri: String, mimeType: String? = nil, text: String? = nil, - general: GeneralFields = .init() + _meta: [String: Value]? = nil ) { self.uri = uri self.mimeType = mimeType self.text = text self.blob = nil - self.general = general + self._meta = _meta } private init( uri: String, mimeType: String? = nil, blob: String, - general: GeneralFields = .init() + _meta: [String: Value]? = nil ) { self.uri = uri self.mimeType = mimeType self.text = nil self.blob = blob - self.general = general + self._meta = _meta } private enum CodingKeys: String, CodingKey { @@ -149,10 +142,8 @@ public struct Resource: Hashable, Codable, Sendable { mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) text = try container.decodeIfPresent(String.self, forKey: .text) blob = try container.decodeIfPresent(String.self, forKey: .blob) - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) + let metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) } public func encode(to encoder: Encoder) throws { @@ -161,13 +152,10 @@ public struct Resource: Hashable, Codable, Sendable { try container.encodeIfPresent(mimeType, forKey: .mimeType) try container.encodeIfPresent(text, forKey: .text) try container.encodeIfPresent(blob, forKey: .blob) - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - private static var reservedGeneralFieldNames: Set { - ["uri", "mimeType", "text", "blob"] + // Encode _meta + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } } @@ -244,12 +232,45 @@ public enum ListResources: Method { } public struct Result: Hashable, Codable, Sendable { - public let resources: [Resource] - public let nextCursor: String? + let resources: [Resource] + let nextCursor: String? + var _meta: [String: Value]? + var extraFields: [String: Value]? - public init(resources: [Resource], nextCursor: String? = nil) { + public init( + resources: [Resource], + nextCursor: String? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.resources = resources self.nextCursor = nextCursor + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case resources, nextCursor + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(resources, forKey: .resources) + try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + resources = try container.decode([Resource].self, forKey: .resources) + nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -269,9 +290,41 @@ public enum ReadResource: Method { public struct Result: Hashable, Codable, Sendable { public let contents: [Resource.Content] - - public init(contents: [Resource.Content]) { + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? + + init( + contents: [Resource.Content], + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.contents = contents + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case contents + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(contents, forKey: .contents) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + contents = try container.decode([Resource.Content].self, forKey: .contents) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -296,16 +349,47 @@ public enum ListResourceTemplates: Method { public struct Result: Hashable, Codable, Sendable { public let templates: [Resource.Template] public let nextCursor: String? - - public init(templates: [Resource.Template], nextCursor: String? = nil) { + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? + + init( + templates: [Resource.Template], + nextCursor: String? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.templates = templates self.nextCursor = nextCursor + self._meta = _meta + self.extraFields = extraFields } - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case templates = "resourceTemplates" case nextCursor } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(templates, forKey: .templates) + try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + templates = try container.decode([Resource.Template].self, forKey: .templates) + nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } } } diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 57bff742..eb5af26e 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -19,8 +19,8 @@ public struct Tool: Hashable, Codable, Sendable { public let inputSchema: Value /// The tool output schema, defining expected output structure public let outputSchema: Value? - /// General MCP fields (e.g. `_meta`). - public var general: GeneralFields + /// Metadata fields for the tool (see spec for _meta usage) + public var _meta: [String: Value]? /// Annotations that provide display-facing and operational information for a Tool. /// @@ -95,8 +95,8 @@ public struct Tool: Hashable, Codable, Sendable { description: String?, inputSchema: Value, annotations: Annotations = nil, - general: GeneralFields = .init(), - outputSchema: Value? = nil + outputSchema: Value? = nil, + _meta: [String: Value]? = nil ) { self.name = name self.title = title @@ -104,7 +104,7 @@ public struct Tool: Hashable, Codable, Sendable { self.inputSchema = inputSchema self.outputSchema = outputSchema self.annotations = annotations - self.general = general + self._meta = _meta } /// Content types that can be returned by a tool @@ -239,10 +239,8 @@ public struct Tool: Hashable, Codable, Sendable { outputSchema = try container.decodeIfPresent(Value.self, forKey: .outputSchema) annotations = try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() - let dynamic = try decoder.container(keyedBy: DynamicCodingKey.self) - general = try GeneralFields.decode( - from: dynamic, - reservedKeyNames: Self.reservedGeneralFieldNames) + let metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) } public func encode(to encoder: Encoder) throws { @@ -255,13 +253,8 @@ public struct Tool: Hashable, Codable, Sendable { if !annotations.isEmpty { try container.encode(annotations, forKey: .annotations) } - try general.encode( - into: encoder, - reservedKeyNames: Self.reservedGeneralFieldNames) - } - - private static var reservedGeneralFieldNames: Set { - ["name", "title", "description", "inputSchema", "outputSchema", "annotations"] + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } } @@ -285,12 +278,45 @@ public enum ListTools: Method { } public struct Result: Hashable, Codable, Sendable { - public let tools: [Tool] - public let nextCursor: String? + let tools: [Tool] + let nextCursor: String? + var _meta: [String: Value]? + var extraFields: [String: Value]? - public init(tools: [Tool], nextCursor: String? = nil) { + public init( + tools: [Tool], + nextCursor: String? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.tools = tools self.nextCursor = nextCursor + self._meta = _meta + self.extraFields = extraFields + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case tools, nextCursor + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tools, forKey: .tools) + try container.encodeIfPresent(nextCursor, forKey: .nextCursor) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tools = try container.decode([Tool].self, forKey: .tools) + nextCursor = try container.decodeIfPresent(String.self, forKey: .nextCursor) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -314,39 +340,66 @@ public enum CallTool: Method { public let content: [Tool.Content] public let structuredContent: Value? public let isError: Bool? + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? public init( content: [Tool.Content] = [], structuredContent: Value? = nil, - isError: Bool? = nil + isError: Bool? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) { self.content = content self.structuredContent = structuredContent self.isError = isError + self._meta = _meta + self.extraFields = extraFields } public init( content: [Tool.Content] = [], structuredContent: Output, - isError: Bool? = nil + isError: Bool? = nil, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil ) throws { let encoded = try Value(structuredContent) self.init( content: content, structuredContent: Optional.some(encoded), - isError: isError + isError: isError, + _meta: _meta, + extraFields: extraFields ) } - public init( - structuredContent: Output, - isError: Bool? = nil - ) throws { - try self.init( - content: [], - structuredContent: structuredContent, - isError: isError - ) + private enum CodingKeys: String, CodingKey, CaseIterable { + case content, structuredContent, isError + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(content, forKey: .content) + try container.encodeIfPresent(structuredContent, forKey: .structuredContent) + try container.encodeIfPresent(isError, forKey: .isError) + + var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &dynamicContainer) + try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decode([Tool.Content].self, forKey: .content) + structuredContent = try container.decodeIfPresent(Value.self, forKey: .structuredContent) + isError = try container.decodeIfPresent(Bool.self, forKey: .isError) + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Tests/MCPTests/GeneralFieldsTests.swift b/Tests/MCPTests/GeneralFieldsTests.swift deleted file mode 100644 index 979bd5f5..00000000 --- a/Tests/MCPTests/GeneralFieldsTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Testing - -import class Foundation.JSONDecoder -import class Foundation.JSONEncoder -import class Foundation.JSONSerialization - -@testable import MCP - -@Suite("General Fields") -struct GeneralFieldsTests { - private struct Payload: Codable, Hashable, Sendable { - let message: String - } - - private enum TestMethod: Method { - static let name = "test.general" - typealias Parameters = Payload - typealias Result = Payload - } - - @Test("Encoding includes meta and custom fields") - func testEncodingGeneralFields() throws { - let meta = try MetaFields(values: ["vendor.example/request-id": .string("abc123")]) - let general = GeneralFields(meta: meta, additional: ["custom": .int(5)]) - - let request = Request( - id: 42, - method: TestMethod.name, - params: Payload(message: "hello"), - generalFields: general - ) - - let data = try JSONEncoder().encode(request) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["vendor.example/request-id"] as? String == "abc123") - #expect(json?["custom"] as? Int == 5) - } - - @Test("Decoding restores general fields") - func testDecodingGeneralFields() throws { - let payload: [String: Any] = [ - "jsonrpc": "2.0", - "id": 7, - "method": TestMethod.name, - "params": ["message": "hi"], - "_meta": ["vendor.example/session": "s42"], - "custom-data": ["value": 1], - ] - - let data = try JSONSerialization.data(withJSONObject: payload) - let decoded = try JSONDecoder().decode(Request.self, from: data) - - let metaValue = decoded.generalFields.meta?.dictionary["vendor.example/session"] - #expect(metaValue == .string("s42")) - #expect(decoded.generalFields.additional["custom-data"] == .object(["value": .int(1)])) - } - - @Test("Reserved fields are ignored when encoding extras") - func testReservedFieldsIgnored() throws { - let general = GeneralFields( - meta: nil, - additional: ["method": .string("override"), "custom": .bool(true)] - ) - - let request = Request( - id: 1, - method: TestMethod.name, - params: Payload(message: "ping"), - generalFields: general - ) - - let data = try JSONEncoder().encode(request) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - #expect(json?["method"] as? String == TestMethod.name) - #expect(json?["custom"] as? Bool == true) - - let decoded = try JSONDecoder().decode(Request.self, from: data) - #expect(decoded.generalFields.additional["method"] == nil) - #expect(decoded.generalFields.additional["custom"] == .bool(true)) - } - - @Test("Invalid meta key is rejected") - func testInvalidMetaKey() { - #expect(throws: GeneralFieldError.invalidMetaKey("invalid key")) { - _ = try MetaFields(values: ["invalid key": .int(1)]) - } - } - - @Test("Response encoding includes general fields") - func testResponseGeneralFields() throws { - let meta = try MetaFields(values: ["vendor.example/status": .string("partial")]) - let general = GeneralFields(meta: meta, additional: ["progress": .int(50)]) - let response = Response( - id: 99, - result: .success(Payload(message: "ok")), - general: general - ) - - let data = try JSONEncoder().encode(response) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["vendor.example/status"] as? String == "partial") - #expect(json?["progress"] as? Int == 50) - - let decoded = try JSONDecoder().decode(Response.self, from: data) - #expect(decoded.general.additional["progress"] == .int(50)) - #expect( - decoded.general.meta?.dictionary["vendor.example/status"] - == .string("partial") - ) - } - - @Test("Tool encoding and decoding with general fields") - func testToolGeneralFields() throws { - let meta = try MetaFields(values: [ - "vendor.example/outputTemplate": .string("ui://widget/kanban-board.html") - ]) - let general = GeneralFields( - meta: meta, - additional: ["openai/toolInvocation/invoking": .string("Displaying the board")] - ) - - let tool = Tool( - name: "kanban-board", - title: "Kanban Board", - description: "Display kanban widget", - inputSchema: try Value(["type": "object"]), - general: general - ) - - let data = try JSONEncoder().encode(tool) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - - let metaObject = json?["_meta"] as? [String: Any] - #expect( - metaObject?["vendor.example/outputTemplate"] as? String - == "ui://widget/kanban-board.html") - #expect(json?["openai/toolInvocation/invoking"] as? String == "Displaying the board") - - let decoded = try JSONDecoder().decode(Tool.self, from: data) - #expect( - decoded.general.additional["openai/toolInvocation/invoking"] - == .string("Displaying the board")) - #expect( - decoded.general.meta?.dictionary["vendor.example/outputTemplate"] - == .string("ui://widget/kanban-board.html") - ) - } - - @Test("Resource content encodes meta") - func testResourceContentGeneralFields() throws { - let meta = try MetaFields(values: [ - "openai/widgetPrefersBorder": .bool(true) - ]) - let general = GeneralFields( - meta: meta, - additional: ["openai/widgetDomain": .string("https://chatgpt.com")] - ) - - let content = Resource.Content.text( - "
Widget
", - uri: "ui://widget/kanban-board.html", - mimeType: "text/html", - general: general - ) - - let data = try JSONEncoder().encode(content) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let metaObject = json?["_meta"] as? [String: Any] - - #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) - #expect(json?["openai/widgetDomain"] as? String == "https://chatgpt.com") - - let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) - #expect( - decoded.general.meta?.dictionary["openai/widgetPrefersBorder"] == .bool(true)) - #expect(decoded.general.additional["openai/widgetDomain"] == .string("https://chatgpt.com")) - } -} diff --git a/Tests/MCPTests/MetaFieldsTests.swift b/Tests/MCPTests/MetaFieldsTests.swift new file mode 100644 index 00000000..a722f9f7 --- /dev/null +++ b/Tests/MCPTests/MetaFieldsTests.swift @@ -0,0 +1,411 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder +import class Foundation.JSONSerialization + +@testable import MCP + +@Suite("Meta Fields") +struct MetaFieldsTests { + private struct Payload: Codable, Hashable, Sendable { + let message: String + } + + private enum TestMethod: Method { + static let name = "test.general" + typealias Parameters = Payload + typealias Result = Payload + } + + @Test("Encoding includes meta and custom fields") + func testEncodingGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/request-id": .string("abc123")] + let extra: [String: Value] = ["custom": .int(5)] + + let request = Request( + id: 42, + method: TestMethod.name, + params: Payload(message: "hello"), + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/request-id"] as? String == "abc123") + #expect(json?["custom"] as? Int == 5) + } + + @Test("Decoding restores general fields") + func testDecodingGeneralFields() throws { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": 7, + "method": TestMethod.name, + "params": ["message": "hi"], + "_meta": ["vendor.example/session": "s42"], + "custom-data": ["value": 1], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + let metaValue = decoded._meta?["vendor.example/session"] + #expect(metaValue == .string("s42")) + #expect(decoded.extraFields?["custom-data"] == .object(["value": .int(1)])) + } + + @Test("Reserved fields are ignored when encoding extras") + func testReservedFieldsIgnored() throws { + let extra: [String: Value] = ["method": .string("override"), "custom": .bool(true)] + + let request = Request( + id: 1, + method: TestMethod.name, + params: Payload(message: "ping"), + _meta: nil, + extraFields: extra + ) + + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + #expect(json?["method"] as? String == TestMethod.name) + #expect(json?["custom"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Request.self, from: data) + #expect(decoded.extraFields?["method"] == nil) + #expect(decoded.extraFields?["custom"] == .bool(true)) + } + + @Test("Invalid meta key is rejected") + func testInvalidMetaKey() { + #expect(throws: MetaFieldError.invalidMetaKey("invalid key")) { + let meta: [String: Value] = ["invalid key": .int(1)] + let request = Request( + id: 1, + method: TestMethod.name, + params: Payload(message: "test"), + _meta: meta, + extraFields: nil + ) + _ = try JSONEncoder().encode(request) + } + } + + @Test("Response encoding includes general fields") + func testResponseGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/status": .string("partial")] + let extra: [String: Value] = ["progress": .int(50)] + let response = Response( + id: 99, + result: .success(Payload(message: "ok")), + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(response) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/status"] as? String == "partial") + #expect(json?["progress"] as? Int == 50) + + let decoded = try JSONDecoder().decode(Response.self, from: data) + #expect(decoded.extraFields?["progress"] == .int(50)) + #expect(decoded._meta?["vendor.example/status"] == .string("partial")) + } + + @Test("Tool encoding and decoding with general fields") + func testToolGeneralFields() throws { + let meta: [String: Value] = [ + "vendor.example/outputTemplate": .string("ui://widget/kanban-board.html") + ] + + let tool = Tool( + name: "kanban-board", + title: "Kanban Board", + description: "Display kanban widget", + inputSchema: try Value(["type": "object"]), + _meta: meta + ) + + let data = try JSONEncoder().encode(tool) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect( + metaObject?["vendor.example/outputTemplate"] as? String + == "ui://widget/kanban-board.html") + + let decoded = try JSONDecoder().decode(Tool.self, from: data) + #expect( + decoded._meta?["vendor.example/outputTemplate"] + == .string("ui://widget/kanban-board.html") + ) + } + + @Test("Resource content encodes meta") + func testResourceContentGeneralFields() throws { + let meta: [String: Value] = [ + "openai/widgetPrefersBorder": .bool(true) + ] + + let content = Resource.Content.text( + "
Widget
", + uri: "ui://widget/kanban-board.html", + mimeType: "text/html", + _meta: meta + ) + + let data = try JSONEncoder().encode(content) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + + #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) + #expect(decoded._meta?["openai/widgetPrefersBorder"] == .bool(true)) + } + + @Test("Initialize.Result encoding with meta and extra fields") + func testInitializeResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/build": .string("v1.0.0")] + let extra: [String: Value] = ["serverTime": .int(1_234_567_890)] + + let result = Initialize.Result( + protocolVersion: "2024-11-05", + capabilities: Server.Capabilities(), + serverInfo: Server.Info(name: "test", version: "1.0"), + instructions: "Test server", + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/build"] as? String == "v1.0.0") + #expect(json?["serverTime"] as? Int == 1_234_567_890) + + let decoded = try JSONDecoder().decode(Initialize.Result.self, from: data) + #expect(decoded._meta?["vendor.example/build"] == .string("v1.0.0")) + #expect(decoded.extraFields?["serverTime"] == .int(1_234_567_890)) + } + + @Test("ListTools.Result encoding with meta and extra fields") + func testListToolsResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/page": .int(1)] + let extra: [String: Value] = ["totalCount": .int(42)] + + let tool = Tool( + name: "test", + description: "A test tool", + inputSchema: try Value(["type": "object"]) + ) + + let result = ListTools.Result( + tools: [tool], + nextCursor: "page2", + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/page"] as? Int == 1) + #expect(json?["totalCount"] as? Int == 42) + + let decoded = try JSONDecoder().decode(ListTools.Result.self, from: data) + #expect(decoded._meta?["vendor.example/page"] == .int(1)) + #expect(decoded.extraFields?["totalCount"] == .int(42)) + } + + @Test("CallTool.Result encoding with meta and extra fields") + func testCallToolResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/executionTime": .int(150)] + let extra: [String: Value] = ["cacheHit": .bool(true)] + + let result = CallTool.Result( + content: [.text("Result data")], + isError: false, + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/executionTime"] as? Int == 150) + #expect(json?["cacheHit"] as? Bool == true) + + let decoded = try JSONDecoder().decode(CallTool.Result.self, from: data) + #expect(decoded._meta?["vendor.example/executionTime"] == .int(150)) + #expect(decoded.extraFields?["cacheHit"] == .bool(true)) + } + + @Test("ListResources.Result encoding with meta and extra fields") + func testListResourcesResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/cacheControl": .string("max-age=3600")] + let extra: [String: Value] = ["refreshRate": .int(60)] + + let resource = Resource( + name: "test.txt", + uri: "file://test.txt", + description: "Test resource", + mimeType: "text/plain" + ) + + let result = ListResources.Result( + resources: [resource], + nextCursor: nil, + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + let metaObject = json["_meta"] as! [String: Any] + #expect(metaObject["vendor.example/cacheControl"] as? String == "max-age=3600") + #expect(json["refreshRate"] as? Int == 60) + + let decoded = try JSONDecoder().decode(ListResources.Result.self, from: data) + #expect(decoded._meta?["vendor.example/cacheControl"] == Value.string("max-age=3600")) + #expect(decoded.extraFields?["refreshRate"] == Value.int(60)) + } + + @Test("ReadResource.Result encoding with meta and extra fields") + func testReadResourceResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/encoding": .string("utf-8")] + let extra: [String: Value] = ["fileSize": .int(1024)] + + let result = ReadResource.Result( + contents: [.text("file contents", uri: "file://test.txt")], + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/encoding"] as? String == "utf-8") + #expect(json?["fileSize"] as? Int == 1024) + + let decoded = try JSONDecoder().decode(ReadResource.Result.self, from: data) + #expect(decoded._meta?["vendor.example/encoding"] == .string("utf-8")) + #expect(decoded.extraFields?["fileSize"] == .int(1024)) + } + + @Test("ListPrompts.Result encoding with meta and extra fields") + func testListPromptsResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/category": .string("system")] + let extra: [String: Value] = ["featured": .bool(true)] + + let prompt = Prompt( + name: "greeting", + description: "A greeting prompt" + ) + + let result = ListPrompts.Result( + prompts: [prompt], + nextCursor: nil, + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/category"] as? String == "system") + #expect(json?["featured"] as? Bool == true) + + let decoded = try JSONDecoder().decode(ListPrompts.Result.self, from: data) + #expect(decoded._meta?["vendor.example/category"] == .string("system")) + #expect(decoded.extraFields?["featured"] == .bool(true)) + } + + @Test("GetPrompt.Result encoding with meta and extra fields") + func testGetPromptResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/version": .int(2)] + let extra: [String: Value] = ["lastModified": .string("2024-01-01")] + + let message = Prompt.Message.user("Hello") + + let result = GetPrompt.Result( + description: "A test prompt", + messages: [message], + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + let metaObject = json["_meta"] as! [String: Any] + #expect(metaObject["vendor.example/version"] as? Int == 2) + #expect(json["lastModified"] as? String == "2024-01-01") + + let decoded = try JSONDecoder().decode(GetPrompt.Result.self, from: data) + #expect(decoded._meta?["vendor.example/version"] == Value.int(2)) + #expect(decoded.extraFields?["lastModified"] == Value.string("2024-01-01")) + } + + @Test("CreateSamplingMessage.Result encoding with meta and extra fields") + func testSamplingResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/model-version": .string("gpt-4-0613")] + let extra: [String: Value] = ["tokensUsed": .int(250)] + + let result = CreateSamplingMessage.Result( + model: "gpt-4", + stopReason: .endTurn, + role: .assistant, + content: .text("Hello!"), + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/model-version"] as? String == "gpt-4-0613") + #expect(json?["tokensUsed"] as? Int == 250) + + let decoded = try JSONDecoder().decode(CreateSamplingMessage.Result.self, from: data) + #expect(decoded._meta?["vendor.example/model-version"] == .string("gpt-4-0613")) + #expect(decoded.extraFields?["tokensUsed"] == .int(250)) + } + + @Test("CreateElicitation.Result encoding with meta and extra fields") + func testElicitationResultGeneralFields() throws { + let meta: [String: Value] = ["vendor.example/timestamp": .int(1_640_000_000)] + let extra: [String: Value] = ["userAgent": .string("TestApp/1.0")] + + let result = CreateElicitation.Result( + action: .accept, + content: ["response": .string("user input")], + _meta: meta, + extraFields: extra + ) + + let data = try JSONEncoder().encode(result) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/timestamp"] as? Int == 1_640_000_000) + #expect(json?["userAgent"] as? String == "TestApp/1.0") + + let decoded = try JSONDecoder().decode(CreateElicitation.Result.self, from: data) + #expect(decoded._meta?["vendor.example/timestamp"] == .int(1_640_000_000)) + #expect(decoded.extraFields?["userAgent"] == .string("TestApp/1.0")) + } +} From 1ff4e1d112e188fa169b0c5033b85c2676b369ec Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 20:15:13 -0400 Subject: [PATCH 12/18] Elicitation in README --- README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/README.md b/README.md index ffb881f3..f5ddbbe3 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,102 @@ This human-in-the-loop design ensures that users maintain control over what the LLM sees and generates, even when servers initiate the requests. +### Elicitation + +Elicitation allows servers to request structured information directly from users through the client. +This is useful when servers need user input that wasn't provided in the original request, +such as credentials, configuration choices, or approval for sensitive operations. + +> [!TIP] +> Elicitation requests flow from **server to client**, +> similar to sampling. +> Clients must register a handler to respond to elicitation requests from servers. + +#### Client-Side: Handling Elicitation Requests + +Register an elicitation handler to respond to server requests: + +```swift +// Register an elicitation handler in the client +await client.setElicitationHandler { parameters in + // Display the request to the user + print("Server requests: \(parameters.message)") + + // If a schema was provided, validate against it + if let schema = parameters.requestedSchema { + print("Required fields: \(schema.required ?? [])") + print("Schema: \(schema.properties)") + } + + // Present UI to collect user input + let userResponse = presentElicitationUI(parameters) + + // Return the user's response + if userResponse.accepted { + return CreateElicitation.Result( + action: .accept, + content: userResponse.data + ) + } else if userResponse.canceled { + return CreateElicitation.Result(action: .cancel) + } else { + return CreateElicitation.Result(action: .decline) + } +} +``` + +#### Server-Side: Requesting User Input + +Servers can request information from users through elicitation: + +```swift +// Request credentials from the user +let schema = Elicitation.RequestSchema( + title: "API Credentials Required", + description: "Please provide your API credentials to continue", + properties: [ + "apiKey": .object([ + "type": .string("string"), + "description": .string("Your API key") + ]), + "apiSecret": .object([ + "type": .string("string"), + "description": .string("Your API secret") + ]) + ], + required: ["apiKey", "apiSecret"] +) + +let result = try await client.request( + CreateElicitation.self, + params: CreateElicitation.Parameters( + message: "This operation requires API credentials", + requestedSchema: schema + ) +) + +switch result.action { +case .accept: + if let credentials = result.content { + let apiKey = credentials["apiKey"]?.stringValue + let apiSecret = credentials["apiSecret"]?.stringValue + // Use the credentials... + } +case .decline: + // User declined to provide credentials + throw MCPError.invalidRequest("User declined credential request") +case .cancel: + // User canceled the operation + throw MCPError.invalidRequest("Operation canceled by user") +} +``` + +Common use cases for elicitation: +- **Authentication**: Request credentials when needed rather than upfront +- **Confirmation**: Ask for user approval before sensitive operations +- **Configuration**: Collect preferences or settings during operation +- **Missing information**: Request additional details not provided initially + ### Error Handling Handle common client errors: From 687fa7277c8ffd2b5437b66cc845ecb2bfcd5cc0 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 20:15:24 -0400 Subject: [PATCH 13/18] Fix orphaned test --- Tests/MCPTests/ToolTests.swift | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index 56c6ce69..e187af87 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -460,21 +460,21 @@ struct ToolTests { #expect(Bool(false), "Expected success result") } } -} -@Test("Tool with missing description") -func testToolWithMissingDescription() throws { - let jsonString = """ - { - "name": "test_tool", - "inputSchema": {} - } - """ - let jsonData = jsonString.data(using: .utf8)! + @Test("Tool with missing description") + func testToolWithMissingDescription() throws { + let jsonString = """ + { + "name": "test_tool", + "inputSchema": {} + } + """ + let jsonData = jsonString.data(using: .utf8)! - let tool = try JSONDecoder().decode(Tool.self, from: jsonData) + let tool = try JSONDecoder().decode(Tool.self, from: jsonData) - #expect(tool.name == "test_tool") - #expect(tool.description == nil) - #expect(tool.inputSchema == [:]) + #expect(tool.name == "test_tool") + #expect(tool.description == nil) + #expect(tool.inputSchema == [:]) + } } From 5dece544b970f5fd5760c90c53fe14f16a5395fd Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 20:41:41 -0400 Subject: [PATCH 14/18] Add title to more structs --- Sources/MCP/Client/Client.swift | 10 +++++-- Sources/MCP/Server/Prompts.swift | 48 ++++++++++++++++++++++++------ Sources/MCP/Server/Resources.swift | 32 ++++++++++++++++---- Sources/MCP/Server/Server.swift | 10 +++++-- Sources/MCP/Server/Tools.swift | 29 ++++++++++++------ Tests/MCPTests/PromptTests.swift | 8 ++++- Tests/MCPTests/ResourceTests.swift | 8 +++-- Tests/MCPTests/ToolTests.swift | 31 +++++++++++++++++++ 8 files changed, 145 insertions(+), 31 deletions(-) diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift index a2d749c9..2d966661 100644 --- a/Sources/MCP/Client/Client.swift +++ b/Sources/MCP/Client/Client.swift @@ -34,11 +34,14 @@ public actor Client { public struct Info: Hashable, Codable, Sendable { /// The client name public var name: String + /// A human-readable title for display purposes + public var title: String? /// The client version public var version: String - public init(name: String, version: String) { + public init(name: String, version: String, title: String? = nil) { self.name = name + self.title = title self.version = version } } @@ -100,6 +103,8 @@ public actor Client { private let clientInfo: Client.Info /// The client name public nonisolated var name: String { clientInfo.name } + /// A human-readable client title + public nonisolated var title: String? { clientInfo.title } /// The client version public nonisolated var version: String { clientInfo.version } @@ -169,9 +174,10 @@ public actor Client { public init( name: String, version: String, + title: String? = nil, configuration: Configuration = .default ) { - self.clientInfo = Client.Info(name: name, version: version) + self.clientInfo = Client.Info(name: name, version: version, title: title) self.capabilities = Capabilities() self.configuration = configuration } diff --git a/Sources/MCP/Server/Prompts.swift b/Sources/MCP/Server/Prompts.swift index 8868937f..e04ca7e7 100644 --- a/Sources/MCP/Server/Prompts.swift +++ b/Sources/MCP/Server/Prompts.swift @@ -11,6 +11,8 @@ import Foundation public struct Prompt: Hashable, Codable, Sendable { /// The prompt name public let name: String + /// A human-readable prompt title + public let title: String? /// The prompt description public let description: String? /// The prompt arguments @@ -18,20 +20,28 @@ public struct Prompt: Hashable, Codable, Sendable { /// Optional metadata about this prompt public var _meta: [String: Value]? - public init(name: String, description: String? = nil, arguments: [Argument]? = nil, meta: [String: Value]? = nil) { + public init( + name: String, + title: String? = nil, + description: String? = nil, + arguments: [Argument]? = nil, + meta: [String: Value]? = nil + ) { self.name = name + self.title = title self.description = description self.arguments = arguments self._meta = meta } private enum CodingKeys: String, CodingKey { - case name, description, arguments + case name, title, description, arguments } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) + try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(arguments, forKey: .arguments) @@ -42,6 +52,7 @@ public struct Prompt: Hashable, Codable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) + title = try container.decodeIfPresent(String.self, forKey: .title) description = try container.decodeIfPresent(String.self, forKey: .description) arguments = try container.decodeIfPresent([Argument].self, forKey: .arguments) @@ -53,13 +64,21 @@ public struct Prompt: Hashable, Codable, Sendable { public struct Argument: Hashable, Codable, Sendable { /// The argument name public let name: String + /// A human-readable argument title + public let title: String? /// The argument description public let description: String? /// Whether the argument is required public let required: Bool? - public init(name: String, description: String? = nil, required: Bool? = nil) { + public init( + name: String, + title: String? = nil, + description: String? = nil, + required: Bool? = nil + ) { self.name = name + self.title = title self.description = description self.required = required } @@ -122,25 +141,30 @@ public struct Prompt: Hashable, Codable, Sendable { public struct Reference: Hashable, Codable, Sendable { /// The prompt reference name public let name: String + /// A human-readable prompt title + public let title: String? - public init(name: String) { + public init(name: String, title: String? = nil) { self.name = name + self.title = title } private enum CodingKeys: String, CodingKey { - case type, name + case type, name, title } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode("ref/prompt", forKey: .type) try container.encode(name, forKey: .name) + try container.encodeIfPresent(title, forKey: .title) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) _ = try container.decode(String.self, forKey: .type) name = try container.decode(String.self, forKey: .name) + title = try container.decodeIfPresent(String.self, forKey: .title) } } } @@ -271,7 +295,9 @@ public enum ListPrompts: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -281,7 +307,8 @@ public enum ListPrompts: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -333,7 +360,9 @@ public enum GetPrompt: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -343,7 +372,8 @@ public enum GetPrompt: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index f0d50d00..f23e6863 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -10,6 +10,8 @@ import Foundation public struct Resource: Hashable, Codable, Sendable { /// The resource name public var name: String + /// A human-readable resource title + public var title: String? /// The resource URI public var uri: String /// The resource description @@ -24,12 +26,14 @@ public struct Resource: Hashable, Codable, Sendable { public init( name: String, uri: String, + title: String? = nil, description: String? = nil, mimeType: String? = nil, metadata: [String: String]? = nil, _meta: [String: Value]? = nil ) { self.name = name + self.title = title self.uri = uri self.description = description self.mimeType = mimeType @@ -40,6 +44,7 @@ public struct Resource: Hashable, Codable, Sendable { private enum CodingKeys: String, CodingKey { case name case uri + case title case description case mimeType case metadata @@ -49,6 +54,7 @@ public struct Resource: Hashable, Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) uri = try container.decode(String.self, forKey: .uri) + title = try container.decodeIfPresent(String.self, forKey: .title) description = try container.decodeIfPresent(String.self, forKey: .description) mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) metadata = try container.decodeIfPresent([String: String].self, forKey: .metadata) @@ -60,6 +66,7 @@ public struct Resource: Hashable, Codable, Sendable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) try container.encode(uri, forKey: .uri) + try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(mimeType, forKey: .mimeType) try container.encodeIfPresent(metadata, forKey: .metadata) @@ -165,6 +172,8 @@ public struct Resource: Hashable, Codable, Sendable { public var uriTemplate: String /// The template name public var name: String + /// A human-readable template title + public var title: String? /// The template description public var description: String? /// The resource MIME type @@ -173,11 +182,13 @@ public struct Resource: Hashable, Codable, Sendable { public init( uriTemplate: String, name: String, + title: String? = nil, description: String? = nil, mimeType: String? = nil ) { self.uriTemplate = uriTemplate self.name = name + self.title = title self.description = description self.mimeType = mimeType } @@ -260,7 +271,9 @@ public enum ListResources: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -270,7 +283,8 @@ public enum ListResources: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -315,7 +329,9 @@ public enum ReadResource: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -324,7 +340,8 @@ public enum ReadResource: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -378,7 +395,9 @@ public enum ListResourceTemplates: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -388,7 +407,8 @@ public enum ListResourceTemplates: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Server/Server.swift b/Sources/MCP/Server/Server.swift index 0fe625c7..71059dcd 100644 --- a/Sources/MCP/Server/Server.swift +++ b/Sources/MCP/Server/Server.swift @@ -30,11 +30,14 @@ public actor Server { public struct Info: Hashable, Codable, Sendable { /// The server name public let name: String + /// A human-readable server title for display + public let title: String? /// The server version public let version: String - public init(name: String, version: String) { + public init(name: String, version: String, title: String? = nil) { self.name = name + self.title = title self.version = version } } @@ -126,6 +129,8 @@ public actor Server { /// The server name public nonisolated var name: String { serverInfo.name } + /// A human-readable server title + public nonisolated var title: String? { serverInfo.title } /// The server version public nonisolated var version: String { serverInfo.version } /// Instructions describing how to use the server and its features @@ -161,11 +166,12 @@ public actor Server { public init( name: String, version: String, + title: String? = nil, instructions: String? = nil, capabilities: Server.Capabilities = .init(), configuration: Configuration = .default ) { - self.serverInfo = Server.Info(name: name, version: version) + self.serverInfo = Server.Info(name: name, version: version, title: title) self.capabilities = capabilities self.configuration = configuration self.instructions = instructions diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index eb5af26e..f117dd4d 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -122,7 +122,8 @@ public struct Tool: Hashable, Codable, Sendable { ) /// Resource link case resourceLink( - uri: String, name: String, description: String? = nil, mimeType: String? = nil, + uri: String, name: String, title: String? = nil, description: String? = nil, + mimeType: String? = nil, annotations: Resource.Annotations? = nil ) @@ -174,13 +175,14 @@ public struct Tool: Hashable, Codable, Sendable { case "resourceLink": let uri = try container.decode(String.self, forKey: .uri) let name = try container.decode(String.self, forKey: .name) + let title = try container.decodeIfPresent(String.self, forKey: .title) let description = try container.decodeIfPresent(String.self, forKey: .description) let mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) let annotations = try container.decodeIfPresent( Resource.Annotations.self, forKey: .annotations) self = .resourceLink( - uri: uri, name: name, description: description, mimeType: mimeType, - annotations: annotations) + uri: uri, name: name, title: title, description: description, + mimeType: mimeType, annotations: annotations) default: throw DecodingError.dataCorruptedError( forKey: .type, in: container, debugDescription: "Unknown tool content type") @@ -210,10 +212,12 @@ public struct Tool: Hashable, Codable, Sendable { try container.encodeIfPresent(text, forKey: .text) try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(annotations, forKey: .annotations) - case .resourceLink(let uri, let name, let description, let mimeType, let annotations): + case .resourceLink( + let uri, let name, let title, let description, let mimeType, let annotations): try container.encode("resourceLink", forKey: .type) try container.encode(uri, forKey: .uri) try container.encode(name, forKey: .name) + try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(mimeType, forKey: .mimeType) try container.encodeIfPresent(annotations, forKey: .annotations) @@ -306,7 +310,9 @@ public enum ListTools: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -316,7 +322,8 @@ public enum ListTools: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } @@ -388,18 +395,22 @@ public enum CallTool: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) content = try container.decode([Tool.Content].self, forKey: .content) - structuredContent = try container.decodeIfPresent(Value.self, forKey: .structuredContent) + structuredContent = try container.decodeIfPresent( + Value.self, forKey: .structuredContent) isError = try container.decodeIfPresent(Bool.self, forKey: .isError) let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Tests/MCPTests/PromptTests.swift b/Tests/MCPTests/PromptTests.swift index 20e5c279..561bdfca 100644 --- a/Tests/MCPTests/PromptTests.swift +++ b/Tests/MCPTests/PromptTests.swift @@ -9,20 +9,24 @@ struct PromptTests { func testPromptInitialization() throws { let argument = Prompt.Argument( name: "test_arg", + title: "Test Argument Title", description: "A test argument", required: true ) let prompt = Prompt( name: "test_prompt", + title: "Test Prompt Title", description: "A test prompt", arguments: [argument] ) #expect(prompt.name == "test_prompt") + #expect(prompt.title == "Test Prompt Title") #expect(prompt.description == "A test prompt") #expect(prompt.arguments?.count == 1) #expect(prompt.arguments?[0].name == "test_arg") + #expect(prompt.arguments?[0].title == "Test Argument Title") #expect(prompt.arguments?[0].description == "A test argument") #expect(prompt.arguments?[0].required == true) } @@ -104,8 +108,9 @@ struct PromptTests { @Test("Prompt Reference validation") func testPromptReference() throws { - let reference = Prompt.Reference(name: "test_prompt") + let reference = Prompt.Reference(name: "test_prompt", title: "Test Prompt Title") #expect(reference.name == "test_prompt") + #expect(reference.title == "Test Prompt Title") let encoder = JSONEncoder() let decoder = JSONDecoder() @@ -114,6 +119,7 @@ struct PromptTests { let decoded = try decoder.decode(Prompt.Reference.self, from: data) #expect(decoded.name == "test_prompt") + #expect(decoded.title == "Test Prompt Title") } @Test("GetPrompt parameters validation") diff --git a/Tests/MCPTests/ResourceTests.swift b/Tests/MCPTests/ResourceTests.swift index 54036327..4361afc5 100644 --- a/Tests/MCPTests/ResourceTests.swift +++ b/Tests/MCPTests/ResourceTests.swift @@ -10,6 +10,7 @@ struct ResourceTests { let resource = Resource( name: "test_resource", uri: "file://test.txt", + title: "Test Resource Title", description: "A test resource", mimeType: "text/plain", metadata: ["key": "value"] @@ -17,6 +18,7 @@ struct ResourceTests { #expect(resource.name == "test_resource") #expect(resource.uri == "file://test.txt") + #expect(resource.title == "Test Resource Title") #expect(resource.description == "A test resource") #expect(resource.mimeType == "text/plain") #expect(resource.metadata?["key"] == "value") @@ -27,6 +29,7 @@ struct ResourceTests { let resource = Resource( name: "test_resource", uri: "file://test.txt", + title: "Test Resource Title", description: "Test resource description", mimeType: "text/plain", metadata: ["key1": "value1", "key2": "value2"] @@ -40,6 +43,7 @@ struct ResourceTests { #expect(decoded.name == resource.name) #expect(decoded.uri == resource.uri) + #expect(decoded.title == resource.title) #expect(decoded.description == resource.description) #expect(decoded.mimeType == resource.mimeType) #expect(decoded.metadata == resource.metadata) @@ -86,7 +90,7 @@ struct ResourceTests { let emptyParams = ListResources.Parameters() #expect(emptyParams.cursor == nil) } - + @Test("ListResources request decoding with omitted params") func testListResourcesRequestDecodingWithOmittedParams() throws { // Test decoding when params field is omitted @@ -101,7 +105,7 @@ struct ResourceTests { #expect(decoded.id == "test-id") #expect(decoded.method == ListResources.name) } - + @Test("ListResources request decoding with null params") func testListResourcesRequestDecodingWithNullParams() throws { // Test decoding when params field is null diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index e187af87..921e1bc1 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -301,6 +301,37 @@ struct ToolTests { } } + @Test("Resource link content includes title") + func testToolContentResourceLinkEncoding() throws { + let content = Tool.Content.resourceLink( + uri: "file://resource.txt", + name: "resource_name", + title: "Resource Title", + description: "Resource description", + mimeType: "text/plain" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString.contains("\"title\":\"Resource Title\"")) + + let decoded = try decoder.decode(Tool.Content.self, from: data) + if case .resourceLink( + let uri, let name, let title, let description, let mimeType, let annotations + ) = decoded { + #expect(uri == "file://resource.txt") + #expect(name == "resource_name") + #expect(title == "Resource Title") + #expect(description == "Resource description") + #expect(mimeType == "text/plain") + #expect(annotations == nil) + } else { + #expect(Bool(false), "Expected resourceLink content") + } + } + @Test("Audio content encoding and decoding") func testToolContentAudioEncoding() throws { let content = Tool.Content.audio( From 0ac567059cddbff6ed8ffe2ced1dc7e3238ee41e Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 21:05:50 -0400 Subject: [PATCH 15/18] Public init --- Sources/MCP/Server/Resources.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index f23e6863..06beec2d 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -309,7 +309,7 @@ public enum ReadResource: Method { /// Extra fields for this result (index signature) public var extraFields: [String: Value]? - init( + public init( contents: [Resource.Content], _meta: [String: Value]? = nil, extraFields: [String: Value]? = nil @@ -371,7 +371,7 @@ public enum ListResourceTemplates: Method { /// Extra fields for this result (index signature) public var extraFields: [String: Value]? - init( + public init( templates: [Resource.Template], nextCursor: String? = nil, _meta: [String: Value]? = nil, From dc00a8c50cd43cf3eb661e1adf9138ce03e22e7c Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Mon, 27 Oct 2025 21:18:47 -0400 Subject: [PATCH 16/18] Fix meta validation and make tests more generic --- Sources/MCP/Base/MetaHelpers.swift | 110 +++++++++++++++------------ Tests/MCPTests/MetaFieldsTests.swift | 28 ++++++- 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/Sources/MCP/Base/MetaHelpers.swift b/Sources/MCP/Base/MetaHelpers.swift index 46c2c0c7..3f0c49f2 100644 --- a/Sources/MCP/Base/MetaHelpers.swift +++ b/Sources/MCP/Base/MetaHelpers.swift @@ -24,11 +24,12 @@ struct DynamicCodingKey: CodingKey { /// Error thrown when meta field validation fails enum MetaFieldError: Error, LocalizedError, Equatable { case invalidMetaKey(String) - + var errorDescription: String? { switch self { case .invalidMetaKey(let key): - return "Invalid _meta key: '\(key)'. Keys must follow the format: [prefix/]name where prefix is dot-separated labels and name is alphanumeric with hyphens, underscores, or dots." + return + "Invalid _meta key: '\(key)'. Keys must follow the format: [prefix/]name where prefix is dot-separated labels and name is alphanumeric with hyphens, underscores, or dots." } } } @@ -47,33 +48,30 @@ func validateMetaKey(_ key: String) throws { func isValidMetaKey(_ key: String) -> Bool { // Empty keys are invalid guard !key.isEmpty else { return false } - - let parts = key.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) - - let prefix: Substring? - let name: Substring - - if parts.count == 2 { - prefix = parts[0] - name = parts[1] - } else { - prefix = nil - name = parts.first ?? "" - } - - // Validate prefix if present - if let prefix, !prefix.isEmpty { - let labels = prefix.split(separator: ".", omittingEmptySubsequences: false) - guard !labels.isEmpty else { return false } - for label in labels { - guard isValidPrefixLabel(label) else { return false } + + let parts = key.split(separator: "/", omittingEmptySubsequences: false) + + // At minimum we must have a name segment + guard let name = parts.last, !name.isEmpty else { return false } + + // Validate each prefix segment if present + let prefixSegments = parts.dropLast() + if !prefixSegments.isEmpty { + for segment in prefixSegments { + // Empty segments (e.g. "vendor//name") are invalid + guard !segment.isEmpty else { return false } + + let labels = segment.split(separator: ".", omittingEmptySubsequences: false) + guard !labels.isEmpty else { return false } + for label in labels { + guard isValidPrefixLabel(label) else { return false } + } } } - + // Validate name - guard !name.isEmpty else { return false } guard isValidName(name) else { return false } - + return true } @@ -100,41 +98,46 @@ private func isValidPrefixLabel(_ label: Substring) -> Bool { private func isValidName(_ name: Substring) -> Bool { guard let first = name.first, first.isLetter || first.isNumber else { return false } guard let last = name.last, last.isLetter || last.isNumber else { return false } - + for character in name { - if character.isLetter || character.isNumber || character == "-" || character == "_" || character == "." { + if character.isLetter || character.isNumber || character == "-" || character == "_" + || character == "." + { continue } return false } - + return true } // Character extensions for validation -private extension Character { - var isLetter: Bool { +extension Character { + fileprivate var isLetter: Bool { unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } } - - var isNumber: Bool { + + fileprivate var isNumber: Bool { unicodeScalars.allSatisfy { CharacterSet.decimalDigits.contains($0) } } } /// Encodes a _meta dictionary into a container, validating keys -func encodeMeta(_ meta: [String: Value]?, to container: inout KeyedEncodingContainer) throws { +func encodeMeta( + _ meta: [String: Value]?, to container: inout KeyedEncodingContainer +) throws { guard let meta = meta, !meta.isEmpty else { return } - + // Validate all keys before encoding for key in meta.keys { try validateMetaKey(key) } - + // Encode the _meta object let metaCodingKey = DynamicCodingKey(stringValue: metaKey)! - var metaContainer = container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) - + var metaContainer = container.nestedContainer( + keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) + for (key, value) in meta { let dynamicKey = DynamicCodingKey(stringValue: key)! try metaContainer.encode(value, forKey: dynamicKey) @@ -142,9 +145,12 @@ func encodeMeta(_ meta: [String: Value]?, to container: inout KeyedEncodingConta } /// Encodes extra fields (index signature) into a container -func encodeExtraFields(_ extraFields: [String: Value]?, to container: inout KeyedEncodingContainer, excluding excludedKeys: Set = []) throws { +func encodeExtraFields( + _ extraFields: [String: Value]?, to container: inout KeyedEncodingContainer, + excluding excludedKeys: Set = [] +) throws { guard let extraFields = extraFields, !extraFields.isEmpty else { return } - + for (key, value) in extraFields where key != metaKey && !excludedKeys.contains(key) { let dynamicKey = DynamicCodingKey(stringValue: key)! try container.encode(value, forKey: dynamicKey) @@ -152,34 +158,40 @@ func encodeExtraFields(_ extraFields: [String: Value]?, to container: inout Keye } /// Decodes a _meta dictionary from a container -func decodeMeta(from container: KeyedDecodingContainer) throws -> [String: Value]? { +func decodeMeta(from container: KeyedDecodingContainer) throws -> [String: Value]? +{ let metaCodingKey = DynamicCodingKey(stringValue: metaKey)! - + guard container.contains(metaCodingKey) else { return nil } - - let metaContainer = try container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) + + let metaContainer = try container.nestedContainer( + keyedBy: DynamicCodingKey.self, forKey: metaCodingKey) var meta: [String: Value] = [:] - + for key in metaContainer.allKeys { // Validate each key as we decode try validateMetaKey(key.stringValue) let value = try metaContainer.decode(Value.self, forKey: key) meta[key.stringValue] = value } - + return meta.isEmpty ? nil : meta } /// Decodes extra fields (index signature) from a container -func decodeExtraFields(from container: KeyedDecodingContainer, excluding excludedKeys: Set = []) throws -> [String: Value]? { +func decodeExtraFields( + from container: KeyedDecodingContainer, + excluding excludedKeys: Set = [] +) throws -> [String: Value]? { var extraFields: [String: Value] = [:] - - for key in container.allKeys where key.stringValue != metaKey && !excludedKeys.contains(key.stringValue) { + + for key in container.allKeys + where key.stringValue != metaKey && !excludedKeys.contains(key.stringValue) { let value = try container.decode(Value.self, forKey: key) extraFields[key.stringValue] = value } - + return extraFields.isEmpty ? nil : extraFields } diff --git a/Tests/MCPTests/MetaFieldsTests.swift b/Tests/MCPTests/MetaFieldsTests.swift index a722f9f7..8a6970a7 100644 --- a/Tests/MCPTests/MetaFieldsTests.swift +++ b/Tests/MCPTests/MetaFieldsTests.swift @@ -147,10 +147,32 @@ struct MetaFieldsTests { ) } + @Test("Meta keys allow nested prefixes") + func testMetaKeyNestedPrefixes() throws { + let meta: [String: Value] = [ + "vendor.example/toolInvocation/invoking": .bool(true) + ] + + let tool = Tool( + name: "invoke", + description: "Invoke tool", + inputSchema: [:], + _meta: meta + ) + + let data = try JSONEncoder().encode(tool) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let metaObject = json?["_meta"] as? [String: Any] + #expect(metaObject?["vendor.example/toolInvocation/invoking"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Tool.self, from: data) + #expect(decoded._meta?["vendor.example/toolInvocation/invoking"] == .bool(true)) + } + @Test("Resource content encodes meta") func testResourceContentGeneralFields() throws { let meta: [String: Value] = [ - "openai/widgetPrefersBorder": .bool(true) + "vendor.example/widgetPrefersBorder": .bool(true) ] let content = Resource.Content.text( @@ -164,10 +186,10 @@ struct MetaFieldsTests { let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] let metaObject = json?["_meta"] as? [String: Any] - #expect(metaObject?["openai/widgetPrefersBorder"] as? Bool == true) + #expect(metaObject?["vendor.example/widgetPrefersBorder"] as? Bool == true) let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) - #expect(decoded._meta?["openai/widgetPrefersBorder"] == .bool(true)) + #expect(decoded._meta?["vendor.example/widgetPrefersBorder"] == .bool(true)) } @Test("Initialize.Result encoding with meta and extra fields") From 7d5855e730ef3e759c85767a96bfb8886c982169 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Tue, 4 Nov 2025 20:41:10 -0500 Subject: [PATCH 17/18] Format and make Result properties public again --- Sources/MCP/Base/Lifecycle.swift | 19 +++++++++++-------- Sources/MCP/Base/Messages.swift | 24 ++++++++++++++---------- Sources/MCP/Client/Sampling.swift | 10 +++++++--- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Sources/MCP/Base/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift index 495b71ca..d3362368 100644 --- a/Sources/MCP/Base/Lifecycle.swift +++ b/Sources/MCP/Base/Lifecycle.swift @@ -42,12 +42,12 @@ public enum Initialize: Method { } public struct Result: Hashable, Codable, Sendable { - let protocolVersion: String - let capabilities: Server.Capabilities - let serverInfo: Server.Info - let instructions: String? - var _meta: [String: Value]? - var extraFields: [String: Value]? + public let protocolVersion: String + public let capabilities: Server.Capabilities + public let serverInfo: Server.Info + public let instructions: String? + public var _meta: [String: Value]? + public var extraFields: [String: Value]? public init( protocolVersion: String, @@ -78,7 +78,9 @@ public enum Initialize: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -90,7 +92,8 @@ public enum Initialize: Method { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } diff --git a/Sources/MCP/Base/Messages.swift b/Sources/MCP/Base/Messages.swift index 19e61379..edf722b7 100644 --- a/Sources/MCP/Base/Messages.swift +++ b/Sources/MCP/Base/Messages.swift @@ -123,11 +123,12 @@ public struct Request: Hashable, Identifiable, Codable, Sendable { try container.encode(id, forKey: .id) try container.encode(method, forKey: .method) try container.encode(params, forKey: .params) - + // Encode _meta and extra fields, excluding JSON-RPC protocol fields var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -172,7 +173,8 @@ extension Request { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -254,8 +256,8 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { } public init( - id: ID, - result: M.Result, + id: ID, + result: M.Result, _meta: [String: Value]? = nil, extraFields: [String: Value]? = nil ) { @@ -263,8 +265,8 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { } public init( - id: ID, - error: MCPError, + id: ID, + error: MCPError, _meta: [String: Value]? = nil, extraFields: [String: Value]? = nil ) { @@ -285,11 +287,12 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { case .failure(let error): try container.encode(error, forKey: .error) } - + // Encode _meta and extra fields, excluding JSON-RPC protocol fields var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { @@ -313,7 +316,8 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } diff --git a/Sources/MCP/Client/Sampling.swift b/Sources/MCP/Client/Sampling.swift index 886b80a2..dec0f81e 100644 --- a/Sources/MCP/Client/Sampling.swift +++ b/Sources/MCP/Client/Sampling.swift @@ -256,19 +256,23 @@ public enum CreateSamplingMessage: Method { var dynamicContainer = encoder.container(keyedBy: DynamicCodingKey.self) try encodeMeta(_meta, to: &dynamicContainer) - try encodeExtraFields(extraFields, to: &dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + try encodeExtraFields( + extraFields, to: &dynamicContainer, + excluding: Set(CodingKeys.allCases.map(\.rawValue))) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) model = try container.decode(String.self, forKey: .model) - stopReason = try container.decodeIfPresent(Sampling.StopReason.self, forKey: .stopReason) + stopReason = try container.decodeIfPresent( + Sampling.StopReason.self, forKey: .stopReason) role = try container.decode(Sampling.Message.Role.self, forKey: .role) content = try container.decode(Sampling.Message.Content.self, forKey: .content) let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) _meta = try decodeMeta(from: dynamicContainer) - extraFields = try decodeExtraFields(from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } } From 0dd973424da4b407a03e55e16476c11388f3f97f Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Tue, 4 Nov 2025 21:14:38 -0500 Subject: [PATCH 18/18] Revert more visibility changes --- Sources/MCP/Server/Resources.swift | 8 ++++---- Sources/MCP/Server/Tools.swift | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift index 06beec2d..b3377fd3 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -243,10 +243,10 @@ public enum ListResources: Method { } public struct Result: Hashable, Codable, Sendable { - let resources: [Resource] - let nextCursor: String? - var _meta: [String: Value]? - var extraFields: [String: Value]? + public let resources: [Resource] + public let nextCursor: String? + public var _meta: [String: Value]? + public var extraFields: [String: Value]? public init( resources: [Resource], diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index f117dd4d..91bb7cbd 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -282,10 +282,10 @@ public enum ListTools: Method { } public struct Result: Hashable, Codable, Sendable { - let tools: [Tool] - let nextCursor: String? - var _meta: [String: Value]? - var extraFields: [String: Value]? + public let tools: [Tool] + public let nextCursor: String? + public var _meta: [String: Value]? + public var extraFields: [String: Value]? public init( tools: [Tool],