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/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/README.md b/README.md index ac23a4c3..f5ddbbe3 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 @@ -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: @@ -774,8 +870,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 +964,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 +982,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/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift index 7d3e7119..d3362368 100644 --- a/Sources/MCP/Base/Lifecycle.swift +++ b/Sources/MCP/Base/Lifecycle.swift @@ -46,6 +46,55 @@ public enum Initialize: Method { 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, + 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 b9058f7e..edf722b7 100644 --- a/Sources/MCP/Base/Messages.swift +++ b/Sources/MCP/Base/Messages.swift @@ -37,8 +37,12 @@ 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) -> Request { + Request(id: id, method: name, params: Empty(), _meta: _meta, extraFields: extraFields) } } @@ -50,18 +54,33 @@ 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) -> Request { + 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) -> Response { - Response(id: id, result: result) + public static func response( + id: ID, + result: Self.Result, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) -> Response { + Response(id: id, result: result, _meta: _meta, extraFields: extraFields) } /// 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) -> Response { + Response(id: id, error: error, _meta: _meta, extraFields: extraFields) } } @@ -75,14 +94,26 @@ 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) { + /// 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.id = id self.method = method self.params = params + self._meta = _meta + self.extraFields = extraFields } - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, CaseIterable { case jsonrpc, id, method, params } @@ -92,6 +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))) } } @@ -133,6 +170,11 @@ extension Request { codingPath: container.codingPath, debugDescription: "Invalid params field")) } + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -196,18 +238,42 @@ 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) { + /// 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) { self.id = id - self.result = .success(result) + self.result = result + self._meta = _meta + self.extraFields = extraFields } - public init(id: ID, error: MCPError) { - self.id = id - self.result = .failure(error) + 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) } - private enum CodingKeys: String, CodingKey { + 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, CaseIterable { case jsonrpc, id, result, error } @@ -221,6 +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))) } public init(from decoder: Decoder) throws { @@ -241,6 +313,11 @@ public struct Response: Hashable, Identifiable, Codable, Sendable { codingPath: container.codingPath, debugDescription: "Invalid response")) } + + let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: dynamicContainer) + extraFields = try decodeExtraFields( + from: dynamicContainer, excluding: Set(CodingKeys.allCases.map(\.rawValue))) } } @@ -249,18 +326,21 @@ 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), + _meta: response._meta, + extraFields: response.extraFields) case .failure(let error): - // Keep the original error without re-encoding/decoding - self.result = .failure(error) + self = Response( + id: response.id, + result: .failure(error), + _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..3f0c49f2 --- /dev/null +++ b/Sources/MCP/Base/MetaHelpers.swift @@ -0,0 +1,197 @@ +// 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: "/", 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 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 +extension Character { + fileprivate var isLetter: Bool { + unicodeScalars.allSatisfy { CharacterSet.letters.contains($0) } + } + + 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 { + 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/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/Client/Client.swift b/Sources/MCP/Client/Client.swift index 696ffd14..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 } } @@ -60,8 +63,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 +79,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 } @@ -91,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 } @@ -160,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 } @@ -636,7 +651,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. @@ -656,6 +672,28 @@ 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..3597afad --- /dev/null +++ b/Sources/MCP/Client/Elicitation.swift @@ -0,0 +1,123 @@ +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]? + /// 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, + _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..dec0f81e 100644 --- a/Sources/MCP/Client/Sampling.swift +++ b/Sources/MCP/Client/Sampling.swift @@ -222,17 +222,57 @@ 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..e04ca7e7 100644 --- a/Sources/MCP/Server/Prompts.swift +++ b/Sources/MCP/Server/Prompts.swift @@ -11,28 +11,74 @@ 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 public let arguments: [Argument]? - - public init(name: String, description: String? = nil, arguments: [Argument]? = nil) { + /// Optional metadata about this prompt + public var _meta: [String: Value]? + + 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, 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) + + 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) + title = try container.decodeIfPresent(String.self, forKey: .title) + 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 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 } @@ -95,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) } } } @@ -216,12 +267,48 @@ 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 +332,48 @@ 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 12f67335..b3377fd3 100644 --- a/Sources/MCP/Server/Resources.swift +++ b/Sources/MCP/Server/Resources.swift @@ -6,10 +6,12 @@ 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 + /// A human-readable resource title + public var title: String? /// The resource URI public var uri: String /// The resource description @@ -18,19 +20,58 @@ public struct Resource: Hashable, Codable, Sendable { public var mimeType: String? /// The resource metadata public var metadata: [String: String]? + /// Metadata fields for the resource (see spec for _meta usage) + public var _meta: [String: Value]? public init( name: String, uri: String, + title: String? = nil, description: String? = nil, mimeType: String? = nil, - metadata: [String: 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 self.metadata = metadata + self._meta = _meta + } + + private enum CodingKeys: String, CodingKey { + case name + case uri + case title + 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) + 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) + let metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) + } + + 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(title, forKey: .title) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } /// Content of a resource. @@ -43,27 +84,85 @@ public struct Resource: Hashable, Codable, Sendable { 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) + /// Metadata fields (see spec for _meta usage) + public var _meta: [String: Value]? + + public static func text( + _ content: String, + uri: String, + mimeType: String? = nil, + _meta: [String: Value]? = nil + ) -> Self { + .init(uri: uri, mimeType: mimeType, text: content, _meta: _meta) } - public static func binary(_ data: Data, uri: String, mimeType: String? = nil) -> Self { - .init(uri: uri, mimeType: mimeType, blob: data.base64EncodedString()) + public static func binary( + _ data: Data, + uri: String, + mimeType: String? = nil, + _meta: [String: Value]? = nil + ) -> Self { + .init( + uri: uri, + mimeType: mimeType, + blob: data.base64EncodedString(), + _meta: _meta + ) } - private init(uri: String, mimeType: String? = nil, text: String? = nil) { + private init( + uri: String, + mimeType: String? = nil, + text: String? = nil, + _meta: [String: Value]? = nil + ) { self.uri = uri self.mimeType = mimeType self.text = text self.blob = nil + self._meta = _meta } - private init(uri: String, mimeType: String? = nil, blob: String) { + private init( + uri: String, + mimeType: String? = nil, + blob: String, + _meta: [String: Value]? = nil + ) { self.uri = uri self.mimeType = mimeType self.text = nil self.blob = blob + self._meta = _meta + } + + 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 metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) + } + + 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) + + // Encode _meta + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } } @@ -73,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 @@ -81,21 +182,51 @@ 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 } } + + // 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: - /// 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" @@ -105,7 +236,7 @@ public enum ListResources: Method { public init() { self.cursor = nil } - + public init(cursor: String) { self.cursor = cursor } @@ -114,16 +245,52 @@ public enum ListResources: Method { public struct Result: Hashable, Codable, Sendable { public let resources: [Resource] public let nextCursor: String? + public var _meta: [String: Value]? + public 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))) } } } /// 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" @@ -137,15 +304,50 @@ public enum ReadResource: Method { public struct Result: Hashable, Codable, Sendable { public let 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]? - public init(contents: [Resource.Content]) { + public 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))) } } } /// 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" @@ -155,7 +357,7 @@ public enum ListResourceTemplates: Method { public init() { self.cursor = nil } - + public init(cursor: String) { self.cursor = cursor } @@ -164,21 +366,55 @@ public enum ListResourceTemplates: Method { public struct Result: Hashable, Codable, Sendable { public let templates: [Resource.Template] public let nextCursor: String? + /// Optional metadata about this result + public var _meta: [String: Value]? + /// Extra fields for this result (index signature) + public var extraFields: [String: Value]? - public init(templates: [Resource.Template], nextCursor: String? = nil) { + public 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))) + } } } /// 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" @@ -186,7 +422,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" @@ -198,7 +434,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/Server.swift b/Sources/MCP/Server/Server.swift index 6ba1e27b..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,20 +129,21 @@ 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 /// - /// 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] = [:] @@ -162,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 fd10d934..91bb7cbd 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -7,14 +7,20 @@ 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 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? + /// 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. /// @@ -85,14 +91,20 @@ public struct Tool: Hashable, Codable, Sendable { /// 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 + annotations: Annotations = nil, + outputSchema: Value? = nil, + _meta: [String: 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 @@ -104,15 +116,29 @@ 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, title: String? = nil, 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 @@ -138,9 +164,25 @@ 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 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, title: title, description: description, + mimeType: mimeType, annotations: annotations) default: throw DecodingError.dataCorruptedError( forKey: .type, in: container, debugDescription: "Unknown tool content type") @@ -163,46 +205,67 @@ 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 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) } } } 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 metaContainer = try decoder.container(keyedBy: DynamicCodingKey.self) + _meta = try decodeMeta(from: metaContainer) } 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) } + var metaContainer = encoder.container(keyedBy: DynamicCodingKey.self) + try encodeMeta(_meta, to: &metaContainer) } } // 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" @@ -221,16 +284,52 @@ public enum ListTools: Method { public struct Result: Hashable, Codable, Sendable { public let tools: [Tool] public let nextCursor: String? + public var _meta: [String: Value]? + public 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))) } } } /// 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" @@ -246,17 +345,78 @@ public enum CallTool: Method { public struct Result: Hashable, Codable, Sendable { 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], isError: Bool? = nil) { + public init( + content: [Tool.Content] = [], + structuredContent: Value? = 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, + _meta: [String: Value]? = nil, + extraFields: [String: Value]? = nil + ) throws { + let encoded = try Value(structuredContent) + self.init( + content: content, + structuredContent: Optional.some(encoded), + isError: isError, + _meta: _meta, + extraFields: extraFields + ) + } + + 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))) } } } /// 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/ClientTests.swift b/Tests/MCPTests/ClientTests.swift index 6fcc87a4..60557dac 100644 --- a/Tests/MCPTests/ClientTests.swift +++ b/Tests/MCPTests/ClientTests.swift @@ -345,13 +345,13 @@ struct ClientTests { let request1 = Ping.request() let request2 = Ping.request() - var resultTask1: Task? - var resultTask2: Task? + let taskCollector = TaskCollector>() - try await client.withBatch { batch in - resultTask1 = try await batch.addRequest(request1) - resultTask2 = try await batch.addRequest(request2) + 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 @@ -380,12 +380,16 @@ struct ClientTests { // Queue the batch response try await transport.queue(batch: [anyResponse1, anyResponse2]) - // Wait for results and verify - guard let task1 = resultTask1, let task2 = resultTask2 else { + let resultTasks = await taskCollector.snapshot() + #expect(resultTasks.count == 2) + guard resultTasks.count == 2 else { #expect(Bool(false), "Result tasks not created") return } + let task1 = resultTasks[0] + let task2 = resultTasks[1] + _ = try await task1.value // Should succeed _ = try await task2.value // Should succeed @@ -426,12 +430,13 @@ struct ClientTests { let request1 = Ping.request() // Success let request2 = Ping.request() // Error - var resultTasks: [Task] = [] + let taskCollector = TaskCollector>() - try await client.withBatch { batch in - resultTasks.append(try await batch.addRequest(request1)) - resultTasks.append(try await batch.addRequest(request2)) + 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 @@ -447,6 +452,7 @@ struct ClientTests { 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") @@ -504,9 +510,10 @@ struct ClientTests { initTask.cancel() // Call withBatch but don't add any requests - try await client.withBatch { _ in + 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 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) + } +} diff --git a/Tests/MCPTests/Helpers/TaskCollector.swift b/Tests/MCPTests/Helpers/TaskCollector.swift new file mode 100644 index 00000000..f09f8781 --- /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 + } +} diff --git a/Tests/MCPTests/MetaFieldsTests.swift b/Tests/MCPTests/MetaFieldsTests.swift new file mode 100644 index 00000000..8a6970a7 --- /dev/null +++ b/Tests/MCPTests/MetaFieldsTests.swift @@ -0,0 +1,433 @@ +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("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] = [ + "vendor.example/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?["vendor.example/widgetPrefersBorder"] as? Bool == true) + + let decoded = try JSONDecoder().decode(Resource.Content.self, from: data) + #expect(decoded._meta?["vendor.example/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")) + } +} 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 b08963b3..921e1bc1 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!") @@ -254,15 +290,48 @@ struct ToolTests { 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 { + 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("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( @@ -422,7 +491,6 @@ struct ToolTests { #expect(Bool(false), "Expected success result") } } -} @Test("Tool with missing description") func testToolWithMissingDescription() throws { @@ -433,10 +501,11 @@ struct ToolTests { } """ 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 == [:]) - } \ No newline at end of file + } +} 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") } }