← All courses ← Course home

Lesson 04 of 05

Map MCP tools into Foundation Models

List tools over JSON-RPC, wrap each one as a Foundation Models Tool with a runtime schema, and keep session.respond as the chat loop.

Agent brief (llms.md)

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.

tools/listJSON tools
BridgeDynamic Tool
SessionSame respond loop

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.

Rule. The chat loop stays 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 Tool wraps 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.