Skip to content

Commit fbe03f0

Browse files
committed
Implement Tool.Annotations
1 parent 402904e commit fbe03f0

File tree

2 files changed

+232
-2
lines changed

2 files changed

+232
-2
lines changed

Sources/MCP/Server/Tools.swift

+79-2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,81 @@ public struct Tool: Hashable, Codable, Sendable {
1616
/// The tool input schema
1717
public let inputSchema: Value?
1818

19-
public init(name: String, description: String, inputSchema: Value? = nil) {
19+
/// Annotations that provide display-facing and operational information for a Tool.
20+
///
21+
/// - Note: All properties in `ToolAnnotations` are **hints**.
22+
/// They are not guaranteed to provide a faithful description of
23+
/// tool behavior (including descriptive properties like `title`).
24+
///
25+
/// Clients should never make tool use decisions based on `ToolAnnotations`
26+
/// received from untrusted servers.
27+
public struct Annotations: Hashable, Codable, Sendable, ExpressibleByNilLiteral {
28+
/// A human-readable title for the tool
29+
public var title: String?
30+
31+
/// If true, the tool may perform destructive updates to its environment.
32+
/// If false, the tool performs only additive updates.
33+
/// (This property is meaningful only when `readOnlyHint == false`)
34+
///
35+
/// When unspecified, the implicit default is `true`.
36+
public var destructiveHint: Bool?
37+
38+
/// If true, calling the tool repeatedly with the same arguments
39+
/// will have no additional effect on its environment.
40+
/// (This property is meaningful only when `readOnlyHint == false`)
41+
///
42+
/// When unspecified, the implicit default is `false`.
43+
public var idempotentHint: Bool?
44+
45+
/// If true, this tool may interact with an "open world" of external
46+
/// entities. If false, the tool's domain of interaction is closed.
47+
/// For example, the world of a web search tool is open, whereas that
48+
/// of a memory tool is not.
49+
///
50+
/// When unspecified, the implicit default is `true`.
51+
public var openWorldHint: Bool?
52+
53+
/// If true, the tool does not modify its environment.
54+
///
55+
/// When unspecified, the implicit default is `false`.
56+
public var readOnlyHint: Bool?
57+
58+
/// Returns true if all properties are nil
59+
public var isEmpty: Bool {
60+
title == nil && readOnlyHint == nil && destructiveHint == nil && idempotentHint == nil
61+
&& openWorldHint == nil
62+
}
63+
64+
public init(
65+
title: String? = nil,
66+
readOnlyHint: Bool? = nil,
67+
destructiveHint: Bool? = nil,
68+
idempotentHint: Bool? = nil,
69+
openWorldHint: Bool? = nil
70+
) {
71+
self.title = title
72+
self.readOnlyHint = readOnlyHint
73+
self.destructiveHint = destructiveHint
74+
self.idempotentHint = idempotentHint
75+
self.openWorldHint = openWorldHint
76+
}
77+
78+
public init(nilLiteral: ()) {}
79+
}
80+
81+
/// Optional annotations that provide display-facing and operational information
82+
public var annotations: Annotations
83+
84+
public init(
85+
name: String,
86+
description: String,
87+
inputSchema: Value? = nil,
88+
annotations: Annotations = nil
89+
) {
2090
self.name = name
2191
self.description = description
2292
self.inputSchema = inputSchema
93+
self.annotations = annotations
2394
}
2495

2596
/// Content types that can be returned by a tool
@@ -92,13 +163,16 @@ public struct Tool: Hashable, Codable, Sendable {
92163
case name
93164
case description
94165
case inputSchema
166+
case annotations
95167
}
96168

97169
public init(from decoder: Decoder) throws {
98170
let container = try decoder.container(keyedBy: CodingKeys.self)
99171
name = try container.decode(String.self, forKey: .name)
100172
description = try container.decode(String.self, forKey: .description)
101173
inputSchema = try container.decodeIfPresent(Value.self, forKey: .inputSchema)
174+
annotations =
175+
try container.decodeIfPresent(Tool.Annotations.self, forKey: .annotations) ?? .init()
102176
}
103177

104178
public func encode(to encoder: Encoder) throws {
@@ -108,6 +182,9 @@ public struct Tool: Hashable, Codable, Sendable {
108182
if let schema = inputSchema {
109183
try container.encode(schema, forKey: .inputSchema)
110184
}
185+
if !annotations.isEmpty {
186+
try container.encode(annotations, forKey: .annotations)
187+
}
111188
}
112189
}
113190

@@ -120,7 +197,7 @@ public enum ListTools: Method {
120197

121198
public struct Parameters: NotRequired, Hashable, Codable, Sendable {
122199
public let cursor: String?
123-
200+
124201
public init() {
125202
self.cursor = nil
126203
}

Tests/MCPTests/ToolTests.swift

+153
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,159 @@ struct ToolTests {
2020
#expect(tool.inputSchema != nil)
2121
}
2222

23+
@Test("Tool Annotations initialization and properties")
24+
func testToolAnnotationsInitialization() throws {
25+
// Empty annotations
26+
let emptyAnnotations = Tool.Annotations()
27+
#expect(emptyAnnotations.isEmpty)
28+
#expect(emptyAnnotations.title == nil)
29+
#expect(emptyAnnotations.readOnlyHint == nil)
30+
#expect(emptyAnnotations.destructiveHint == nil)
31+
#expect(emptyAnnotations.idempotentHint == nil)
32+
#expect(emptyAnnotations.openWorldHint == nil)
33+
34+
// Full annotations
35+
let fullAnnotations = Tool.Annotations(
36+
title: "Test Tool",
37+
readOnlyHint: true,
38+
destructiveHint: false,
39+
idempotentHint: true,
40+
openWorldHint: false
41+
)
42+
43+
#expect(!fullAnnotations.isEmpty)
44+
#expect(fullAnnotations.title == "Test Tool")
45+
#expect(fullAnnotations.readOnlyHint == true)
46+
#expect(fullAnnotations.destructiveHint == false)
47+
#expect(fullAnnotations.idempotentHint == true)
48+
#expect(fullAnnotations.openWorldHint == false)
49+
50+
// Partial annotations - should not be empty
51+
let partialAnnotations = Tool.Annotations(title: "Partial Test")
52+
#expect(!partialAnnotations.isEmpty)
53+
#expect(partialAnnotations.title == "Partial Test")
54+
55+
// Initialize with nil literal
56+
let nilAnnotations: Tool.Annotations = nil
57+
#expect(nilAnnotations.isEmpty)
58+
}
59+
60+
@Test("Tool Annotations encoding and decoding")
61+
func testToolAnnotationsEncodingDecoding() throws {
62+
let annotations = Tool.Annotations(
63+
title: "Test Tool",
64+
readOnlyHint: true,
65+
destructiveHint: false,
66+
idempotentHint: true,
67+
openWorldHint: false
68+
)
69+
70+
#expect(!annotations.isEmpty)
71+
72+
let encoder = JSONEncoder()
73+
let decoder = JSONDecoder()
74+
75+
let data = try encoder.encode(annotations)
76+
let decoded = try decoder.decode(Tool.Annotations.self, from: data)
77+
78+
#expect(decoded.title == annotations.title)
79+
#expect(decoded.readOnlyHint == annotations.readOnlyHint)
80+
#expect(decoded.destructiveHint == annotations.destructiveHint)
81+
#expect(decoded.idempotentHint == annotations.idempotentHint)
82+
#expect(decoded.openWorldHint == annotations.openWorldHint)
83+
84+
// Test that empty annotations are encoded as expected
85+
let emptyAnnotations = Tool.Annotations()
86+
let emptyData = try encoder.encode(emptyAnnotations)
87+
let decodedEmpty = try decoder.decode(Tool.Annotations.self, from: emptyData)
88+
89+
#expect(decodedEmpty.isEmpty)
90+
}
91+
92+
@Test("Tool with annotations encoding and decoding")
93+
func testToolWithAnnotationsEncodingDecoding() throws {
94+
let annotations = Tool.Annotations(
95+
title: "Calculator",
96+
destructiveHint: false
97+
)
98+
99+
let tool = Tool(
100+
name: "calculate",
101+
description: "Performs calculations",
102+
inputSchema: .object([
103+
"expression": .string("Mathematical expression to evaluate")
104+
]),
105+
annotations: annotations
106+
)
107+
108+
let encoder = JSONEncoder()
109+
let decoder = JSONDecoder()
110+
111+
let data = try encoder.encode(tool)
112+
let decoded = try decoder.decode(Tool.self, from: data)
113+
114+
#expect(decoded.name == tool.name)
115+
#expect(decoded.description == tool.description)
116+
#expect(decoded.annotations.title == annotations.title)
117+
#expect(decoded.annotations.destructiveHint == annotations.destructiveHint)
118+
119+
// Verify that the annotations field is properly included in the JSON
120+
let jsonString = String(data: data, encoding: .utf8)!
121+
#expect(jsonString.contains("\"annotations\""))
122+
#expect(jsonString.contains("\"title\":\"Calculator\""))
123+
}
124+
125+
@Test("Tool with empty annotations")
126+
func testToolWithEmptyAnnotations() throws {
127+
var tool = Tool(
128+
name: "test_tool",
129+
description: "Test tool description"
130+
)
131+
132+
do {
133+
#expect(tool.annotations.isEmpty)
134+
135+
let encoder = JSONEncoder()
136+
let data = try encoder.encode(tool)
137+
138+
// Verify that empty annotations are not included in the JSON
139+
let jsonString = String(data: data, encoding: .utf8)!
140+
#expect(!jsonString.contains("\"annotations\""))
141+
}
142+
143+
do {
144+
tool.annotations.title = "Test"
145+
146+
#expect(!tool.annotations.isEmpty)
147+
148+
let encoder = JSONEncoder()
149+
let data = try encoder.encode(tool)
150+
151+
// Verify that empty annotations are not included in the JSON
152+
let jsonString = String(data: data, encoding: .utf8)!
153+
#expect(jsonString.contains("\"annotations\""))
154+
}
155+
}
156+
157+
@Test("Tool with nil literal annotations")
158+
func testToolWithNilLiteralAnnotations() throws {
159+
let tool = Tool(
160+
name: "test_tool",
161+
description: "Test tool description",
162+
inputSchema: nil,
163+
annotations: nil
164+
)
165+
166+
#expect(tool.annotations.isEmpty)
167+
168+
let encoder = JSONEncoder()
169+
let data = try encoder.encode(tool)
170+
171+
// Verify that nil literal annotations are not included in the JSON
172+
let jsonString = String(data: data, encoding: .utf8)!
173+
#expect(!jsonString.contains("\"annotations\""))
174+
}
175+
23176
@Test("Tool encoding and decoding")
24177
func testToolEncodingDecoding() throws {
25178
let tool = Tool(

0 commit comments

Comments
 (0)