The client has two jobs. It sends JSON-RPC to the server. It also presents each listed MCP tool as a Foundation Models Tool so LanguageModelSession can call it.
A compiled Tool uses @Generable for arguments. MCP tools arrive at runtime, so you cannot use that macro for each one. Apple's runtime path is DynamicGenerationSchema. Build a schema from the MCP inputSchema, then wrap tools/call in call(arguments:).
import FoundationModels
actor MCPClient {
private let server: MCPServer
private var nextID = 1
init(server: MCPServer) {
self.server = server
}
func listTools() async throws -> [[String: Any]] {
let response = await server.handle([
"jsonrpc": "2.0",
"id": nextID,
"method": "tools/list",
"params": [:]
])
nextID += 1
let result = response["result"] as? [String: Any]
return result?["tools"] as? [[String: Any]] ?? []
}
func call(name: String, arguments: [String: Any]) async throws -> String {
let response = await server.handle([
"jsonrpc": "2.0",
"id": nextID,
"method": "tools/call",
"params": ["name": name, "arguments": arguments]
])
nextID += 1
return readToolText(response)
}
}
struct MCPBridgedTool: Tool {
let name: String
let description: String
let client: MCPClient
var parameters: GenerationSchema
func call(arguments: GeneratedContent) async throws -> String {
let values = try dictionary(from: arguments)
return try await client.call(name: name, arguments: values)
}
}
func makeSession(client: MCPClient) async throws -> LanguageModelSession {
let listed = try await client.listTools()
let tools: [any Tool] = try listed.map { item in
MCPBridgedTool(
name: item["name"] as? String ?? "unnamed",
description: item["description"] as? String ?? "",
client: client,
parameters: try schema(from: item["inputSchema"])
)
}
return LanguageModelSession(
model: SystemLanguageModel.default,
tools: tools,
instructions: "Use tools when you need live app data. Do not invent tool names."
)
}
schema(from:) is your adapter around DynamicGenerationSchema. Confirm the initializer names in the Xcode 27 docs. If a listed tool has no schema, pass an empty object schema so the model can still call it with no arguments.
Create the session after tools/list. If the server later adds a tool, make a new session or a Dynamic Profile that rebuilds the tool list. Do not mutate tools on a session that is responding. See WWDC26 session 242 for profile and tool-list changes.
Read Run tools in a safe box, Stop prompt attacks and leaks, and Core AI vs Core ML vs MLX before you let a bridged tool touch files, network, or private text.
session.respond(to:). The bridge is the only new type between the model and MCP.Next, add one new server tool and prove the chat loop does not change.
Key concepts
- The client sends JSON-RPC to the server.
- A bridged
Toolwraps each listed MCP tool with a runtime schema. - MCP tools use a dynamic schema from MCP JSON Schema, not
@Generable. - Create the session after
tools/list.
Takeaways
- Refresh or rebuild the session if the server adds tools later.
- Do not mutate tools on a session that is responding.
- Confirm dynamic schema initializer names in the Xcode 27 docs.