V0.11.x (#199)
* 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:
parent
6fa1a97b41
commit
6112a3995a
17 changed files with 11854 additions and 828 deletions
249
Tests/MCPTests/CancellationTests.swift
Normal file
249
Tests/MCPTests/CancellationTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
598
Tests/MCPTests/CompletionTests.swift
Normal file
598
Tests/MCPTests/CompletionTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
626
Tests/MCPTests/ElicitationTests.swift
Normal file
626
Tests/MCPTests/ElicitationTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
899
Tests/MCPTests/HTTPServerTransportTests.swift
Normal file
899
Tests/MCPTests/HTTPServerTransportTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
509
Tests/MCPTests/LoggingTests.swift
Normal file
509
Tests/MCPTests/LoggingTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
351
Tests/MCPTests/MetaFieldsTests.swift
Normal file
351
Tests/MCPTests/MetaFieldsTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
390
Tests/MCPTests/ProgressTests.swift
Normal file
390
Tests/MCPTests/ProgressTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
494
Tests/MCPTests/RootsTests.swift
Normal file
494
Tests/MCPTests/RootsTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == [:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue