From ed7bb9076f9f8c91e2584ca9b7201df0e03defb1 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Fri, 23 Oct 2026 15:07:39 +0300 Subject: [PATCH] Support tool metadata in MCP tool discovery --- Package.swift | 1 + Sources/MCP/MCP.swift | 5 +- Sources/MCP/Metadata.swift | 20 ++++++ .../MCPProtocolTests/ToolMetadataTests.swift | 63 +++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 Sources/MCP/Metadata.swift create mode 100644 Tests/MCPProtocolTests/ToolMetadataTests.swift diff --git a/Package.swift b/Package.swift index 2ae0d6b..ac8702c 100644 --- a/Package.swift +++ b/Package.swift @@ -67,6 +67,7 @@ let package = Package( .product(name: "Logging", package: "swift-log") ] ), + .testTarget(name: "MCPProtocolTests", dependencies: ["MCP"]), .testTarget( name: "AdaMCPTests", dependencies: [ diff --git a/Sources/MCP/MCP.swift b/Sources/MCP/MCP.swift index b59a1d5..7bd64b0 100644 --- a/Sources/MCP/MCP.swift +++ b/Sources/MCP/MCP.swift @@ -168,12 +168,15 @@ public struct Tool: Codable, Sendable, Equatable { public var description: String? public var inputSchema: Value public var annotations: Annotations? + /// Optional metadata advertised to clients through `tools/list`. + public var _meta: Metadata? - public init(name: String, description: String? = nil, inputSchema: Value, annotations: Annotations? = nil) { + public init(name: String, description: String? = nil, inputSchema: Value, annotations: Annotations? = nil, _meta: Metadata? = nil) { self.name = name self.description = description self.inputSchema = inputSchema self.annotations = annotations + self._meta = _meta } } diff --git a/Sources/MCP/Metadata.swift b/Sources/MCP/Metadata.swift new file mode 100644 index 0000000..9e4c330 --- /dev/null +++ b/Sources/MCP/Metadata.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Arbitrary protocol metadata, encoded directly as a JSON object inside `_meta`. +public struct Metadata: Codable, Sendable, Equatable { + public var additionalFields: [String: Value] + + public init(additionalFields: [String: Value] = [:]) { + self.additionalFields = additionalFields + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + additionalFields = try container.decode([String: Value].self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(additionalFields) + } +} diff --git a/Tests/MCPProtocolTests/ToolMetadataTests.swift b/Tests/MCPProtocolTests/ToolMetadataTests.swift new file mode 100644 index 0000000..844e7be --- /dev/null +++ b/Tests/MCPProtocolTests/ToolMetadataTests.swift @@ -0,0 +1,63 @@ +import Foundation +import MCP +import Testing + +@Suite("Tool metadata") +struct ToolMetadataTests { + @Test("Tool metadata uses a flat JSON object and preserves arbitrary values") + func wireFormat() throws { + let fields: [String: Value] = [ + "sloppy/verification": "build", + "vendor/nested": ["enabled": true, "values": [1, "two", nil]], + ] + let tool = Tool(name: "editor.build.start", inputSchema: ["type": "object"], _meta: Metadata(additionalFields: fields)) + let data = try JSONEncoder().encode(tool) + let json = try JSONDecoder().decode(Value.self, from: data) + #expect(json.objectValue?["_meta"] == .object(fields)) + #expect(try JSONDecoder().decode(Tool.self, from: data) == tool) + } + + @Test("Legacy tools without metadata retain their wire format") + func legacyTool() throws { + let data = Data(#"{"name":"legacy","inputSchema":{"type":"object"}}"#.utf8) + let tool = try JSONDecoder().decode(Tool.self, from: data) + #expect(tool._meta == nil) + let encoded = try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(tool)) + #expect(encoded.objectValue?["_meta"] == nil) + #expect(Tool(name: "legacy", inputSchema: ["type": "object"]) == tool) + } + + @Test("Non-object metadata is rejected") + func invalidMetadata() { + for value in ["[]", "42", #""text""#] { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(Metadata.self, from: Data(value.utf8)) + } + } + } + + @Test("tools/list delivers verification metadata through the real client and server", .timeLimit(.minutes(1))) + func listToolsRoundTrip() async throws { + let tool = Tool( + name: "editor.test.start", inputSchema: ["type": "object"], + annotations: .init(readOnlyHint: false), + _meta: Metadata(additionalFields: ["sloppy/verification": "test"]) + ) + let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair() + let server = Server(name: "metadata-server", version: "1") + await server.withMethodHandler(ListTools.self) { _ in .init(tools: [tool]) } + let client = Client(name: "metadata-client", version: "1") + try await server.start(transport: serverTransport) + do { + try await client.connect(transport: clientTransport) + let (tools, _) = try await client.listTools() + #expect(tools == [tool]) + } catch { + await client.disconnect() + await server.stop() + throw error + } + await client.disconnect() + await server.stop() + } +}