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
3 changed files with 11854 additions and 828 deletions
384
Sources/MCPConformance/Client/main.swift
Normal file
384
Sources/MCPConformance/Client/main.swift
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* Everything client - a single conformance test client that handles all scenarios.
|
||||
*
|
||||
* Usage: mcp-everything-client <server-url>
|
||||
*
|
||||
* The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
|
||||
* which is set by the conformance test runner.
|
||||
*
|
||||
* This client routes to the appropriate behavior based on the scenario name,
|
||||
* consolidating all the individual test clients into one.
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import MCP
|
||||
|
||||
// MARK: - Scenario Handlers
|
||||
|
||||
typealias ScenarioHandler = ([String]) async throws -> Void
|
||||
|
||||
// MARK: - Basic Scenarios
|
||||
|
||||
/// Basic client that connects, initializes, and lists tools
|
||||
func runInitializeScenario(_ args: [String]) async throws {
|
||||
var logger = Logger(
|
||||
label: "mcp.conformance.client.initialize",
|
||||
factory: { StreamLogHandler.standardError(label: $0) }
|
||||
)
|
||||
logger.logLevel = .debug
|
||||
|
||||
logger.debug("Starting initialize scenario")
|
||||
|
||||
// Get server URL from args
|
||||
guard let serverURLString = args.last,
|
||||
let serverURL = URL(string: serverURLString) else {
|
||||
throw ConformanceError.invalidArguments("Valid server URL is required")
|
||||
}
|
||||
|
||||
// Create HTTP transport
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: serverURL,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// Create client
|
||||
let client = Client(name: "test-client", version: "1.0.0")
|
||||
|
||||
// Connect
|
||||
let initResult = try await client.connect(transport: transport)
|
||||
logger.debug("Successfully connected to MCP server", metadata: [
|
||||
"serverName": "\(initResult.serverInfo.name)",
|
||||
"serverVersion": "\(initResult.serverInfo.version)"
|
||||
])
|
||||
|
||||
// List tools
|
||||
let (tools, _) = try await client.listTools()
|
||||
logger.debug("Successfully listed tools", metadata: [
|
||||
"toolCount": "\(tools.count)"
|
||||
])
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect()
|
||||
|
||||
logger.debug("Initialize scenario completed successfully")
|
||||
}
|
||||
|
||||
/// Client that calls the add_numbers tool
|
||||
func runToolsCallScenario(_ args: [String]) async throws {
|
||||
var logger = Logger(
|
||||
label: "mcp.conformance.client.tools_call",
|
||||
factory: { StreamLogHandler.standardError(label: $0) }
|
||||
)
|
||||
logger.logLevel = .debug
|
||||
|
||||
logger.debug("Starting tools_call scenario")
|
||||
|
||||
// Get server URL from args
|
||||
guard let serverURLString = args.last,
|
||||
let serverURL = URL(string: serverURLString) else {
|
||||
throw ConformanceError.invalidArguments("Valid server URL is required")
|
||||
}
|
||||
|
||||
// Create HTTP transport
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: serverURL,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// Create client
|
||||
let client = Client(name: "test-client", version: "1.0.0")
|
||||
|
||||
// Connect
|
||||
try await client.connect(transport: transport)
|
||||
logger.debug("Successfully connected to MCP server")
|
||||
|
||||
// List tools
|
||||
let (tools, _) = try await client.listTools()
|
||||
logger.debug("Successfully listed tools", metadata: [
|
||||
"toolCount": "\(tools.count)"
|
||||
])
|
||||
|
||||
// Call the add_numbers tool
|
||||
if tools.contains(where: { $0.name == "add_numbers" }) {
|
||||
let result = try await client.callTool(
|
||||
name: "add_numbers",
|
||||
arguments: ["a": 5, "b": 3]
|
||||
)
|
||||
logger.debug("Tool call result", metadata: [
|
||||
"isError": "\(result.isError ?? false)",
|
||||
"contentCount": "\(result.content.count)"
|
||||
])
|
||||
} else {
|
||||
logger.warning("add_numbers tool not found")
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect()
|
||||
|
||||
logger.debug("Tools call scenario completed successfully")
|
||||
}
|
||||
|
||||
// MARK: - SSE Scenarios
|
||||
|
||||
/// Handler for SSE-related scenarios (retry, reconnection, etc.)
|
||||
func runSSEScenario(_ args: [String]) async throws {
|
||||
var logger = Logger(
|
||||
label: "mcp.conformance.client.sse",
|
||||
factory: { StreamLogHandler.standardError(label: $0) }
|
||||
)
|
||||
logger.logLevel = .debug
|
||||
|
||||
logger.debug("Starting SSE scenario")
|
||||
|
||||
// Get server URL from args
|
||||
guard let serverURLString = args.last,
|
||||
let serverURL = URL(string: serverURLString) else {
|
||||
throw ConformanceError.invalidArguments("Valid server URL is required")
|
||||
}
|
||||
|
||||
// Create HTTP transport with streaming enabled
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: serverURL,
|
||||
streaming: true,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// Create client
|
||||
let client = Client(name: "test-client", version: "1.0.0")
|
||||
|
||||
// Connect - this will start the SSE stream in the background
|
||||
let initResult = try await client.connect(transport: transport)
|
||||
logger.debug("Successfully connected to MCP server", metadata: [
|
||||
"serverName": "\(initResult.serverInfo.name)",
|
||||
"serverVersion": "\(initResult.serverInfo.version)"
|
||||
])
|
||||
|
||||
// Give the GET SSE stream time to establish
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
|
||||
// Call the test_reconnection tool to trigger SSE stream closure and retry test.
|
||||
// The server will close the POST SSE stream without the response,
|
||||
// then deliver it on the GET SSE stream after we reconnect.
|
||||
logger.debug("Calling test_reconnection tool...")
|
||||
let result = try await client.callTool(name: "test_reconnection", arguments: [:])
|
||||
logger.debug("Tool call result received", metadata: [
|
||||
"isError": "\(result.isError ?? false)",
|
||||
"contentCount": "\(result.content.count)"
|
||||
])
|
||||
|
||||
// Keep the connection open briefly for the test to collect timing data
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect()
|
||||
|
||||
logger.debug("SSE scenario completed")
|
||||
}
|
||||
|
||||
/// Client that handles elicitation-sep1034-client-defaults scenario
|
||||
/// Tests that client properly applies default values for omitted fields
|
||||
func runElicitationSEP1034ClientDefaults(_ args: [String]) async throws {
|
||||
var logger = Logger(
|
||||
label: "mcp.conformance.client.elicitation_client_defaults",
|
||||
factory: { StreamLogHandler.standardError(label: $0) }
|
||||
)
|
||||
logger.logLevel = .debug
|
||||
|
||||
logger.debug("Starting elicitation-sep1034-client-defaults scenario")
|
||||
|
||||
// Get server URL from args
|
||||
guard let serverURLString = args.last,
|
||||
let serverURL = URL(string: serverURLString) else {
|
||||
throw ConformanceError.invalidArguments("Valid server URL is required")
|
||||
}
|
||||
|
||||
// Create HTTP transport with streaming enabled for bidirectional communication
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: serverURL,
|
||||
streaming: true,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// Create client with elicitation capabilities
|
||||
let client = Client(
|
||||
name: "test-client",
|
||||
version: "1.0.0",
|
||||
capabilities: Client.Capabilities(
|
||||
elicitation: Client.Capabilities.Elicitation(form: .init(), url: .init())
|
||||
)
|
||||
)
|
||||
|
||||
// Set up elicitation handler that accepts defaults BEFORE connecting
|
||||
await client.withElicitationHandler { [logger] params in
|
||||
let message: String
|
||||
switch params {
|
||||
case .form(let formParams):
|
||||
message = formParams.message
|
||||
case .url(let urlParams):
|
||||
message = urlParams.message
|
||||
}
|
||||
|
||||
logger.debug("Elicitation handler invoked", metadata: [
|
||||
"message": "\(message)"
|
||||
])
|
||||
|
||||
// Accept with default values applied
|
||||
// The schema has optional fields with defaults:
|
||||
// name: "John Doe", age: 30, score: 95.5, status: "active", verified: true
|
||||
return CreateElicitation.Result(
|
||||
action: .accept,
|
||||
content: [
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"score": 95.5,
|
||||
"status": "active",
|
||||
"verified": true
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Connect
|
||||
try await client.connect(transport: transport)
|
||||
logger.debug("Successfully connected to MCP server")
|
||||
|
||||
// List tools
|
||||
let (tools, _) = try await client.listTools()
|
||||
logger.debug("Successfully listed tools", metadata: [
|
||||
"toolCount": "\(tools.count)"
|
||||
])
|
||||
|
||||
// Call the test_client_elicitation_defaults tool
|
||||
if tools.contains(where: { $0.name == "test_client_elicitation_defaults" }) {
|
||||
let result = try await client.callTool(
|
||||
name: "test_client_elicitation_defaults",
|
||||
arguments: [:]
|
||||
)
|
||||
logger.debug("Tool call result", metadata: [
|
||||
"isError": "\(result.isError ?? false)",
|
||||
"contentCount": "\(result.content.count)"
|
||||
])
|
||||
} else {
|
||||
logger.warning("test_client_elicitation_defaults tool not found")
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect()
|
||||
|
||||
logger.debug("Elicitation client defaults scenario completed successfully")
|
||||
}
|
||||
|
||||
// MARK: - Default Handler for Unimplemented Scenarios
|
||||
|
||||
/// Default handler that performs basic connection test for unimplemented scenarios
|
||||
func runDefaultScenario(_ args: [String]) async throws {
|
||||
var logger = Logger(
|
||||
label: "mcp.conformance.client.default",
|
||||
factory: { StreamLogHandler.standardError(label: $0) }
|
||||
)
|
||||
logger.logLevel = .debug
|
||||
|
||||
logger.debug("Running default scenario handler")
|
||||
|
||||
// Get server URL from args
|
||||
guard let serverURLString = args.last,
|
||||
let serverURL = URL(string: serverURLString) else {
|
||||
throw ConformanceError.invalidArguments("Valid server URL is required")
|
||||
}
|
||||
|
||||
// Create HTTP transport
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: serverURL,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
// Create client
|
||||
let client = Client(name: "test-client", version: "1.0.0")
|
||||
|
||||
// Connect
|
||||
let initResult = try await client.connect(transport: transport)
|
||||
logger.debug("Successfully connected to MCP server", metadata: [
|
||||
"serverName": "\(initResult.serverInfo.name)",
|
||||
"serverVersion": "\(initResult.serverInfo.version)"
|
||||
])
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect()
|
||||
|
||||
logger.debug("Default scenario completed successfully")
|
||||
}
|
||||
|
||||
// MARK: - Scenario Registry
|
||||
|
||||
nonisolated(unsafe) let scenarioHandlers: [String: ScenarioHandler] = [
|
||||
"initialize": runInitializeScenario,
|
||||
"tools_call": runToolsCallScenario,
|
||||
"sse-retry": runSSEScenario,
|
||||
"elicitation-sep1034-client-defaults": runElicitationSEP1034ClientDefaults,
|
||||
// Note: Other scenarios (auth/*) will use the default handler
|
||||
]
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum ConformanceError: Error, CustomStringConvertible {
|
||||
case missingScenario
|
||||
case invalidArguments(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .missingScenario:
|
||||
return "MCP_CONFORMANCE_SCENARIO environment variable not set"
|
||||
case .invalidArguments(let message):
|
||||
return "Invalid arguments: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConformanceClient {
|
||||
static func run() async {
|
||||
do {
|
||||
// Get scenario from environment
|
||||
guard let scenario = ProcessInfo.processInfo.environment["MCP_CONFORMANCE_SCENARIO"] else {
|
||||
var stderr = StandardError()
|
||||
print("Error: MCP_CONFORMANCE_SCENARIO environment variable not set", to: &stderr)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
// Get server URL from arguments (last argument)
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
guard !args.isEmpty else {
|
||||
var stderr = StandardError()
|
||||
print("Usage: mcp-everything-client <server-url>", to: &stderr)
|
||||
print("Error: Server URL is required", to: &stderr)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
// Get handler for scenario, or use default if not implemented
|
||||
let handler = scenarioHandlers[scenario] ?? runDefaultScenario
|
||||
|
||||
// Log if using default handler
|
||||
if scenarioHandlers[scenario] == nil {
|
||||
var stderr = StandardError()
|
||||
print("⚠️ Scenario '\(scenario)' not fully implemented - using default handler", to: &stderr)
|
||||
}
|
||||
|
||||
// Run the scenario
|
||||
try await handler(args)
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
var stderr = StandardError()
|
||||
print("Error: \(error)", to: &stderr)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
struct StandardError: TextOutputStream {
|
||||
mutating func write(_ string: String) {
|
||||
FileHandle.standardError.write(Data(string.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
await ConformanceClient.run()
|
||||
429
Sources/MCPConformance/Server/HTTPApp.swift
Normal file
429
Sources/MCPConformance/Server/HTTPApp.swift
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import Foundation
|
||||
import Logging
|
||||
import MCP
|
||||
@preconcurrency import NIOCore
|
||||
@preconcurrency import NIOPosix
|
||||
@preconcurrency import NIOHTTP1
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
actor HTTPApp {
|
||||
/// Configuration for the HTTP application.
|
||||
struct Configuration: Sendable {
|
||||
/// The host address to bind to.
|
||||
var host: String
|
||||
|
||||
/// The port to bind to.
|
||||
var port: Int
|
||||
|
||||
/// The MCP endpoint path.
|
||||
var endpoint: String
|
||||
|
||||
/// Session timeout in seconds.
|
||||
var sessionTimeout: TimeInterval
|
||||
|
||||
/// SSE retry interval in milliseconds for priming events.
|
||||
var retryInterval: Int?
|
||||
|
||||
init(
|
||||
host: String = "127.0.0.1",
|
||||
port: Int = 3000,
|
||||
endpoint: String = "/mcp",
|
||||
sessionTimeout: TimeInterval = 3600,
|
||||
retryInterval: Int? = nil
|
||||
) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.endpoint = endpoint
|
||||
self.sessionTimeout = sessionTimeout
|
||||
self.retryInterval = retryInterval
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory function to create MCP Server instances for each session.
|
||||
typealias ServerFactory = @Sendable (String) async throws -> Server
|
||||
|
||||
private let configuration: Configuration
|
||||
private let serverFactory: ServerFactory
|
||||
private let validationPipeline: (any HTTPRequestValidationPipeline)?
|
||||
private var channel: Channel?
|
||||
private var sessions: [String: SessionContext] = [:]
|
||||
|
||||
nonisolated let logger: Logger
|
||||
|
||||
struct SessionContext {
|
||||
let server: Server
|
||||
let transport: StatefulHTTPServerTransport
|
||||
let createdAt: Date
|
||||
var lastAccessedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
/// Creates a new HTTP application.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - configuration: Application configuration.
|
||||
/// - validationPipeline: Custom validation pipeline passed to each transport.
|
||||
/// If `nil`, transports use their sensible defaults.
|
||||
/// - serverFactory: Factory function to create Server instances for each session.
|
||||
/// - logger: Optional logger instance.
|
||||
init(
|
||||
configuration: Configuration = Configuration(),
|
||||
validationPipeline: (any HTTPRequestValidationPipeline)? = nil,
|
||||
serverFactory: @escaping ServerFactory,
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.serverFactory = serverFactory
|
||||
self.validationPipeline = validationPipeline
|
||||
self.logger = logger ?? Logger(
|
||||
label: "mcp.http.app",
|
||||
factory: { _ in SwiftLogNoOpLogHandler() }
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience initializer with individual parameters.
|
||||
init(
|
||||
host: String = "127.0.0.1",
|
||||
port: Int = 3000,
|
||||
endpoint: String = "/mcp",
|
||||
serverFactory: @escaping ServerFactory,
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
self.init(
|
||||
configuration: Configuration(host: host, port: port, endpoint: endpoint),
|
||||
serverFactory: serverFactory,
|
||||
logger: logger
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Starts the HTTP application.
|
||||
///
|
||||
/// This starts the NIO HTTP server and begins accepting connections.
|
||||
/// The call blocks until the server is shut down via ``stop()``.
|
||||
func start() async throws {
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
|
||||
let bootstrap = ServerBootstrap(group: group)
|
||||
.serverChannelOption(ChannelOptions.backlog, value: 256)
|
||||
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelInitializer { channel in
|
||||
channel.pipeline.configureHTTPServerPipeline().flatMap {
|
||||
channel.pipeline.addHandler(HTTPHandler(app: self))
|
||||
}
|
||||
}
|
||||
.childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1)
|
||||
|
||||
logger.info(
|
||||
"Starting MCP HTTP application",
|
||||
metadata: [
|
||||
"host": "\(configuration.host)",
|
||||
"port": "\(configuration.port)",
|
||||
"endpoint": "\(configuration.endpoint)",
|
||||
]
|
||||
)
|
||||
|
||||
let channel = try await bootstrap.bind(host: configuration.host, port: configuration.port).get()
|
||||
self.channel = channel
|
||||
|
||||
Task { await sessionCleanupLoop() }
|
||||
|
||||
try await channel.closeFuture.get()
|
||||
}
|
||||
|
||||
/// Stops the HTTP application gracefully, closing all sessions.
|
||||
func stop() async {
|
||||
await closeAllSessions()
|
||||
try? await channel?.close()
|
||||
channel = nil
|
||||
logger.info("MCP HTTP application stopped")
|
||||
}
|
||||
|
||||
// MARK: - Request Routing
|
||||
|
||||
var endpoint: String { configuration.endpoint }
|
||||
|
||||
/// Routes an incoming HTTP request to the appropriate session transport.
|
||||
///
|
||||
/// - Requests with a valid `Mcp-Session-Id` are forwarded to the matching transport.
|
||||
/// - POST requests with an `initialize` body create a new session.
|
||||
/// - All other requests without a session return an error.
|
||||
func handleHTTPRequest(_ request: HTTPRequest) async -> HTTPResponse {
|
||||
let sessionID = request.header(HTTPHeaderName.sessionID)
|
||||
|
||||
// Route to existing session
|
||||
if let sessionID, var session = sessions[sessionID] {
|
||||
session.lastAccessedAt = Date()
|
||||
sessions[sessionID] = session
|
||||
|
||||
let response = await session.transport.handleRequest(request)
|
||||
|
||||
// Clean up on successful DELETE
|
||||
if request.method.uppercased() == "DELETE" && response.statusCode == 200 {
|
||||
sessions.removeValue(forKey: sessionID)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// No session — check for initialize request
|
||||
if request.method.uppercased() == "POST",
|
||||
let body = request.body,
|
||||
let kind = JSONRPCMessageKind(data: body),
|
||||
kind.isInitializeRequest
|
||||
{
|
||||
return await createSessionAndHandle(request)
|
||||
}
|
||||
|
||||
// No session and not initialize
|
||||
if sessionID != nil {
|
||||
return .error(statusCode: 404, .invalidRequest("Not Found: Session not found or expired"))
|
||||
}
|
||||
return .error(
|
||||
statusCode: 400,
|
||||
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
private struct FixedSessionIDGenerator: SessionIDGenerator {
|
||||
let sessionID: String
|
||||
func generateSessionID() -> String { sessionID }
|
||||
}
|
||||
|
||||
private func createSessionAndHandle(_ request: HTTPRequest) async -> HTTPResponse {
|
||||
let sessionID = UUID().uuidString
|
||||
|
||||
let transport = StatefulHTTPServerTransport(
|
||||
sessionIDGenerator: FixedSessionIDGenerator(sessionID: sessionID),
|
||||
validationPipeline: validationPipeline,
|
||||
retryInterval: configuration.retryInterval,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
do {
|
||||
let server = try await serverFactory(sessionID)
|
||||
try await server.start(transport: transport)
|
||||
|
||||
sessions[sessionID] = SessionContext(
|
||||
server: server,
|
||||
transport: transport,
|
||||
createdAt: Date(),
|
||||
lastAccessedAt: Date()
|
||||
)
|
||||
|
||||
let response = await transport.handleRequest(request)
|
||||
|
||||
// If transport returned an error, clean up
|
||||
if case .error = response {
|
||||
sessions.removeValue(forKey: sessionID)
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
await transport.disconnect()
|
||||
return .error(
|
||||
statusCode: 500,
|
||||
.internalError("Failed to create session: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeSession(_ sessionID: String) async {
|
||||
guard let session = sessions.removeValue(forKey: sessionID) else { return }
|
||||
await session.transport.disconnect()
|
||||
logger.info("Closed session", metadata: ["sessionID": "\(sessionID)"])
|
||||
}
|
||||
|
||||
private func closeAllSessions() async {
|
||||
for sessionID in sessions.keys {
|
||||
await closeSession(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionCleanupLoop() async {
|
||||
while true {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
|
||||
let now = Date()
|
||||
let expired = sessions.filter { _, context in
|
||||
now.timeIntervalSince(context.lastAccessedAt) > configuration.sessionTimeout
|
||||
}
|
||||
|
||||
for (sessionID, _) in expired {
|
||||
logger.info("Session expired", metadata: ["sessionID": "\(sessionID)"])
|
||||
await closeSession(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIO HTTP Handler
|
||||
|
||||
/// Thin NIO adapter that converts between NIO HTTP types and the framework-agnostic
|
||||
/// `HTTPRequest`/`HTTPResponse` types, delegating all logic to the `HTTPApp`.
|
||||
private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = HTTPServerRequestPart
|
||||
typealias OutboundOut = HTTPServerResponsePart
|
||||
|
||||
private let app: HTTPApp
|
||||
|
||||
private struct RequestState {
|
||||
var head: HTTPRequestHead
|
||||
var bodyBuffer: ByteBuffer
|
||||
}
|
||||
|
||||
private var requestState: RequestState?
|
||||
|
||||
init(app: HTTPApp) {
|
||||
self.app = app
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
let part = unwrapInboundIn(data)
|
||||
|
||||
switch part {
|
||||
case .head(let head):
|
||||
requestState = RequestState(
|
||||
head: head,
|
||||
bodyBuffer: context.channel.allocator.buffer(capacity: 0)
|
||||
)
|
||||
case .body(var buffer):
|
||||
requestState?.bodyBuffer.writeBuffer(&buffer)
|
||||
case .end:
|
||||
guard let state = requestState else { return }
|
||||
requestState = nil
|
||||
|
||||
nonisolated(unsafe) let ctx = context
|
||||
Task { @MainActor in
|
||||
await self.handleRequest(state: state, context: ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Request Processing
|
||||
|
||||
private func handleRequest(state: RequestState, context: ChannelHandlerContext) async {
|
||||
let head = state.head
|
||||
let path = head.uri.split(separator: "?").first.map(String.init) ?? head.uri
|
||||
let endpoint = await app.endpoint
|
||||
|
||||
guard path == endpoint else {
|
||||
await writeResponse(
|
||||
.error(statusCode: 404, .invalidRequest("Not Found")),
|
||||
version: head.version,
|
||||
context: context
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let httpRequest = makeHTTPRequest(from: state)
|
||||
let response = await app.handleHTTPRequest(httpRequest)
|
||||
await writeResponse(response, version: head.version, context: context)
|
||||
}
|
||||
|
||||
// MARK: - NIO ↔ HTTPRequest/HTTPResponse Conversion
|
||||
|
||||
private func makeHTTPRequest(from state: RequestState) -> HTTPRequest {
|
||||
// Combine multiple header values per RFC 7230
|
||||
var headers: [String: String] = [:]
|
||||
for (name, value) in state.head.headers {
|
||||
if let existing = headers[name] {
|
||||
headers[name] = existing + ", " + value
|
||||
} else {
|
||||
headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
let body: Data?
|
||||
if state.bodyBuffer.readableBytes > 0,
|
||||
let bytes = state.bodyBuffer.getBytes(at: 0, length: state.bodyBuffer.readableBytes)
|
||||
{
|
||||
body = Data(bytes)
|
||||
} else {
|
||||
body = nil
|
||||
}
|
||||
|
||||
return HTTPRequest(
|
||||
method: state.head.method.rawValue,
|
||||
headers: headers,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
||||
private func writeResponse(
|
||||
_ response: HTTPResponse,
|
||||
version: HTTPVersion,
|
||||
context: ChannelHandlerContext
|
||||
) async {
|
||||
nonisolated(unsafe) let ctx = context
|
||||
let eventLoop = ctx.eventLoop
|
||||
|
||||
// Write response head
|
||||
let statusCode = response.statusCode
|
||||
let headers = response.headers
|
||||
|
||||
switch response {
|
||||
case .stream(let stream, _):
|
||||
eventLoop.execute {
|
||||
var head = HTTPResponseHead(
|
||||
version: version,
|
||||
status: HTTPResponseStatus(statusCode: statusCode)
|
||||
)
|
||||
for (name, value) in headers {
|
||||
head.headers.add(name: name, value: value)
|
||||
}
|
||||
ctx.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
||||
ctx.flush()
|
||||
}
|
||||
|
||||
// Await the SSE stream directly — no Task needed since we're already in one
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
eventLoop.execute {
|
||||
var buffer = ctx.channel.allocator.buffer(capacity: chunk.count)
|
||||
buffer.writeBytes(chunk)
|
||||
ctx.writeAndFlush(
|
||||
self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended with error — close connection
|
||||
}
|
||||
|
||||
eventLoop.execute {
|
||||
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
||||
}
|
||||
|
||||
default:
|
||||
let bodyData = response.bodyData
|
||||
eventLoop.execute {
|
||||
var head = HTTPResponseHead(
|
||||
version: version,
|
||||
status: HTTPResponseStatus(statusCode: statusCode)
|
||||
)
|
||||
for (name, value) in headers {
|
||||
head.headers.add(name: name, value: value)
|
||||
}
|
||||
|
||||
ctx.write(self.wrapOutboundOut(.head(head)), promise: nil)
|
||||
|
||||
if let body = bodyData {
|
||||
var buffer = ctx.channel.allocator.buffer(capacity: body.count)
|
||||
buffer.writeBytes(body)
|
||||
ctx.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
||||
}
|
||||
|
||||
ctx.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
513
Sources/MCPConformance/Server/main.swift
Normal file
513
Sources/MCPConformance/Server/main.swift
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
/**
|
||||
* MCP HTTP Server Wrapper
|
||||
*
|
||||
* HTTP server that wraps the MCP conformance server for testing with the
|
||||
* official conformance framework.
|
||||
*
|
||||
* Usage: mcp-http-server [--port PORT]
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import MCP
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - Test Data
|
||||
|
||||
private let testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
private let testAudioBase64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
||||
|
||||
// MARK: - Server State
|
||||
|
||||
actor ServerState {
|
||||
var resourceSubscriptions: Set<String> = []
|
||||
var watchedResourceContent = "Watched resource content"
|
||||
|
||||
func subscribe(to uri: String) {
|
||||
resourceSubscriptions.insert(uri)
|
||||
}
|
||||
|
||||
func unsubscribe(from uri: String) {
|
||||
resourceSubscriptions.remove(uri)
|
||||
}
|
||||
|
||||
func isSubscribed(to uri: String) -> Bool {
|
||||
resourceSubscriptions.contains(uri)
|
||||
}
|
||||
|
||||
func updateWatchedResource(_ newContent: String) {
|
||||
watchedResourceContent = newContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server Setup
|
||||
|
||||
func createConformanceServer(state: ServerState) async -> Server {
|
||||
let server = Server(
|
||||
name: "mcp-conformance-test-server",
|
||||
version: "1.0.0",
|
||||
capabilities: Server.Capabilities(
|
||||
logging: .init(),
|
||||
prompts: .init(listChanged: true),
|
||||
resources: .init(subscribe: true, listChanged: true),
|
||||
tools: .init(listChanged: true)
|
||||
)
|
||||
)
|
||||
|
||||
// Tools
|
||||
await server.withMethodHandler(ListTools.self) { _ in
|
||||
.init(tools: [
|
||||
Tool(name: "test_simple_text", description: "Tests simple text content response", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_image_content", description: "Tests image content response", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_audio_content", description: "Tests audio content response", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_embedded_resource", description: "Tests embedded resource content response", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_multiple_content_types", description: "Tests response with multiple content types", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_error_handling", description: "Tests error response handling", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_logging", description: "Tests logging capabilities", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_progress", description: "Tests progress notifications", inputSchema: .object(["type": "object", "properties": ["duration_ms": ["type": "number", "description": "Duration in milliseconds to report progress"]]])),
|
||||
Tool(name: "add_numbers", description: "Adds two numbers together", inputSchema: .object(["type": "object", "properties": ["a": ["type": "number", "description": "First number"], "b": ["type": "number", "description": "Second number"]]])),
|
||||
Tool(name: "test_tool_with_progress", description: "Tool reports progress notifications", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_tool_with_logging", description: "Tool sends log messages during execution", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_reconnection", description: "Tests SSE reconnection and resumption with Last-Event-ID", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_sampling", description: "Tests LLM sampling capabilities", inputSchema: .object(["type": "object", "properties": ["prompt": ["type": "string", "description": "Text to send to the LLM"]], "required": ["prompt"]])),
|
||||
Tool(name: "test_elicitation", description: "Tests user input elicitation", inputSchema: .object(["type": "object", "properties": ["message": ["type": "string", "description": "Text displayed to user"]], "required": ["message"]])),
|
||||
Tool(name: "test_elicitation_sep1034_defaults", description: "Tests elicitation with default values (SEP-1034)", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_elicitation_sep1330_enums", description: "Tests elicitation with enum variants (SEP-1330)", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "test_client_elicitation_defaults", description: "Tests that client applies defaults for omitted elicitation fields", inputSchema: .object(["type": "object", "properties": [:]])),
|
||||
Tool(name: "json_schema_2020_12_tool", description: "Tool with JSON Schema 2020-12 features", inputSchema: .object([
|
||||
"$schema": .string("https://json-schema.org/draft/2020-12/schema"),
|
||||
"type": .string("object"),
|
||||
"$defs": .object([
|
||||
"address": .object([
|
||||
"type": .string("object"),
|
||||
"properties": .object([
|
||||
"street": .object(["type": .string("string")]),
|
||||
"city": .object(["type": .string("string")])
|
||||
])
|
||||
])
|
||||
]),
|
||||
"properties": .object([
|
||||
"name": .object(["type": .string("string")]),
|
||||
"address": .object(["$ref": .string("#/$defs/address")])
|
||||
]),
|
||||
"additionalProperties": .bool(false)
|
||||
]))
|
||||
])
|
||||
}
|
||||
|
||||
await server.withMethodHandler(CallTool.self) { [weak server] params in
|
||||
switch params.name {
|
||||
case "test_simple_text":
|
||||
return .init(content: [.text("This is a simple text response for testing.")], isError: false)
|
||||
case "test_image_content":
|
||||
return .init(content: [.image(data: testImageBase64, mimeType: "image/png", metadata: nil)], isError: false)
|
||||
case "test_audio_content":
|
||||
return .init(content: [.audio(data: testAudioBase64, mimeType: "audio/wav")], isError: false)
|
||||
case "test_embedded_resource":
|
||||
return .init(content: [.resource(resource: .text("This is an embedded resource content.", uri: "test://embedded-resource", mimeType: "text/plain"))], isError: false)
|
||||
case "test_multiple_content_types":
|
||||
return .init(content: [
|
||||
.text("Multiple content types test:"),
|
||||
.image(data: testImageBase64, mimeType: "image/png", metadata: nil),
|
||||
.resource(resource: .text("{\"test\":\"data\",\"value\":123}", uri: "test://mixed-content-resource", mimeType: "application/json"))], isError: false)
|
||||
case "test_error_handling":
|
||||
return .init(content: [.text("An error occurred during tool execution")], isError: true)
|
||||
case "test_logging":
|
||||
return .init(content: [.text("Logging test completed")], isError: false)
|
||||
case "test_progress":
|
||||
let duration = params.arguments?["duration_ms"]?.intValue ?? 1000
|
||||
try? await Task.sleep(for: .milliseconds(duration))
|
||||
return .init(content: [.text("Progress test completed")], isError: false)
|
||||
case "add_numbers":
|
||||
guard let a = params.arguments?["a"]?.intValue, let b = params.arguments?["b"]?.intValue else {
|
||||
return .init(content: [.text("Invalid arguments: expected numbers a and b")], isError: true)
|
||||
}
|
||||
return .init(content: [.text("\(a + b)")], isError: false)
|
||||
case "test_tool_with_progress":
|
||||
if let token = params._meta?.progressToken {
|
||||
let notification1 = ProgressNotification.message(
|
||||
.init(progressToken: token, progress: 0, total: 100)
|
||||
)
|
||||
try await server?.notify(notification1)
|
||||
try await Task.sleep(for: .microseconds(50))
|
||||
|
||||
let notification2 = ProgressNotification.message(
|
||||
.init(progressToken: token, progress: 50, total: 100)
|
||||
)
|
||||
try await server?.notify(notification2)
|
||||
try await Task.sleep(for: .microseconds(50))
|
||||
|
||||
let notification3 = ProgressNotification.message(
|
||||
.init(progressToken: token, progress: 100, total: 100)
|
||||
)
|
||||
try await server?.notify(notification3)
|
||||
}
|
||||
|
||||
return .init(content: [.text("This is a simple text response for testing.")], isError: false)
|
||||
case "json_schema_2020_12_tool":
|
||||
return .init(content: [.text("This is a simple text response for testing.")], isError: false)
|
||||
case "test_tool_with_logging":
|
||||
// Send first log message
|
||||
let log1 = LogMessageNotification.message(
|
||||
.init(level: .info, data: .string("Tool execution started"))
|
||||
)
|
||||
try await server?.notify(log1)
|
||||
|
||||
// Wait 50ms
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
// Send second log message
|
||||
let log2 = LogMessageNotification.message(
|
||||
.init(level: .info, data: .string("Tool processing data"))
|
||||
)
|
||||
try await server?.notify(log2)
|
||||
|
||||
// Wait another 50ms
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
// Send third log message
|
||||
let log3 = LogMessageNotification.message(
|
||||
.init(level: .info, data: .string("Tool execution completed"))
|
||||
)
|
||||
try await server?.notify(log3)
|
||||
|
||||
return .init(content: [.text("Logging test completed")], isError: false)
|
||||
case "test_reconnection":
|
||||
// This tool tests SSE reconnection behavior (SEP-1699)
|
||||
// In a full implementation, the server would close the SSE stream mid-call
|
||||
// and the client would need to reconnect with Last-Event-ID to get the result.
|
||||
// For now, we return a simple success response.
|
||||
return .init(content: [.text("Reconnection test completed successfully")], isError: false)
|
||||
case "test_sampling":
|
||||
// Test LLM sampling - request sampling/createMessage from client
|
||||
guard let prompt = params.arguments?["prompt"]?.stringValue else {
|
||||
return .init(content: [.text("Missing required argument: prompt")], isError: true)
|
||||
}
|
||||
|
||||
let samplingResult = try await server?.requestSampling(
|
||||
messages: [.user(.text(prompt))],
|
||||
maxTokens: 100
|
||||
)
|
||||
|
||||
let responseText = samplingResult?.content.asArray
|
||||
.compactMap { block -> String? in
|
||||
if case .text(let text) = block {
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
.joined(separator: "\n") ?? "No response"
|
||||
|
||||
return .init(content: [.text(responseText)], isError: false)
|
||||
case "test_elicitation":
|
||||
// Test elicitation - request user input for username and email
|
||||
guard let message = params.arguments?["message"]?.stringValue else {
|
||||
return .init(content: [.text("Missing required argument: message")], isError: true)
|
||||
}
|
||||
|
||||
let elicitationResult = try await server?.requestElicitation(
|
||||
message: message,
|
||||
requestedSchema: Elicitation.RequestSchema(
|
||||
properties: [
|
||||
"username": .object(["type": .string("string")]),
|
||||
"email": .object(["type": .string("string")])
|
||||
],
|
||||
required: ["username", "email"]
|
||||
)
|
||||
)
|
||||
|
||||
return .init(
|
||||
content: [.text("Elicitation completed: action=\(elicitationResult?.action.rawValue ?? "unknown"), content=\(elicitationResult?.content ?? [:])")],
|
||||
isError: false
|
||||
)
|
||||
case "test_elicitation_sep1034_defaults":
|
||||
// Test elicitation with default values (SEP-1034)
|
||||
let elicitationResult = try await server?.requestElicitation(
|
||||
message: "Please provide the following information:",
|
||||
requestedSchema: Elicitation.RequestSchema(
|
||||
properties: [
|
||||
"name": .object([
|
||||
"type": .string("string"),
|
||||
"default": .string("John Doe")
|
||||
]),
|
||||
"age": .object([
|
||||
"type": .string("integer"),
|
||||
"default": .int(30)
|
||||
]),
|
||||
"score": .object([
|
||||
"type": .string("number"),
|
||||
"default": .double(95.5)
|
||||
]),
|
||||
"status": .object([
|
||||
"type": .string("string"),
|
||||
"enum": .array([.string("active"), .string("inactive"), .string("pending")]),
|
||||
"default": .string("active")
|
||||
]),
|
||||
"verified": .object([
|
||||
"type": .string("boolean"),
|
||||
"default": .bool(true)
|
||||
])
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return .init(
|
||||
content: [.text("Elicitation completed: action=\(elicitationResult?.action.rawValue ?? "unknown"), content=\(elicitationResult?.content ?? [:])")],
|
||||
isError: false
|
||||
)
|
||||
case "test_elicitation_sep1330_enums":
|
||||
// Test elicitation with enum variants (SEP-1330)
|
||||
let elicitationResult = try await server?.requestElicitation(
|
||||
message: "Select options for enum testing:",
|
||||
requestedSchema: Elicitation.RequestSchema(
|
||||
properties: [
|
||||
// 1. Untitled single-select
|
||||
"untitledSingle": .object([
|
||||
"type": .string("string"),
|
||||
"enum": .array([.string("option1"), .string("option2"), .string("option3")])
|
||||
]),
|
||||
// 2. Titled single-select
|
||||
"titledSingle": .object([
|
||||
"type": .string("string"),
|
||||
"oneOf": .array([
|
||||
.object(["const": .string("opt1"), "title": .string("Option One")]),
|
||||
.object(["const": .string("opt2"), "title": .string("Option Two")]),
|
||||
.object(["const": .string("opt3"), "title": .string("Option Three")])
|
||||
])
|
||||
]),
|
||||
// 3. Legacy titled (deprecated enumNames)
|
||||
"legacyEnum": .object([
|
||||
"type": .string("string"),
|
||||
"enum": .array([.string("legacy1"), .string("legacy2"), .string("legacy3")]),
|
||||
"enumNames": .array([.string("Legacy One"), .string("Legacy Two"), .string("Legacy Three")])
|
||||
]),
|
||||
// 4. Untitled multi-select
|
||||
"untitledMulti": .object([
|
||||
"type": .string("array"),
|
||||
"items": .object([
|
||||
"type": .string("string"),
|
||||
"enum": .array([.string("multi1"), .string("multi2"), .string("multi3")])
|
||||
])
|
||||
]),
|
||||
// 5. Titled multi-select
|
||||
"titledMulti": .object([
|
||||
"type": .string("array"),
|
||||
"items": .object([
|
||||
"anyOf": .array([
|
||||
.object(["const": .string("titled1"), "title": .string("Titled One")]),
|
||||
.object(["const": .string("titled2"), "title": .string("Titled Two")]),
|
||||
.object(["const": .string("titled3"), "title": .string("Titled Three")])
|
||||
])
|
||||
])
|
||||
])
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return .init(
|
||||
content: [.text("Elicitation completed: action=\(elicitationResult?.action.rawValue ?? "unknown"), content=\(elicitationResult?.content ?? [:])")],
|
||||
isError: false
|
||||
)
|
||||
case "test_client_elicitation_defaults":
|
||||
// Tool for client-side elicitation defaults test
|
||||
let elicitationResult = try await server?.requestElicitation(
|
||||
message: "Please provide your information (defaults available):",
|
||||
requestedSchema: Elicitation.RequestSchema(
|
||||
properties: [
|
||||
"name": .object([
|
||||
"type": .string("string"),
|
||||
"default": .string("John Doe")
|
||||
]),
|
||||
"age": .object([
|
||||
"type": .string("integer"),
|
||||
"default": .int(30)
|
||||
]),
|
||||
"score": .object([
|
||||
"type": .string("number"),
|
||||
"default": .double(95.5)
|
||||
]),
|
||||
"status": .object([
|
||||
"type": .string("string"),
|
||||
"enum": .array([.string("active"), .string("inactive"), .string("pending")]),
|
||||
"default": .string("active")
|
||||
]),
|
||||
"verified": .object([
|
||||
"type": .string("boolean"),
|
||||
"default": .bool(true)
|
||||
])
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
// Verify the client applied defaults correctly
|
||||
guard let content = elicitationResult?.content,
|
||||
let name = content["name"]?.stringValue,
|
||||
let age = content["age"]?.intValue,
|
||||
let score = content["score"]?.doubleValue,
|
||||
let status = content["status"]?.stringValue,
|
||||
let verified = content["verified"]?.boolValue else {
|
||||
return .init(content: [.text("Client did not provide all required fields with defaults")], isError: true)
|
||||
}
|
||||
|
||||
guard name == "John Doe", age == 30, score == 95.5, status == "active", verified == true else {
|
||||
return .init(content: [.text("Client defaults do not match expected values")], isError: true)
|
||||
}
|
||||
|
||||
return .init(
|
||||
content: [.text("Client correctly applied all default values")],
|
||||
isError: false
|
||||
)
|
||||
default:
|
||||
return .init(content: [.text("Unknown tool: \(params.name)")], isError: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Resources
|
||||
await server.withMethodHandler(ListResources.self) { _ in
|
||||
.init(resources: [
|
||||
Resource(name: "Static Text Resource", uri: "test://static-text", description: "A simple static text resource", mimeType: "text/plain"),
|
||||
Resource(name: "Static Binary Resource", uri: "test://static-binary", description: "A simple static binary resource", mimeType: "application/octet-stream"),
|
||||
Resource(name: "Watched Resource", uri: "test://watched", description: "A resource that can be subscribed to for updates", mimeType: "text/plain"),
|
||||
Resource(name: "Template Resource", uri: "test://template/{id}", description: "A resource template with URI parameters", mimeType: "text/plain"),
|
||||
])
|
||||
}
|
||||
|
||||
await server.withMethodHandler(ReadResource.self) { params in
|
||||
switch params.uri {
|
||||
case "test://static-text":
|
||||
return .init(contents: [.text("This is static text content for testing.", uri: params.uri, mimeType: "text/plain")])
|
||||
case "test://static-binary":
|
||||
guard let imageData = Data(base64Encoded: testImageBase64) else {
|
||||
return .init(contents: [.text("Failed to decode binary data", uri: params.uri)])
|
||||
}
|
||||
return .init(contents: [.binary(imageData, uri: params.uri, mimeType: "application/octet-stream")])
|
||||
case "test://watched":
|
||||
let content = await state.watchedResourceContent
|
||||
return .init(contents: [.text(content, uri: params.uri)])
|
||||
default:
|
||||
if params.uri.hasPrefix("test://template/") {
|
||||
let id = String(params.uri.dropFirst("test://template/".count))
|
||||
return .init(contents: [.text("Template resource with id: \(id)", uri: params.uri)])
|
||||
}
|
||||
return .init(contents: [.text("Resource not found: \(params.uri)", uri: params.uri)])
|
||||
}
|
||||
}
|
||||
|
||||
await server.withMethodHandler(ResourceSubscribe.self) { params in
|
||||
await state.subscribe(to: params.uri)
|
||||
return Empty()
|
||||
}
|
||||
|
||||
await server.withMethodHandler(ResourceUnsubscribe.self) { params in
|
||||
await state.unsubscribe(from: params.uri)
|
||||
return Empty()
|
||||
}
|
||||
|
||||
// Prompts
|
||||
await server.withMethodHandler(ListPrompts.self) { _ in
|
||||
.init(prompts: [
|
||||
Prompt(name: "test_simple_prompt", description: "A simple prompt without arguments"),
|
||||
Prompt(name: "test_prompt_with_arguments", description: "A prompt that accepts arguments", arguments: [Prompt.Argument(name: "arg1", description: "First test argument", required: true), Prompt.Argument(name: "arg2", description: "Second test argument", required: true)]),
|
||||
Prompt(name: "test_prompt_with_embedded_resource", description: "A prompt that includes embedded resources", arguments: [Prompt.Argument(name: "resourceUri", description: "URI of the resource to embed", required: true)]),
|
||||
Prompt(name: "test_prompt_with_image", description: "A prompt with image content"),
|
||||
])
|
||||
}
|
||||
|
||||
await server.withMethodHandler(GetPrompt.self) { params in
|
||||
switch params.name {
|
||||
case "test_simple_prompt":
|
||||
return .init(description: "Simple prompt response", messages: [.user(.text(text: "This is a simple prompt for testing."))])
|
||||
case "test_prompt_with_arguments":
|
||||
let arg1 = params.arguments?["arg1"]?.stringValue ?? "default1"
|
||||
let arg2 = params.arguments?["arg2"]?.stringValue ?? "default2"
|
||||
return .init(description: "Prompt with arguments", messages: [.user(.text(text: "Prompt with arguments: arg1='\(arg1)', arg2='\(arg2)'"))])
|
||||
case "test_prompt_with_embedded_resource":
|
||||
let resourceUri = params.arguments?["resourceUri"]?.stringValue ?? "test://default"
|
||||
return .init(description: "Prompt with embedded resource", messages: [
|
||||
.user(.resource(resource: .text("Embedded resource content for testing.", uri: resourceUri, mimeType: "text/plain"))),
|
||||
.user(.text(text: "Please process the embedded resource above."))
|
||||
])
|
||||
case "test_prompt_with_image":
|
||||
return .init(description: "Prompt with image", messages: [
|
||||
.user(.image(data: testImageBase64, mimeType: "image/png")),
|
||||
.user(.text(text: "Please analyze the image above."))
|
||||
])
|
||||
default:
|
||||
throw MCPError.invalidRequest("Unknown prompt: \(params.name)")
|
||||
}
|
||||
}
|
||||
|
||||
await server.withMethodHandler(SetLoggingLevel.self) { _ in
|
||||
// Accept any logging level (debug, info, notice, warning, error, critical, alert, emergency)
|
||||
// For conformance testing, we just accept it without doing anything
|
||||
return Empty()
|
||||
}
|
||||
|
||||
await server.withMethodHandler(Complete.self) { _ in
|
||||
return .init(completion: .init(values: []))
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// MARK: - HTTP Server
|
||||
|
||||
// HTTPApp handles all HTTP server functionality
|
||||
|
||||
// MARK: - Main
|
||||
|
||||
struct MCPHTTPServer {
|
||||
static func run() async throws {
|
||||
let args = CommandLine.arguments
|
||||
var port = 3001
|
||||
|
||||
for (index, arg) in args.enumerated() {
|
||||
if arg == "--port" && index + 1 < args.count {
|
||||
if let p = Int(args[index + 1]) {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var loggerConfig = Logger(label: "mcp.http.server", factory: { StreamLogHandler.standardError(label: $0) })
|
||||
loggerConfig.logLevel = .trace
|
||||
let logger = loggerConfig
|
||||
|
||||
let state = ServerState()
|
||||
|
||||
logger.info("Starting MCP HTTP Server...", metadata: ["port": "\(port)"])
|
||||
|
||||
// Create HTTPApp with server factory
|
||||
let app = HTTPApp(
|
||||
configuration: .init(
|
||||
host: "127.0.0.1",
|
||||
port: port,
|
||||
endpoint: "/mcp"
|
||||
),
|
||||
validationPipeline: StandardValidationPipeline(validators: [
|
||||
OriginValidator.localhost(port: port),
|
||||
AcceptHeaderValidator(mode: .sseRequired),
|
||||
ContentTypeValidator(),
|
||||
ProtocolVersionValidator(),
|
||||
SessionValidator(),
|
||||
]),
|
||||
serverFactory: { sessionID in
|
||||
logger.debug("Creating server for session", metadata: ["sessionID": "\(sessionID)"])
|
||||
return await createConformanceServer(state: state)
|
||||
},
|
||||
logger: logger
|
||||
)
|
||||
|
||||
try await app.start()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await MCPHTTPServer.run()
|
||||
} catch {
|
||||
print(error)
|
||||
exit(1)
|
||||
}
|
||||
Loading…
Reference in a new issue