From ec5441aaad15dfdd5e3a946a5b74cae53bf273cc Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Thu, 3 Apr 2025 05:55:54 -0700 Subject: [PATCH] Implement Tool.Annotations --- Sources/MCP/Server/Tools.swift | 83 +++++++++++++++++- Tests/MCPTests/ToolTests.swift | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift index 7475557..260dab9 100644 --- a/Sources/MCP/Server/Tools.swift +++ b/Sources/MCP/Server/Tools.swift @@ -16,10 +16,83 @@ public struct Tool: Hashable, Codable, Sendable { /// The tool input schema public let inputSchema: Value? - public init(name: String, description: String, inputSchema: Value? = nil) { + /// Annotations that provide display-facing and operational information for a Tool. + /// + /// - Note: All properties in `ToolAnnotations` are **hints**. + /// They are not guaranteed to provide a faithful description of + /// tool behavior (including descriptive properties like `title`). + /// + /// Clients should never make tool use decisions based on `ToolAnnotations` + /// received from untrusted servers. + public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral { + /// A human-readable title for the tool + public var title: String? + + /// If true, the tool may perform destructive updates to its environment. + /// If false, the tool performs only additive updates. + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// When unspecified, the implicit default is `true`. + public var destructiveHint: Bool? + + /// If true, calling the tool repeatedly with the same arguments + /// will have no additional effect on its environment. + /// (This property is meaningful only when `readOnlyHint == false`) + /// + /// When unspecified, the implicit default is `false`. + public var idempotentHint: Bool? + + /// If true, this tool may interact with an "open world" of external + /// entities. If false, the tool's domain of interaction is closed. + /// For example, the world of a web search tool is open, whereas that + /// of a memory tool is not. + /// + /// When unspecified, the implicit default is `true`. + public var openWorldHint: Bool? + + /// If true, the tool does not modify its environment. + /// + /// When unspecified, the implicit default is `false`. + public var readOnlyHint: Bool? + + /// Returns true if all properties are nil + public var isEmpty: Bool { + title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil + && openWorldHint == nil + } + + public init( + title: String? = nil, + readOnlyHint: Bool? = nil, + destructiveHint: Bool? = nil, + idempotentHint: Bool? = nil, + openWorldHint: Bool? = nil + ) { + self.title = title + self.readOnlyHint = readOnlyHint + self.destructiveHint = destructiveHint + self.idempotentHint = idempotentHint + self.openWorldHint = openWorldHint + } + + /// Initialize an empty annotations object + public init(nilLiteral: ()) {} + } + + /// Annotations that provide display-facing and operational information + public var annotations: Annotations + + /// Initialize a tool with a name, description, input schema, and annotations + public init( + name: String, + description: String, + inputSchema: Value? = nil, + annotations: Annotations = nil + ) { self.name = name self.description = description self.inputSchema = inputSchema + self.annotations = annotations } /// Content types that can be returned by a tool @@ -92,6 +165,7 @@ public struct Tool: Hashable, Codable, Sendable { case name case description case inputSchema + case annotations } public init(from decoder: Decoder) throws { @@ -99,6 +173,8 @@ public struct Tool: Hashable, Codable, Sendable { name = try container.decode(String.self, forKey: .name) description = try container.decode(String.self, forKey: .description) inputSchema = try container.decodeIfPresent(Value.self, forKey: .inputSchema) + annotations = + try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init() } public func encode(to encoder: Encoder) throws { @@ -108,6 +184,9 @@ public struct Tool: Hashable, Codable, Sendable { if let schema = inputSchema { try container.encode(schema, forKey: .inputSchema) } + if !annotations.isEmpty { + try container.encode(annotations, forKey: .annotations) + } } } @@ -120,7 +199,7 @@ public enum ListTools: Method { public struct Parameters: NotRequired, Hashable, Codable, Sendable { public let cursor: String? - + public init() { self.cursor = nil } diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift index 0ab0c50..52d9248 100644 --- a/Tests/MCPTests/ToolTests.swift +++ b/Tests/MCPTests/ToolTests.swift @@ -20,6 +20,159 @@ struct ToolTests { #expect(tool.inputSchema != nil) } + @Test("Tool Annotations initialization and properties") + func testToolAnnotationsInitialization() throws { + // Empty annotations + let emptyAnnotations = Tool.Annotations() + #expect(emptyAnnotations.isEmpty) + #expect(emptyAnnotations.title == nil) + #expect(emptyAnnotations.readOnlyHint == nil) + #expect(emptyAnnotations.destructiveHint == nil) + #expect(emptyAnnotations.idempotentHint == nil) + #expect(emptyAnnotations.openWorldHint == nil) + + // Full annotations + let fullAnnotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!fullAnnotations.isEmpty) + #expect(fullAnnotations.title == "Test Tool") + #expect(fullAnnotations.readOnlyHint == true) + #expect(fullAnnotations.destructiveHint == false) + #expect(fullAnnotations.idempotentHint == true) + #expect(fullAnnotations.openWorldHint == false) + + // Partial annotations - should not be empty + let partialAnnotations = Tool.Annotations(title: "Partial Test") + #expect(!partialAnnotations.isEmpty) + #expect(partialAnnotations.title == "Partial Test") + + // Initialize with nil literal + let nilAnnotations: Tool.Annotations = nil + #expect(nilAnnotations.isEmpty) + } + + @Test("Tool Annotations encoding and decoding") + func testToolAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Test Tool", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + + #expect(!annotations.isEmpty) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(annotations) + let decoded = try decoder.decode(Tool.Annotations.self, from: data) + + #expect(decoded.title == annotations.title) + #expect(decoded.readOnlyHint == annotations.readOnlyHint) + #expect(decoded.destructiveHint == annotations.destructiveHint) + #expect(decoded.idempotentHint == annotations.idempotentHint) + #expect(decoded.openWorldHint == annotations.openWorldHint) + + // Test that empty annotations are encoded as expected + let emptyAnnotations = Tool.Annotations() + let emptyData = try encoder.encode(emptyAnnotations) + let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData) + + #expect(decodedEmpty.isEmpty) + } + + @Test("Tool with annotations encoding and decoding") + func testToolWithAnnotationsEncodingDecoding() throws { + let annotations = Tool.Annotations( + title: "Calculator", + destructiveHint: false + ) + + let tool = Tool( + name: "calculate", + description: "Performs calculations", + inputSchema: .object([ + "expression": .string("Mathematical expression to evaluate") + ]), + annotations: annotations + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.annotations.title == annotations.title) + #expect(decoded.annotations.destructiveHint == annotations.destructiveHint) + + // Verify that the annotations field is properly included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) + #expect(jsonString.contains("\"title\":\"Calculator\"")) + } + + @Test("Tool with empty annotations") + func testToolWithEmptyAnnotations() throws { + var tool = Tool( + name: "test_tool", + description: "Test tool description" + ) + + do { + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) + } + + do { + tool.annotations.title = "Test" + + #expect(!tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that empty annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(jsonString.contains("\"annotations\"")) + } + } + + @Test("Tool with nil literal annotations") + func testToolWithNilLiteralAnnotations() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: nil, + annotations: nil + ) + + #expect(tool.annotations.isEmpty) + + let encoder = JSONEncoder() + let data = try encoder.encode(tool) + + // Verify that nil literal annotations are not included in the JSON + let jsonString = String(data: data, encoding: .utf8)! + #expect(!jsonString.contains("\"annotations\"")) + } + @Test("Tool encoding and decoding") func testToolEncodingDecoding() throws { let tool = Tool(