MCP

Connect Model Context Protocol servers and bridge their tools into the loop.

The Model Context Protocol (MCP) is a standard way for servers to expose tools to a model. MCPClient connects to an MCP server, lists its tools, and turns each one into an ordinary tool you pass to generateText or streamText. The bridged tools validate arguments against the server's schemas and run in the same agent loop as your local tools.

Quick start

Point a client at a transport, connect, and hand tools() to a generation call. This uses DeepWiki, a public MCP server that answers questions about GitHub repositories and needs no key, so it runs as-is:

let mcp = MCPClient(transport: MCPHTTPTransport(
  url: URL(string: "https://mcp.deepwiki.com/mcp")!
))
try await mcp.connect()
defer { Task { await mcp.close() } }

let result = try await generateText(
  model: AnthropicModel("claude-sonnet-5"),
  prompt: "Use the DeepWiki tools to explain how the modelcontextprotocol/modelcontextprotocol repository is organized.",
  tools: try await mcp.tools(),
  stopWhen: [stepCountIs(5)]
)

tools() follows nextCursor pagination, so servers that page their tool list are fully enumerated in one call. connect() runs the initialize handshake once and is safe to call again; close() releases the transport (and, for stdio, terminates the subprocess).

Transports

The client is transport-agnostic. Any MCPTransport works; the SDK ships three.

Streamable HTTP

MCPHTTPTransport is the default. It speaks JSON-RPC over POST with SSE or JSON responses, carries mcp-session-id sessions, and takes custom headers for auth:

MCPHTTPTransport(
  url: URL(string: "https://mcp.example.com/mcp")!,
  headers: ["Authorization": "Bearer \(token)"]
)

Stdio

MCPStdioTransport (macOS and Linux) launches a local server as a subprocess and speaks newline-delimited JSON-RPC over its stdin and stdout. A requestTimeout (default 60 seconds) bounds each call, so a hung or silent server fails instead of blocking forever:

let mcp = MCPClient(transport: MCPStdioTransport(
  command: "npx",
  arguments: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
  requestTimeout: 30
))
try await mcp.connect()
defer { Task { await mcp.close() } }

Pass environment: to override variables for the child process and workingDirectory: to set its launch directory.

Legacy HTTP+SSE

MCPSSETransport speaks the older HTTP+SSE protocol: a long-lived GET event stream for server messages plus a POST endpoint the server advertises for requests. Reach for it only with servers that predate Streamable HTTP. It takes the same headers and requestTimeout options as the HTTP transport:

MCPSSETransport(url: URL(string: "https://legacy.example.com/sse")!)

OAuth

Hosted MCP servers usually sit behind OAuth rather than a static bearer token. Hand a transport an MCPOAuthSession and it attaches the access token, refreshes it when the server answers 401, and tells you when a browser round-trip is needed. Both MCPHTTPTransport and MCPSSETransport take an auth: argument:

let serverURL = URL(string: "https://mcp.notion.com/mcp")!
let auth = MCPOAuthSession(serverURL: serverURL, provider: myProvider)
let mcp = MCPClient(transport: MCPHTTPTransport(url: serverURL, auth: auth))

do {
  let tools = try await mcp.tools()
} catch AIError.authorizationRequired(let url) {
  // open `url`, then hand back the URL the browser was redirected to:
  try await auth.complete(callbackURL: redirected)
}

myProvider is your MCPOAuthClientProvider: it owns the redirect URI, the client metadata used for dynamic client registration, and storage for tokens, the registered client, the PKCE verifier, and the CSRF state. Persist tokens and client information if you want sign-in to survive a relaunch.

The session performs the full MCP discovery chain on demand: the 401's WWW-Authenticate header names the protected-resource metadata document, which names the authorization server — commonly a different host than the MCP server itself. Authorization uses PKCE (S256), sends the RFC 8707 resource indicator on the authorization, token, and refresh requests, and verifies state on the callback before exchanging the code. When the authorization server advertises a registration_endpoint, the client registers itself dynamically the first time; otherwise seed your provider with credentials you obtained out of band.

MCPOAuthFlow exposes the same steps individually — discover, registerClientIfNeeded, startAuthorization, handleCallback, refresh — if you want to drive the flow yourself.

Detecting tool-definition drift (rug pull)

A compromised or malicious MCP server can change its tool definitions after you approved them, injecting instructions into a description or widening an input schema to capture more data. Fingerprint the tools you approved, then compare on every later fetch:

let baseline = fingerprintTools(try await mcp.tools())
// store baseline, then on a later fetch:
let drift = detectToolDrift(fingerprintTools(try await mcp.tools()), baseline: baseline)
if drift.hasDrift {
  // drift.changed / drift.added / drift.removed
  // block or force re-approval; do not silently pass mutated tools to the model
}

Your app owns baseline storage and decides the response. A fingerprint covers each tool's description and resolved input schema and is independent of JSON key order, so a server reordering schema keys does not read as drift.

Examples

See 27-MCPTools.swift for runnable HTTP, stdio, legacy SSE, OAuth, and rug-pull examples.