* 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>
This commit is contained in:
Maksym Mova 2026-02-19 13:09:31 +02:00 • committed by GitHub
commit 6112a3995a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 11854 additions and 828 deletions

View file

@ -0,0 +1,249 @@
import Foundation
import Testing
@testable import MCP
@Suite("Cancellation Tests")
struct CancellationTests {
@Test("Client sends CancelledNotification")
func testClientSendsCancellation() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init()
)
// Start server
try await server.start(transport: serverTransport)
// Connect client
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Send a ping request
let pingRequest = Ping.request()
let context = try await client.send(pingRequest)
try await Task.sleep(for: .milliseconds(10))
// Cancel the request
try await client.cancelRequest(context.requestID, reason: "Test cancellation")
try await Task.sleep(for: .milliseconds(50))
// Verify cancellation was sent (server should have received it)
// The test passes if no errors occur and the request is cancelled
await client.disconnect()
await server.stop()
}
@Test("Client receives and processes CancelledNotification")
func testClientReceivesCancellation() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init()
)
// Start server and connect client
try await server.start(transport: serverTransport)
// Register a slow ping handler (must be after start to override default)
await server.withMethodHandler(Ping.self) { _ in
try await Task.sleep(for: .seconds(5))
return Empty()
}
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Send a request using send
let pingRequest = Ping.request()
let context = try await client.send(pingRequest)
// Server cancels the request while it's being awaited
try await Task.sleep(for: .milliseconds(50))
try await server.cancelRequest(pingRequest.id, reason: "Server cancelled")
// Try to get result - should throw CancellationError
do {
_ = try await context.value
Issue.record("Expected CancellationError but request succeeded")
} catch is CancellationError {
// Expected
} catch {
Issue.record("Expected CancellationError but got: \(error)")
}
await client.disconnect()
await server.stop()
}
@Test("RequestContext structure")
func testRequestContextStructure() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init()
)
// Start server and connect client
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Create a request with send
let pingRequest = Ping.request()
let context: RequestContext<Ping.Result> = try await client.send(pingRequest)
// Verify the context has the correct requestID
#expect(context.requestID == pingRequest.id)
// Await the result through the context
let result = try await context.value
#expect(result == Empty())
await client.disconnect()
await server.stop()
}
@Test("callTool with RequestContext overload")
func testCallToolWithRequestContext() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(tools: .init())
)
// Register a tool handler
await server.withMethodHandler(ListTools.self) { _ in
.init(tools: [Tool(name: "testTool", description: "A test tool", inputSchema: .object([:]))])
}
await server.withMethodHandler(CallTool.self) { params in
return .init(content: [.text("Result for \(params.name)")], isError: false)
}
// Start server and connect client
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Use the callTool overload that returns RequestContext (non-async version)
let context: RequestContext<CallTool.Result> = try await client.callTool(name: "testTool", arguments: ["test": "value"])
// Verify we got a context
#expect(context.requestID != ID(stringLiteral: ""))
// Get the result
let result = try await context.value
#expect(result.content == [.text("Result for testTool")])
#expect(result.isError == false)
await client.disconnect()
await server.stop()
}
@Test("Cancel callTool using RequestContext overload")
func testCancelCallToolWithRequestContext() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(tools: .init())
)
// Register a tool handler that takes time
await server.withMethodHandler(ListTools.self) { _ in
.init(tools: [Tool(name: "slowTool", description: "A slow tool", inputSchema: .object([:]))])
}
await server.withMethodHandler(CallTool.self) { params in
try await Task.sleep(for: .seconds(5))
return .init(content: [.text("Should not reach here")], isError: false)
}
// Start server and connect client
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Use the callTool overload that returns RequestContext (non-async version)
let context: RequestContext<CallTool.Result> = try await client.callTool(name: "slowTool", arguments: [:])
// Cancel after a short delay
try await Task.sleep(for: .milliseconds(50))
try await client.cancelRequest(context.requestID, reason: "Test timeout")
// Try to get result - should throw CancellationError
do {
_ = try await context.value
Issue.record("Expected CancellationError but request succeeded")
} catch is CancellationError {
// Expected
} catch {
Issue.record("Expected CancellationError but got: \(error)")
}
await client.disconnect()
await server.stop()
}
@Test("CancelledNotification prevents response")
func testCancellationPreventsResponse() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init()
)
// Start server and connect client
try await server.start(transport: serverTransport)
// Register a slow handler (must be after start to override default)
await server.withMethodHandler(Ping.self) { _ in
try await Task.sleep(for: .seconds(10))
return Empty()
}
_ = try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(50))
// Send a ping request
let pingRequest = Ping.request()
let context = try await client.send(pingRequest)
// Cancel the request while it's being awaited
try await Task.sleep(for: .milliseconds(50))
try await client.cancelRequest(context.requestID, reason: "Test cancellation")
// Try to get result - should throw CancellationError (proving no response was sent)
do {
_ = try await context.value
Issue.record("Expected CancellationError but request succeeded")
} catch is CancellationError {
// Expected - this proves the server didn't send a response
} catch {
Issue.record("Expected CancellationError but got: \(error)")
}
await client.disconnect()
await server.stop()
}
}

View file

@ -3,7 +3,7 @@ import Testing
@testable import MCP
@Suite("Client Tests")
@Suite("Client Tests", .timeLimit(.minutes(1)))
struct ClientTests {
@Test("Client connect and disconnect")
func testClientConnectAndDisconnect() async throws {
@ -345,8 +345,8 @@ struct ClientTests {
let request1 = Ping.request()
let request2 = Ping.request()
var resultTask1: Task<Ping.Result, Swift.Error>?
var resultTask2: Task<Ping.Result, Swift.Error>?
nonisolated(unsafe) var resultTask1: Task<Ping.Result, Swift.Error>?
nonisolated(unsafe) var resultTask2: Task<Ping.Result, Swift.Error>?
try await client.withBatch { batch in
resultTask1 = try await batch.addRequest(request1)
@ -426,7 +426,7 @@ struct ClientTests {
let request1 = Ping.request() // Success
let request2 = Ping.request() // Error
var resultTasks: [Task<Ping.Result, Swift.Error>] = []
nonisolated(unsafe) var resultTasks: [Task<Ping.Result, Swift.Error>] = []
try await client.withBatch { batch in
resultTasks.append(try await batch.addRequest(request1))

View file

@ -0,0 +1,598 @@
import Foundation
import Testing
@testable import MCP
@Suite("Completion Tests")
struct CompletionTests {
// MARK: - Reference Types Tests
@Test("PromptReference initialization and encoding")
func testPromptReferenceEncodingDecoding() throws {
let ref = PromptReference(name: "code_review")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(ref)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["type"] as? String == "ref/prompt")
#expect(json?["name"] as? String == "code_review")
// Test decoding
let decoder = JSONDecoder()
let decoded = try decoder.decode(PromptReference.self, from: data)
#expect(decoded.name == "code_review")
}
@Test("ResourceReference initialization and encoding")
func testResourceReferenceEncodingDecoding() throws {
let ref = ResourceReference(uri: "file:///path/to/resource")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(ref)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["type"] as? String == "ref/resource")
#expect(json?["uri"] as? String == "file:///path/to/resource")
// Test decoding
let decoder = JSONDecoder()
let decoded = try decoder.decode(ResourceReference.self, from: data)
#expect(decoded.uri == "file:///path/to/resource")
}
@Test("CompletionReference prompt case encoding")
func testCompletionReferencePromptEncoding() throws {
let ref = CompletionReference.prompt(PromptReference(name: "test"))
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(ref)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["type"] as? String == "ref/prompt")
#expect(json?["name"] as? String == "test")
}
@Test("CompletionReference resource case encoding")
func testCompletionReferenceResourceEncoding() throws {
let ref = CompletionReference.resource(ResourceReference(uri: "file:///test"))
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(ref)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["type"] as? String == "ref/resource")
#expect(json?["uri"] as? String == "file:///test")
}
@Test("CompletionReference decoding prompt type")
func testCompletionReferenceDecodingPrompt() throws {
let json = """
{
"type": "ref/prompt",
"name": "code_review"
}
"""
let decoder = JSONDecoder()
let ref = try decoder.decode(CompletionReference.self, from: json.data(using: .utf8)!)
if case .prompt(let promptRef) = ref {
#expect(promptRef.name == "code_review")
} else {
Issue.record("Expected prompt reference")
}
}
@Test("CompletionReference decoding resource type")
func testCompletionReferenceDecodingResource() throws {
let json = """
{
"type": "ref/resource",
"uri": "file:///path"
}
"""
let decoder = JSONDecoder()
let ref = try decoder.decode(CompletionReference.self, from: json.data(using: .utf8)!)
if case .resource(let resourceRef) = ref {
#expect(resourceRef.uri == "file:///path")
} else {
Issue.record("Expected resource reference")
}
}
// MARK: - Complete Request Tests
@Test("Complete request initialization")
func testCompleteRequestInitialization() throws {
let ref = CompletionReference.prompt(PromptReference(name: "code_review"))
let argument = Complete.Parameters.Argument(name: "language", value: "py")
let request = Complete.request(.init(ref: ref, argument: argument))
#expect(request.method == "completion/complete")
#expect(request.params.argument.name == "language")
#expect(request.params.argument.value == "py")
}
@Test("Complete request with context")
func testCompleteRequestWithContext() throws {
let ref = CompletionReference.prompt(PromptReference(name: "code_review"))
let argument = Complete.Parameters.Argument(name: "framework", value: "fla")
let context = Complete.Parameters.Context(arguments: ["language": .string("python")])
let request = Complete.request(.init(ref: ref, argument: argument, context: context))
#expect(request.params.context != nil)
#expect(request.params.context?.arguments["language"] == .string("python"))
}
@Test("Complete request encoding")
func testCompleteRequestEncoding() throws {
let ref = CompletionReference.prompt(PromptReference(name: "code_review"))
let argument = Complete.Parameters.Argument(name: "language", value: "py")
let request = Complete.request(.init(ref: ref, argument: argument))
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["jsonrpc"] as? String == "2.0")
#expect(json?["method"] as? String == "completion/complete")
guard let params = json?["params"] as? [String: Any] else {
Issue.record("Failed to get params")
return
}
guard let refDict = params["ref"] as? [String: Any] else {
Issue.record("Failed to get ref")
return
}
#expect(refDict["type"] as? String == "ref/prompt")
#expect(refDict["name"] as? String == "code_review")
guard let arg = params["argument"] as? [String: Any] else {
Issue.record("Failed to get argument")
return
}
#expect(arg["name"] as? String == "language")
#expect(arg["value"] as? String == "py")
}
@Test("Complete request decoding")
func testCompleteRequestDecoding() throws {
let json = """
{
"jsonrpc": "2.0",
"id": "test-id",
"method": "completion/complete",
"params": {
"ref": {
"type": "ref/prompt",
"name": "code_review"
},
"argument": {
"name": "language",
"value": "py"
}
}
}
"""
let decoder = JSONDecoder()
let request = try decoder.decode(Request<Complete>.self, from: json.data(using: .utf8)!)
#expect(request.method == "completion/complete")
#expect(request.params.argument.name == "language")
#expect(request.params.argument.value == "py")
if case .prompt(let promptRef) = request.params.ref {
#expect(promptRef.name == "code_review")
} else {
Issue.record("Expected prompt reference")
}
}
// MARK: - Complete Result Tests
@Test("Complete result initialization")
func testCompleteResultInitialization() throws {
let completion = Complete.Result.Completion(
values: ["python", "pytorch", "pyside"],
total: 10,
hasMore: true
)
let result = Complete.Result(completion: completion)
#expect(result.completion.values.count == 3)
#expect(result.completion.values[0] == "python")
#expect(result.completion.total == 10)
#expect(result.completion.hasMore == true)
}
@Test("Complete result encoding")
func testCompleteResultEncoding() throws {
let completion = Complete.Result.Completion(
values: ["python", "pytorch"],
total: 2,
hasMore: false
)
let result = Complete.Result(completion: completion)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let completionDict = json?["completion"] as? [String: Any]
let values = completionDict?["values"] as? [String]
#expect(values == ["python", "pytorch"])
#expect(completionDict?["total"] as? Int == 2)
#expect(completionDict?["hasMore"] as? Bool == false)
}
@Test("Complete result decoding")
func testCompleteResultDecoding() throws {
let json = """
{
"completion": {
"values": ["python", "pytorch", "pyside"],
"total": 10,
"hasMore": true
}
}
"""
let decoder = JSONDecoder()
let result = try decoder.decode(Complete.Result.self, from: json.data(using: .utf8)!)
#expect(result.completion.values.count == 3)
#expect(result.completion.values == ["python", "pytorch", "pyside"])
#expect(result.completion.total == 10)
#expect(result.completion.hasMore == true)
}
@Test("Complete result with optional fields")
func testCompleteResultWithOptionalFields() throws {
let completion = Complete.Result.Completion(
values: ["value1"],
total: nil,
hasMore: nil
)
#expect(completion.values == ["value1"])
#expect(completion.total == nil)
#expect(completion.hasMore == nil)
}
// MARK: - Client Integration Tests
@Test("Client complete for prompt argument")
func testClientCompleteForPrompt() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler for complete on server
await server.withMethodHandler(Complete.self) { params in
#expect(params.argument.name == "language")
#expect(params.argument.value == "py")
if case .prompt(let promptRef) = params.ref {
#expect(promptRef.name == "code_review")
} else {
Issue.record("Expected prompt reference")
}
return .init(
completion: .init(
values: ["python", "pytorch", "pyside"],
total: 10,
hasMore: true
)
)
}
try await server.start(transport: serverTransport)
let initResult = try await client.connect(transport: clientTransport)
// Verify completions capability is advertised
#expect(initResult.capabilities.completions != nil)
// Request completions
let completion = try await client.complete(
promptName: "code_review",
argumentName: "language",
argumentValue: "py"
)
#expect(completion.values == ["python", "pytorch", "pyside"])
#expect(completion.total == 10)
#expect(completion.hasMore == true)
await client.disconnect()
await server.stop()
}
@Test("Client complete for resource argument")
func testClientCompleteForResource() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler for complete on server
await server.withMethodHandler(Complete.self) { params in
#expect(params.argument.name == "path")
#expect(params.argument.value == "/usr/")
if case .resource(let resourceRef) = params.ref {
#expect(resourceRef.uri == "file:///{path}")
} else {
Issue.record("Expected resource reference")
}
return .init(
completion: .init(
values: ["/usr/bin", "/usr/lib", "/usr/local"],
total: 3,
hasMore: false
)
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Request completions for resource
let completion = try await client.complete(
resourceURI: "file:///{path}",
argumentName: "path",
argumentValue: "/usr/"
)
#expect(completion.values == ["/usr/bin", "/usr/lib", "/usr/local"])
#expect(completion.total == 3)
#expect(completion.hasMore == false)
await client.disconnect()
await server.stop()
}
@Test("Client complete with context")
func testClientCompleteWithContext() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler for complete on server
await server.withMethodHandler(Complete.self) { params in
#expect(params.argument.name == "framework")
#expect(params.argument.value == "fla")
#expect(params.context != nil)
#expect(params.context?.arguments["language"] == .string("python"))
return .init(
completion: .init(
values: ["flask"],
total: 1,
hasMore: false
)
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Request completions with context
let completion = try await client.complete(
promptName: "code_review",
argumentName: "framework",
argumentValue: "fla",
context: ["language": .string("python")]
)
#expect(completion.values == ["flask"])
#expect(completion.total == 1)
#expect(completion.hasMore == false)
await client.disconnect()
await server.stop()
}
@Test("Client complete fails without completions capability")
func testClientCompleteFailsWithoutCapability() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0", configuration: .strict)
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init() // No completions capability
)
try await server.start(transport: serverTransport)
let initResult = try await client.connect(transport: clientTransport)
// Verify completions capability is NOT advertised
#expect(initResult.capabilities.completions == nil)
// Attempt to request completions should fail in strict mode
await #expect(throws: MCPError.self) {
try await client.complete(
promptName: "test",
argumentName: "arg",
argumentValue: "val"
)
}
await client.disconnect()
await server.stop()
}
@Test("Empty completion values")
func testEmptyCompletionValues() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler that returns empty results
await server.withMethodHandler(Complete.self) { _ in
return .init(
completion: .init(
values: [],
total: 0,
hasMore: false
)
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
let completion = try await client.complete(
promptName: "test",
argumentName: "arg",
argumentValue: "xyz"
)
#expect(completion.values.isEmpty)
#expect(completion.total == 0)
#expect(completion.hasMore == false)
await client.disconnect()
await server.stop()
}
@Test("Maximum completion values (100 items)")
func testMaximumCompletionValues() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler that returns 100 items
await server.withMethodHandler(Complete.self) { _ in
let values = (1...100).map { "value\($0)" }
return .init(
completion: .init(
values: values,
total: 200,
hasMore: true
)
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
let completion = try await client.complete(
promptName: "test",
argumentName: "arg",
argumentValue: ""
)
#expect(completion.values.count == 100)
#expect(completion.values.first == "value1")
#expect(completion.values.last == "value100")
#expect(completion.total == 200)
#expect(completion.hasMore == true)
await client.disconnect()
await server.stop()
}
@Test("Fuzzy matching completion scenario")
func testFuzzyMatchingScenario() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(completions: .init())
)
// Register handler that implements fuzzy matching
await server.withMethodHandler(Complete.self) { params in
let input = params.argument.value.lowercased()
let allLanguages = ["python", "perl", "php", "pascal", "prolog", "javascript", "java"]
// Simple prefix matching
let matches = allLanguages.filter { $0.lowercased().hasPrefix(input) }
return .init(
completion: .init(
values: matches,
total: matches.count,
hasMore: false
)
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Test with "p" prefix
let completion1 = try await client.complete(
promptName: "language_selector",
argumentName: "language",
argumentValue: "p"
)
#expect(completion1.values.count == 5) // python, perl, php, pascal, prolog
// Test with "py" prefix
let completion2 = try await client.complete(
promptName: "language_selector",
argumentName: "language",
argumentValue: "py"
)
#expect(completion2.values == ["python"])
// Test with "ja" prefix
let completion3 = try await client.complete(
promptName: "language_selector",
argumentName: "language",
argumentValue: "ja"
)
#expect(completion3.values == ["javascript", "java"])
await client.disconnect()
await server.stop()
}
}

View file

@ -0,0 +1,626 @@
import Testing
import class Foundation.JSONDecoder
import class Foundation.JSONEncoder
@testable import MCP
@Suite("Elicitation Tests")
struct ElicitationTests {
@Test("Request schema encoding and decoding")
func testSchemaCoding() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let decoder = JSONDecoder()
let schema = Elicitation.RequestSchema(
title: "Contact Information",
description: "Used to follow up after onboarding",
properties: [
"name": [
"type": "string",
"title": "Full Name",
"description": "Enter your legal name",
"minLength": 2,
"maxLength": 120,
],
"email": [
"type": "string",
"title": "Email Address",
"description": "Where we can reach you",
"format": "email",
],
"age": [
"type": "integer",
"minimum": 18,
"maximum": 110,
],
"marketingOptIn": [
"type": "boolean",
"title": "Marketing opt-in",
"default": false,
],
],
required: ["name", "email"]
)
let data = try encoder.encode(schema)
let decoded = try decoder.decode(Elicitation.RequestSchema.self, from: data)
#expect(decoded == schema)
}
@Test("Enumeration support")
func testEnumerationSupport() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let property: Value = [
"type": "string",
"title": "Department",
"enum": ["engineering", "design", "product"],
"enumNames": ["Engineering", "Design", "Product"],
]
let data = try encoder.encode(property)
let decoded = try decoder.decode(Value.self, from: data)
let object = decoded.objectValue
let enumeration = object?["enum"]?.arrayValue?.compactMap { $0.stringValue }
let enumNames = object?["enumNames"]?.arrayValue?.compactMap { $0.stringValue }
#expect(enumeration == ["engineering", "design", "product"])
#expect(enumNames == ["Engineering", "Design", "Product"])
}
@Test("CreateElicitation.Parameters coding")
func testParametersCoding() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let schema = Elicitation.RequestSchema(
properties: [
"username": [
"type": "string",
"minLength": 2,
"maxLength": 39,
]
],
required: ["username"]
)
let parameters = CreateElicitation.Parameters.form(
.init(
message: "Please share your GitHub username",
requestedSchema: schema,
_meta: Metadata(additionalFields: ["flow": "onboarding"])
)
)
let data = try encoder.encode(parameters)
let decoded = try decoder.decode(CreateElicitation.Parameters.self, from: data)
#expect(decoded == parameters)
}
@Test("CreateElicitation.Result coding")
func testResultCoding() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let result = CreateElicitation.Result(
action: .accept,
content: ["username": "octocat", "age": 30]
)
let data = try encoder.encode(result)
let decoded = try decoder.decode(CreateElicitation.Result.self, from: data)
#expect(decoded == result)
}
@Test("Client capabilities include elicitation")
func testClientCapabilitiesIncludeElicitation() throws {
let capabilities = Client.Capabilities(
elicitation: .init()
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(capabilities)
let decoded = try decoder.decode(Client.Capabilities.self, from: data)
#expect(decoded == capabilities)
}
@Test("Client elicitation handler registration")
func testClientElicitationHandlerRegistration() async throws {
let client = Client(name: "TestClient", version: "1.0")
let handlerClient = await client.withElicitationHandler { parameters in
if case .form(let formParams) = parameters {
#expect(formParams.message == "Collect input")
}
return CreateElicitation.Result(action: .decline)
}
#expect(handlerClient === client)
}
}
@Suite("Elicitation 2025-11-25 Spec Tests")
struct Elicitation2025_11_25Tests {
@Test("URL mode parameters encoding and decoding")
func testURLModeParameters() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let params = CreateElicitation.Parameters.url(
.init(
message: "Please authenticate",
url: "https://example.com/auth",
elicitationId: "elicit-123"
)
)
let data = try encoder.encode(params)
let decoded = try decoder.decode(CreateElicitation.Parameters.self, from: data)
#expect(decoded == params)
}
@Test("Form mode backward compatibility")
func testFormModeBackwardCompatibility() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let params = CreateElicitation.Parameters.form(
.init(message: "Enter your name")
)
let data = try encoder.encode(params)
let decoded = try decoder.decode(CreateElicitation.Parameters.self, from: data)
#expect(decoded == params)
}
@Test("ElicitationCompleteNotification")
func testElicitationCompleteNotification() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let notification = ElicitationCompleteNotification.Parameters(
elicitationId: "elicit-456"
)
let data = try encoder.encode(notification)
let decoded = try decoder.decode(
ElicitationCompleteNotification.Parameters.self, from: data
)
#expect(decoded == notification)
}
@Test("Client elicitation capabilities with sub-capabilities")
func testElicitationSubCapabilities() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let capabilities = Client.Capabilities(
elicitation: .init(form: .init(), url: .init())
)
let data = try encoder.encode(capabilities)
let decoded = try decoder.decode(Client.Capabilities.self, from: data)
#expect(decoded == capabilities)
}
@Test("URLElicitationRequiredError encoding and decoding")
func testURLElicitationRequiredError() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let elicitationInfo = URLElicitationInfo(
elicitationId: "elicit-789",
url: "https://example.com/verify",
message: "Please verify your identity"
)
let error = MCPError.urlElicitationRequired(
message: "Authentication required",
elicitations: [elicitationInfo]
)
let data = try encoder.encode(error)
let decoded = try decoder.decode(MCPError.self, from: data)
#expect(decoded == error)
}
}
@Suite("Elicitation Integration Tests")
struct ElicitationIntegrationTests {
@Test("Form-based elicitation flow")
func testFormElicitationFlow() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "FormTestServer",
version: "1.0"
)
let client = Client(
name: "FormTestClient",
version: "1.0",
capabilities: .init(elicitation: .init())
)
// Register handler on client that validates parameters
await client.withElicitationHandler { parameters in
if case .form(let formParams) = parameters {
#expect(formParams.message == "Please enter your details")
#expect(formParams.requestedSchema?.properties["email"] != nil)
#expect(formParams._meta?["flow"]?.stringValue == "onboarding")
// Return accepted response
return CreateElicitation.Result(
action: .accept,
content: [
"email": "user@example.com",
"name": "Test User"
]
)
} else {
Issue.record("Expected form parameters")
return CreateElicitation.Result(action: .decline)
}
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Server requests elicitation with form parameters
let schema = Elicitation.RequestSchema(
title: "User Details",
properties: [
"email": ["type": "string", "format": "email"],
"name": ["type": "string", "minLength": 2]
],
required: ["email"]
)
let result = try await server.requestElicitation(
message: "Please enter your details",
requestedSchema: schema,
_meta: Metadata(additionalFields: ["flow": "onboarding"])
)
// Verify the response
#expect(result.action == .accept)
#expect(result.content?["email"]?.stringValue == "user@example.com")
#expect(result.content?["name"]?.stringValue == "Test User")
await server.stop()
await client.disconnect()
}
@Test("URL-based elicitation flow")
func testURLElicitationFlow() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "URLTestServer",
version: "1.0"
)
let client = Client(
name: "URLTestClient",
version: "1.0",
capabilities: .init(elicitation: .init(url: .init()))
)
// Register handler on client that validates URL parameters
await client.withElicitationHandler { parameters in
if case .url(let urlParams) = parameters {
#expect(urlParams.message == "Please authenticate")
#expect(urlParams.url == "https://example.com/auth")
#expect(urlParams.elicitationId.isEmpty == false)
#expect(urlParams._meta?["provider"]?.stringValue == "oauth")
// Return accepted response
return CreateElicitation.Result(
action: .accept,
content: ["token": "auth-token-123"]
)
} else {
Issue.record("Expected URL parameters")
return CreateElicitation.Result(action: .decline)
}
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Server requests elicitation with URL parameters
let result = try await server.requestElicitation(
message: "Please authenticate",
url: "https://example.com/auth",
elicitationId: "elicit-test-123",
_meta: Metadata(additionalFields: ["provider": "oauth"])
)
// Verify the response
#expect(result.action == .accept)
#expect(result.content?["token"]?.stringValue == "auth-token-123")
await server.stop()
await client.disconnect()
}
@Test("Declined elicitation")
func testDeclinedElicitation() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "DeclineTestServer",
version: "1.0"
)
let client = Client(
name: "DeclineTestClient",
version: "1.0",
capabilities: .init(elicitation: .init())
)
// Register handler that declines
await client.withElicitationHandler { _ in
CreateElicitation.Result(action: .decline)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let result = try await server.requestElicitation(
message: "Optional question"
)
#expect(result.action == .decline)
#expect(result.content == nil)
await server.stop()
await client.disconnect()
}
@Test("Elicitation without handler fails")
func testElicitationWithoutHandlerFails() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "ErrorTestServer",
version: "1.0"
)
let client = Client(
name: "ErrorTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should throw an error because client doesn't have elicitation capability
await #expect(throws: MCPError.self) {
_ = try await server.requestElicitation(
message: "Test message"
)
}
await server.stop()
await client.disconnect()
}
@Test("Strict mode succeeds when client declares elicitation capability")
func testElicitationStrictCapabilitiesSuccess() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "StrictTestServer",
version: "1.0",
configuration: .strict
)
let client = Client(
name: "StrictTestClient",
version: "1.0",
capabilities: .init(elicitation: .init()),
configuration: .strict
)
// Register elicitation handler
await client.withElicitationHandler { _ in
CreateElicitation.Result(
action: .accept,
content: ["response": "strict mode success"]
)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because client declares elicitation capability
let result = try await server.requestElicitation(
message: "Test message"
)
#expect(result.action == .accept)
#expect(result.content?["response"]?.stringValue == "strict mode success")
await server.stop()
await client.disconnect()
}
@Test("Strict mode fails when client doesn't declare elicitation capability")
func testElicitationStrictCapabilitiesError() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "StrictTestServer",
version: "1.0",
configuration: .strict
)
// Client WITHOUT elicitation capability in strict mode
let client = Client(
name: "StrictTestClient",
version: "1.0",
capabilities: .init(),
configuration: .strict
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should fail because client doesn't declare elicitation capability in strict mode
await #expect(throws: MCPError.self) {
_ = try await server.requestElicitation(
message: "Test message"
)
}
await server.stop()
await client.disconnect()
}
@Test("Non-strict mode succeeds even without client capability declaration")
func testElicitationNonStrictCapabilities() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "NonStrictTestServer",
version: "1.0",
configuration: .default // Non-strict mode
)
// Client WITHOUT elicitation capability in non-strict mode
let client = Client(
name: "NonStrictTestClient",
version: "1.0",
capabilities: .init(),
configuration: .default
)
// Register elicitation handler anyway
await client.withElicitationHandler { _ in
CreateElicitation.Result(
action: .accept,
content: ["response": "non-strict mode success"]
)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because server is in non-strict mode
let result = try await server.requestElicitation(
message: "Test message"
)
#expect(result.action == .accept)
#expect(result.content?["response"]?.stringValue == "non-strict mode success")
await server.stop()
await client.disconnect()
}
@Test("Complex schema validation")
func testComplexSchemaValidation() async throws {
let schema = Elicitation.RequestSchema(
title: "User Profile",
description: "Complete user profile information",
properties: [
"username": [
"type": "string",
"minLength": 3,
"maxLength": 20,
"pattern": "^[a-zA-Z0-9_]+$"
],
"email": [
"type": "string",
"format": "email"
],
"age": [
"type": "integer",
"minimum": 18,
"maximum": 120
],
"preferences": [
"type": "object",
"properties": [
"theme": ["type": "string", "enum": ["light", "dark"]],
"notifications": ["type": "boolean"]
]
]
],
required: ["username", "email"]
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
let decoder = JSONDecoder()
let data = try encoder.encode(schema)
let decoded = try decoder.decode(Elicitation.RequestSchema.self, from: data)
#expect(decoded == schema)
}
@Test("Multiple elicitation requests in sequence")
func testSequentialElicitations() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "SequentialTestServer",
version: "1.0"
)
let client = Client(
name: "SequentialTestClient",
version: "1.0",
capabilities: .init(elicitation: .init())
)
// Register handler that echoes the message
await client.withElicitationHandler { parameters in
if case .form(let formParams) = parameters {
return CreateElicitation.Result(
action: .accept,
content: ["echo": Value(stringLiteral: formParams.message)]
)
} else {
return CreateElicitation.Result(action: .decline)
}
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Make multiple sequential requests
let result1 = try await server.requestElicitation(message: "First question")
#expect(result1.action == .accept)
#expect(result1.content?["echo"]?.stringValue == "First question")
let result2 = try await server.requestElicitation(message: "Second question")
#expect(result2.action == .accept)
#expect(result2.content?["echo"]?.stringValue == "Second question")
let result3 = try await server.requestElicitation(message: "Third question")
#expect(result3.action == .accept)
#expect(result3.content?["echo"]?.stringValue == "Third question")
await server.stop()
await client.disconnect()
}
}

View file

@ -209,12 +209,12 @@ import Testing
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint] (request: URLRequest) in
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == nil)
#expect(request.value(forHTTPHeaderField: "MCP-Session-Id") == nil)
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/json",
"Mcp-Session-Id": newSessionID,
"MCP-Session-Id": newSessionID,
])!
return (response, Data())
}
@ -247,12 +247,12 @@ import Testing
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint] (request: URLRequest) in
#expect(request.readBody() == firstMessageData)
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == nil)
#expect(request.value(forHTTPHeaderField: "MCP-Session-Id") == nil)
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/json",
"Mcp-Session-Id": initialSessionID,
"MCP-Session-Id": initialSessionID,
])!
return (response, Data())
}
@ -262,7 +262,7 @@ import Testing
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint] (request: URLRequest) in
#expect(request.readBody() == secondMessageData)
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == initialSessionID)
#expect(request.value(forHTTPHeaderField: "MCP-Session-Id") == initialSessionID)
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
@ -368,7 +368,7 @@ import Testing
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/json",
"Mcp-Session-Id": initialSessionID,
"MCP-Session-Id": initialSessionID,
])!
return (response, Data())
}
@ -387,7 +387,7 @@ import Testing
// Set up the second handler for the 404 response
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint, initialSessionID] (request: URLRequest) in
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == initialSessionID)
#expect(request.value(forHTTPHeaderField: "MCP-Session-Id") == initialSessionID)
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!
return (response, Data("Not Found".utf8))
@ -450,7 +450,7 @@ import Testing
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Accept") == "text/event-stream")
#expect(
request.value(forHTTPHeaderField: "Mcp-Session-Id") == "test-session-123")
request.value(forHTTPHeaderField: "MCP-Session-Id") == "test-session-123")
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
@ -512,7 +512,7 @@ import Testing
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Accept") == "text/event-stream")
#expect(
request.value(forHTTPHeaderField: "Mcp-Session-Id") == "test-session-123")
request.value(forHTTPHeaderField: "MCP-Session-Id") == "test-session-123")
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
@ -724,6 +724,40 @@ import Testing
try await transport.send(messageData)
await transport.disconnect()
}
@Test("Send With Protocol Version Header", .httpClientTransportSetup)
func testProtocolVersionHeader() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let protocolVersion = "2025-11-25"
let transport = HTTPClientTransport(
endpoint: testEndpoint,
configuration: configuration,
streaming: false,
protocolVersion: protocolVersion,
logger: nil
)
try await transport.connect()
let messageData = #"{"jsonrpc":"2.0","method":"test","id":6}"#.data(using: .utf8)!
await MockURLProtocol.requestHandlerStorage.setHandler {
[testEndpoint, protocolVersion] (request: URLRequest) in
// Verify the protocol version header is present
#expect(
request.value(forHTTPHeaderField: "MCP-Protocol-Version")
== protocolVersion)
let response = HTTPURLResponse(
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, Data())
}
try await transport.send(messageData)
await transport.disconnect()
}
#endif // !canImport(FoundationNetworking)
}
#endif // swift(>=6.1)

View file

@ -0,0 +1,899 @@
import Foundation
import Testing
@testable import MCP
// MARK: - Test Helpers
private struct FixedSessionIDGenerator: SessionIDGenerator {
let sessionID: String
func generateSessionID() -> String { sessionID }
}
private func makeInitializeBody(id: String = "1") -> Data {
let json: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": [
"protocolVersion": "2025-11-25",
"capabilities": [:] as [String: Any],
"clientInfo": ["name": "test", "version": "1.0"],
] as [String: Any],
]
return try! JSONSerialization.data(withJSONObject: json)
}
private func makeNotificationBody(method: String = "notifications/initialized") -> Data {
let json: [String: Any] = ["jsonrpc": "2.0", "method": method]
return try! JSONSerialization.data(withJSONObject: json)
}
private func makeRequestBody(id: String = "2", method: String = "tools/list") -> Data {
let json: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": [:] as [String: Any],
]
return try! JSONSerialization.data(withJSONObject: json)
}
private func makeResponseBody(id: String = "2") -> Data {
let json: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"result": ["tools": []] as [String: Any],
]
return try! JSONSerialization.data(withJSONObject: json)
}
private func makeStatefulPOSTRequest(body: Data, sessionID: String? = nil) -> HTTPRequest {
var headers: [String: String] = [
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
]
if let sessionID {
headers["Mcp-Session-Id"] = sessionID
}
return HTTPRequest(method: "POST", headers: headers, body: body)
}
private func makeGETRequest(sessionID: String, lastEventID: String? = nil) -> HTTPRequest {
var headers: [String: String] = [
"Accept": "text/event-stream",
"Mcp-Session-Id": sessionID,
]
if let lastEventID {
headers["Last-Event-Id"] = lastEventID
}
return HTTPRequest(method: "GET", headers: headers)
}
private func makeDELETERequest(sessionID: String) -> HTTPRequest {
HTTPRequest(
method: "DELETE",
headers: ["Mcp-Session-Id": sessionID]
)
}
private func makeStatelessPOSTRequest(body: Data) -> HTTPRequest {
HTTPRequest(
method: "POST",
headers: [
"Content-Type": "application/json",
"Accept": "application/json",
],
body: body
)
}
private func makeStatefulTransport(
sessionIDGenerator: any SessionIDGenerator = UUIDSessionIDGenerator()
) -> StatefulHTTPServerTransport {
StatefulHTTPServerTransport(
sessionIDGenerator: sessionIDGenerator,
validationPipeline: StandardValidationPipeline(validators: [])
)
}
private func makeStatelessTransport() -> StatelessHTTPServerTransport {
StatelessHTTPServerTransport(
validationPipeline: StandardValidationPipeline(validators: [])
)
}
/// Drains an SSE stream, collecting raw SSE chunks.
private actor ChunkCollector {
var chunks: [Data] = []
func append(_ data: Data) { chunks.append(data) }
func getChunks() -> [Data] { chunks }
}
private func drainSSEStream(
_ response: HTTPResponse,
maxChunks: Int = 10,
timeout: Duration = .seconds(2)
) async -> [Data] {
guard case .stream(let stream, _) = response else { return [] }
let collector = ChunkCollector()
let task = Task {
for try await chunk in stream {
await collector.append(chunk)
if await collector.getChunks().count >= maxChunks { break }
}
}
// Wait for stream to finish or timeout
try? await Task.sleep(for: timeout)
task.cancel()
return await collector.getChunks()
}
/// Initializes a stateful transport session and returns the session ID.
/// Spawns a background task to consume the receive stream and send the init response.
private func initializeSession(
transport: StatefulHTTPServerTransport,
sessionID: String? = nil
) async throws -> String {
try await transport.connect()
let initBody = makeInitializeBody()
// Background task: read the init request from receive() and send back a response
let respondTask = Task {
let stream = await transport.receive()
for try await data in stream {
// Check if this is the initialize request
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let method = json["method"] as? String, method == "initialize",
let id = json["id"]
{
let idString: String
if let s = id as? String { idString = s }
else if let n = id as? Int { idString = String(n) }
else { continue }
let responseJSON: [String: Any] = [
"jsonrpc": "2.0",
"id": idString,
"result": [
"protocolVersion": "2025-11-25",
"serverInfo": ["name": "test", "version": "1.0"],
"capabilities": [:] as [String: Any],
] as [String: Any],
]
let responseData = try JSONSerialization.data(withJSONObject: responseJSON)
try await transport.send(responseData)
return
}
}
}
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: initBody)
)
// Extract session ID
guard let sid = response.headers[HTTPHeaderName.sessionID] else {
throw MCPError.internalError("No session ID in init response")
}
// Drain the SSE stream so the response task can complete
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
// Wait for the respond task
try? await respondTask.value
return sid
}
// MARK: - StatefulHTTPServerTransport Tests
@Suite("StatefulHTTPServerTransport Tests")
struct StatefulHTTPServerTransportTests {
// MARK: - Lifecycle
@Test("Connect succeeds")
func testConnectSucceeds() async throws {
let transport = makeStatefulTransport()
try await transport.connect()
await transport.disconnect()
}
@Test("Double connect throws")
func testDoubleConnectThrows() async throws {
let transport = makeStatefulTransport()
try await transport.connect()
do {
try await transport.connect()
Issue.record("Expected error on double connect")
} catch {
// Expected
}
await transport.disconnect()
}
@Test("Send after disconnect throws connectionClosed")
func testSendAfterDisconnectThrows() async throws {
let transport = makeStatefulTransport()
try await transport.connect()
await transport.disconnect()
do {
try await transport.send(Data("test".utf8))
Issue.record("Expected connectionClosed error")
} catch let error as MCPError {
#expect(error == .connectionClosed)
}
}
// MARK: - POST Initialize
@Test("Initialize creates session and returns SSE stream")
func testInitializeCreatesSession() async throws {
let transport = makeStatefulTransport(
sessionIDGenerator: FixedSessionIDGenerator(sessionID: "test-session-42")
)
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeInitializeBody())
)
#expect(response.statusCode == 200)
#expect(response.headers[HTTPHeaderName.sessionID] == "test-session-42")
if case .stream = response {
// Expected
} else {
Issue.record("Expected .stream response, got \(response)")
}
// Drain stream
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
@Test("Initialize with invalid session ID returns 500")
func testInitializeWithInvalidSessionIDReturns500() async throws {
// Control character \t is 0x09, outside valid range 0x21-0x7E
let transport = makeStatefulTransport(
sessionIDGenerator: FixedSessionIDGenerator(sessionID: "bad\tsession")
)
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeInitializeBody())
)
#expect(response.statusCode == 500)
}
@Test("Custom SessionIDGenerator is used")
func testCustomSessionIDGenerator() async throws {
let transport = makeStatefulTransport(
sessionIDGenerator: FixedSessionIDGenerator(sessionID: "custom-id-abc")
)
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeInitializeBody())
)
#expect(response.headers[HTTPHeaderName.sessionID] == "custom-id-abc")
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
@Test("Default UUIDSessionIDGenerator produces valid session ID")
func testDefaultGeneratorProducesUUID() async throws {
let transport = makeStatefulTransport()
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeInitializeBody())
)
let sessionID = response.headers[HTTPHeaderName.sessionID]
#expect(sessionID != nil)
// UUID format: 8-4-4-4-12 hex chars
if let sid = sessionID {
#expect(sid.count == 36)
#expect(sid.contains("-"))
}
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
// MARK: - POST Notification
@Test("Notification returns 202 Accepted")
func testNotificationReturns202() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeNotificationBody(),
sessionID: sessionID
)
)
#expect(response.statusCode == 202)
await transport.disconnect()
}
@Test("Notification yields to receive stream")
func testNotificationYieldsToReceive() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let notificationBody = makeNotificationBody(method: "notifications/test")
// Start receiving
let receiveTask = Task<Data?, any Error> {
let stream = await transport.receive()
for try await data in stream {
// Skip init request if still in stream
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let method = json["method"] as? String, method == "notifications/test"
{
return data
}
}
return nil
}
// Small delay to let receive() start
try await Task.sleep(for: .milliseconds(50))
_ = await transport.handleRequest(
makeStatefulPOSTRequest(body: notificationBody, sessionID: sessionID)
)
let received = try await receiveTask.value
#expect(received != nil)
await transport.disconnect()
}
// MARK: - POST Request/Response
@Test("POST request returns SSE stream")
func testRequestReturnsSSEStream() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeRequestBody(id: "req-1"),
sessionID: sessionID
)
)
#expect(response.statusCode == 200)
if case .stream = response {
// Expected
} else {
Issue.record("Expected .stream response")
}
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
@Test("Response is routed to matching request SSE stream")
func testResponseRoutedToRequestStream() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let requestID = "route-test-1"
// POST a request
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeRequestBody(id: requestID, method: "tools/list"),
sessionID: sessionID
)
)
guard case .stream(let stream, _) = response else {
Issue.record("Expected .stream response")
return
}
// Collect SSE chunks in background
let collectTask = Task {
var chunks: [Data] = []
for try await chunk in stream {
chunks.append(chunk)
}
return chunks
}
// Give stream time to start
try await Task.sleep(for: .milliseconds(50))
// Consume the request from receive and send the response
let responseBody = makeResponseBody(id: requestID)
try await transport.send(responseBody)
// Collect all SSE chunks
let chunks = try await collectTask.value
// Should have at least one chunk containing the response data
let allText = chunks.map { String(decoding: $0, as: UTF8.self) }.joined()
#expect(allText.contains("data:"))
#expect(allText.contains(requestID))
await transport.disconnect()
}
// MARK: - GET Stream
@Test("GET returns standalone SSE stream")
func testGetReturnsSSEStream() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeGETRequest(sessionID: sessionID)
)
#expect(response.statusCode == 200)
if case .stream = response {
// Expected
} else {
Issue.record("Expected .stream response for GET")
}
if case .stream(let stream, _) = response {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
@Test("Server-initiated message routed to GET stream")
func testServerMessageRoutedToGetStream() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
// Open GET stream
let getResponse = await transport.handleRequest(
makeGETRequest(sessionID: sessionID)
)
guard case .stream(let stream, _) = getResponse else {
Issue.record("Expected .stream response for GET")
return
}
// Collect chunks
let collectTask = Task {
var chunks: [Data] = []
for try await chunk in stream {
chunks.append(chunk)
// priming + message
if chunks.count >= 2 { break }
}
return chunks
}
try await Task.sleep(for: .milliseconds(50))
// Send a notification (server-initiated)
let notification: [String: Any] = [
"jsonrpc": "2.0",
"method": "notifications/test",
"params": [:] as [String: Any],
]
let notifData = try JSONSerialization.data(withJSONObject: notification)
try await transport.send(notifData)
let chunks = try await collectTask.value
let allText = chunks.map { String(decoding: $0, as: UTF8.self) }.joined()
#expect(allText.contains("data:"))
// JSONSerialization may escape "/" as "\/" in some configurations
#expect(allText.contains("notifications/test") || allText.contains("notifications\\/test"))
await transport.disconnect()
}
@Test("Second GET returns 409 Conflict")
func testSecondGetReturns409() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
// First GET
let first = await transport.handleRequest(makeGETRequest(sessionID: sessionID))
#expect(first.statusCode == 200)
// Second GET
let second = await transport.handleRequest(makeGETRequest(sessionID: sessionID))
#expect(second.statusCode == 409)
if case .stream(let stream, _) = first {
Task { for try await _ in stream {} }
}
await transport.disconnect()
}
// MARK: - DELETE
@Test("DELETE terminates session")
func testDeleteTerminatesSession() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeDELETERequest(sessionID: sessionID)
)
#expect(response.statusCode == 200)
}
@Test("Requests after DELETE return 404")
func testRequestsAfterDeleteReturn404() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
// DELETE
_ = await transport.handleRequest(makeDELETERequest(sessionID: sessionID))
// POST after delete
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeRequestBody(),
sessionID: sessionID
)
)
#expect(response.statusCode == 404)
}
// MARK: - Terminated State
@Test("All methods return 404 when terminated")
func testAllMethodsReturn404WhenTerminated() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
await transport.disconnect()
let post = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeRequestBody(), sessionID: sessionID)
)
#expect(post.statusCode == 404)
let get = await transport.handleRequest(makeGETRequest(sessionID: sessionID))
#expect(get.statusCode == 404)
let delete = await transport.handleRequest(makeDELETERequest(sessionID: sessionID))
#expect(delete.statusCode == 404)
}
// MARK: - Error Cases
@Test("Unsupported method returns 405")
func testUnsupportedMethodReturns405() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
HTTPRequest(
method: "PUT",
headers: ["Mcp-Session-Id": sessionID],
body: Data("test".utf8)
)
)
#expect(response.statusCode == 405)
await transport.disconnect()
}
@Test("Empty body returns 400")
func testEmptyBodyReturns400() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: Data(), sessionID: sessionID)
)
#expect(response.statusCode == 400)
await transport.disconnect()
}
@Test("Invalid JSON body returns 400")
func testInvalidJSONReturns400() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: Data("not json".utf8), sessionID: sessionID)
)
#expect(response.statusCode == 400)
await transport.disconnect()
}
// MARK: - Resumability
@Test("GET with Last-Event-ID replays stored events")
func testGetWithLastEventIDReplaysEvents() async throws {
let transport = makeStatefulTransport()
let sessionID = try await initializeSession(transport: transport)
// POST a request to create events in the store
let requestID = "resume-test"
let postResponse = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeRequestBody(id: requestID),
sessionID: sessionID
)
)
guard case .stream(let postStream, _) = postResponse else {
Issue.record("Expected .stream")
return
}
// Collect the priming event to get its ID
let eventIDHolder = ChunkCollector()
let collectTask = Task {
for try await chunk in postStream {
await eventIDHolder.append(chunk)
break // Just get the first chunk (priming)
}
}
try await Task.sleep(for: .milliseconds(50))
// Send the response to create a stored event
try await transport.send(makeResponseBody(id: requestID))
try? await collectTask.value
// Parse event ID from the collected priming event
let collectedChunks = await eventIDHolder.getChunks()
let primingEventID: String? = collectedChunks.first.flatMap { chunk in
let text = String(decoding: chunk, as: UTF8.self)
guard let range = text.range(of: "id: ") else { return nil }
let afterID = text[range.upperBound...]
guard let newline = afterID.firstIndex(of: "\n") else { return nil }
return String(afterID[..<newline])
}
// Now try to resume with GET + Last-Event-ID
if let eventID = primingEventID {
let getResponse = await transport.handleRequest(
makeGETRequest(sessionID: sessionID, lastEventID: eventID)
)
// Should succeed (200) and replay events
#expect(getResponse.statusCode == 200)
if case .stream(let stream, _) = getResponse {
Task { for try await _ in stream {} }
}
}
await transport.disconnect()
}
}
// MARK: - StatelessHTTPServerTransport Tests
@Suite("StatelessHTTPServerTransport Tests")
struct StatelessHTTPServerTransportTests {
// MARK: - Lifecycle
@Test("Connect succeeds")
func testConnectSucceeds() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
await transport.disconnect()
}
@Test("Double connect throws")
func testDoubleConnectThrows() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
do {
try await transport.connect()
Issue.record("Expected error on double connect")
} catch {
// Expected
}
await transport.disconnect()
}
@Test("Send after disconnect throws connectionClosed")
func testSendAfterDisconnectThrows() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
await transport.disconnect()
do {
try await transport.send(Data("test".utf8))
Issue.record("Expected connectionClosed error")
} catch let error as MCPError {
#expect(error == .connectionClosed)
}
}
// MARK: - POST Request/Response
@Test("Request waits for response then returns JSON")
func testRequestWaitsForResponse() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let requestBody = makeRequestBody(id: "42", method: "tools/list")
let responseBody = makeResponseBody(id: "42")
// handleRequest blocks waiting for response
let handleTask = Task {
await transport.handleRequest(
makeStatelessPOSTRequest(body: requestBody)
)
}
// Give handleRequest time to register the waiter
try await Task.sleep(for: .milliseconds(50))
// Consume the request from receive and send the response
try await transport.send(responseBody)
let httpResponse = await handleTask.value
#expect(httpResponse.statusCode == 200)
#expect(httpResponse.bodyData == responseBody)
#expect(httpResponse.headers[HTTPHeaderName.contentType] == ContentType.json)
await transport.disconnect()
}
@Test("Notification returns 202 Accepted")
func testNotificationReturns202() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let response = await transport.handleRequest(
makeStatelessPOSTRequest(body: makeNotificationBody())
)
#expect(response.statusCode == 202)
await transport.disconnect()
}
@Test("Notification yields to receive stream")
func testNotificationYieldsToReceive() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let notificationBody = makeNotificationBody(method: "notifications/test")
// Start receiving
let receiveTask = Task {
let stream = await transport.receive()
var iterator = stream.makeAsyncIterator()
return try await iterator.next()
}
try await Task.sleep(for: .milliseconds(50))
_ = await transport.handleRequest(
makeStatelessPOSTRequest(body: notificationBody)
)
let received = try await receiveTask.value
#expect(received == notificationBody)
await transport.disconnect()
}
// MARK: - Unsupported Methods
@Test("GET returns 405 Method Not Allowed")
func testGetReturns405() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let response = await transport.handleRequest(
HTTPRequest(method: "GET", headers: ["Accept": "text/event-stream"])
)
#expect(response.statusCode == 405)
await transport.disconnect()
}
@Test("DELETE returns 405 Method Not Allowed")
func testDeleteReturns405() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let response = await transport.handleRequest(
HTTPRequest(method: "DELETE")
)
#expect(response.statusCode == 405)
await transport.disconnect()
}
// MARK: - Error Cases
@Test("Empty body returns 400")
func testEmptyBodyReturns400() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let response = await transport.handleRequest(
makeStatelessPOSTRequest(body: Data())
)
#expect(response.statusCode == 400)
await transport.disconnect()
}
@Test("Invalid JSON body returns 400")
func testInvalidJSONReturns400() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let response = await transport.handleRequest(
makeStatelessPOSTRequest(body: Data("not json".utf8))
)
#expect(response.statusCode == 400)
await transport.disconnect()
}
// MARK: - Terminated State
@Test("After disconnect returns 404")
func testAfterDisconnectReturns404() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
await transport.disconnect()
let response = await transport.handleRequest(
makeStatelessPOSTRequest(body: makeRequestBody())
)
#expect(response.statusCode == 404)
}
@Test("Disconnect cancels waiting requests")
func testDisconnectCancelsWaitingRequests() async throws {
let transport = makeStatelessTransport()
try await transport.connect()
let requestBody = makeRequestBody(id: "cancel-test")
// Start a request that will block
let handleTask = Task {
await transport.handleRequest(
makeStatelessPOSTRequest(body: requestBody)
)
}
try await Task.sleep(for: .milliseconds(50))
// Disconnect while request is pending
await transport.disconnect()
let response = await handleTask.value
// Should return error (500 or similar) since waiter was cancelled
#expect(response.statusCode == 500)
}
}

View file

@ -0,0 +1,509 @@
import Foundation
import Testing
@testable import MCP
@Suite("Logging Tests")
struct LoggingTests {
// MARK: - LogLevel Tests
@Test("LogLevel case values")
func testLogLevelCases() throws {
#expect(LogLevel.debug.rawValue == "debug")
#expect(LogLevel.info.rawValue == "info")
#expect(LogLevel.notice.rawValue == "notice")
#expect(LogLevel.warning.rawValue == "warning")
#expect(LogLevel.error.rawValue == "error")
#expect(LogLevel.critical.rawValue == "critical")
#expect(LogLevel.alert.rawValue == "alert")
#expect(LogLevel.emergency.rawValue == "emergency")
}
@Test("LogLevel encoding and decoding")
func testLogLevelEncodingDecoding() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
for level in LogLevel.allCases {
let data = try encoder.encode(level)
let decoded = try decoder.decode(LogLevel.self, from: data)
#expect(decoded == level)
// Verify it encodes to the raw string value
let jsonString = String(data: data, encoding: .utf8)
#expect(jsonString == "\"\(level.rawValue)\"")
}
}
// MARK: - SetLoggingLevel Tests
@Test("SetLoggingLevel request initialization")
func testSetLoggingLevelRequest() throws {
let request = SetLoggingLevel.request(.init(level: .info))
#expect(request.method == "logging/setLevel")
#expect(request.params.level == LogLevel.info)
}
@Test("SetLoggingLevel request encoding")
func testSetLoggingLevelRequestEncoding() throws {
let request = SetLoggingLevel.request(.init(level: .error))
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json?["jsonrpc"] as? String == "2.0")
#expect(json?["method"] as? String == "logging/setLevel")
let params = json?["params"] as? [String: Any]
#expect(params?["level"] as? String == "error")
}
@Test("SetLoggingLevel request decoding")
func testSetLoggingLevelRequestDecoding() throws {
let json = """
{
"jsonrpc": "2.0",
"id": "test-id",
"method": "logging/setLevel",
"params": {
"level": "warning"
}
}
"""
let decoder = JSONDecoder()
let request = try decoder.decode(Request<SetLoggingLevel>.self, from: json.data(using: .utf8)!)
#expect(request.method == "logging/setLevel")
#expect(request.params.level == .warning)
}
@Test("SetLoggingLevel response")
func testSetLoggingLevelResponse() throws {
let response = SetLoggingLevel.response(id: .random)
if case .success = response.result {
// Success case
} else {
Issue.record("Expected success result")
}
}
// MARK: - LogMessageNotification Tests
@Test("LogMessageNotification initialization")
func testLogMessageNotificationInitialization() throws {
let data = Value.object([
"message": Value.string("Test log message"),
"code": Value.int(42)
])
let params = LogMessageNotification.Parameters(
level: .info,
logger: "test-logger",
data: data
)
#expect(params.level == LogLevel.info)
#expect(params.logger == "test-logger")
#expect(params.data == data)
}
@Test("LogMessageNotification with nil logger")
func testLogMessageNotificationWithNilLogger() throws {
let data = Value.object(["message": Value.string("Test")])
let params = LogMessageNotification.Parameters(
level: .debug,
logger: nil,
data: data
)
#expect(params.level == LogLevel.debug)
#expect(params.logger == nil)
#expect(params.data == data)
}
@Test("LogMessageNotification encoding")
func testLogMessageNotificationEncoding() throws {
let data = Value.object([
"error": Value.string("Connection failed"),
"details": .object([
"host": Value.string("localhost"),
"port": Value.int(5432)
])
])
let notification = LogMessageNotification.message(
.init(level: .error, logger: "database", data: data)
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let encodedData = try encoder.encode(notification)
let json = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any]
guard let jsonValue = json else {
Issue.record("Failed to parse JSON")
return
}
#expect(jsonValue["jsonrpc"] as? String == "2.0")
#expect(jsonValue["method"] as? String == "notifications/message")
guard let params = jsonValue["params"] as? [String: Any] else {
Issue.record("Failed to get params")
return
}
#expect(params["level"] as? String == "error")
#expect(params["logger"] as? String == "database")
guard let dataDict = params["data"] as? [String: Any] else {
Issue.record("Failed to get data")
return
}
#expect(dataDict["error"] as? String == "Connection failed")
}
@Test("LogMessageNotification decoding")
func testLogMessageNotificationDecoding() throws {
let json = """
{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {
"level": "info",
"logger": "app",
"data": {
"message": "Server started",
"port": 8080
}
}
}
"""
let decoder = JSONDecoder()
let notification = try decoder.decode(Message<LogMessageNotification>.self, from: json.data(using: .utf8)!)
#expect(notification.method == "notifications/message")
#expect(notification.params.level == LogLevel.info)
#expect(notification.params.logger == "app")
if case .object(let dataDict) = notification.params.data {
#expect(dataDict["message"] == Value.string("Server started"))
#expect(dataDict["port"] == Value.int(8080))
} else {
Issue.record("Expected object data")
}
}
// MARK: - Client Integration Tests
@Test("Client setLoggingLevel sends correct request")
func testClientSetLoggingLevel() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(logging: .init())
)
actor TestState {
var receivedLevel: LogLevel?
func setLevel(_ level: LogLevel) { receivedLevel = level }
func getLevel() -> LogLevel? { receivedLevel }
}
let state = TestState()
// Register handler for setLoggingLevel on server
await server.withMethodHandler(SetLoggingLevel.self) { params in
await state.setLevel(params.level)
return Empty()
}
try await server.start(transport: serverTransport)
let initResult = try await client.connect(transport: clientTransport)
// Verify logging capability is advertised
#expect(initResult.capabilities.logging != nil)
// Call setLoggingLevel
try await client.setLoggingLevel(.warning)
// Give time for message processing
try await Task.sleep(for: .milliseconds(100))
// Verify the handler was called
#expect(await state.getLevel() == .warning)
await client.disconnect()
await server.stop()
}
@Test("Client setLoggingLevel fails without logging capability")
func testClientSetLoggingLevelFailsWithoutCapability() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0", configuration: .strict)
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init() // No logging capability
)
try await server.start(transport: serverTransport)
let initResult = try await client.connect(transport: clientTransport)
// Verify logging capability is NOT advertised
#expect(initResult.capabilities.logging == nil)
// Attempt to set logging level should fail in strict mode
await #expect(throws: MCPError.self) {
try await client.setLoggingLevel(.info)
}
await client.disconnect()
await server.stop()
}
// MARK: - Server Integration Tests
@Test("Server log method sends notification")
func testServerLogMethod() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(logging: .init())
)
actor TestState {
var logMessages: [(level: LogLevel, logger: String?, data: Value)] = []
func addLog(level: LogLevel, logger: String?, data: Value) {
logMessages.append((level, logger, data))
}
func getLogs() -> [(level: LogLevel, logger: String?, data: Value)] { logMessages }
}
let state = TestState()
// Register handler for log notifications on client
await client.onNotification(LogMessageNotification.self) { message in
await state.addLog(
level: message.params.level,
logger: message.params.logger,
data: message.params.data
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Send a log message
let logData = Value.object([
"message": Value.string("Test log"),
"count": Value.int(42)
])
try await server.log(level: .info, logger: "test", data: logData)
// Wait for message processing
try await Task.sleep(for: .milliseconds(100))
// Verify the notification was received
let logs = await state.getLogs()
#expect(logs.count == 1)
#expect(logs[0].level == LogLevel.info)
#expect(logs[0].logger == "test")
#expect(logs[0].data == logData)
await client.disconnect()
await server.stop()
}
@Test("Server log method with codable data")
func testServerLogMethodWithCodableData() async throws {
struct LogData: Codable, Hashable {
let message: String
let timestamp: String
let code: Int
}
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(logging: .init())
)
actor TestState {
var logMessages: [(level: LogLevel, logger: String?, data: Value)] = []
func addLog(level: LogLevel, logger: String?, data: Value) {
logMessages.append((level, logger, data))
}
func getLogs() -> [(level: LogLevel, logger: String?, data: Value)] { logMessages }
}
let state = TestState()
// Register handler for log notifications on client
await client.onNotification(LogMessageNotification.self) { message in
await state.addLog(
level: message.params.level,
logger: message.params.logger,
data: message.params.data
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Send a log message with codable data
let logData = LogData(
message: "Error occurred",
timestamp: "2025-01-29T12:00:00Z",
code: 500
)
try await server.log(level: .error, logger: "api", data: logData)
// Wait for message processing
try await Task.sleep(for: .milliseconds(100))
// Verify the notification was received
let logs = await state.getLogs()
#expect(logs.count == 1)
#expect(logs[0].level == LogLevel.error)
#expect(logs[0].logger == "api")
// Verify data content
if case .object(let dataDict) = logs[0].data {
#expect(dataDict["message"] == Value.string("Error occurred"))
#expect(dataDict["timestamp"] == Value.string("2025-01-29T12:00:00Z"))
#expect(dataDict["code"] == Value.int(500))
} else {
Issue.record("Expected object data")
}
await client.disconnect()
await server.stop()
}
@Test("Server log without logger name")
func testServerLogWithoutLoggerName() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(logging: .init())
)
actor TestState {
var logMessages: [(level: LogLevel, logger: String?, data: Value)] = []
func addLog(level: LogLevel, logger: String?, data: Value) {
logMessages.append((level, logger, data))
}
func getLogs() -> [(level: LogLevel, logger: String?, data: Value)] { logMessages }
}
let state = TestState()
// Register handler for log notifications on client
await client.onNotification(LogMessageNotification.self) { message in
await state.addLog(
level: message.params.level,
logger: message.params.logger,
data: message.params.data
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Send a log message without logger name
let logData = Value.object(["message": Value.string("Generic log")])
try await server.log(level: .debug, data: logData)
// Wait for message processing
try await Task.sleep(for: .milliseconds(100))
// Verify the notification was received
let logs = await state.getLogs()
#expect(logs.count == 1)
#expect(logs[0].level == LogLevel.debug)
#expect(logs[0].logger == nil)
await client.disconnect()
await server.stop()
}
@Test("Multiple log levels sent correctly")
func testMultipleLogLevels() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let client = Client(name: "TestClient", version: "1.0")
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(logging: .init())
)
actor TestState {
var logMessages: [(level: LogLevel, logger: String?, data: Value)] = []
func addLog(level: LogLevel, logger: String?, data: Value) {
logMessages.append((level, logger, data))
}
func getLogs() -> [(level: LogLevel, logger: String?, data: Value)] { logMessages }
}
let state = TestState()
// Register handler for log notifications on client
await client.onNotification(LogMessageNotification.self) { message in
await state.addLog(
level: message.params.level,
logger: message.params.logger,
data: message.params.data
)
}
try await server.start(transport: serverTransport)
_ = try await client.connect(transport: clientTransport)
// Send log messages at different levels
try await server.log(level: .debug, data: Value.object(["msg": Value.string("Debug message")]))
try await server.log(level: .info, data: Value.object(["msg": Value.string("Info message")]))
try await server.log(level: .warning, data: Value.object(["msg": Value.string("Warning message")]))
try await server.log(level: .error, data: Value.object(["msg": Value.string("Error message")]))
try await server.log(level: .critical, data: Value.object(["msg": Value.string("Critical message")]))
// Wait for message processing
try await Task.sleep(for: .milliseconds(200))
// Verify all notifications were received
let logs = await state.getLogs()
#expect(logs.count == 5)
#expect(logs[0].level == LogLevel.debug)
#expect(logs[1].level == LogLevel.info)
#expect(logs[2].level == LogLevel.warning)
#expect(logs[3].level == LogLevel.error)
#expect(logs[4].level == LogLevel.critical)
await client.disconnect()
await server.stop()
}
}

View file

@ -0,0 +1,351 @@
import Testing
import class Foundation.JSONDecoder
import class Foundation.JSONEncoder
import class Foundation.JSONSerialization
@testable import MCP
@Suite("Meta Fields")
struct MetaFieldsTests {
private struct Payload: Codable, Hashable, Sendable {
let message: String
}
private enum TestMethod: Method {
static let name = "test.general"
typealias Parameters = Payload
typealias Result = Payload
}
@Test("Encoding includes meta and custom fields")
func testEncodingGeneralFields() throws {
let meta: Metadata = Metadata(additionalFields: ["vendor.example/request-id": .string("abc123")])
let request = Request<TestMethod>(
id: 42,
method: TestMethod.name,
params: Payload(message: "hello"),
_meta: meta
)
let data = try JSONEncoder().encode(request)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/request-id"] as? String == "abc123")
}
@Test("Decoding restores general fields")
func testDecodingGeneralFields() throws {
let payload: [String: Any] = [
"jsonrpc": "2.0",
"id": 7,
"method": TestMethod.name,
"params": ["message": "hi"],
"_meta": ["vendor.example/session": "s42"],
"custom-data": ["value": 1],
]
let data = try JSONSerialization.data(withJSONObject: payload)
let decoded = try JSONDecoder().decode(Request<TestMethod>.self, from: data)
let metaValue = decoded._meta?["vendor.example/session"]
#expect(metaValue == .string("s42"))
}
@Test("Response encoding includes general fields")
func testResponseGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/status": .string("partial")])
let response = Response<TestMethod>(
id: 99,
result: .success(Payload(message: "ok")),
_meta: meta
)
let data = try JSONEncoder().encode(response)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/status"] as? String == "partial")
let decoded = try JSONDecoder().decode(Response<TestMethod>.self, from: data)
#expect(decoded._meta?["vendor.example/status"] == .string("partial"))
}
@Test("Tool encoding and decoding with general fields")
func testToolGeneralFields() throws {
let meta = Metadata(additionalFields: [
"vendor.example/outputTemplate": .string("ui://widget/kanban-board.html")
])
let tool = Tool(
name: "kanban-board",
title: "Kanban Board",
description: "Display kanban widget",
inputSchema: try Value(["type": "object"]),
_meta: meta
)
let data = try JSONEncoder().encode(tool)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(
metaObject?["vendor.example/outputTemplate"] as? String
== "ui://widget/kanban-board.html")
let decoded = try JSONDecoder().decode(Tool.self, from: data)
#expect(
decoded._meta?["vendor.example/outputTemplate"]
== .string("ui://widget/kanban-board.html")
)
}
@Test("Meta keys allow nested prefixes")
func testMetaKeyNestedPrefixes() throws {
let meta = Metadata(additionalFields: [
"vendor.example/toolInvocation/invoking": .bool(true)
])
let tool = Tool(
name: "invoke",
description: "Invoke tool",
inputSchema: [:],
_meta: meta
)
let data = try JSONEncoder().encode(tool)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/toolInvocation/invoking"] as? Bool == true)
let decoded = try JSONDecoder().decode(Tool.self, from: data)
#expect(decoded._meta?["vendor.example/toolInvocation/invoking"] == .bool(true))
}
@Test("Resource content encodes meta")
func testResourceContentGeneralFields() throws {
let meta = Metadata(additionalFields: [
"vendor.example/widgetPrefersBorder": .bool(true)
])
let content = Resource.Content.text(
"<div>Widget</div>",
uri: "ui://widget/kanban-board.html",
mimeType: "text/html",
_meta: meta
)
let data = try JSONEncoder().encode(content)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/widgetPrefersBorder"] as? Bool == true)
let decoded = try JSONDecoder().decode(Resource.Content.self, from: data)
#expect(decoded._meta?["vendor.example/widgetPrefersBorder"] == .bool(true))
}
@Test("Initialize.Result encoding with meta")
func testInitializeResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/build": .string("v1.0.0")])
let result = Initialize.Result(
protocolVersion: "2024-11-05",
capabilities: Server.Capabilities(),
serverInfo: Server.Info(name: "test", version: "1.0"),
instructions: "Test server",
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/build"] as? String == "v1.0.0")
let decoded = try JSONDecoder().decode(Initialize.Result.self, from: data)
#expect(decoded._meta?["vendor.example/build"] == .string("v1.0.0"))
}
@Test("ListTools.Result encoding with meta")
func testListToolsResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/page": .int(1)])
let tool = Tool(
name: "test",
description: "A test tool",
inputSchema: try Value(["type": "object"])
)
let result = ListTools.Result(
tools: [tool],
nextCursor: "page2",
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/page"] as? Int == 1)
let decoded = try JSONDecoder().decode(ListTools.Result.self, from: data)
#expect(decoded._meta?["vendor.example/page"] == .int(1))
}
@Test("CallTool.Result encoding with meta")
func testCallToolResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/executionTime": .int(150)])
let result = CallTool.Result(
content: [.text("Result data")],
isError: false,
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/executionTime"] as? Int == 150)
let decoded = try JSONDecoder().decode(CallTool.Result.self, from: data)
#expect(decoded._meta?["vendor.example/executionTime"] == .int(150))
}
@Test("ListResources.Result encoding with meta")
func testListResourcesResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/cacheControl": .string("max-age=3600")])
let resource = Resource(
name: "test.txt",
uri: "file://test.txt",
description: "Test resource",
mimeType: "text/plain"
)
let result = ListResources.Result(
resources: [resource],
nextCursor: nil,
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let metaObject = json["_meta"] as! [String: Any]
#expect(metaObject["vendor.example/cacheControl"] as? String == "max-age=3600")
let decoded = try JSONDecoder().decode(ListResources.Result.self, from: data)
#expect(decoded._meta?["vendor.example/cacheControl"] == Value.string("max-age=3600"))
}
@Test("ReadResource.Result encoding with meta")
func testReadResourceResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/encoding": .string("utf-8")])
let result = ReadResource.Result(
contents: [.text("file contents", uri: "file://test.txt")],
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/encoding"] as? String == "utf-8")
let decoded = try JSONDecoder().decode(ReadResource.Result.self, from: data)
#expect(decoded._meta?["vendor.example/encoding"] == .string("utf-8"))
}
@Test("ListPrompts.Result encoding with meta")
func testListPromptsResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/category": .string("system")])
let prompt = Prompt(
name: "greeting",
description: "A greeting prompt"
)
let result = ListPrompts.Result(
prompts: [prompt],
nextCursor: nil,
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/category"] as? String == "system")
let decoded = try JSONDecoder().decode(ListPrompts.Result.self, from: data)
#expect(decoded._meta?["vendor.example/category"] == .string("system"))
}
@Test("GetPrompt.Result encoding with meta")
func testGetPromptResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/version": .int(2)])
let message = Prompt.Message.user("Hello")
let result = GetPrompt.Result(
description: "A test prompt",
messages: [message],
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let metaObject = json["_meta"] as! [String: Any]
#expect(metaObject["vendor.example/version"] as? Int == 2)
let decoded = try JSONDecoder().decode(GetPrompt.Result.self, from: data)
#expect(decoded._meta?["vendor.example/version"] == Value.int(2))
}
@Test("CreateSamplingMessage.Result encoding with meta")
func testSamplingResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/model-version": .string("gpt-4-0613")])
let result = CreateSamplingMessage.Result(
model: "gpt-4",
stopReason: .endTurn,
role: .assistant,
content: .text("Hello!"),
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/model-version"] as? String == "gpt-4-0613")
let decoded = try JSONDecoder().decode(CreateSamplingMessage.Result.self, from: data)
#expect(decoded._meta?["vendor.example/model-version"] == .string("gpt-4-0613"))
}
@Test("CreateElicitation.Result encoding with meta")
func testElicitationResultGeneralFields() throws {
let meta = Metadata(additionalFields: ["vendor.example/timestamp": .int(1_640_000_000)])
let result = CreateElicitation.Result(
action: .accept,
content: ["response": .string("user input")],
_meta: meta
)
let data = try JSONEncoder().encode(result)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let metaObject = json?["_meta"] as? [String: Any]
#expect(metaObject?["vendor.example/timestamp"] as? Int == 1_640_000_000)
let decoded = try JSONDecoder().decode(CreateElicitation.Result.self, from: data)
#expect(decoded._meta?["vendor.example/timestamp"] == .int(1_640_000_000))
}
}

View file

@ -34,7 +34,11 @@ import Testing
func start(queue: DispatchQueue) {
// Simulate successful connection by default
Task { @MainActor in
self.updateState(.ready)
if mockError != nil {
self.updateState(mockState)
} else {
self.updateState(.ready)
}
}
}
@ -268,7 +272,7 @@ import Testing
// Simulate failure before connecting
mockConnection.simulateFailure(error: NWError.posix(POSIXErrorCode.ECONNRESET))
do {
try await transport.connect()
Issue.record("Expected connect to throw an error")
@ -496,33 +500,6 @@ import Testing
_ = await receiveTask.result
}
@Test("Connection State Transitions")
func testConnectionStateTransitions() async throws {
let mockConnection = MockNetworkConnection()
let transport = NetworkTransport(
mockConnection,
heartbeatConfig: .disabled
)
// Test setup -> preparing -> ready transition
mockConnection.simulatePreparing()
try await Task.sleep(for: .milliseconds(100))
mockConnection.simulateReady()
try await transport.connect()
#expect(mockConnection.state == .ready)
// Test ready -> failed transition
mockConnection.simulateFailure(error: NWError.posix(POSIXErrorCode.ECONNRESET))
try await Task.sleep(for: .milliseconds(100))
if case .failed = mockConnection.state {
// expected
} else {
Issue.record("Expected state to be failed")
}
await transport.disconnect()
}
@Test("Partial Message Reception")
func testPartialMessageReception() async throws {
let mockConnection = MockNetworkConnection()

View file

@ -0,0 +1,390 @@
import Foundation
import Testing
@testable import MCP
@Suite("Progress Tests")
struct ProgressTests {
// MARK: - ProgressToken Tests
@Test("ProgressToken string encoding and decoding")
func testProgressTokenStringEncodingDecoding() throws {
let token = ProgressToken.string("test-token-123")
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(token)
let decoded = try decoder.decode(ProgressToken.self, from: data)
#expect(decoded == token)
if case .string(let value) = decoded {
#expect(value == "test-token-123")
} else {
#expect(Bool(false), "Expected string token")
}
}
@Test("ProgressToken integer encoding and decoding")
func testProgressTokenIntegerEncodingDecoding() throws {
let token = ProgressToken.integer(42)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(token)
let decoded = try decoder.decode(ProgressToken.self, from: data)
#expect(decoded == token)
if case .integer(let value) = decoded {
#expect(value == 42)
} else {
#expect(Bool(false), "Expected integer token")
}
}
@Test("ProgressToken unique generation")
func testProgressTokenUnique() {
let token1 = ProgressToken.unique()
let token2 = ProgressToken.unique()
#expect(token1 != token2)
if case .string(let value1) = token1, case .string(let value2) = token2 {
#expect(value1 != value2)
} else {
#expect(Bool(false), "Expected string tokens")
}
}
@Test("ProgressToken hashable")
func testProgressTokenHashable() {
let token1 = ProgressToken.string("test")
let token2 = ProgressToken.string("test")
let token3 = ProgressToken.integer(1)
let token4 = ProgressToken.integer(1)
#expect(token1 == token2)
#expect(token3 == token4)
#expect(token1 != token3)
let set: Set<ProgressToken> = [token1, token2, token3, token4]
#expect(set.count == 2)
}
// MARK: - RequestMeta Tests
@Test("RequestMeta empty initialization")
func testRequestMetaEmptyInit() throws {
let meta = Metadata()
#expect(meta.progressToken == nil)
#expect(meta.fields.isEmpty)
}
@Test("RequestMeta with progress token")
func testRequestMetaWithProgressToken() throws {
let token = ProgressToken.string("my-token")
let meta = Metadata(progressToken: token)
#expect(meta.progressToken == token)
#expect(meta.fields["progressToken"] == .string("my-token"))
}
@Test("RequestMeta with integer progress token")
func testRequestMetaWithIntegerProgressToken() throws {
let token = ProgressToken.integer(42)
let meta = Metadata(progressToken: token)
#expect(meta.progressToken == token)
#expect(meta.fields["progressToken"] == .int(42))
}
@Test("RequestMeta encoding with progress token")
func testRequestMetaEncodingWithProgressToken() throws {
let token = ProgressToken.string("my-token")
let meta = Metadata(progressToken: token)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(meta)
let decoded = try decoder.decode(Metadata.self, from: data)
#expect(decoded.progressToken == token)
}
@Test("RequestMeta encoding without progress token")
func testRequestMetaEncodingWithoutProgressToken() throws {
let meta = Metadata()
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(meta)
let decoded = try decoder.decode(Metadata.self, from: data)
#expect(decoded.progressToken == nil)
}
@Test("RequestMeta JSON representation with progress token")
func testRequestMetaJSONWithProgressToken() throws {
let token = ProgressToken.string("test-token")
let meta = Metadata(progressToken: token)
let encoder = JSONEncoder()
let data = try encoder.encode(meta)
let jsonString = String(data: data, encoding: .utf8)!
#expect(jsonString.contains("progressToken"))
#expect(jsonString.contains("test-token"))
}
@Test("RequestMeta with additional fields")
func testRequestMetaWithAdditionalFields() throws {
let meta = Metadata(
progressToken: .string("token"),
additionalFields: ["customField": .string("customValue")]
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(meta)
let decoded = try decoder.decode(Metadata.self, from: data)
#expect(decoded.progressToken == .string("token"))
#expect(decoded.fields["customField"] == .string("customValue"))
}
@Test("RequestMeta with only additional fields")
func testRequestMetaWithOnlyAdditionalFields() throws {
let meta = Metadata(additionalFields: [
"customKey": .int(123),
"anotherKey": .string("value")
])
#expect(meta.progressToken == nil)
#expect(meta.fields["customKey"] == .int(123))
#expect(meta.fields["anotherKey"] == .string("value"))
}
// MARK: - ProgressNotification Tests
@Test("ProgressNotification name")
func testProgressNotificationName() {
#expect(ProgressNotification.name == "notifications/progress")
}
@Test("ProgressNotification parameters encoding and decoding")
func testProgressNotificationParametersEncodingDecoding() throws {
let params = ProgressNotification.Parameters(
progressToken: .string("test-token"),
progress: 50,
total: 100,
message: "Processing..."
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(params)
let decoded = try decoder.decode(ProgressNotification.Parameters.self, from: data)
#expect(decoded.progressToken == .string("test-token"))
#expect(decoded.progress == 50)
#expect(decoded.total == 100)
#expect(decoded.message == "Processing...")
}
@Test("ProgressNotification parameters without optional fields")
func testProgressNotificationParametersWithoutOptionals() throws {
let params = ProgressNotification.Parameters(
progressToken: .integer(42),
progress: 75
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(params)
let decoded = try decoder.decode(ProgressNotification.Parameters.self, from: data)
#expect(decoded.progressToken == .integer(42))
#expect(decoded.progress == 75)
#expect(decoded.total == nil)
#expect(decoded.message == nil)
}
@Test("ProgressNotification message creation")
func testProgressNotificationMessage() throws {
let params = ProgressNotification.Parameters(
progressToken: .string("my-token"),
progress: 30,
total: 100,
message: "Step 3 of 10"
)
let message = ProgressNotification.message(params)
#expect(message.method == "notifications/progress")
#expect(message.params.progressToken == .string("my-token"))
#expect(message.params.progress == 30)
#expect(message.params.total == 100)
#expect(message.params.message == "Step 3 of 10")
}
// MARK: - CallTool with _meta Tests
@Test("CallTool parameters without meta")
func testCallToolParametersWithoutMeta() throws {
let params = CallTool.Parameters(name: "test_tool", arguments: ["key": "value"])
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(params)
let decoded = try decoder.decode(CallTool.Parameters.self, from: data)
#expect(decoded.name == "test_tool")
#expect(decoded.arguments?["key"] == .string("value"))
#expect(decoded._meta == nil)
// Verify _meta is not included in JSON when nil
let jsonString = String(data: data, encoding: .utf8)!
#expect(!jsonString.contains("_meta"))
}
@Test("CallTool parameters with progress token")
func testCallToolParametersWithProgressToken() throws {
let token = ProgressToken.string("call-tool-token")
let meta = Metadata(progressToken: token)
let params = CallTool.Parameters(name: "test_tool", arguments: ["key": "value"], meta: meta)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(params)
let decoded = try decoder.decode(CallTool.Parameters.self, from: data)
#expect(decoded.name == "test_tool")
#expect(decoded.arguments?["key"] == .string("value"))
#expect(decoded._meta?.progressToken == token)
// Verify _meta is included in JSON
let jsonString = String(data: data, encoding: .utf8)!
#expect(jsonString.contains("_meta"))
#expect(jsonString.contains("progressToken"))
#expect(jsonString.contains("call-tool-token"))
}
@Test("CallTool request encoding with progress token")
func testCallToolRequestEncodingWithProgressToken() throws {
let token = ProgressToken.string("request-token")
let meta = Metadata(progressToken: token)
let request = CallTool.request(.init(name: "my_tool", arguments: ["arg": 42], meta: meta))
let encoder = JSONEncoder()
let data = try encoder.encode(request)
let jsonString = String(data: data, encoding: .utf8)!
// Note: JSON encoding may escape forward slashes as \/
#expect(jsonString.contains("tools") && jsonString.contains("call"))
#expect(jsonString.contains("my_tool"))
#expect(jsonString.contains("_meta"))
#expect(jsonString.contains("progressToken"))
#expect(jsonString.contains("request-token"))
}
@Test("CallTool request decoding with _meta")
func testCallToolRequestDecodingWithMeta() throws {
let jsonString = """
{
"jsonrpc": "2.0",
"id": "test-id",
"method": "tools/call",
"params": {
"_meta": {
"progressToken": "decoded-token"
},
"name": "decoded_tool",
"arguments": {"x": 10}
}
}
"""
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let request = try decoder.decode(Request<CallTool>.self, from: data)
#expect(request.id == "test-id")
#expect(request.method == "tools/call")
#expect(request.params.name == "decoded_tool")
#expect(request.params.arguments?["x"] == .int(10))
#expect(request.params._meta?.progressToken == .string("decoded-token"))
}
@Test("CallTool request decoding without _meta")
func testCallToolRequestDecodingWithoutMeta() throws {
let jsonString = """
{
"jsonrpc": "2.0",
"id": "test-id",
"method": "tools/call",
"params": {
"name": "tool_without_meta",
"arguments": {"y": 20}
}
}
"""
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let request = try decoder.decode(Request<CallTool>.self, from: data)
#expect(request.params.name == "tool_without_meta")
#expect(request.params.arguments?["y"] == .int(20))
#expect(request.params._meta == nil)
}
@Test("Client notification updates should fire when server sends them")
func testClientNotificationUpdates() async throws {
let pair = await InMemoryTransport.createConnectedPair()
let client = Client(name: "testClient", version: "1")
let token = ProgressToken.unique()
var progresses: [Double] = []
await client.onNotification(ProgressNotification.self) { message in
let receivedToken = message.params.progressToken
#expect(receivedToken == token)
await MainActor.run {
progresses.append(message.params.progress)
}
}
let server = Server(name: "testServer", version: "1")
let expectedToolCallResult = CallTool.Result(content: [.text("success")])
await server.withMethodHandler(CallTool.self) { params in
if let token = params._meta?.progressToken {
for i in 1...5 {
let notification = ProgressNotification.message(
.init(progressToken: token, progress: Double(i * 20))
)
try await server.notify(notification)
}
}
return .init(content: [.text("success")])
}
try await server.start(transport: pair.server)
try await client.connect(transport: pair.client)
let context: RequestContext<CallTool.Result> = try await client.callTool(name: "random", meta: Metadata(progressToken: token))
let result = try await context.value
#expect(progresses == [20, 40, 60, 80, 100])
#expect(result.content == expectedToolCallResult.content)
#expect(result.isError == nil)
}
}

View file

@ -9,20 +9,24 @@ struct PromptTests {
func testPromptInitialization() throws {
let argument = Prompt.Argument(
name: "test_arg",
title: "Test Argument Title",
description: "A test argument",
required: true
)
let prompt = Prompt(
name: "test_prompt",
title: "Test Prompt Title",
description: "A test prompt",
arguments: [argument]
)
#expect(prompt.name == "test_prompt")
#expect(prompt.title == "Test Prompt Title")
#expect(prompt.description == "A test prompt")
#expect(prompt.arguments?.count == 1)
#expect(prompt.arguments?[0].name == "test_arg")
#expect(prompt.arguments?[0].title == "Test Argument Title")
#expect(prompt.arguments?[0].description == "A test argument")
#expect(prompt.arguments?[0].required == true)
}
@ -84,19 +88,20 @@ struct PromptTests {
}
// Test resource content
let resourceContent = Prompt.Message.Content.resource(
let textResourceContent = Resource.Content.text(
"Sample text",
uri: "file://test.txt",
mimeType: "text/plain",
text: "Sample text",
blob: "blob_data"
mimeType: "text/plain"
)
let resourceContent = Prompt.Message.Content.resource(resource: textResourceContent, annotations: nil, _meta: nil)
let resourceData = try encoder.encode(resourceContent)
let decodedResource = try decoder.decode(Prompt.Message.Content.self, from: resourceData)
if case .resource(let uri, let mimeType, let text, let blob) = decodedResource {
#expect(uri == "file://test.txt")
#expect(mimeType == "text/plain")
#expect(text == "Sample text")
#expect(blob == "blob_data")
if case .resource(let resourceData, let annotations, let _meta) = decodedResource {
#expect(resourceData.uri == "file://test.txt")
#expect(resourceData.mimeType == "text/plain")
#expect(resourceData.text == "Sample text")
#expect(annotations == nil)
#expect(_meta == nil)
} else {
#expect(Bool(false), "Expected resource content")
}
@ -104,8 +109,7 @@ struct PromptTests {
@Test("Prompt Reference validation")
func testPromptReference() throws {
let reference = Prompt.Reference(name: "test_prompt")
#expect(reference.name == "test_prompt")
let reference = Prompt.Reference(name: "test_prompt", title: "Test Prompt Title")
let encoder = JSONEncoder()
let decoder = JSONDecoder()
@ -113,43 +117,59 @@ struct PromptTests {
let data = try encoder.encode(reference)
let decoded = try decoder.decode(Prompt.Reference.self, from: data)
#expect(decoded.name == "test_prompt")
#expect(decoded == reference)
}
@Test("GetPrompt parameters validation")
func testGetPromptParameters() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let arguments: [String: Value] = [
"param1": .string("value1"),
"param2": .int(42),
]
let params = GetPrompt.Parameters(name: "test_prompt", arguments: arguments)
#expect(params.name == "test_prompt")
#expect(params.arguments?["param1"] == .string("value1"))
#expect(params.arguments?["param2"] == .int(42))
let data = try encoder.encode(params)
let decoded = try decoder.decode(GetPrompt.Parameters.self, from: data)
#expect(decoded == params)
}
@Test("GetPrompt result validation")
func testGetPromptResult() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let messages: [Prompt.Message] = [
.user("User message"),
.assistant("Assistant response"),
]
let result = GetPrompt.Result(description: "Test description", messages: messages)
#expect(result.description == "Test description")
#expect(result.messages.count == 2)
#expect(result.messages[0].role == .user)
#expect(result.messages[1].role == .assistant)
let data = try encoder.encode(result)
let decoded = try decoder.decode(GetPrompt.Result.self, from: data)
#expect(decoded == result)
}
@Test("ListPrompts parameters validation")
func testListPromptsParameters() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let params = ListPrompts.Parameters(cursor: "next_page")
#expect(params.cursor == "next_page")
let data = try encoder.encode(params)
let decoded = try decoder.decode(ListPrompts.Parameters.self, from: data)
#expect(decoded == params)
let emptyParams = ListPrompts.Parameters()
#expect(emptyParams.cursor == nil)
let emptyData = try encoder.encode(emptyParams)
let decodedEmpty = try decoder.decode(ListPrompts.Parameters.self, from: emptyData)
#expect(decodedEmpty == emptyParams)
}
@Test("ListPrompts request decoding with omitted params")
@ -184,16 +204,19 @@ struct PromptTests {
@Test("ListPrompts result validation")
func testListPromptsResult() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let prompts = [
Prompt(name: "prompt1", description: "First prompt"),
Prompt(name: "prompt2", description: "Second prompt"),
]
let result = ListPrompts.Result(prompts: prompts, nextCursor: "next_page")
#expect(result.prompts.count == 2)
#expect(result.prompts[0].name == "prompt1")
#expect(result.prompts[1].name == "prompt2")
#expect(result.nextCursor == "next_page")
let data = try encoder.encode(result)
let decoded = try decoder.decode(ListPrompts.Result.self, from: data)
#expect(decoded == result)
}
@Test("PromptListChanged notification name validation")
@ -243,15 +266,19 @@ struct PromptTests {
}
// Test with resource content
let resourceMessage: Prompt.Message = .user(
.resource(
uri: "file://test.txt", mimeType: "text/plain", text: "Sample text", blob: nil))
let resourceContent = Resource.Content.text(
"Sample text",
uri: "file://test.txt",
mimeType: "text/plain"
)
let resourceMessage: Prompt.Message = .user(.resource(resource: resourceContent, annotations: nil, _meta: nil))
#expect(resourceMessage.role == .user)
if case .resource(let uri, let mimeType, let text, let blob) = resourceMessage.content {
#expect(uri == "file://test.txt")
#expect(mimeType == "text/plain")
#expect(text == "Sample text")
#expect(blob == nil)
if case .resource(let resource, let annotations, let _meta) = resourceMessage.content {
#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")
}
@ -443,3 +470,423 @@ struct PromptTests {
#expect(decoded[3].role == .assistant)
}
}
@Suite("Prompt Integration Tests")
struct PromptIntegrationTests {
@Test("List prompts with empty results")
func testListPromptsEmpty() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
// Register list prompts handler
await server.withMethodHandler(ListPrompts.self) { _ in
ListPrompts.Result(prompts: [])
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let (prompts, nextCursor) = try await client.listPrompts()
#expect(prompts.isEmpty)
#expect(nextCursor == nil)
await server.stop()
await client.disconnect()
}
@Test("List prompts with multiple results")
func testListPromptsWithResults() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
let expectedPrompts = [
Prompt(
name: "greeting",
title: "Greeting Prompt",
description: "A friendly greeting prompt"
),
Prompt(
name: "interview",
title: "Interview Prompt",
description: "An interview preparation prompt",
arguments: [
Prompt.Argument(
name: "position",
title: "Job Position",
description: "The job position to interview for",
required: true
)
]
),
]
// Register list prompts handler
await server.withMethodHandler(ListPrompts.self) { _ in
ListPrompts.Result(prompts: expectedPrompts, nextCursor: "page2")
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let (prompts, nextCursor) = try await client.listPrompts()
#expect(prompts == expectedPrompts)
#expect(nextCursor == "page2")
await server.stop()
await client.disconnect()
}
@Test("Get prompt with messages")
func testGetPrompt() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
let expectedMessages: [Prompt.Message] = [
.user("Hello, I'd like to schedule an interview for the \(Value.string("Software Engineer")) position"),
.assistant("I'd be happy to help you prepare for the Software Engineer interview. Let's discuss your background."),
]
// Register get prompt handler
await server.withMethodHandler(GetPrompt.self) { params in
#expect(params.name == "interview")
#expect(params.arguments?["position"]?.stringValue == "Software Engineer")
return GetPrompt.Result(
description: "Interview preparation prompt",
messages: expectedMessages
)
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let (description, messages) = try await client.getPrompt(
name: "interview",
arguments: ["position": .string("Software Engineer")]
)
#expect(description == "Interview preparation prompt")
#expect(messages == expectedMessages)
await server.stop()
await client.disconnect()
}
@Test("Get prompt with mixed content types")
func testGetPromptMixedContent() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
let expectedMessages: [Prompt.Message] = [
.user("Please review this design mockup:"),
.user(.image(data: "base64_image_data", mimeType: "image/png")),
.assistant("I'll analyze the design mockup for you."),
.assistant(.audio(data: "base64_audio_data", mimeType: "audio/mp3")),
]
// Register get prompt handler
await server.withMethodHandler(GetPrompt.self) { params in
#expect(params.name == "design_review")
return GetPrompt.Result(
description: "Design review prompt with multimedia",
messages: expectedMessages
)
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let (description, messages) = try await client.getPrompt(name: "design_review")
#expect(description == "Design review prompt with multimedia")
#expect(messages == expectedMessages)
await server.stop()
await client.disconnect()
}
@Test("Prompt with resource content")
func testPromptWithResourceContent() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
let resourceContent = Resource.Content.text(
"Code review content",
uri: "file://code.swift",
mimeType: "text/plain"
)
let expectedMessages: [Prompt.Message] = [
.user("Review this code:"),
.user(.resource(resource: resourceContent, annotations: nil, _meta: nil)),
.assistant("I'll review the code for you."),
]
// Register get prompt handler
await server.withMethodHandler(GetPrompt.self) { _ in
GetPrompt.Result(
description: "Code review prompt",
messages: expectedMessages
)
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
let (description, messages) = try await client.getPrompt(name: "code_review")
#expect(description == "Code review prompt")
#expect(messages == expectedMessages)
await server.stop()
await client.disconnect()
}
@Test("List prompts with pagination")
func testListPromptsWithPagination() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "PromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init())
)
// Register list prompts handler with pagination
await server.withMethodHandler(ListPrompts.self) { params in
if let cursor = params.cursor {
#expect(cursor == "page2")
return ListPrompts.Result(
prompts: [
Prompt(name: "prompt3", description: "Third prompt"),
Prompt(name: "prompt4", description: "Fourth prompt"),
],
nextCursor: nil
)
} else {
return ListPrompts.Result(
prompts: [
Prompt(name: "prompt1", description: "First prompt"),
Prompt(name: "prompt2", description: "Second prompt"),
],
nextCursor: "page2"
)
}
}
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// First page
let (page1Prompts, page1Cursor) = try await client.listPrompts()
#expect(page1Prompts.count == 2)
#expect(page1Prompts[0].name == "prompt1")
#expect(page1Prompts[1].name == "prompt2")
#expect(page1Cursor == "page2")
// Second page
let (page2Prompts, page2Cursor) = try await client.listPrompts(cursor: "page2")
#expect(page2Prompts.count == 2)
#expect(page2Prompts[0].name == "prompt3")
#expect(page2Prompts[1].name == "prompt4")
#expect(page2Cursor == nil)
await server.stop()
await client.disconnect()
}
@Test("Prompt without capability fails")
func testPromptWithoutCapabilityFails() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
// Server WITHOUT prompts capability
let server = Server(
name: "PromptTestServer",
version: "1.0"
)
let client = Client(
name: "PromptTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should throw an error because server doesn't have prompts capability
await #expect(throws: MCPError.self) {
_ = try await client.listPrompts()
}
await server.stop()
await client.disconnect()
}
@Test("Strict mode succeeds when server declares prompts capability")
func testPromptStrictCapabilitiesSuccess() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "StrictPromptTestServer",
version: "1.0",
capabilities: .init(prompts: .init()),
configuration: .strict
)
// Register list prompts handler
await server.withMethodHandler(ListPrompts.self) { _ in
ListPrompts.Result(
prompts: [
Prompt(name: "test", description: "Test prompt")
]
)
}
let client = Client(
name: "StrictPromptTestClient",
version: "1.0",
configuration: .strict
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because server declares prompts capability
let (prompts, _) = try await client.listPrompts()
#expect(prompts.count == 1)
#expect(prompts[0].name == "test")
await server.stop()
await client.disconnect()
}
@Test("Strict mode fails when server doesn't declare prompts capability")
func testPromptStrictCapabilitiesError() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
// Server WITHOUT prompts capability in strict mode
let server = Server(
name: "StrictPromptTestServer",
version: "1.0",
capabilities: .init(),
configuration: .strict
)
let client = Client(
name: "StrictPromptTestClient",
version: "1.0",
configuration: .strict
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should fail because server doesn't declare prompts capability in strict mode
await #expect(throws: MCPError.self) {
_ = try await client.listPrompts()
}
await server.stop()
await client.disconnect()
}
@Test("Non-strict mode succeeds even without server capability declaration")
func testPromptNonStrictCapabilities() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
// Server WITHOUT prompts capability in non-strict mode
let server = Server(
name: "NonStrictPromptTestServer",
version: "1.0",
capabilities: .init(),
configuration: .default
)
// Register list prompts handler anyway
await server.withMethodHandler(ListPrompts.self) { _ in
ListPrompts.Result(
prompts: [
Prompt(name: "non_strict_test", description: "Non-strict test prompt")
]
)
}
let client = Client(
name: "NonStrictPromptTestClient",
version: "1.0",
configuration: .default
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because client is in non-strict mode
let (prompts, _) = try await client.listPrompts()
#expect(prompts.count == 1)
#expect(prompts[0].name == "non_strict_test")
await server.stop()
await client.disconnect()
}
}

View file

@ -10,6 +10,7 @@ struct ResourceTests {
let resource = Resource(
name: "test_resource",
uri: "file://test.txt",
title: "Test Resource Title",
description: "A test resource",
mimeType: "text/plain",
metadata: ["key": "value"]
@ -17,6 +18,7 @@ struct ResourceTests {
#expect(resource.name == "test_resource")
#expect(resource.uri == "file://test.txt")
#expect(resource.title == "Test Resource Title")
#expect(resource.description == "A test resource")
#expect(resource.mimeType == "text/plain")
#expect(resource.metadata?["key"] == "value")
@ -27,6 +29,7 @@ struct ResourceTests {
let resource = Resource(
name: "test_resource",
uri: "file://test.txt",
title: "Test Resource Title",
description: "Test resource description",
mimeType: "text/plain",
metadata: ["key1": "value1", "key2": "value2"]
@ -40,6 +43,7 @@ struct ResourceTests {
#expect(decoded.name == resource.name)
#expect(decoded.uri == resource.uri)
#expect(decoded.title == resource.title)
#expect(decoded.description == resource.description)
#expect(decoded.mimeType == resource.mimeType)
#expect(decoded.metadata == resource.metadata)
@ -86,7 +90,7 @@ struct ResourceTests {
let emptyParams = ListResources.Parameters()
#expect(emptyParams.cursor == nil)
}
@Test("ListResources request decoding with omitted params")
func testListResourcesRequestDecodingWithOmittedParams() throws {
// Test decoding when params field is omitted
@ -101,7 +105,7 @@ struct ResourceTests {
#expect(decoded.id == "test-id")
#expect(decoded.method == ListResources.name)
}
@Test("ListResources request decoding with null params")
func testListResourcesRequestDecodingWithNullParams() throws {
// Test decoding when params field is null

View file

@ -0,0 +1,494 @@
import Foundation
import Testing
@testable import MCP
@Suite("Roots Tests")
struct RootsTests {
@Test("Root initialization with file:// URI")
func testRootInitialization() throws {
let root = Root(uri: "file:///workspace", name: "Workspace")
#expect(root.uri == "file:///workspace")
#expect(root.name == "Workspace")
}
@Test("Root initialization without name")
func testRootInitializationWithoutName() throws {
let root = Root(uri: "file:///home/user/docs")
#expect(root.uri == "file:///home/user/docs")
#expect(root.name == nil)
}
@Test("Root encoding and decoding")
func testRootEncodingDecoding() throws {
let root = Root(uri: "file:///workspace", name: "Workspace")
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(root)
let decoded = try decoder.decode(Root.self, from: data)
#expect(decoded.uri == root.uri)
#expect(decoded.name == root.name)
}
@Test("Root encoding and decoding without name")
func testRootEncodingDecodingWithoutName() throws {
let root = Root(uri: "file:///home/user/docs")
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(root)
let decoded = try decoder.decode(Root.self, from: data)
#expect(decoded.uri == root.uri)
#expect(decoded.name == nil)
}
@Test("Root JSON encoding format")
func testRootJSONFormat() throws {
let root = Root(uri: "file:///workspace", name: "Workspace")
let encoder = JSONEncoder()
let data = try encoder.encode(root)
let jsonString = String(data: data, encoding: .utf8)
#expect(jsonString?.contains("\"uri\"") == true)
#expect(jsonString?.contains("\"name\"") == true)
// The URI might be escaped differently, so just check it's present
#expect(jsonString?.contains("workspace") == true)
#expect(jsonString?.contains("Workspace") == true)
}
@Test("ListRoots method name")
func testListRootsMethodName() throws {
#expect(ListRoots.name == "roots/list")
}
@Test("ListRoots request creation")
func testListRootsRequestCreation() throws {
let request = ListRoots.request()
#expect(request.method == "roots/list")
#expect(request.params == Empty())
}
@Test("ListRoots request decoding with omitted params")
func testListRootsRequestDecodingWithOmittedParams() throws {
// Test decoding when params field is omitted
let jsonString = """
{"jsonrpc":"2.0","id":"test-id","method":"roots/list"}
"""
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let decoded = try decoder.decode(Request<ListRoots>.self, from: data)
#expect(decoded.id == "test-id")
#expect(decoded.method == ListRoots.name)
}
@Test("ListRoots request decoding with null params")
func testListRootsRequestDecodingWithNullParams() throws {
// Test decoding when params field is null
let jsonString = """
{"jsonrpc":"2.0","id":"test-id","method":"roots/list","params":null}
"""
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let decoded = try decoder.decode(Request<ListRoots>.self, from: data)
#expect(decoded.id == "test-id")
#expect(decoded.method == ListRoots.name)
}
@Test("ListRoots result validation")
func testListRootsResult() throws {
let roots = [
Root(uri: "file:///workspace", name: "Workspace"),
Root(uri: "file:///home/user/docs", name: "Documents"),
]
let result = ListRoots.Result(roots: roots)
#expect(result.roots.count == 2)
#expect(result.roots[0].uri == "file:///workspace")
#expect(result.roots[0].name == "Workspace")
#expect(result.roots[1].uri == "file:///home/user/docs")
#expect(result.roots[1].name == "Documents")
}
@Test("ListRoots result encoding and decoding")
func testListRootsResultEncodingDecoding() throws {
let roots = [
Root(uri: "file:///workspace", name: "Workspace"),
Root(uri: "file:///home/user/docs"),
]
let result = ListRoots.Result(roots: roots)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(result)
let decoded = try decoder.decode(ListRoots.Result.self, from: data)
#expect(decoded.roots.count == result.roots.count)
#expect(decoded.roots[0].uri == result.roots[0].uri)
#expect(decoded.roots[0].name == result.roots[0].name)
#expect(decoded.roots[1].uri == result.roots[1].uri)
#expect(decoded.roots[1].name == result.roots[1].name)
}
@Test("ListRoots response format")
func testListRootsResponseFormat() throws {
let response = ListRoots.response(
id: "test-id",
result: ListRoots.Result(
roots: [
Root(uri: "file:///workspace", name: "Workspace")
]
)
)
let encoder = JSONEncoder()
let data = try encoder.encode(response)
let jsonString = String(data: data, encoding: .utf8)
#expect(jsonString?.contains("\"jsonrpc\"") == true)
#expect(jsonString?.contains("\"2.0\"") == true)
#expect(jsonString?.contains("\"id\"") == true)
#expect(jsonString?.contains("\"result\"") == true)
#expect(jsonString?.contains("\"roots\"") == true)
}
@Test("RootsListChangedNotification name")
func testRootsListChangedNotificationName() throws {
#expect(RootsListChangedNotification.name == "notifications/roots/list_changed")
}
@Test("RootsListChangedNotification message creation")
func testRootsListChangedNotificationMessage() throws {
let notification = RootsListChangedNotification.message()
#expect(notification.method == "notifications/roots/list_changed")
#expect(notification.params == Empty())
}
@Test("RootsListChangedNotification encoding")
func testRootsListChangedNotificationEncoding() throws {
let notification = RootsListChangedNotification.message()
let encoder = JSONEncoder()
let data = try encoder.encode(notification)
let jsonString = String(data: data, encoding: .utf8)
#expect(jsonString?.contains("\"jsonrpc\"") == true)
#expect(jsonString?.contains("\"2.0\"") == true)
#expect(jsonString?.contains("\"method\"") == true)
// The method name might be escaped differently, so check key parts
#expect(jsonString?.contains("roots") == true)
#expect(jsonString?.contains("list_changed") == true)
}
@Test("Client.Capabilities.Roots initialization")
func testClientCapabilitiesRoots() throws {
let roots = Client.Capabilities.Roots(listChanged: true)
#expect(roots.listChanged == true)
}
@Test("Client.Capabilities.Roots optional listChanged")
func testClientCapabilitiesRootsOptional() throws {
let roots = Client.Capabilities.Roots()
#expect(roots.listChanged == nil)
}
@Test("Client.Capabilities with roots")
func testClientCapabilitiesWithRoots() throws {
let capabilities = Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
#expect(capabilities.roots?.listChanged == true)
}
@Test("Root hashable conformance")
func testRootHashable() throws {
let root1 = Root(uri: "file:///workspace", name: "Workspace")
let root2 = Root(uri: "file:///workspace", name: "Workspace")
let root3 = Root(uri: "file:///other", name: "Other")
#expect(root1 == root2)
#expect(root1 != root3)
// Test in a Set
let set: Set<Root> = [root1, root2, root3]
#expect(set.count == 2) // root1 and root2 should be the same
}
@Test("Client Capabilities struct with roots encoding")
func testClientCapabilitiesWithRootsEncoding() throws {
let capabilities = Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let data = try encoder.encode(capabilities)
let decoded = try decoder.decode(Client.Capabilities.self, from: data)
#expect(decoded.roots?.listChanged == true)
}
// MARK: - Integration Tests
@Test("Server listRoots with client that has roots capability")
func testServerListRootsWithCapableClient() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0",
configuration: .strict
)
let testRoots = [
Root(uri: "file:///workspace", name: "Workspace"),
Root(uri: "file:///home/user/docs", name: "Documents"),
]
let client = Client(
name: "test-client",
version: "1.0.0",
capabilities: Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
)
await client.withRootsHandler {
return testRoots
}
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Wait for initialization to complete
try await Task.sleep(for: .milliseconds(100))
// Server requests roots from client - should succeed
let roots = try await server.listRoots()
#expect(roots == testRoots)
// Cleanup
await server.stop()
await client.disconnect()
}
@Test("Server listRoots fails when client lacks roots capability (strict mode)")
func testServerListRootsFailsWithoutCapability() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0",
configuration: .strict
)
// Client has NO roots capability (default empty capabilities)
let client = Client(
name: "test-client",
version: "1.0.0"
)
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Wait for initialization to complete
try await Task.sleep(for: .milliseconds(100))
// Server tries to request roots - should fail
await #expect(throws: MCPError.self) {
try await server.listRoots()
}
// Cleanup
await server.stop()
await client.disconnect()
}
@Test("Server listRoots succeeds when client lacks capability (non-strict mode)")
func testServerListRootsNonStrictMode() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0",
configuration: .default // Non-strict mode
)
let testRoots = [Root(uri: "file:///workspace")]
// Client has NO roots capability but registers handler anyway
let client = Client(
name: "test-client",
version: "1.0.0"
)
await client.withRootsHandler {
return testRoots
}
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Wait for initialization to complete
try await Task.sleep(for: .milliseconds(100))
// Server requests roots - should succeed in non-strict mode
let roots = try await server.listRoots()
#expect(roots == testRoots)
// Cleanup
await server.stop()
await client.disconnect()
}
@Test("Client sends roots list changed notification")
func testClientSendsRootsListChangedNotification() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0"
)
let client = Client(
name: "test-client",
version: "1.0.0",
capabilities: Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
)
// Register notification handler on server
nonisolated(unsafe) var didReceive = false
await server.onNotification(RootsListChangedNotification.self) { _ in
didReceive = true
}
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Wait for initialization
try await Task.sleep(for: .milliseconds(100))
// Client sends notification
try await client.notifyRootsChanged()
// Wait for notification to be processed
try await Task.sleep(for: .milliseconds(100))
// Verify notification was received
#expect(didReceive)
// Cleanup
await server.stop()
await client.disconnect()
}
@Test("Server listRoots fails when client has no handler registered")
func testServerListRootsFailsWithoutHandler() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0",
configuration: .default
)
let client = Client(
name: "test-client",
version: "1.0.0",
capabilities: Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
)
// NO handler registered on client
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Wait for initialization
try await Task.sleep(for: .milliseconds(100))
// Server requests roots - should fail with method not found
await #expect(throws: MCPError.self) {
try await server.listRoots()
}
// Cleanup
await server.stop()
await client.disconnect()
}
@Test("Multiple roots requests work correctly")
func testMultipleRootsRequests() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "test-server",
version: "1.0.0"
)
nonisolated(unsafe) var count = 0
let client = Client(
name: "test-client",
version: "1.0.0",
capabilities: Client.Capabilities(
roots: Client.Capabilities.Roots(listChanged: true)
)
)
await client.withRootsHandler {
count += 1
return [Root(uri: "file:///workspace\(count)")]
}
// Start server and client
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
try await Task.sleep(for: .milliseconds(100))
// Make multiple requests
let roots1 = try await server.listRoots()
#expect(roots1.count == 1)
#expect(roots1[0].uri == "file:///workspace1")
let roots2 = try await server.listRoots()
#expect(roots2.count == 1)
#expect(roots2[0].uri == "file:///workspace2")
let roots3 = try await server.listRoots()
#expect(roots3.count == 1)
#expect(roots3[0].uri == "file:///workspace3")
// Cleanup
await server.stop()
await client.disconnect()
}
}

View file

@ -6,12 +6,6 @@ import class Foundation.JSONEncoder
@testable import MCP
#if canImport(System)
import System
#else
@preconcurrency import SystemPackage
#endif
@Suite("Sampling Tests")
struct SamplingTests {
@Test("Sampling.Message encoding and decoding")
@ -26,7 +20,7 @@ struct SamplingTests {
let decodedTextMessage = try decoder.decode(Sampling.Message.self, from: textData)
#expect(decodedTextMessage.role == .user)
if case .text(let text) = decodedTextMessage.content {
if case .single(.text(let text)) = decodedTextMessage.content {
#expect(text == "Hello, world!")
} else {
#expect(Bool(false), "Expected text content")
@ -40,7 +34,7 @@ struct SamplingTests {
let decodedImageMessage = try decoder.decode(Sampling.Message.self, from: imageData)
#expect(decodedImageMessage.role == .assistant)
if case .image(let data, let mimeType) = decodedImageMessage.content {
if case .single(.image(let data, let mimeType)) = decodedImageMessage.content {
#expect(data == "base64imagedata")
#expect(mimeType == "image/png")
} else {
@ -127,7 +121,7 @@ struct SamplingTests {
temperature: 0.7,
maxTokens: 150,
stopSequences: ["END", "STOP"],
metadata: ["provider": "test"]
_meta: Metadata(additionalFields: ["provider": "test"])
)
let data = try encoder.encode(parameters)
@ -142,7 +136,7 @@ struct SamplingTests {
#expect(decoded.stopSequences?.count == 2)
#expect(decoded.stopSequences?[0] == "END")
#expect(decoded.stopSequences?[1] == "STOP")
#expect(decoded.metadata?["provider"]?.stringValue == "test")
#expect(decoded._meta?["provider"]?.stringValue == "test")
}
@Test("CreateMessage result")
@ -164,7 +158,7 @@ struct SamplingTests {
#expect(decoded.stopReason == .endTurn)
#expect(decoded.role == .assistant)
if case .text(let text) = decoded.content {
if case .single(.text(let text)) = decoded.content {
#expect(text == "The weather is sunny and 75°F.")
} else {
#expect(Bool(false), "Expected text content")
@ -242,45 +236,6 @@ struct SamplingTests {
#expect(handlerClient === client)
}
@Test("Server sampling request method")
func testServerSamplingRequestMethod() async throws {
let transport = MockTransport()
let server = Server(
name: "TestServer",
version: "1.0",
capabilities: .init(sampling: .init())
)
try await server.start(transport: transport)
// Test that server can attempt to request sampling
let messages: [Sampling.Message] = [
.user("Test message")
]
do {
_ = try await server.requestSampling(
messages: messages,
maxTokens: 100
)
#expect(
Bool(false),
"Should have thrown an error for unimplemented bidirectional communication")
} catch let error as MCPError {
if case .internalError(let message) = error {
#expect(
message?.contains("Bidirectional sampling requests not yet implemented") == true
)
} else {
#expect(Bool(false), "Expected internalError, got \(error)")
}
} catch {
#expect(Bool(false), "Expected MCPError, got \(error)")
}
await server.stop()
}
@Test("Sampling message content JSON format")
func testSamplingMessageContentJSONFormat() throws {
let encoder = JSONEncoder()
@ -335,7 +290,7 @@ struct SamplingTests {
// Test user message factory method
let userMessage: Sampling.Message = .user("Hello, world!")
#expect(userMessage.role == .user)
if case .text(let text) = userMessage.content {
if case .single(.text(let text)) = userMessage.content {
#expect(text == "Hello, world!")
} else {
#expect(Bool(false), "Expected text content")
@ -344,7 +299,7 @@ struct SamplingTests {
// Test assistant message factory method
let assistantMessage: Sampling.Message = .assistant("Hi there!")
#expect(assistantMessage.role == .assistant)
if case .text(let text) = assistantMessage.content {
if case .single(.text(let text)) = assistantMessage.content {
#expect(text == "Hi there!")
} else {
#expect(Bool(false), "Expected text content")
@ -354,7 +309,7 @@ struct SamplingTests {
let imageMessage: Sampling.Message = .user(
.image(data: "base64data", mimeType: "image/png"))
#expect(imageMessage.role == .user)
if case .image(let data, let mimeType) = imageMessage.content {
if case .single(.image(let data, let mimeType)) = imageMessage.content {
#expect(data == "base64data")
#expect(mimeType == "image/png")
} else {
@ -367,7 +322,7 @@ struct SamplingTests {
// Test string literal assignment
let content: Sampling.Message.Content = "Hello from string literal"
if case .text(let text) = content {
if case .single(.text(let text)) = content {
#expect(text == "Hello from string literal")
} else {
#expect(Bool(false), "Expected text content")
@ -375,7 +330,7 @@ struct SamplingTests {
// Test in message creation
let message: Sampling.Message = .user("Direct string literal")
if case .text(let text) = message.content {
if case .single(.text(let text)) = message.content {
#expect(text == "Direct string literal")
} else {
#expect(Bool(false), "Expected text content")
@ -404,7 +359,7 @@ struct SamplingTests {
let content: Sampling.Message.Content =
"Hello \(userName), the temperature in \(location) is \(temperature)°F"
if case .text(let text) = content {
if case .single(.text(let text)) = content {
#expect(text == "Hello Alice, the temperature in San Francisco is 72°F")
} else {
#expect(Bool(false), "Expected text content")
@ -413,7 +368,7 @@ struct SamplingTests {
// Test in message creation with interpolation
let message = Sampling.Message.user(
"Welcome \(userName)! Today's weather in \(location) is \(temperature)°F")
if case .text(let text) = message.content {
if case .single(.text(let text)) = message.content {
#expect(text == "Welcome Alice! Today's weather in San Francisco is 72°F")
} else {
#expect(Bool(false), "Expected text content")
@ -425,7 +380,7 @@ struct SamplingTests {
let listMessage: Sampling.Message = .assistant(
"You have \(count) items: \(items.joined(separator: ", "))")
if case .text(let text) = listMessage.content {
if case .single(.text(let text)) = listMessage.content {
#expect(text == "You have 3 items: apples, bananas, oranges")
} else {
#expect(Bool(false), "Expected text content")
@ -442,7 +397,7 @@ struct SamplingTests {
let userMessage: Sampling.Message = .user(
"Hi, I'm \(customerName) and I have an issue with order \(orderNumber)")
#expect(userMessage.role == .user)
if case .text(let text) = userMessage.content {
if case .single(.text(let text)) = userMessage.content {
#expect(text == "Hi, I'm Bob and I have an issue with order ORD-12345")
} else {
#expect(Bool(false), "Expected text content")
@ -453,7 +408,7 @@ struct SamplingTests {
"Hello \(customerName), I can help you with your \(issueType) issue for order \(orderNumber)"
)
#expect(assistantMessage.role == .assistant)
if case .text(let text) = assistantMessage.content {
if case .single(.text(let text)) = assistantMessage.content {
#expect(
text
== "Hello Bob, I can help you with your delivery delay issue for order ORD-12345"
@ -475,7 +430,7 @@ struct SamplingTests {
#expect(conversation.count == 4)
// Verify interpolated content
if case .text(let text) = conversation[2].content {
if case .single(.text(let text)) = conversation[2].content {
#expect(text == "I have an issue with order ORD-12345 - it's a delivery delay")
} else {
#expect(Bool(false), "Expected text content")
@ -519,10 +474,10 @@ struct SamplingTests {
#expect(mixedContent.count == 4)
// Verify content types
if case .text = mixedContent[0].content,
case .image = mixedContent[1].content,
case .text = mixedContent[2].content,
case .text = mixedContent[3].content
if case .single(.text) = mixedContent[0].content,
case .single(.image) = mixedContent[1].content,
case .single(.text) = mixedContent[2].content,
case .single(.text) = mixedContent[3].content
{
// All content types are correct
} else {
@ -545,67 +500,29 @@ struct SamplingTests {
@Suite("Sampling Integration Tests")
struct SamplingIntegrationTests {
@Test(
.timeLimit(.minutes(1))
)
func testSamplingCapabilitiesNegotiation() async throws {
let (clientToServerRead, clientToServerWrite) = try FileDescriptor.pipe()
let (serverToClientRead, serverToClientWrite) = try FileDescriptor.pipe()
var logger = Logger(
label: "mcp.test.sampling",
factory: { StreamLogHandler.standardError(label: $0) })
logger.logLevel = .debug
let serverTransport = StdioTransport(
input: clientToServerRead,
output: serverToClientWrite,
logger: logger
)
let clientTransport = StdioTransport(
input: serverToClientRead,
output: clientToServerWrite,
logger: logger
)
// Server with sampling capability
let server = Server(
name: "SamplingTestServer",
version: "1.0.0",
capabilities: .init(
sampling: .init(), // Enable sampling
tools: .init()
)
)
// Client (capabilities will be set during initialization)
let client = Client(
name: "SamplingTestClient",
version: "1.0"
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
await server.stop()
await client.disconnect()
try? clientToServerRead.close()
try? clientToServerWrite.close()
try? serverToClientRead.close()
try? serverToClientWrite.close()
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testSamplingHandlerRegistration() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "SamplingHandlerTestServer",
version: "1.0",
capabilities: .init(sampling: .init())
)
let client = Client(
name: "SamplingHandlerTestClient",
version: "1.0"
)
nonisolated(unsafe) var handlerCalled = false
// Register sampling handler
let handlerClient = await client.withSamplingHandler { parameters in
await client.withSamplingHandler { parameters in
handlerCalled = true
#expect(parameters.messages.count == 1)
// Mock LLM response
return CreateSamplingMessage.Result(
model: "test-model-v1",
@ -615,26 +532,59 @@ struct SamplingIntegrationTests {
)
}
// Verify method chaining works
#expect(
handlerClient === client, "withSamplingHandler should return self for method chaining")
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Note: We can't test the actual handler invocation without bidirectional transport,
// but we can verify the handler registration doesn't crash and returns correctly
// Test that the handler actually gets called
let messages: [Sampling.Message] = [.user("Test")]
let result = try await server.requestSampling(messages: messages, maxTokens: 100)
#expect(handlerCalled, "Sampling handler should have been called")
#expect(result.model == "test-model-v1")
#expect(result.stopReason == .endTurn)
await server.stop()
await client.disconnect()
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testServerSamplingRequestAPI() async throws {
let transport = MockTransport()
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "SamplingRequestTestServer",
version: "1.0",
capabilities: .init(sampling: .init())
)
try await server.start(transport: transport)
let client = Client(
name: "SamplingTestClient",
version: "1.0"
)
// Register sampling handler on client to respond to server's request
let responseContentString = "Based on the analysis, sales show strong growth through Q3 with Q4 stabilization."
await client.withSamplingHandler { parameters in
// Verify the request parameters were passed correctly
#expect(parameters.messages.count == 3)
#expect(parameters.systemPrompt == "You are a business analyst expert.")
#expect(parameters.includeContext == .thisServer)
#expect(parameters.temperature == 0.7)
#expect(parameters.maxTokens == 500)
#expect(parameters.stopSequences?.count == 2)
#expect(parameters._meta?["requestId"]?.stringValue == "test-123")
// Return mock LLM response
return CreateSamplingMessage.Result(
model: "claude-4-sonnet",
stopReason: .endTurn,
role: .assistant,
content: .text(responseContentString)
)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Test sampling request with comprehensive parameters
let messages: [Sampling.Message] = [
@ -653,43 +603,37 @@ struct SamplingIntegrationTests {
intelligencePriority: 0.9
)
// Test that the API accepts all parameters correctly
do {
_ = try await server.requestSampling(
messages: messages,
modelPreferences: modelPreferences,
systemPrompt: "You are a business analyst expert.",
includeContext: .thisServer,
temperature: 0.7,
maxTokens: 500,
stopSequences: ["END_ANALYSIS", "\n\n---"],
metadata: [
"requestId": "test-123",
"priority": "high",
"department": "analytics",
]
)
#expect(Bool(false), "Should throw error for unimplemented bidirectional communication")
} catch let error as MCPError {
if case .internalError(let message) = error {
#expect(
message?.contains("Bidirectional sampling requests not yet implemented")
== true,
"Should indicate bidirectional communication not implemented"
)
} else {
#expect(Bool(false), "Expected internalError, got \(error)")
}
} catch {
#expect(Bool(false), "Expected MCPError, got \(error)")
// Test that the API works end-to-end
let result = try await server.requestSampling(
messages: messages,
modelPreferences: modelPreferences,
systemPrompt: "You are a business analyst expert.",
includeContext: .thisServer,
temperature: 0.7,
maxTokens: 500,
stopSequences: ["END_ANALYSIS", "\n\n---"],
_meta: Metadata(additionalFields: [
"requestId": "test-123",
"priority": "high",
"department": "analytics",
])
)
// Verify the response
#expect(result.model == "claude-4-sonnet")
#expect(result.stopReason == .endTurn)
#expect(result.role == .assistant)
if case .single(.text(let text)) = result.content {
#expect(text == responseContentString)
} else {
Issue.record("Expected text content")
}
await server.stop()
await client.disconnect()
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testSamplingMessageTypes() async throws {
// Test comprehensive message content types
let textMessage: Sampling.Message = .user("What do you see in this data?")
@ -708,28 +652,15 @@ struct SamplingIntegrationTests {
// Test text message
let textData = try encoder.encode(textMessage)
let decodedTextMessage = try decoder.decode(Sampling.Message.self, from: textData)
#expect(decodedTextMessage.role == .user)
if case .text(let text) = decodedTextMessage.content {
#expect(text == "What do you see in this data?")
} else {
#expect(Bool(false), "Expected text content")
}
#expect(decodedTextMessage == textMessage)
// Test image message
let imageData = try encoder.encode(imageMessage)
let decodedImageMessage = try decoder.decode(Sampling.Message.self, from: imageData)
#expect(decodedImageMessage.role == .user)
if case .image(let data, let mimeType) = decodedImageMessage.content {
#expect(data.contains("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"))
#expect(mimeType == "image/png")
} else {
#expect(Bool(false), "Expected image content")
}
#expect(decodedImageMessage == imageMessage)
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testSamplingResultTypes() async throws {
// Test different result content types and stop reasons
let textResult = CreateSamplingMessage.Result(
@ -765,78 +696,199 @@ struct SamplingIntegrationTests {
// Test text result
let textData = try encoder.encode(textResult)
let decodedTextResult = try decoder.decode(
CreateSamplingMessage.Result.self, from: textData)
#expect(decodedTextResult.model == "claude-4-sonnet")
#expect(decodedTextResult.stopReason == .endTurn)
#expect(decodedTextResult.role == .assistant)
CreateSamplingMessage.Result.self, from: textData
)
#expect(decodedTextResult == textResult)
// Test image result
let imageData = try encoder.encode(imageResult)
let decodedImageResult = try decoder.decode(
CreateSamplingMessage.Result.self, from: imageData)
#expect(decodedImageResult.model == "dall-e-3")
#expect(decodedImageResult.stopReason == .maxTokens)
CreateSamplingMessage.Result.self, from: imageData
)
#expect(decodedImageResult == imageResult)
// Test stop sequence result
let stopData = try encoder.encode(stopSequenceResult)
let decodedStopResult = try decoder.decode(
CreateSamplingMessage.Result.self, from: stopData)
#expect(decodedStopResult.stopReason == .stopSequence)
CreateSamplingMessage.Result.self, from: stopData
)
#expect(decodedStopResult == stopSequenceResult)
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testSamplingErrorHandling() async throws {
let transport = MockTransport()
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "ErrorTestServer",
version: "1.0",
capabilities: .init() // No sampling capability
capabilities: .init(sampling: .init())
)
try await server.start(transport: transport)
// Client WITHOUT sampling capability
let client = Client(
name: "ErrorTestClient",
version: "1.0"
)
// Test sampling request on server without sampling capability
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Test sampling request - should fail because client doesn't support sampling
let messages: [Sampling.Message] = [
.user("Test message")
]
do {
await #expect(throws: MCPError.self) {
_ = try await server.requestSampling(
messages: messages,
maxTokens: 100
)
#expect(Bool(false), "Should throw error for missing connection")
} catch let error as MCPError {
if case .internalError(let message) = error {
#expect(
message?.contains("Server connection not initialized") == true
|| message?.contains("Bidirectional sampling requests not yet implemented")
== true,
"Should indicate connection or implementation issue"
)
} else {
#expect(Bool(false), "Expected internalError, got \(error)")
}
} catch {
#expect(Bool(false), "Expected MCPError, got \(error)")
}
await server.stop()
await client.disconnect()
}
@Test(
.timeLimit(.minutes(1))
)
@Test("Strict mode succeeds when client declares sampling capability")
func testSamplingStrictCapabilitiesSuccess() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "StrictTestServer",
version: "1.0",
capabilities: .init(sampling: .init()),
configuration: .strict
)
let client = Client(
name: "StrictTestClient",
version: "1.0",
capabilities: .init(sampling: .init()),
configuration: .strict
)
// Register sampling handler
await client.withSamplingHandler { _ in
CreateSamplingMessage.Result(
model: "test-model",
stopReason: .endTurn,
role: .assistant,
content: .text("Strict mode success")
)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because client declares sampling capability
let result = try await server.requestSampling(
messages: [.user("Test message")],
maxTokens: 100
)
#expect(result.model == "test-model")
#expect(result.role == .assistant)
if case .single(.text(let text)) = result.content {
#expect(text == "Strict mode success")
} else {
Issue.record("Expected text content")
}
await server.stop()
await client.disconnect()
}
@Test("Strict mode fails when client doesn't declare sampling capability")
func testSamplingStrictCapabilitiesError() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "StrictTestServer",
version: "1.0",
capabilities: .init(sampling: .init()),
configuration: .strict
)
// Client WITHOUT sampling capability in strict mode
let client = Client(
name: "StrictTestClient",
version: "1.0",
capabilities: .init(),
configuration: .strict
)
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should fail because client doesn't declare sampling capability in strict mode
await #expect(throws: MCPError.self) {
_ = try await server.requestSampling(
messages: [.user("Test message")],
maxTokens: 100
)
}
await server.stop()
await client.disconnect()
}
@Test("Non-strict mode succeeds even without client capability declaration")
func testSamplingNonStrictCapabilities() async throws {
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(
name: "NonStrictTestServer",
version: "1.0",
capabilities: .init(sampling: .init()),
configuration: .default // Non-strict mode
)
// Client WITHOUT sampling capability in non-strict mode
let client = Client(
name: "NonStrictTestClient",
version: "1.0",
capabilities: .init(),
configuration: .default
)
// Register sampling handler anyway
await client.withSamplingHandler { _ in
CreateSamplingMessage.Result(
model: "test-model",
stopReason: .endTurn,
role: .assistant,
content: .text("Non-strict mode success")
)
}
try await server.start(transport: serverTransport)
try await client.connect(transport: clientTransport)
// Should succeed because server is in non-strict mode
let result = try await server.requestSampling(
messages: [.user("Test message")],
maxTokens: 100
)
#expect(result.model == "test-model")
if case .single(.text(let text)) = result.content {
#expect(text == "Non-strict mode success")
} else {
Issue.record("Expected text content")
}
await server.stop()
await client.disconnect()
}
@Test
func testSamplingParameterValidation() async throws {
// Test parameter validation and edge cases
let validMessages: [Sampling.Message] = [
.user("Valid message")
]
_ = [Sampling.Message]() // Test empty messages array.
// Test with valid parameters
let validParams = CreateSamplingMessage.Parameters(
messages: validMessages,
@ -859,10 +911,10 @@ struct SamplingIntegrationTests {
temperature: 0.7,
maxTokens: 500,
stopSequences: ["STOP", "END"],
metadata: [
_meta: Metadata(additionalFields: [
"sessionId": "test-session-123",
"userId": "user-456",
]
])
)
#expect(comprehensiveParams.messages.count == 1)
@ -872,7 +924,7 @@ struct SamplingIntegrationTests {
#expect(comprehensiveParams.temperature == 0.7)
#expect(comprehensiveParams.maxTokens == 500)
#expect(comprehensiveParams.stopSequences?.count == 2)
#expect(comprehensiveParams.metadata?.count == 2)
#expect(comprehensiveParams._meta?.fields.count == 2)
// Test encoding/decoding of comprehensive parameters
let encoder = JSONEncoder()
@ -881,19 +933,10 @@ struct SamplingIntegrationTests {
let data = try encoder.encode(comprehensiveParams)
let decoded = try decoder.decode(CreateSamplingMessage.Parameters.self, from: data)
#expect(decoded.messages.count == 1)
#expect(decoded.modelPreferences?.costPriority?.doubleValue == 0.5)
#expect(decoded.systemPrompt == "You are a helpful assistant.")
#expect(decoded.includeContext == .allServers)
#expect(decoded.temperature == 0.7)
#expect(decoded.maxTokens == 500)
#expect(decoded.stopSequences?[0] == "STOP")
#expect(decoded.metadata?["sessionId"]?.stringValue == "test-session-123")
#expect(decoded == comprehensiveParams)
}
@Test(
.timeLimit(.minutes(1))
)
@Test
func testSamplingWorkflowScenarios() async throws {
// Test realistic sampling workflow scenarios
@ -922,7 +965,7 @@ struct SamplingIntegrationTests {
temperature: 0.3, // Lower temperature for analytical tasks
maxTokens: 400,
stopSequences: ["---END---"],
metadata: ["analysisType": "customer-feedback"]
_meta: Metadata(additionalFields: ["analysisType": "customer-feedback"])
)
// Scenario 2: Creative Content Generation
@ -942,7 +985,7 @@ struct SamplingIntegrationTests {
systemPrompt: "You are a creative marketing copywriter.",
temperature: 0.8, // Higher temperature for creativity
maxTokens: 200,
metadata: ["contentType": "marketing-copy"]
_meta: Metadata(additionalFields: ["contentType": "marketing-copy"])
)
// Test parameter encoding for both scenarios
@ -951,10 +994,6 @@ struct SamplingIntegrationTests {
let analysisData = try encoder.encode(dataAnalysisParams)
let creativeData = try encoder.encode(creativeParams)
// Verify both encode successfully
#expect(analysisData.count > 0)
#expect(creativeData.count > 0)
// Test decoding
let decoder = JSONDecoder()
let decodedAnalysis = try decoder.decode(
@ -962,9 +1001,116 @@ struct SamplingIntegrationTests {
let decodedCreative = try decoder.decode(
CreateSamplingMessage.Parameters.self, from: creativeData)
#expect(decodedAnalysis.temperature == 0.3)
#expect(decodedCreative.temperature == 0.8)
#expect(decodedAnalysis.modelPreferences?.intelligencePriority?.doubleValue == 0.9)
#expect(decodedCreative.modelPreferences?.costPriority?.doubleValue == 0.4)
#expect(decodedAnalysis == dataAnalysisParams)
#expect(decodedCreative == creativeParams)
}
}
@Suite("Sampling 2025-11-25 Spec Tests")
struct Sampling2025_11_25Tests {
@Test("Audio content encoding and decoding")
func testAudioContent() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let audioMessage: Sampling.Message = .user(
.audio(data: "base64audiodata", mimeType: "audio/mp3"))
let data = try encoder.encode(audioMessage)
let decoded = try decoder.decode(Sampling.Message.self, from: data)
#expect(decoded.role == .user)
if case .single(.audio(let audioData, let mimeType)) = decoded.content {
#expect(audioData == "base64audiodata")
#expect(mimeType == "audio/mp3")
} else {
#expect(Bool(false), "Expected audio content")
}
}
@Test("StopReason.toolUse encoding and decoding")
func testToolUseStopReason() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let result = CreateSamplingMessage.Result(
model: "claude-4",
stopReason: .toolUse,
role: .assistant,
content: .single(.text("I need to use a tool"))
)
let data = try encoder.encode(result)
let decoded = try decoder.decode(CreateSamplingMessage.Result.self, from: data)
#expect(decoded == result)
}
@Test("Multiple content blocks")
func testMultipleContentBlocks() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let blocks: [Sampling.Message.Content.ContentBlock] = [
.text("Here's an image:"),
.image(data: "imagedata", mimeType: "image/png"),
.text("And some audio:"),
.audio(data: "audiodata", mimeType: "audio/mp3")
]
let content = Sampling.Message.Content.multiple(blocks)
let message = Sampling.Message.assistant(content)
let data = try encoder.encode(message)
let decoded = try decoder.decode(Sampling.Message.self, from: data)
#expect(decoded == message)
}
@Test("Tools parameter encoding and decoding")
func testToolsParameter() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let tool = Tool(
name: "get_weather",
description: "Get current weather",
inputSchema: [
"type": "object",
"properties": [
"location": ["type": "string"]
]
]
)
let params = CreateSamplingMessage.Parameters(
messages: [.user("What's the weather?")],
maxTokens: 100,
tools: [tool],
toolChoice: .init(mode: .auto)
)
let data = try encoder.encode(params)
let decoded = try decoder.decode(CreateSamplingMessage.Parameters.self, from: data)
#expect(decoded == params)
}
@Test("Client sampling capabilities with sub-capabilities")
func testSamplingSubCapabilities() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let capabilities = Client.Capabilities(
sampling: .init(tools: .init(), context: .init())
)
#expect(capabilities.sampling?.tools != nil)
#expect(capabilities.sampling?.context != nil)
let data = try encoder.encode(capabilities)
let decoded = try decoder.decode(Client.Capabilities.self, from: data)
#expect(decoded == capabilities)
}
}

View file

@ -39,7 +39,7 @@ struct ServerTests {
try await server.start(transport: transport)
// Wait for message processing and response
try await Task.sleep(for: .milliseconds(100))
try await Task.sleep(for: .milliseconds(200))
#expect(await transport.sentMessages.count == 1)
@ -143,29 +143,45 @@ struct ServerTests {
@Test("JSON-RPC batch processing")
func testJSONRPCBatchProcessing() async throws {
let transport = MockTransport()
let (clientTransport, serverTransport) = await InMemoryTransport.createConnectedPair()
let server = Server(name: "TestServer", version: "1.0")
// Connect transports
try await clientTransport.connect()
try await serverTransport.connect()
// Start receiving messages on client side
let receiveTask = Task {
var responses: [String] = []
for try await data in await clientTransport.receive() {
if let response = String(data: data, encoding: .utf8) {
responses.append(response)
}
// Stop after receiving 2 responses (initialize + batch)
if responses.count == 2 {
break
}
}
return responses
}
// Start the server
try await server.start(transport: transport)
try await server.start(transport: serverTransport)
// Initialize the server first
try await transport.queue(
request: Initialize.request(
.init(
protocolVersion: Version.latest,
capabilities: .init(),
clientInfo: .init(name: "TestClient", version: "1.0")
)
let initRequest = Initialize.request(
.init(
protocolVersion: Version.latest,
capabilities: .init(),
clientInfo: .init(name: "TestClient", version: "1.0")
)
)
let initData = try JSONEncoder().encode(AnyRequest(initRequest))
try await clientTransport.send(initData)
// Wait for server to initialize and respond
// Wait for initialization
try await Task.sleep(for: .milliseconds(100))
// Clear sent messages
await transport.clearMessages()
// Create a batch with multiple requests
let batchJSON = """
[
@ -173,19 +189,20 @@ struct ServerTests {
{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}
]
"""
let batch = try JSONDecoder().decode([AnyRequest].self, from: batchJSON.data(using: .utf8)!)
// Send the batch request
try await transport.queue(batch: batch)
let batchData = batchJSON.data(using: .utf8)!
try await clientTransport.send(batchData)
// Wait for batch processing
try await Task.sleep(for: .milliseconds(100))
try await Task.sleep(for: .milliseconds(200))
// Verify response
let sentMessages = await transport.sentMessages
#expect(sentMessages.count == 1)
// Get responses
let responses = try await receiveTask.value
#expect(responses.count == 2)
// Verify the batch response (second response)
if responses.count >= 2 {
let batchResponse = responses[1]
if let batchResponse = sentMessages.first {
// Should be an array
#expect(batchResponse.hasPrefix("["))
#expect(batchResponse.hasSuffix("]"))
@ -196,6 +213,7 @@ struct ServerTests {
}
await server.stop()
await transport.disconnect()
await clientTransport.disconnect()
await serverTransport.disconnect()
}
}

View file

@ -20,6 +20,8 @@ struct ToolTests {
#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")
@ -202,6 +204,40 @@ struct ToolTests {
#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!")
@ -223,7 +259,7 @@ struct ToolTests {
let content = Tool.Content.image(
data: "base64data",
mimeType: "image/png",
metadata: ["width": "100", "height": "100"]
metadata: .init(additionalFields: ["width": "100", "height": "100"])
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
@ -243,26 +279,60 @@ struct ToolTests {
@Test("Resource content encoding and decoding")
func testToolContentResourceEncoding() throws {
let content = Tool.Content.resource(
let resourceContent = Resource.Content.text(
"Sample text",
uri: "file://test.txt",
mimeType: "text/plain",
text: "Sample text"
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 uri, let mimeType, let text) = decoded {
#expect(uri == "file://test.txt")
#expect(mimeType == "text/plain")
#expect(text == "Sample text")
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(
@ -422,7 +492,6 @@ struct ToolTests {
#expect(Bool(false), "Expected success result")
}
}
}
@Test("Tool with missing description")
func testToolWithMissingDescription() throws {
@ -433,10 +502,11 @@ struct ToolTests {
}
"""
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 == [:])
}
}
}

View file

@ -41,13 +41,22 @@ struct VersioningTests {
@Test("Server's supported versions correctly defined")
func testServerSupportedVersions() {
#expect(Version.supported.contains("2025-11-25"))
#expect(Version.supported.contains("2025-06-18"))
#expect(Version.supported.contains("2025-03-26"))
#expect(Version.supported.contains("2024-11-05"))
#expect(Version.supported.count == 2)
#expect(Version.supported.count == 4)
}
@Test("Server's latest version is correct")
func testServerLatestVersion() {
#expect(Version.latest == "2025-03-26")
#expect(Version.latest == "2025-11-25")
}
@Test("Client requests new 2025-11-25 version")
func testClientRequests2025_11_25Version() {
let clientVersion = "2025-11-25"
let negotiatedVersion = Version.negotiate(clientRequestedVersion: clientVersion)
#expect(negotiatedVersion == "2025-11-25")
}
}