Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ let package = Package(
.product(name: "Logging", package: "swift-log")
]
),
.testTarget(name: "MCPProtocolTests", dependencies: ["MCP"]),
.testTarget(
name: "AdaMCPTests",
dependencies: [
Expand Down
5 changes: 4 additions & 1 deletion Sources/MCP/MCP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
20 changes: 20 additions & 0 deletions Sources/MCP/Metadata.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
63 changes: 63 additions & 0 deletions Tests/MCPProtocolTests/ToolMetadataTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading