From 32cf6cb7e6e28bdde737b0a2e17507d47dd06aed Mon Sep 17 00:00:00 2001 From: Mattt Date: Tue, 6 May 2025 09:24:20 -0700 Subject: [PATCH] Expand README with more information about client and server usage (#99) * Expand README with more information about client and server usage * Add discussion of initialize hook for server * Add section about request batching * Trim trailing whitespace * Rename section to Request Batching --- README.md | 736 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 641 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index e48732c..e946368 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # MCP Swift SDK -Swift implementation of the [Model Context Protocol][mcp] (MCP). +Official Swift SDK for the [Model Context Protocol][mcp] (MCP). + +## Overview + +The Model Context Protocol (MCP) defines a standardized way +for applications to communicate with AI and ML models. +This Swift SDK provides a robust implementation of both client and server components. ## Requirements - Swift 6.0+ (Xcode 16+) -See the [Platform Availability](#platform-availability) section below -for more information about platform requirements. +See the [Platform Availability](#platform-availability) section below +for platform-specific requirements. ## Installation @@ -21,7 +27,20 @@ dependencies: [ ] ``` -## Usage +Then add the dependency to your target: + +```swift +.target( + name: "YourTarget", + dependencies: [ + .product(name: "MCP", package: "swift-sdk") + ] +) +``` + +## Client Usage + +The client component allows your application to connect to MCP servers. ### Basic Client Setup @@ -37,31 +56,262 @@ try await client.connect(transport: transport) // Initialize the connection let result = try await client.initialize() + +// Check server capabilities +if result.capabilities.tools != nil { + // Server supports tools (implicitly including tool calling if the 'tools' capability object is present) +} ``` -### Streaming HTTP Transport +### Transport Options for Clients -The HTTP transport supports streaming mode for real-time communication using Server-Sent Events (SSE): +#### Stdio Transport + +For local subprocess communication: ```swift -import MCP +// Create a stdio transport (simplest option) +let transport = StdioTransport() +try await client.connect(transport: transport) +``` +#### HTTP Transport + +For remote server communication: + +```swift // Create a streaming HTTP transport let transport = HTTPClientTransport( endpoint: URL(string: "http://localhost:8080")!, + streaming: true // Enable Server-Sent Events for real-time updates +) +try await client.connect(transport: transport) +``` + +### Tools + +Tools represent functions that can be called by the client: + +```swift +// List available tools +let tools = try await client.listTools() +print("Available tools: \(tools.map { $0.name }.joined(separator: ", "))") + +// Call a tool with arguments +let (content, isError) = try await client.callTool( + name: "image-generator", + arguments: [ + "prompt": "A serene mountain landscape at sunset", + "style": "photorealistic", + "width": 1024, + "height": 768 + ] ) -// Initialize the client with streaming transport -let client = Client(name: "MyApp", version: "1.0.0") -try await client.connect(transport: transport) - -// Initialize the connection -let result = try await client.initialize() - -// The transport will automatically handle SSE events -// and deliver them through the client's notification handlers +// Handle tool content +for item in content { + switch item { + case .text(let text): + print("Generated text: \(text)") + case .image(let data, let mimeType, let metadata): + if let width = metadata?["width"] as? Int, + let height = metadata?["height"] as? Int { + print("Generated \(width)x\(height) image of type \(mimeType)") + // Save or display the image data + } + case .audio(let data, let mimeType): + print("Received audio data of type \(mimeType)") + case .resource(let uri, let mimeType, let text): + print("Received resource from \(uri) of type \(mimeType)") + if let text = text { + print("Resource text: \(text)") + } + } +} ``` +### Resources + +Resources represent data that can be accessed and potentially subscribed to: + +```swift +// List available resources +let (resources, nextCursor) = try await client.listResources() +print("Available resources: \(resources.map { $0.uri }.joined(separator: ", "))") + +// Read a resource +let contents = try await client.readResource(uri: "resource://example") +print("Resource content: \(contents)") + +// Subscribe to resource updates if supported +if result.capabilities.resources.subscribe { + try await client.subscribeToResource(uri: "resource://example") + + // Register notification handler + await client.onNotification(ResourceUpdatedNotification.self) { message in + let uri = message.params.uri + print("Resource \(uri) updated with new content") + + // Fetch the updated resource content + let updatedContents = try await client.readResource(uri: uri) + print("Updated resource content received") + } +} +``` + +### Prompts + +Prompts represent templated conversation starters: + +```swift +// List available prompts +let (prompts, nextCursor) = try await client.listPrompts() +print("Available prompts: \(prompts.map { $0.name }.joined(separator: ", "))") + +// Get a prompt with arguments +let (description, messages) = try await client.getPrompt( + name: "customer-service", + arguments: [ + "customerName": "Alice", + "orderNumber": "ORD-12345", + "issue": "delivery delay" + ] +) + +// Use the prompt messages in your application +print("Prompt description: \(description)") +for message in messages { + if case .text(text: let text) = message.content { + print("\(message.role): \(text)") + } +} +``` + +### Error Handling + +Handle common client errors: + +```swift +do { + let result = try await client.initialize() + // Success +} catch let error as MCPError { + print("MCP Error: \(error.localizedDescription)") +} catch { + print("Unexpected error: \(error)") +} +``` + +### Advanced Client Features + +#### Strict vs Non-Strict Configuration + +Configure client behavior for capability checking: + +```swift +// Strict configuration - fail fast if a capability is missing +let strictClient = Client( + name: "StrictClient", + version: "1.0.0", + configuration: .strict +) + +// With strict configuration, calling a method for an unsupported capability +// will throw an error immediately without sending a request +do { + // This will throw an error if resources.list capability is not available + let resources = try await strictClient.listResources() +} catch let error as MCPError { + print("Capability not available: \(error.localizedDescription)") +} + +// Default (non-strict) configuration - attempt the request anyway +let client = Client( + name: "FlexibleClient", + version: "1.0.0", + configuration: .default +) + +// With default configuration, the client will attempt the request +// even if the capability wasn't advertised by the server +do { + let resources = try await client.listResources() +} catch let error as MCPError { + // Still handle the error if the server rejects the request + print("Server rejected request: \(error.localizedDescription)") +} +``` + +#### Request Batching + +Improve performance by sending multiple requests in a single batch: + +```swift +// Array to hold tool call tasks +var toolTasks: [Task] = [] + +// Send a batch of requests +try await client.withBatch { batch in + // Add multiple tool calls to the batch + for i in 0..<10 { + toolTasks.append( + try await batch.addRequest( + CallTool.request(.init(name: "square", arguments: ["n": i])) + ) + ) + } +} + +// Process results after the batch is sent +print("Processing \(toolTasks.count) tool results...") +for (index, task) in toolTasks.enumerated() { + do { + let result = try await task.value + print("\(index): \(result.content)") + } catch { + print("\(index) failed: \(error)") + } +} +``` + +You can also batch different types of requests: + +```swift +// Declare task variables +var pingTask: Task? +var promptTask: Task? + +// Send a batch with different request types +try await client.withBatch { batch in + pingTask = try await batch.addRequest(Ping.request()) + promptTask = try await batch.addRequest( + GetPrompt.request(.init(name: "greeting")) + ) +} + +// Process individual results +do { + if let pingTask = pingTask { + try await pingTask.value + print("Ping successful") + } + + if let promptTask = promptTask { + let promptResult = try await promptTask.value + print("Prompt: \(promptResult.description ?? "None")") + } +} catch { + print("Error processing batch results: \(error)") +} +``` + +> [!NOTE] +> `Server` automatically handles batch requests from MCP clients. + +## Server Usage + +The server component allows your application to host model capabilities and respond to client requests. + ### Basic Server Setup ```swift @@ -69,14 +319,12 @@ import MCP // Initialize the server with capabilities let server = Server( - name: "MyServer", + name: "MyModelServer", version: "1.0.0", capabilities: .init( - prompts: .init(), - resources: .init( - subscribe: true - ), - tools: .init() + prompts: .init(listChanged: true), + resources: .init(subscribe: true, listChanged: true), + tools: .init(listChanged: true) ) ) @@ -84,82 +332,365 @@ let server = Server( let transport = StdioTransport() try await server.start(transport: transport) -// Register method handlers -server.withMethodHandler(ReadResource.self) { params in - // Handle resource read request - let uri = params.uri - let content = [Resource.Content.text("Example content")] - return .init(contents: content) -} - -// Register notification handlers -server.onNotification(ResourceUpdatedNotification.self) { message in - // Handle resource update notification -} - -// Stop the server when done -await server.stop() +// Now register handlers for the capabilities you've enabled ``` -### Working with Tools +### Tools + +Register tool handlers to respond to client tool calls: ```swift -// List available tools -let tools = try await client.listTools() +// Register a tool list handler +server.withMethodHandler(ListTools.self) { _ in + let tools = [ + Tool( + name: "weather", + description: "Get current weather for a location", + inputSchema: .object([ + "location": .string("City name or coordinates"), + "units": .string("Units of measurement, e.g., metric, imperial") + ]) + ), + Tool( + name: "calculator", + description: "Perform calculations", + inputSchema: .object([ + "expression": .string("Mathematical expression to evaluate") + ]) + ) + ] + return .init(tools: tools) +} -// Call a tool -let (content, isError) = try await client.callTool( - name: "example-tool", - arguments: ["key": "value"] -) +// Register a tool call handler +server.withMethodHandler(CallTool.self) { params in + switch params.name { + case "weather": + let location = params.arguments?["location"]?.stringValue ?? "Unknown" + let units = params.arguments?["units"]?.stringValue ?? "metric" + let weatherData = getWeatherData(location: location, units: units) // Your implementation + return .init( + content: [.text("Weather for \(location): \(weatherData.temperature)°, \(weatherData.conditions)")], + isError: false + ) -// Handle tool content -for item in content { - switch item { - case .text(let text): - print(text) - case .image(let data, let mimeType, let metadata): - // Handle image data + case "calculator": + if let expression = params.arguments?["expression"]?.stringValue { + let result = evaluateExpression(expression) // Your implementation + return .init(content: [.text("\(result)")], isError: false) + } else { + return .init(content: [.text("Missing expression parameter")], isError: true) + } + + default: + return .init(content: [.text("Unknown tool")], isError: true) } } ``` -### Working with Resources +### Resources + +Implement resource handlers for data access: ```swift -// List available resources -let (resources, nextCursor) = try await client.listResources() +// Register a resource list handler +server.withMethodHandler(ListResources.self) { params in + let resources = [ + Resource( + uri: "resource://knowledge-base/articles", + name: "Knowledge Base Articles", + description: "Collection of support articles and documentation" + ), + Resource( + uri: "resource://system/status", + name: "System Status", + description: "Current system operational status" + ) + ] + return .init(resources: resources, nextCursor: nil) +} -// Read a resource -let contents = try await client.readResource(uri: "resource://example") +// Register a resource read handler +server.withMethodHandler(ReadResource.self) { params in + switch params.uri { + case "resource://knowledge-base/articles": + return .init(contents: [Resource.Content.text("# Knowledge Base\n\nThis is the content of the knowledge base...", uri: params.uri)]) -// Subscribe to resource updates -try await client.subscribeToResource(uri: "resource://example") + case "resource://system/status": + let status = getCurrentSystemStatus() // Your implementation + let statusJson = """ + { + "status": "\(status.overall)", + "components": { + "database": "\(status.database)", + "api": "\(status.api)", + "model": "\(status.model)" + }, + "lastUpdated": "\(status.timestamp)" + } + """ + return .init(contents: [Resource.Content.text(statusJson, uri: params.uri, mimeType: "application/json")]) -// Handle resource updates -await client.onNotification(ResourceUpdatedNotification.self) { message in - let uri = message.params.uri - let content = message.params.content - // Handle the update + default: + throw MCPError.invalidParams("Unknown resource URI: \(params.uri)") + } +} + +// Register a resource subscribe handler +server.withMethodHandler(SubscribeToResource.self) { params in + // Store subscription for later notifications. + // Client identity for multi-client scenarios needs to be managed by the server application, + // potentially using information from the initialize handshake if the server handles one client post-init. + // addSubscription(clientID: /* some_client_identifier */, uri: params.uri) + print("Client subscribed to \(params.uri). Server needs to implement logic to track this subscription.") + return .init() } ``` -### Working with Prompts +### Prompts + +Implement prompt handlers: ```swift -// List available prompts -let (prompts, nextCursor) = try await client.listPrompts() +// Register a prompt list handler +server.withMethodHandler(ListPrompts.self) { params in + let prompts = [ + Prompt( + name: "interview", + description: "Job interview conversation starter", + arguments: [ + .init(name: "position", description: "Job position", required: true), + .init(name: "company", description: "Company name", required: true), + .init(name: "interviewee", description: "Candidate name") + ] + ), + Prompt( + name: "customer-support", + description: "Customer support conversation starter", + arguments: [ + .init(name: "issue", description: "Customer issue", required: true), + .init(name: "product", description: "Product name", required: true) + ] + ) + ] + return .init(prompts: prompts, nextCursor: nil) +} -// Get a prompt with arguments -let (description, messages) = try await client.getPrompt( - name: "example-prompt", - arguments: ["key": "value"] +// Register a prompt get handler +server.withMethodHandler(GetPrompt.self) { params in + switch params.name { + case "interview": + let position = params.arguments?["position"]?.stringValue ?? "Software Engineer" + let company = params.arguments?["company"]?.stringValue ?? "Acme Corp" + let interviewee = params.arguments?["interviewee"]?.stringValue ?? "Candidate" + + let description = "Job interview for \(position) position at \(company)" + let messages: [Prompt.Message] = [ + .init(role: .user, content: .text(text: "You are an interviewer for the \(position) position at \(company).")), + .init(role: .user, content: .text(text: "Hello, I'm \(interviewee) and I'm here for the \(position) interview.")), + .init(role: .assistant, content: .text(text: "Hi \(interviewee), welcome to \(company)! I'd like to start by asking about your background and experience.")) + ] + + return .init(description: description, messages: messages) + + case "customer-support": + // Similar implementation for customer support prompt + + default: + throw MCPError.invalidParams("Unknown prompt name: \(params.name)") + } +} +``` + +#### Initialize Hook + +Control client connections with an initialize hook: + +```swift +// Start the server with an initialize hook +try await server.start(transport: transport) { clientInfo, clientCapabilities in + // Validate client info + guard clientInfo.name != "BlockedClient" else { + throw MCPError.invalidRequest("This client is not allowed") + } + + // You can also inspect client capabilities + if clientCapabilities.tools == nil { + print("Client does not support tools") + } + + // Perform any server-side setup based on client info + print("Client \(clientInfo.name) v\(clientInfo.version) connected") + + // If the hook completes without throwing, initialization succeeds +} +``` + +### Graceful Shutdown + +We recommend using +[Swift Service Lifecycle](https://github.com/swift-server/swift-service-lifecycle) +for managing startup and shutdown of services. + +First, add the dependency to your `Package.swift`: + +```swift +.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.3.0"), +``` + +Then implement the MCP server as a `Service`: + +```swift +import MCP +import ServiceLifecycle +import Logging + +struct MCPService: Service { + let server: Server + let transport: Transport + + init(server: Server, transport: Transport) { + self.server = server + self.transport = transport + } + + func run() async throws { + // Start the server + try await server.start(transport: transport) + + // Keep running until external cancellation + try await Task.sleep(for: .days(365 * 100)) // Effectively forever + } + + func shutdown() async throws { + // Gracefully shutdown the server + await server.stop() + } +} +``` + +Then use it in your application: + +```swift +import MCP +import ServiceLifecycle +import Logging + +let logger = Logger(label: "com.example.mcp-server") + +// Create the MCP server +let server = Server( + name: "MyModelServer", + version: "1.0.0", + capabilities: .init( + prompts: .init(listChanged: true), + resources: .init(subscribe: true, listChanged: true), + tools: .init(listChanged: true) + ), + logger: logger ) + +// Add handlers directly to the server +server.withMethodHandler(ListTools.self) { _ in + // Your implementation + return .init(tools: [ + Tool(name: "example", description: "An example tool") + ]) +} + +server.withMethodHandler(CallTool.self) { params in + // Your implementation + return .init(content: [.text("Tool result")], isError: false) +} + +// Create MCP service and other services +let transport = StdioTransport(logger: logger) +let mcpService = MCPService(server: server, transport: transport) +let databaseService = DatabaseService() // Your other services + +// Create service group with signal handling +let serviceGroup = ServiceGroup( + services: [mcpService, databaseService], + configuration: .init( + gracefulShutdownSignals: [.sigterm, .sigint] + ), + logger: logger +) + +// Run the service group - this blocks until shutdown +try await serviceGroup.run() +``` + +This approach has several benefits: + +- **Signal handling**: + Automatically traps SIGINT, SIGTERM and triggers graceful shutdown +- **Graceful shutdown**: + Properly shuts down your MCP server and other services +- **Timeout-based shutdown**: + Configurable shutdown timeouts to prevent hanging processes +- **Advanced service management**: + [`ServiceLifecycle`](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/documentation/servicelifecycle) + also supports service dependencies, conditional services, + and other useful features. + +## Transports + +MCP's transport layer handles communication between clients and servers. +The Swift SDK provides multiple built-in transports: + +| Transport | Description | Platforms | Best for | +|-----------|-------------|-----------|----------| +| [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift) | Implements [stdio transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) using standard input/output streams | Apple platforms, Linux with glibc | Local subprocesses, CLI tools | +| [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift) | Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) using Foundation's URL Loading System | All platforms with Foundation | Remote servers, web applications | +| [`NetworkTransport`](/Sources/MCP/Base/Transports/NetworkTransport.swift) | Custom transport using Apple's Network framework for TCP/UDP connections | Apple platforms only | Low-level networking, custom protocols | + +### Custom Transport Implementation + +You can implement a custom transport by conforming to the `Transport` protocol: + +```swift +import MCP +import Foundation + +public actor MyCustomTransport: Transport { + public nonisolated let logger: Logger + private var isConnected = false + private let messageStream: AsyncThrowingStream + private let messageContinuation: AsyncThrowingStream.Continuation + + public init(logger: Logger? = nil) { + self.logger = logger ?? Logger(label: "my.custom.transport") + + var continuation: AsyncThrowingStream.Continuation! + self.messageStream = AsyncThrowingStream { continuation = $0 } + self.messageContinuation = continuation + } + + public func connect() async throws { + // Implement your connection logic + isConnected = true + } + + public func disconnect() async { + // Implement your disconnection logic + isConnected = false + messageContinuation.finish() + } + + public func send(_ data: Data) async throws { + // Implement your message sending logic + } + + public func receive() -> AsyncThrowingStream { + return messageStream + } +} ``` ## Platform Availability -The Swift SDK has the following Apple platform requirements: +The Swift SDK has the following platform requirements: | Platform | Minimum Version | |----------|----------------| @@ -168,39 +699,54 @@ The Swift SDK has the following Apple platform requirements: | watchOS | 9.0+ | | tvOS | 16.0+ | | visionOS | 1.0+ | +| Linux | Distributions with `glibc` | -While the core library works on any platform supporting Swift 6 -(including Linux and Windows), -running a client or server requires a compatible transport. +While the core library works on any platform supporting Swift 6 +(including Linux and Windows), +running a client or server requires a compatible transport. -MCP's transport layer handles communication between clients and servers. -The Swift SDK provides multiple built-in transports: +We're actively working to expand platform support: +- [Alpine Linux support](https://github.com/modelcontextprotocol/swift-sdk/pull/64) +- [Windows support](https://github.com/modelcontextprotocol/swift-sdk/pull/64) -- [`StdioTransport`](/Sources/MCP/Base/Transports/StdioTransport.swift): - Implements [stdio](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) transport. - Available on Apple platforms - and Linux distributions with glibc (Ubuntu, Debian, Fedora, CentOS, RHEL). -- [`HTTPClientTransport`](/Sources/MCP/Base/Transports/HTTPClientTransport.swift): - Implements [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) - using Foundation [URL Loading System](https://developer.apple.com/documentation/foundation/url-loading-system) -- [`NetworkTransport`](/Sources/MCP/Base/Transports/NetworkTransport.swift): - Implements custom transport over TCP and UDP - using Apple's [Networking framework](https://developer.apple.com/documentation/network). - Available exclusively on Apple platforms. +## Debugging and Logging -You can implement a custom transport for platforms without built-in support. [^1] +Enable logging to help troubleshoot issues: -For custom transport implementation details, -see the [`Transport` protocol](/Sources/MCP/Base/Transport.swift). +```swift +import Logging +import MCP -[^1]: We're working to add stdio transport support for [Alpine Linux](https://github.com/modelcontextprotocol/swift-sdk/pull/64) and [Windows](https://github.com/modelcontextprotocol/swift-sdk/pull/64). If you're interested in either of those platforms, please take a look at their respective PRs and leave a comment! +// Configure Logger +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = .debug + return handler +} + +// Create logger +let logger = Logger(label: "com.example.mcp") + +// Pass to client/server +let client = Client(name: "MyApp", version: "1.0.0", logger: logger) + +// Pass to transport +let transport = StdioTransport(logger: logger) +``` + +## Additional Resources + +- [MCP Specification](https://modelcontextprotocol.io/specification/2025-03-26/) +- [Protocol Documentation](https://modelcontextprotocol.io) +- [GitHub Repository](https://github.com/modelcontextprotocol/swift-sdk) ## Changelog -This project follows [Semantic Versioning](https://semver.org/). -For pre-1.0 releases, minor version increments (0.X.0) may contain breaking changes. +This project follows [Semantic Versioning](https://semver.org/). +For pre-1.0 releases, +minor version increments (0.X.0) may contain breaking changes. -For details about changes in each release, +For details about changes in each release, see the [GitHub Releases page](https://github.com/modelcontextprotocol/swift-sdk/releases). ## License