← All courses ← Course home

Lesson 03 of 05

Build a small on-device MCP server

Write a Swift actor that answers initialize, tools/list, and tools/call. Keep Foundation Models out of the server.

Agent brief (llms.md)

An on-device MCP server is a Swift type that reads a JSON-RPC request and writes a JSON-RPC result. Keep it an actor so calls do not overlap. Register each tool as a name, a description, a JSON Schema for arguments, and an async function.

RegistryName and schema
ActorOne request at a time
HandlerJSON in, JSON out
import Foundation

struct MCPTool: Sendable {
    let name: String
    let description: String
    let inputSchema: [String: Any]
    let run: @Sendable ([String: Any]) async throws -> [String: Any]
}

actor MCPServer {
    private var tools: [String: MCPTool] = [:]

    func register(_ tool: MCPTool) {
        tools[tool.name] = tool
    }

    func handle(_ request: [String: Any]) async -> [String: Any] {
        let id = request["id"]
        let method = request["method"] as? String ?? ""
        switch method {
        case "initialize":
            return ok(id, [
                "protocolVersion": "2025-03-26",
                "capabilities": ["tools": ["listChanged": false]],
                "serverInfo": ["name": "on-device-mcp", "version": "1.0"]
            ])
        case "tools/list":
            let listed = tools.values.map {
                [
                    "name": $0.name,
                    "description": $0.description,
                    "inputSchema": $0.inputSchema
                ]
            }
            return ok(id, ["tools": listed])
        case "tools/call":
            return await callTool(id: id, params: request["params"] as? [String: Any] ?? [:])
        default:
            return fail(id, code: -32601, message: "Method not found")
        }
    }
}

For tools/call, read params.name and params.arguments. If the name is missing, return JSON-RPC error -32602. If the handler throws, return a tool result with isError set to true, or a JSON-RPC error. Pick one rule and keep it.

private func callTool(id: Any?, params: [String: Any]) async -> [String: Any] {
    guard let name = params["name"] as? String, let tool = tools[name] else {
        return fail(id, code: -32602, message: "Unknown tool")
    }
    let arguments = params["arguments"] as? [String: Any] ?? [:]
    do {
        let payload = try await tool.run(arguments)
        let text = String(data: try JSONSerialization.data(withJSONObject: payload), encoding: .utf8) ?? "{}"
        return ok(id, ["content": [["type": "text", "text": text]], "isError": false])
    } catch {
        return ok(id, ["content": [["type": "text", "text": error.localizedDescription]], "isError": true])
    }
}
JSON requestname and arguments
RegistryFind the handler
JSON resultcontent and isError

On iOS 27, prefer this in-process actor. iOS does not give you a general stdio child process for an MCP binary. A macOS helper can use XPC if you want a second process. That isolation is the next course, Run tools in a safe box.

Rule. The server only speaks JSON-RPC. It does not import Foundation Models. The chat loop stays in the client.

Next, write the client and map listed tools into Foundation Models.

Key concepts

  • An MCPServer actor handles initialize, list, and call.
  • Each tool has a name, description, input schema, and async handler.
  • tools/call returns content with isError.
  • The server speaks JSON-RPC only. It does not import Foundation Models.

Takeaways

  • Keep the server as an actor so calls do not overlap.
  • Return a JSON-RPC error for an unknown tool and keep that rule.
  • On iOS 27 prefer an in-process actor. macOS can use XPC.