Implement Tool.Annotations (#47)

This commit is contained in:
Mattt 2025-04-15 05:42:44 -07:00 • committed by GitHub
commit 4143f35b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 233 additions and 1 deletions

View file

@ -16,10 +16,83 @@ public struct Tool: Hashable, Codable, Sendable {
/// The tool input schema /// The tool input schema
public let inputSchema: Value? 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.name = name
self.description = description self.description = description
self.inputSchema = inputSchema self.inputSchema = inputSchema
self.annotations = annotations
} }
/// Content types that can be returned by a tool /// Content types that can be returned by a tool
@ -103,6 +176,7 @@ public struct Tool: Hashable, Codable, Sendable {
case name case name
case description case description
case inputSchema case inputSchema
case annotations
} }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
@ -110,6 +184,8 @@ public struct Tool: Hashable, Codable, Sendable {
name = try container.decode(String.self, forKey: .name) name = try container.decode(String.self, forKey: .name)
description = try container.decode(String.self, forKey: .description) description = try container.decode(String.self, forKey: .description)
inputSchema = try container.decodeIfPresent(Value.self, forKey: .inputSchema) 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 { public func encode(to encoder: Encoder) throws {
@ -119,6 +195,9 @@ public struct Tool: Hashable, Codable, Sendable {
if let schema = inputSchema { if let schema = inputSchema {
try container.encode(schema, forKey: .inputSchema) try container.encode(schema, forKey: .inputSchema)
} }
if !annotations.isEmpty {
try container.encode(annotations, forKey: .annotations)
}
} }
} }

View file

@ -20,6 +20,159 @@ struct ToolTests {
#expect(tool.inputSchema != nil) #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") @Test("Tool encoding and decoding")
func testToolEncodingDecoding() throws { func testToolEncodingDecoding() throws {
let tool = Tool( let tool = Tool(