* Support 2025-06-18 Spec (#167) * Support OpenAI _meta for Tool * Update init * Add title and outputSchema * Concurreny issue * General fields * Add Generate Fields to Server elements * 4 tab spacing * Update Version * Elicitation * Format * More tests and inits * Elicitation in README * Fix orphaned test * Add title to more structs * Public init * Fix meta validation and make tests more generic * Format and make Result properties public again * Revert more visibility changes * feat: 2025-11-25 phase 1, includes Version, Icon, Progress (#181) * chore: fixed unit tests * feat: added icon and progess base structs * feat: added progress notification and meta to callTool * feat: added tests * feat: simplified RequestMeta encoding/decoding * tests: added unit test to track progress * chore: rebased v0.11.x * feat: implemented cancellation * feat: added overloaded callTool method * fix: encoding Response _meta fix * chore: added old version to the versions list * feat: 2025-11-25 phase 2; added Logging and Completions (#196) * feat: added Logging and Completions * feat: updated EmbeddedResource for the latest spec * chore: changed encoding of meta in Prompts to encodeIfPresent * feat: compliance tests + http server transport (#197) * feat: http server transport * feat: added http server transport * feat: removed unused files * feat: added conformance tests * feat: improved conformace testing * chore: removed redundant files * feat/2025-11-25: sampling, elicitation, roots (#198) * feat: Roots implementation * feat: added unit testing * feat: improved unit tests and fixed NetworkTransport crash * feat: implemented sampling and elicitation * feat: improved sampling tests * feat: improved elicitation tests * feat: improved confirmance tests * feat: improved Prompts unit tests * feat: removed test code * feat: updated conformance tests * chore: removed redundant commas * chore: removed 6.0.3 swift version from ci * chore: improved unit tests a little bit * chore: update README.md * chore: add server progress tracking example to README.md * chore: updated README.md * chore: updated transports table formatting * chore: updated MockNetworkConnection --------- Co-authored-by: Austin Evans <austinjevans@me.com> Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
512 lines
18 KiB
Swift
512 lines
18 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import MCP
|
|
|
|
@Suite("Tool Tests")
|
|
struct ToolTests {
|
|
@Test("Tool initialization with valid parameters")
|
|
func testToolInitialization() throws {
|
|
let tool = Tool(
|
|
name: "test_tool",
|
|
description: "A test tool",
|
|
inputSchema: .object([
|
|
"properties": .object([
|
|
"param1": .string("Test parameter")
|
|
])
|
|
])
|
|
)
|
|
|
|
#expect(tool.name == "test_tool")
|
|
#expect(tool.description == "A test tool")
|
|
#expect(tool.inputSchema != nil)
|
|
#expect(tool.title == nil)
|
|
#expect(tool.outputSchema == 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([
|
|
"properties": .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",
|
|
inputSchema: [:]
|
|
)
|
|
|
|
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: [:],
|
|
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(
|
|
name: "test_tool",
|
|
description: "Test tool description",
|
|
inputSchema: .object([
|
|
"properties": .object([
|
|
"param1": .string("String parameter"),
|
|
"param2": .int(42),
|
|
])
|
|
])
|
|
)
|
|
|
|
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.inputSchema == tool.inputSchema)
|
|
}
|
|
|
|
@Test("Tool encoding and decoding with title and output schema")
|
|
func testToolEncodingDecodingWithTitleAndOutputSchema() throws {
|
|
let tool = Tool(
|
|
name: "test_tool",
|
|
title: "Readable Test Tool",
|
|
description: "Test tool description",
|
|
inputSchema: .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"param1": .string("String parameter")
|
|
]),
|
|
]),
|
|
outputSchema: .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"result": .string("String result")
|
|
]),
|
|
])
|
|
)
|
|
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(tool)
|
|
let decoded = try decoder.decode(Tool.self, from: data)
|
|
|
|
#expect(decoded.title == tool.title)
|
|
#expect(decoded.outputSchema == tool.outputSchema)
|
|
|
|
let jsonString = String(decoding: data, as: UTF8.self)
|
|
#expect(jsonString.contains("\"title\":\"Readable Test Tool\""))
|
|
#expect(jsonString.contains("\"outputSchema\""))
|
|
}
|
|
|
|
@Test("Text content encoding and decoding")
|
|
func testToolContentTextEncoding() throws {
|
|
let content = Tool.Content.text("Hello, world!")
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(content)
|
|
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
|
|
|
if case .text(let text) = decoded {
|
|
#expect(text == "Hello, world!")
|
|
} else {
|
|
#expect(Bool(false), "Expected text content")
|
|
}
|
|
}
|
|
|
|
@Test("Image content encoding and decoding")
|
|
func testToolContentImageEncoding() throws {
|
|
let content = Tool.Content.image(
|
|
data: "base64data",
|
|
mimeType: "image/png",
|
|
metadata: .init(additionalFields: ["width": "100", "height": "100"])
|
|
)
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(content)
|
|
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
|
|
|
if case .image(let data, let mimeType, let metadata) = decoded {
|
|
#expect(data == "base64data")
|
|
#expect(mimeType == "image/png")
|
|
#expect(metadata?["width"] == "100")
|
|
#expect(metadata?["height"] == "100")
|
|
} else {
|
|
#expect(Bool(false), "Expected image content")
|
|
}
|
|
}
|
|
|
|
@Test("Resource content encoding and decoding")
|
|
func testToolContentResourceEncoding() throws {
|
|
let resourceContent = Resource.Content.text(
|
|
"Sample text",
|
|
uri: "file://test.txt",
|
|
mimeType: "text/plain"
|
|
)
|
|
let content = Tool.Content.resource(resource: resourceContent, annotations: nil, _meta: nil)
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(content)
|
|
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
|
|
|
if case .resource(let resource, let annotations, let _meta) = decoded {
|
|
#expect(resource.uri == "file://test.txt")
|
|
#expect(resource.mimeType == "text/plain")
|
|
#expect(resource.text == "Sample text")
|
|
#expect(annotations == nil)
|
|
#expect(_meta == nil)
|
|
} else {
|
|
#expect(Bool(false), "Expected resource content")
|
|
}
|
|
}
|
|
|
|
@Test("Resource link content includes title")
|
|
func testToolContentResourceLinkEncoding() throws {
|
|
let content = Tool.Content.resourceLink(
|
|
uri: "file://resource.txt",
|
|
name: "resource_name",
|
|
title: "Resource Title",
|
|
description: "Resource description",
|
|
mimeType: "text/plain"
|
|
)
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(content)
|
|
let jsonString = String(decoding: data, as: UTF8.self)
|
|
#expect(jsonString.contains("\"title\":\"Resource Title\""))
|
|
|
|
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
|
if case .resourceLink(
|
|
let uri, let name, let title, let description, let mimeType, let annotations
|
|
) = decoded {
|
|
#expect(uri == "file://resource.txt")
|
|
#expect(name == "resource_name")
|
|
#expect(title == "Resource Title")
|
|
#expect(description == "Resource description")
|
|
#expect(mimeType == "text/plain")
|
|
#expect(annotations == nil)
|
|
} else {
|
|
#expect(Bool(false), "Expected resourceLink content")
|
|
}
|
|
}
|
|
|
|
@Test("Audio content encoding and decoding")
|
|
func testToolContentAudioEncoding() throws {
|
|
let content = Tool.Content.audio(
|
|
data: "base64audiodata",
|
|
mimeType: "audio/wav"
|
|
)
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
|
|
let data = try encoder.encode(content)
|
|
let decoded = try decoder.decode(Tool.Content.self, from: data)
|
|
|
|
if case .audio(let data, let mimeType) = decoded {
|
|
#expect(data == "base64audiodata")
|
|
#expect(mimeType == "audio/wav")
|
|
} else {
|
|
#expect(Bool(false), "Expected audio content")
|
|
}
|
|
}
|
|
|
|
@Test("ListTools parameters validation")
|
|
func testListToolsParameters() throws {
|
|
let params = ListTools.Parameters(cursor: "next_page")
|
|
#expect(params.cursor == "next_page")
|
|
|
|
let emptyParams = ListTools.Parameters()
|
|
#expect(emptyParams.cursor == nil)
|
|
}
|
|
|
|
@Test("ListTools request decoding with omitted params")
|
|
func testListToolsRequestDecodingWithOmittedParams() throws {
|
|
// Test decoding when params field is omitted
|
|
let jsonString = """
|
|
{"jsonrpc":"2.0","id":"test-id","method":"tools/list"}
|
|
"""
|
|
let data = jsonString.data(using: .utf8)!
|
|
|
|
let decoder = JSONDecoder()
|
|
let decoded = try decoder.decode(Request<ListTools>.self, from: data)
|
|
|
|
#expect(decoded.id == "test-id")
|
|
#expect(decoded.method == ListTools.name)
|
|
}
|
|
|
|
@Test("ListTools request decoding with null params")
|
|
func testListToolsRequestDecodingWithNullParams() throws {
|
|
// Test decoding when params field is null
|
|
let jsonString = """
|
|
{"jsonrpc":"2.0","id":"test-id","method":"tools/list","params":null}
|
|
"""
|
|
let data = jsonString.data(using: .utf8)!
|
|
|
|
let decoder = JSONDecoder()
|
|
let decoded = try decoder.decode(Request<ListTools>.self, from: data)
|
|
|
|
#expect(decoded.id == "test-id")
|
|
#expect(decoded.method == ListTools.name)
|
|
}
|
|
|
|
@Test("ListTools result validation")
|
|
func testListToolsResult() throws {
|
|
let tools = [
|
|
Tool(name: "tool1", description: "First tool", inputSchema: [:]),
|
|
Tool(name: "tool2", description: "Second tool", inputSchema: [:]),
|
|
]
|
|
|
|
let result = ListTools.Result(tools: tools, nextCursor: "next_page")
|
|
#expect(result.tools.count == 2)
|
|
#expect(result.tools[0].name == "tool1")
|
|
#expect(result.tools[1].name == "tool2")
|
|
#expect(result.nextCursor == "next_page")
|
|
}
|
|
|
|
@Test("CallTool parameters validation")
|
|
func testCallToolParameters() throws {
|
|
let arguments: [String: Value] = [
|
|
"param1": .string("value1"),
|
|
"param2": .int(42),
|
|
]
|
|
|
|
let params = CallTool.Parameters(name: "test_tool", arguments: arguments)
|
|
#expect(params.name == "test_tool")
|
|
#expect(params.arguments?["param1"] == .string("value1"))
|
|
#expect(params.arguments?["param2"] == .int(42))
|
|
}
|
|
|
|
@Test("CallTool success result validation")
|
|
func testCallToolResult() throws {
|
|
let content = [
|
|
Tool.Content.text("Result 1"),
|
|
Tool.Content.text("Result 2"),
|
|
]
|
|
|
|
let result = CallTool.Result(content: content)
|
|
#expect(result.content.count == 2)
|
|
#expect(result.isError == nil)
|
|
|
|
if case .text(let text) = result.content[0] {
|
|
#expect(text == "Result 1")
|
|
} else {
|
|
#expect(Bool(false), "Expected text content")
|
|
}
|
|
}
|
|
|
|
@Test("CallTool error result validation")
|
|
func testCallToolErrorResult() throws {
|
|
let errorContent = [Tool.Content.text("Error message")]
|
|
let errorResult = CallTool.Result(content: errorContent, isError: true)
|
|
#expect(errorResult.content.count == 1)
|
|
#expect(errorResult.isError == true)
|
|
|
|
if case .text(let text) = errorResult.content[0] {
|
|
#expect(text == "Error message")
|
|
} else {
|
|
#expect(Bool(false), "Expected error text content")
|
|
}
|
|
}
|
|
|
|
@Test("ToolListChanged notification name validation")
|
|
func testToolListChangedNotification() throws {
|
|
#expect(ToolListChangedNotification.name == "notifications/tools/list_changed")
|
|
}
|
|
|
|
@Test("ListTools handler invocation without params")
|
|
func testListToolsHandlerWithoutParams() async throws {
|
|
let jsonString = """
|
|
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
|
|
"""
|
|
let jsonData = jsonString.data(using: .utf8)!
|
|
|
|
let anyRequest = try JSONDecoder().decode(AnyRequest.self, from: jsonData)
|
|
|
|
let handler = TypedRequestHandler<ListTools> { request in
|
|
#expect(request.method == ListTools.name)
|
|
#expect(request.id == 1)
|
|
#expect(request.params.cursor == nil)
|
|
|
|
let testTool = Tool(
|
|
name: "test_tool",
|
|
description: "Test tool for verification",
|
|
inputSchema: [:]
|
|
)
|
|
return ListTools.response(id: request.id, result: ListTools.Result(tools: [testTool]))
|
|
}
|
|
|
|
let response = try await handler(anyRequest)
|
|
|
|
if case .success(let value) = response.result {
|
|
let encoder = JSONEncoder()
|
|
let decoder = JSONDecoder()
|
|
let data = try encoder.encode(value)
|
|
let result = try decoder.decode(ListTools.Result.self, from: data)
|
|
|
|
#expect(result.tools.count == 1)
|
|
#expect(result.tools[0].name == "test_tool")
|
|
} else {
|
|
#expect(Bool(false), "Expected success result")
|
|
}
|
|
}
|
|
|
|
@Test("Tool with missing description")
|
|
func testToolWithMissingDescription() throws {
|
|
let jsonString = """
|
|
{
|
|
"name": "test_tool",
|
|
"inputSchema": {}
|
|
}
|
|
"""
|
|
let jsonData = jsonString.data(using: .utf8)!
|
|
|
|
let tool = try JSONDecoder().decode(Tool.self, from: jsonData)
|
|
|
|
#expect(tool.name == "test_tool")
|
|
#expect(tool.description == nil)
|
|
#expect(tool.inputSchema == [:])
|
|
}
|
|
}
|