Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for tool annotations #47

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
83 changes: 81 additions & 2 deletions Sources/MCP/Server/Tools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,13 +165,16 @@ public struct Tool: Hashable, Codable, Sendable {
case name
case description
case inputSchema
case annotations
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
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 {
Expand All @@ -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)
}
}
}

Expand All @@ -120,7 +199,7 @@ public enum ListTools: Method {

public struct Parameters: NotRequired, Hashable, Codable, Sendable {
public let cursor: String?

public init() {
self.cursor = nil
}
Expand Down
153 changes: 153 additions & 0 deletions Tests/MCPTests/ToolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down