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.
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])
}
}
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.
Next, write the client and map listed tools into Foundation Models.
Key concepts
- An
MCPServeractor handles initialize, list, and call. - Each tool has a name, description, input schema, and async handler.
tools/callreturns content withisError.- 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.