
Connect Your App to MCP Servers with the AI SDK
AI SDK 6 made MCP support stable with @ai-sdk/mcp, including OAuth and resources. A hands-on guide to giving your LLM real tools through MCP.
Key takeaways
- AI SDK 6 makes MCP stable in the @ai-sdk/mcp package, adding OAuth, resources, prompts, and elicitation alongside generateText and streamText.
- Create a client with createMCPClient, call tools() to discover the server's schemas, then pass that object straight into generateText or streamText.
- The MCP client is a live connection you must close, ideally in the onFinish callback for streaming so it stays open only during generation.
- Pin tool schemas explicitly for production so a server adding or renaming a tool cannot silently change what your app hands the model.
When AI SDK 6 shipped, the Model Context Protocol support that had been living behind experimental flags finally graduated. MCP is now stable and lives in its own package, @ai-sdk/mcp. The release rolled in the pieces that were missing before: a full OAuth flow for secured servers, access to resources and prompts, and elicitation, which is the mechanism that lets a server pause and ask the user for input partway through a call.
That last sentence is the practical part. Before this, wiring an LLM to an external tool server meant either hand-rolling the protocol or treating MCP as a moving target. Now there is one documented way to do it, and it plugs straight into generateText and streamText. In this post I want to walk through that path end to end: spin up a client, point it at a server, pull the tools the server advertises, and let a model call them. I will also cover OAuth-protected servers, because most real servers you connect to in production will want auth.
I am assuming you already know roughly what MCP is (a standard way for a server to expose tools, resources, and prompts to an LLM). If not, the short version is that it lets you write the tool once on the server side and have any MCP-aware client use it, instead of redefining the same function in every app.
What you'll need
- Node.js 18 or later, and a TypeScript project.
- The AI SDK 6 core package plus the MCP package:
npm install ai @ai-sdk/mcp. - A model provider. The examples use Anthropic through the AI Gateway model string, so you can swap in whatever you already have configured.
- An MCP server to connect to. For local testing a stdio server works (you also need
@modelcontextprotocol/sdkfor that transport). For anything remote you want an HTTP server URL.
Step 1: create the client and connect over a transport
The entry point is createMCPClient. You give it a transport, which is just how the client talks to the server. For production the docs recommend HTTP. Here is the simplest version, a remote server reached over HTTP with a static bearer token in the header.
import { createMCPClient } from '@ai-sdk/mcp';
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: 'https://your-server.com/mcp',
headers: { Authorization: 'Bearer my-api-key' },
},
});
createMCPClient is async because it actually opens the connection and performs the protocol handshake. By the time the promise resolves, the client knows what the server offers.
If you are testing against something running on your own machine, stdio is the easier option. It spawns the server as a child process and talks to it over standard input and output, so there is no network or port to manage.
import { createMCPClient } from '@ai-sdk/mcp';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const mcpClient = await createMCPClient({
transport: new StdioClientTransport({
command: 'node',
args: ['src/stdio/dist/server.js'],
}),
});
There is also an SSE transport with the same shape as HTTP, useful for older servers that only speak server-sent events. For new work I would reach for HTTP first and only drop to SSE if a server forces my hand.
Step 2: discover the tools the server exposes
Once connected, ask the client what tools are available. The tools() method does the discovery and hands back tools already converted into the format the AI SDK expects.
const tools = await mcpClient.tools();
That is the whole thing for schema discovery. The client reads the schemas the server advertises, so you do not write them out yourself. This is the part that saves the most work, since the server is the single source of truth for what each tool takes and returns.
If you would rather pin the schemas in your own code (handy when you want compile-time types, or when you want to be strict about what you accept regardless of what the server claims), you can pass them in explicitly. Discovery is skipped and only the tools you name are exposed.
import { z } from 'zod';
const tools = await mcpClient.tools({
schemas: {
'get-data': {
inputSchema: z.object({
query: z.string().describe('The data query'),
format: z.enum(['json', 'text']).optional(),
}),
},
},
});
You can describe outputs too, which gives you typed results coming back from a tool call.
const tools = await mcpClient.tools({
schemas: {
'get-weather': {
inputSchema: z.object({ location: z.string() }),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
},
},
});
My rule of thumb: start with bare tools() while you are exploring a server, then switch to explicit schemas once you know which tools you actually want in production. The explicit form also means a server adding or renaming a tool will not silently change what your app hands to the model.
Step 3: hand the tools to the model
This is where it pays off. The object from tools() goes straight into generateText or streamText under the tools key. From the model's point of view these are ordinary tools; it does not know or care that they come from an MCP server.
import { streamText } from 'ai';
const result = await streamText({
model: 'anthropic/claude-sonnet-4.5',
tools,
prompt: 'What is the weather in Brooklyn, New York?',
});
The model decides when to call a tool, the SDK routes that call through the MCP client to the server, and the result comes back into the conversation. If the model needs several tools in sequence to answer, it can chain them.
One thing that trips people up: the MCP connection is a live resource and you have to close it. The clean place to do that with streaming is the onFinish callback, so the client stays open exactly as long as the generation runs.
const result = await streamText({
model: 'anthropic/claude-sonnet-4.5',
tools,
prompt: 'Your question',
onFinish: async () => {
await mcpClient.close();
},
});
With generateText you can just await the call and then close the client afterward, since the function resolves only once everything is done.
Step 4: connect to an OAuth-secured server
A static bearer token is fine for a server you control. Most real servers want a proper OAuth flow, and AI SDK 6 handles that for you. It manages the PKCE challenge, token refresh, and dynamic client registration, so you are not implementing the protocol by hand.
You provide an auth provider through the authProvider field on the transport. @ai-sdk/mcp exports the helpers for it.
import { createMCPClient, OAuthClientProvider, auth } from '@ai-sdk/mcp';
const authProvider: OAuthClientProvider = {
redirectUrl: 'https://your-app.com/callback',
clientMetadata: {
redirect_uris: ['https://your-app.com/callback'],
client_name: 'Your App',
},
tokens: async () => loadTokensFromYourStore(),
saveTokens: async (tokens) => persistTokensToYourStore(tokens),
// plus the redirect and code-exchange hooks the flow needs
};
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: 'https://your-server.com/mcp',
authProvider,
redirect: 'error',
},
});
The provider is the bridge between the SDK and wherever you keep tokens. The SDK calls tokens() when it needs the current credentials and saveTokens() after a refresh, so your job is mostly storage. On the very first connection, before any tokens exist, the flow sends the user through the authorization redirect; after that the saved tokens carry the session and get refreshed automatically.
Setting redirect: 'error' means the client throws instead of silently following a redirect, which is what you want when you are catching the authorization step yourself rather than letting it happen invisibly.
While we are here, the same client also reaches resources and prompts, which are the other two things a server can expose. Resources are readable data, and prompts are reusable templates the server defines.
const resources = await mcpClient.listResources();
const resourceData = await mcpClient.readResource({ uri: '...' });
const prompts = await mcpClient.experimental_listPrompts();
const prompt = await mcpClient.experimental_getPrompt({ name: 'code_review' });
Note the experimental_ prefix on the prompt methods. Tools and resources are stable; the prompt helpers are still settling, so treat that part of the surface as more likely to shift in a future release.
Gotchas
A few things I ran into, so you do not have to.
The client is stateful and you own its lifecycle. Forgetting to call close() leaks the connection, and with stdio it leaves a child process hanging around. Tie the close to the end of the generation (onFinish for streaming) or wrap the whole thing in a try/finally.
Bare tools() trusts the server completely. That is great for prototyping and risky for production, because a server can change what it advertises between deploys. When the set of tools matters, pin it with explicit schemas so the model only ever sees what you signed off on.
OAuth needs a real redirect somewhere. The SDK automates the token mechanics, but the user still has to authorize once, and that means a callback route in your app the first time around. If you are connecting from a backend with no browser in the loop, you usually want a service token or a pre-authorized credential rather than the interactive flow.
Tool latency is real network latency. Every MCP tool call is a round trip to the server, and if the model chains three of them, that is three sequential hops before you get an answer. For chatty interactions stream the output so the user sees progress instead of staring at a spinner.
Elicitation changes your control flow. If a server uses elicitation to ask the user something mid-call, you have to handle that request, which means a UI moment in the middle of what looks like a single generation. It is a useful capability, but plan for it rather than discovering it when a server triggers it.
Wrapping up
The shape of this is small once it clicks: create a client, get tools(), pass them to generateText or streamText, close the client. The work AI SDK 6 did was making that the only thing you have to think about, with OAuth, resources, and prompts handled underneath rather than reimplemented per project.
If you are giving an LLM access to anything beyond its own text, MCP is now the path of least resistance, and @ai-sdk/mcp is a thin enough layer that you can read what it is doing. Start with a local stdio server to get the loop working, then point the same code at a real HTTP server when you are ready. The only line that changes is the transport.