Full-Stack Questions

Vercel AI SDK Interview Questions

streamText, tools, structured output, multi-step loops, useChat, testing and security.

Lesson 1140 min read#ai-sdk

On this page

  1. Words used in this lesson
  2. What the AI SDK is made of
  3. What changed across versions (the "outdated tutorial" table)
  4. The core calls
  5. Tools and the loop
  6. Agents
  7. Messages: two shapes
  8. Next.js: route handler plus useChat
  9. Saving chats
  10. Stopping, backpressure and errors
  11. Approval: a human in the loop
  12. Middleware, caching and embeddings
  13. Testing without an API key
  14. Security and cost checklist
  15. MCP: Model Context Protocol
  16. Interview questions
  17. Key takeaways
  18. What's next

In one line: the Vercel AI SDK (the ai package) is a TypeScript toolkit that gives you one way to call any large language model, stream its answer to a React UI, let it call your functions (tools) in a loop, and test all of it without an API key. MCP (Model Context Protocol) is a standard plug that lets those tools live in a separate server instead of inside your app.

You already build React and Next.js apps. The AI SDK questions in an interview check one thing: can you wire a model into a real product safely, with streaming, tools, errors, cost limits and tests? This lesson uses the current major version (version 7, which needs Node.js 22 or newer). Many blog posts and videos still show version 4, 5 or 6 code, so it also shows what changed. That is a favourite trap question.

Words used in this lesson

  • An LLM (large language model) is a program that was trained on a huge amount of text and predicts the next piece of text. You send it text (a prompt) and it sends text back.
  • A token is a small piece of text (about three quarters of an English word). Models read and write tokens, and providers charge per token.
  • A provider is the company or service that runs the model, such as OpenAI, Anthropic or Google. A model is one specific LLM from a provider.
  • A gateway is one service that sits in front of many providers. You use one key and one URL, and you pick the model with a string like openai/gpt-5.
  • A tool is a normal function that you describe to the model (name, purpose, input shape). The model cannot run it. It can only ask you to run it. That ask is a tool call, and your answer is the tool result.
  • A schema is a written description of the shape of data. In this lesson it is a Zod schema. (Zod is a TypeScript library that describes data shapes and checks data against them.)
  • A step is one call to the model. If the model asks for a tool, you run it and make another call. That is a second step.
  • A stream is data that arrives in small pieces over time, so the user sees the first words while the rest are still being written. The pieces travel as SSE (Server-Sent Events): one long HTTP response where the server keeps sending lines that start with data:.
  • An agent is an LLM that uses tools in a loop until a task is done.
  • MCP (Model Context Protocol) is an open standard that describes how an AI app talks to a separate program (an MCP server) that offers tools, data and prompt templates. Think of USB-C: one plug shape, many devices.

What the AI SDK is made of

PackageWhat it gives you
aiThe core: generateText, streamText, tool, Output, embed, ToolLoopAgent, middleware, stream helpers, the AI Gateway provider, and test helpers in ai/test
@ai-sdk/reactThe useChat hook for chat screens (there are matching packages for Vue and Svelte)
@ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google ...Provider packages: they connect the SDK to one provider's API
@ai-sdk/mcpAn MCP client that turns a server's tools into AI SDK tools
@ai-sdk/otelSends OpenTelemetry traces (OpenTelemetry is the standard way to record timing of a request across services)

The SDK has two layers. AI SDK Core runs on the server and talks to models. AI SDK UI runs in the browser and shows streams. A model is just a value you pass in. You can give it a plain string such as 'openai/gpt-5', and the SDK sends it through the AI Gateway using the AI_GATEWAY_API_KEY environment variable. Or you pass a provider object such as openai('gpt-5'). Changing provider is a one-line change.

Difference in one line: generateText waits for the full answer. streamText gives you the answer piece by piece. Both take the same options.

What changed across versions (the "outdated tutorial" table)

Most search results are old. Here is how to date a code sample:

If you see ...It is fromIn version 7 you write
maxSteps: 5v4stopWhen: isStepCount(5)
parameters: z.object(...) in a toolv4inputSchema: z.object(...)
const { input, handleSubmit } = useChat()v4You keep your own useState for the text box and call sendMessage({ text })
message.content string in the UIv4Loop over message.parts
result.toDataStreamResponse()v4createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })
generateObject / streamObjectv3 to v5generateText / streamText with output: Output.object({ schema }) (the old two still run but are deprecated)
stepCountIs(5)v5, v6isStepCount(5) (the old name still works as an alias)
system: '...'v4 to v6instructions: '...'
result.fullStreamv4 to v6result.stream
onFinish, onStepFinishv4 to v6onEnd, onStepEnd
needsApproval on a toolv6toolApproval on the call or agent
experimental_telemetry: { isEnabled: true }v4 to v6Install @ai-sdk/otel, call registerTelemetry(new OpenTelemetry()) once, use telemetry for options
export const runtime = 'edge'old Next.js tipsRemove it; Next.js now says the Edge Runtime is deprecated

Other version 7 facts worth remembering: only ES modules (no require), Node.js 22 or newer, convertToModelMessages is async (since v6), and result.usage now sums all steps (use result.finalStep.usage for the last step only). A command-line tool called a codemod (npx @ai-sdk/codemod v7) does many of these renames for you.

The core calls

JavaScript
import { generateText } from 'ai';const result = await generateText({  model: 'openai/gpt-5',          // any model id or provider object  instructions: 'Answer in one short sentence.',  prompt: 'What is a closure in JavaScript?',  maxOutputTokens: 200,           // cost guard  timeout: 15_000,                // milliseconds});console.log(result.text);console.log(result.usage.totalTokens, result.finishReason); // finishReason is 'stop' here

(I ran this shape against a mock model: it returns text, usage.totalTokens and finishReason: 'stop'.)

Useful settings that every call accepts:

  • instructions is the system prompt: rules for the model that the user did not write. prompt is a single user message. messages is a whole conversation.
  • maxOutputTokens caps how long the answer can be. temperature controls randomness. Set temperature or topP, not both.
  • maxRetries defaults to 2, so a failing call is tried up to three times. Only errors marked retryable are retried. A 401 (bad key) is tried once.
  • abortSignal cancels the call. timeout is a shortcut that makes a signal for you. It can be a number or an object such as { totalMs, stepMs, chunkMs, toolMs }.

Structured output

Often you do not want prose. You want an object you can trust. Pass an output setting with a Zod schema:

JavaScript
import { generateText, Output } from 'ai';import { z } from 'zod';const { output } = await generateText({  model: 'openai/gpt-5',  output: Output.object({    schema: z.object({      sentiment: z.enum(['positive', 'negative']),      score: z.number(),    }),  }),  prompt: 'Rate this review: "Fast delivery, great quality."',});// output is typed: { sentiment: 'positive' | 'negative'; score: number }

The other builders are Output.array({ element }), Output.choice({ options }) (the answer must be one of your strings), Output.json() (any valid JSON, no shape check) and Output.text() (the default). With streamText you read partialOutputStream to get the object as it grows, or elementStream for arrays. I ran these with a mock model. The partial stream gave {}, then {"sentiment":"pos"}, then the full object.

A schema is a request, not a guarantee. The model may still send a value that does not match. The SDK checks the final result with your schema. If the check fails, generateText throws NoObjectGeneratedError, and the error carries the raw text. Partial objects from a stream are not validated, because half an object cannot match yet.

Tools and the loop

JavaScript
import { generateText, tool, isStepCount } from 'ai';import { z } from 'zod';const weather = tool({  description: 'Get the current temperature for a city',  inputSchema: z.object({ city: z.string().min(2) }),  execute: async ({ city }) => ({ city, temperature: 21 }),});const result = await generateText({  model: 'openai/gpt-5',  tools: { weather },  stopWhen: isStepCount(5),  prompt: 'Is it warm in Paris right now?',});

Here is what happens, step by step:

  1. Step 1. The SDK sends the prompt and the tool list (names, descriptions, JSON Schema made from your Zod schema).
  2. The model answers with a tool call, for example weather({ city: 'Paris' }).
  3. The SDK checks the input against your schema. If it is valid, it runs execute on your server.
  4. Step 2. The SDK sends the tool result back to the model. The model writes the final answer.

I ran this with a mock model that returns a tool call first and text second. With isStepCount(5) the result had 2 steps, the text It is 21C, and the tool output in result.toolResults.

The classic gotcha: no stopWhen, no final answer. For generateText and streamText the default is one step. The model asks for the tool, the tool runs, and the call ends. The text comes back empty. In my run, the same mock without stopWhen gave steps: 1 and text: "". Add stopWhen. The loop also stops by itself when the model answers with plain text, when a tool has no execute function (you handle it on the client), or when a tool needs approval.

stopWhen conditions are isStepCount(n), hasToolCall('toolName') (stop when a "finish" tool is called) and isLoopFinished() (never stop early). You can pass an array, and the loop stops when any condition is true. They are only checked after a step that had tool results.

When a tool fails

Failures do not crash the loop. They become tool errors that go back to the model, so it can recover:

What went wrongWhat happens
The model sends input that does not match your schemaexecute is not called. The model gets an InvalidToolInputError text and can try again.
The model asks for a tool that does not existThe model gets a NoSuchToolError text with the list of real tool names.
Your execute throwsA tool-error is recorded and sent to the model. In my test the model then said "I could not do it".

Never put secrets or stack traces in an error message, because the model (and so possibly the user) will read it.

Steering each step

prepareStep runs before every step. It can change the model, the messages, activeTools (the tools allowed in this step) and toolChoice ('auto', 'required', 'none' or one named tool). I verified that returning { activeTools: ['weather'], toolChoice: 'required' } for step 0 sends exactly one tool and required to the model. In version 7, instructions and messages you return from prepareStep carry forward to later steps. In version 6 they only applied to one step.

Secrets for tools: toolsContext

Never let the model choose a customer id, tenant id or API key. Pass those from your server. In version 7 a tool can declare a contextSchema, and you give the values in toolsContext:

JavaScript
import { tool } from 'ai';import { z } from 'zod';export const getOrderStatus = tool({  description: 'Get the status of ONE order of the signed-in customer.',  inputSchema: z.object({ orderId: z.string().regex(/^ord_[a-z0-9]{8}$/) }),  contextSchema: z.object({ customerId: z.string() }),  execute: async ({ orderId }, { context }) => {    // context.customerId came from your session, not from the model    return { orderId, customerId: context.customerId, status: 'shipped' as const };  },});// later: streamText({ ..., tools: { getOrderStatus }, toolsContext: { getOrderStatus: { customerId: session.userId } } })

Each tool sees only its own context. TypeScript catches a wrong key (I changed customerId to customerid and the compiler refused). This is also where the ownership check belongs: look the order up by id and customer id together.

Agents

An agent is a model plus tools plus a loop. ToolLoopAgent packs that into a reusable object:

JavaScript
import { ToolLoopAgent, tool } from 'ai';import { z } from 'zod';export const supportAgent = new ToolLoopAgent({  model: 'openai/gpt-5',  instructions: 'You are a support agent. Be brief.',  tools: {    lookup: tool({      description: 'Find an order',      inputSchema: z.object({ orderId: z.string() }),      execute: async ({ orderId }) => ({ orderId, status: 'shipped' }),    }),  },  // stopWhen defaults to isStepCount(20)});const result = await supportAgent.generate({ prompt: 'Where is order 7?' });

The agent default is 20 steps. I tested a mock model that asked for a tool forever: the agent made exactly 20 model calls and stopped. In a Next.js route you answer with createAgentUIStreamResponse({ agent, uiMessages }), and I checked that the stream shows tool-input-available, then tool-output-available, then the text parts.

Difference in one line: generateText + stopWhen is a loop you configure per call. ToolLoopAgent is the same loop packaged once, with its model, tools, rules and limits in one place. Use the agent when many routes share the same behaviour.

When the steps are known in advance (extract, then classify, then write), do not use an agent. Write normal code that calls generateText three times. It is cheaper, faster and easier to test than hoping a model chooses the right order.

Messages: two shapes

This confuses many people:

ModelMessageUIMessage
Used byThe model calls (generateText, streamText)The screen (useChat) and your database
Shape{ role, content }{ id, role, parts: [...] } with parts like text, tool-weather, reasoning, file
Extra dataNoneid, metadata, tool state (input-available, output-available, approval-requested)

You convert with await convertToModelMessages(uiMessages). Store UI messages in your database, because they keep the full screen state. Tool parts are named tool- plus the tool key, so a tool called weather becomes tool-weather.

Next.js: route handler plus useChat

This is the standard wiring. The route has auth, a rate limit, input checks, tools with a server-owned context, an approval rule, a step limit, an output cap and the client's abort signal:

JavaScript
// app/api/chat/route.tsimport {  convertToModelMessages,  createUIMessageStreamResponse,  isStepCount,  streamText,  toUIMessageStream,  validateUIMessages,  type UIMessage,} from 'ai';import { tools } from '@/lib/tools';export const maxDuration = 60;export async function POST(req: Request) {  const session = await getSession(req);                 // your auth  if (!session) return new Response('Unauthorized', { status: 401 });  if (!(await limit(session.userId))) return new Response('Too many requests', { status: 429 });  const body = (await req.json()) as { messages?: UIMessage[] };  const messages = await validateUIMessages({ messages: body.messages ?? [], tools });  const safe = messages.filter(m => m.role !== 'system');   // never trust a client "system" message  const result = streamText({    model: 'openai/gpt-5',    instructions: "You are a support assistant. Only discuss the signed-in customer's orders.",    messages: await convertToModelMessages(safe, { tools }),    tools,    toolsContext: { getOrderStatus: { customerId: session.userId } },    toolApproval: { refundOrder: 'user-approval' },    stopWhen: isStepCount(6),    maxOutputTokens: 1000,    abortSignal: req.signal,  });  return createUIMessageStreamResponse({    stream: toUIMessageStream({ stream: result.stream }),  });}

(getSession and limit are your own functions. This file type-checks with the real ai types, and I ran the same logic with a mock model, including the approval round trip.)

The client side:

JavaScript
'use client';import { useChat } from '@ai-sdk/react';import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';import { useState } from 'react';export function Chat() {  const [input, setInput] = useState('');  const { messages, sendMessage, status, error, stop, regenerate, addToolApprovalResponse } = useChat({    transport: new DefaultChatTransport({ api: '/api/chat' }),    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,  });  return (    <div>      {messages.map(m => (        <div key={m.id}>          {m.parts.map((part, i) => {            if (part.type === 'text') return <p key={i}>{part.text}</p>;            if (part.type === 'tool-refundOrder' && part.state === 'approval-requested') {              return (                <div key={part.toolCallId}>                  <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>Approve</button>                  <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: false })}>Deny</button>                </div>              );            }            return null;          })}        </div>      ))}      {error && <button onClick={() => regenerate()}>Something went wrong. Retry</button>}      {(status === 'submitted' || status === 'streaming') && <button onClick={() => stop()}>Stop</button>}      <form onSubmit={e => { e.preventDefault(); if (input.trim()) { sendMessage({ text: input }); setInput(''); } }}>        <input value={input} onChange={e => setInput(e.target.value)} disabled={status !== 'ready'} />      </form>    </div>  );}

useChat is a React hook (a function that gives a component state and behaviour) that keeps messages, sends requests through a transport, and reports a status:

  • submitted: the request was sent, no data yet.
  • streaming: pieces are arriving.
  • ready: finished, you can send again.
  • error: something failed. Show a generic message. Do not print server error text to the user.

The transport is the part that talks to your server. DefaultChatTransport does an HTTP POST. You can set headers, body or prepareSendMessagesRequest to change what is sent. There is also DirectChatTransport, which calls an agent in the same process (handy for tests and desktop apps).

Next.js notes (from the current Next.js docs): route handlers use the Node.js runtime by default; export const runtime = 'edge' is deprecated; maxDuration is a segment option that platforms use for the time limit. On Vercel with Fluid Compute the default function duration is 5 minutes, but set maxDuration yourself so the limit is on purpose.

What travels on the wire

The server sends SSE with the response header x-vercel-ai-ui-message-stream: v1. Each line is JSON with a type. I captured a real run: start, start-step, text-start, text-delta (one per piece), text-end, finish-step, finish, then [DONE]. A tool step adds tool-input-start, tool-input-delta, tool-input-available and tool-output-available. Knowing this helps you debug in the browser's Network tab, and helps when a proxy buffers the stream and the user sees nothing until the end.

Saving chats

Store the whole UIMessage[] per chat. A safe design:

  1. The client sends only the last message and the chat id (prepareSendMessagesRequest). Sending the full history is slower and lets the client rewrite the past.
  2. The server loads the saved history, adds the new message, and runs validateUIMessages with your tools. (I tried a tool part with city: 42 and got a TypeValidationError.)
  3. Save in onEnd of toUIMessageStream, passing originalMessages. Make ids on the server with generateMessageId: createIdGenerator({ prefix: 'msg' }).
  4. If the user presses Stop or closes the tab, pass consumeSseStream: consumeStream to createUIMessageStreamResponse, so onEnd still runs and the partial answer is saved.

Resumable streams (reconnect after a refresh) need Redis and an extra GET endpoint. The docs warn that abort and resume do not work together.

Stopping, backpressure and errors

Backpressure means a slow reader can tell a fast writer to wait. The SDK's streams are lazy: they make the next piece only when the reader asks, so a slow browser does not make your server pile up memory.

Stopping. stop() in useChat only closes the browser's request. To also stop paying for the model, pass abortSignal: req.signal to streamText, as in the route above.

Errors in a stream do not throw. Once streaming starts, the HTTP status is already 200. So streamText puts failures into the stream as an error part and calls onError. With no onError, the SDK only logs to the console. I confirmed this with a bad prompt: nothing threw, and onError received an AI_InvalidPromptError. Always set onError to report to your tracker, and wrap code outside the stream in try/catch.

Retries. Failed calls are retried (maxRetries, default 2). A retryable mock error ran 3 times and threw RetryError (reason maxRetriesExceeded). A 401 ran once and threw APICallError.

Approval: a human in the loop

Some tools should not run alone: refunds, deletes, emails. Version 7 has toolApproval, a map from tool name to a rule. A rule is 'user-approval', 'approved', 'denied' (or { type: 'denied', reason }), or a function that decides from the input. Returning undefined means "no approval needed":

JavaScript
toolApproval: {  deleteFile: async ({ path }) => (path.startsWith('/tmp/') ? undefined : 'user-approval'),}

When approval is needed, the call stops and returns a tool-approval-request. Your UI shows buttons, the answer goes back as a tool-approval-response, and a second call continues. I ran the full cycle with a mock model: the first call ended with finishReason: 'tool-calls' and nothing was deleted; after approved: true the file was deleted; with approved: false it was not.

Approval is a safety net, not a security boundary. People click "Approve" without reading. Also use narrow tools, read-only credentials and permission checks inside execute.

Middleware, caching and embeddings

Language model middleware wraps a model so you can add logging, guardrails, default settings or a cache once, without touching each call. You use wrapLanguageModel({ model, middleware }). A middleware has wrapGenerate and/or wrapStream. Several middlewares apply in order. Built-in ones include defaultSettingsMiddleware, extractReasoningMiddleware, extractJsonMiddleware and simulateStreamingMiddleware.

A tiny cache (I ran it: three calls, two with the same prompt, gave only 2 provider calls):

JavaScript
import { gateway, wrapLanguageModel, type LanguageModelMiddleware } from 'ai';const store = new Map<string, unknown>();export const cacheMiddleware: LanguageModelMiddleware = {  specificationVersion: 'v4',  wrapGenerate: async ({ doGenerate, params }) => {    const key = JSON.stringify(params.prompt) + String(params.temperature);    const hit = store.get(key);    if (hit) return hit as Awaited<ReturnType<typeof doGenerate>>;    const result = await doGenerate();    store.set(key, result);    return result;  },};export const model = wrapLanguageModel({  model: gateway('openai/gpt-5'), // wrapLanguageModel needs a model object, not a string  middleware: cacheMiddleware,});

In production use Redis with a time limit, and include the user or tenant in the key when the prompt contains private data. Caching only makes sense when the answer does not depend on fresh data or on the person.

Embeddings. An embedding is a list of numbers that represents the meaning of a text, so similar texts have close numbers. embed({ model, value }) returns one list. embedMany({ model, values }) returns many and splits the work into batches by the model's limit (my test had a limit of 2 per call, so 5 values made 3 calls). cosineSimilarity(a, b) compares two lists: 1 means the same direction, 0 means unrelated. You need embeddings for search by meaning and for RAG (retrieval-augmented generation: finding the right text first and putting it in the prompt).

Telemetry. In version 7, once you call registerTelemetry(...) with an integration, every SDK call reports events (onStart, onToolExecutionStart, onToolExecutionEnd, onStepEnd, onEnd). I registered a plain object and saw start,toolStart,toolEnd,stepEnd,stepEnd,end for a two-step run. Use telemetry: { functionId: 'support-chat' } to name a call. Traces can contain prompts and outputs, so treat them as sensitive data and filter what you record.

Testing without an API key

ai/test exports MockLanguageModelV4 (and MockEmbeddingModelV4). You script the model's answers, so the test is fast, free and the same every time. Pass an array to doGenerate and each call gets the next answer:

JavaScript
import { ToolLoopAgent, tool } from 'ai';import { MockLanguageModelV4 } from 'ai/test';import { expect, it } from 'vitest';import { z } from 'zod';const usage = {  inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },  outputTokens: { total: 5, text: 5, reasoning: undefined },};it('calls the weather tool, then answers', async () => {  const cities: string[] = [];  const model = new MockLanguageModelV4({    doGenerate: [      {        content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'weather', input: '{"city":"Oslo"}' }],        finishReason: { unified: 'tool-calls', raw: undefined },        usage,        warnings: [],      },      {        content: [{ type: 'text', text: 'It is 21 degrees in Oslo.' }],        finishReason: { unified: 'stop', raw: undefined },        usage,        warnings: [],      },    ],  });  const agent = new ToolLoopAgent({    model,    tools: {      weather: tool({        description: 'Get the weather',        inputSchema: z.object({ city: z.string() }),        execute: async ({ city }) => { cities.push(city); return { temperature: 21 }; },      }),    },  });  const result = await agent.generate({ prompt: 'Weather in Oslo?' });  expect(cities).toEqual(['Oslo']);  expect(result.text).toBe('It is 21 degrees in Oslo.');  expect(model.doGenerateCalls).toHaveLength(2);});

This test passes with Vitest. model.doGenerateCalls records every request, so you can also assert what was sent (for example that a secret is not in the prompt, or that activeTools held one tool). For streaming, doStream returns simulateReadableStream({ chunks }) with parts such as text-start, text-delta, text-end and finish. Test your own code (tools, route logic, permission checks) with mocks. Check prompt quality with a separate small set of evals (real model calls on fixed examples that you score), not in the normal unit test run.

Security and cost checklist

  • Keys stay on the server. Never use NEXT_PUBLIC_ for a model key. The browser talks to your route.
  • Authenticate and rate-limit the route (see Rate limiter design and Auth). An open chat route is an open wallet. Also set maxOutputTokens, a step limit and maxDuration.
  • Validate everything from the client (Validation at the edge). A UIMessage from the browser can have role: 'system'. I checked: validateUIMessages accepts it, convertToModelMessages keeps it, and version 7 streamText then rejects it with InvalidPromptError because system messages inside messages are refused by default. Do not turn that off (allowSystemInMessages) for client data. Filter such messages out, as in the route above.
  • Validate tool arguments. The Zod schema is your first check. Add business checks inside execute: does this customer own this order?
  • Prompt injection is when text the model reads (a web page, an email, a tool result) contains instructions, and the model follows them. You cannot fully prevent it with wording. Limit damage instead: least-privilege tools, no tool that mixes private data with outbound network access, approval for side effects, and per-user context from the server.
  • Do not leak. Show a generic error to the user. Log details on the server.
  • Server-side fetch of URLs from models or users can reach internal addresses (SSRF). The SDK blocks private addresses for URLs that providers return. For URLs your tools fetch, add your own allow-list.
  • Sensitive data in telemetry: filter what you record.

MCP: Model Context Protocol

Why it exists

Without a standard, every AI app writes its own connector for every service (GitHub, a database, Slack), and every service writes one for every app. MCP makes it one connector per side. The tool is written once as an MCP server. Any MCP host (an AI app like an IDE or your Next.js app) can use it.

Roles

  • Host: the AI application. It decides which servers to trust and what the model may call.
  • Client: a small part inside the host that keeps one connection to one server.
  • Server: a program that offers tools (functions the model may call), resources (data the app can read, such as a file or a record) and prompts (reusable prompt templates).

The messages use JSON-RPC 2.0 (a small standard for "call method X with parameters Y, get a result"). There are two transports (ways to carry the messages): stdio (the host starts the server as a child process on the same machine, for local tools) and Streamable HTTP (one HTTP endpoint, for remote servers).

The latest spec: stateless

The latest MCP spec version is 2026-07-28. The big change from older versions: no session handshake. Older versions started with an initialize exchange and a session id header. Now every request carries its protocol version and the client's capabilities in a _meta field, so any server instance can answer any request (easy to scale behind a load balancer). A server also answers server/discover, which lists its supported versions, capabilities and name. Servers that need to remember something between calls hand out explicit ids that appear as normal tool arguments. Two deprecations to know: the old HTTP+SSE transport, and the client features sampling, roots and logging.

A tiny server and the AI SDK client (I ran this)

JavaScript
// server.ts  (npm i @modelcontextprotocol/server @modelcontextprotocol/node zod)import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';import { createServer } from 'node:http';import * as z from 'zod/v4';const handler = createMcpHandler(() => {  const server = new McpServer({ name: 'notes', version: '1.0.0' });  server.registerTool(    'add-note',    { description: 'Save a note', inputSchema: z.object({ text: z.string() }) },    async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }),  );  return server; // a fresh server object is built for every request});const nodeHandler = toNodeHandler(handler);const checkHost = localhostHostValidation();const checkOrigin = localhostOriginValidation();createServer((req, res) => {  if (!checkHost(req, res) || !checkOrigin(req, res)) return; // DNS-rebinding guard  void nodeHandler(req, res);}).listen(3000, '127.0.0.1');
JavaScript
// client side, inside your route or scriptimport { createMCPClient } from '@ai-sdk/mcp';import { generateText, isStepCount } from 'ai';const mcp = await createMCPClient({  transport: { type: 'http', url: 'http://127.0.0.1:3000/mcp' },});try {  const tools = await mcp.tools();           // MCP tools become AI SDK tools  const result = await generateText({    model: 'openai/gpt-5',    tools,    stopWhen: isStepCount(5),    prompt: 'Note: ship the release on Friday.',  });} finally {  await mcp.close();                          // always close}

With a mock model I got tool names: ['add-note'] and the tool result Saved: ship it inside the MCP result object. Details that matter in a review:

  • mcp.tools() is schema discovery: every tool the server offers is loaded, with no TypeScript types. mcp.tools({ schemas: { ... } }) is schema definition: you list the tools you want with Zod schemas, so you get types and only those tools.
  • In version 7, MCP HTTP transports do not follow redirects by default (redirect: 'error'), to stop SSRF. Set redirect: 'follow' only if you must.
  • stdio is for local development. For production use HTTP.
  • Close the client when the work is done (in streamText, use the onEnd callback).
  • Retries for tool calls are off by default (maxRetries). Turn them on only for idempotent tools (safe to run twice).
  • Tool annotations such as readOnlyHint are hints from the server. The spec says to treat them as untrusted unless the server is trusted. A safe policy is: run automatically only if the (trusted) server says readOnlyHint: true, otherwise require approval.

MCP security in short

  • Human in the loop. The spec says there should always be a way for a person to deny a tool call, and the UI should show which tools exist and when they run.
  • Authorization (HTTP). Optional, based on OAuth 2.1. The server is an OAuth resource server and must publish Protected Resource Metadata so the client can find the authorization server. Clients must use PKCE and send the resource parameter. Servers must check that a token was issued for them. Passing a client's token on to another API ("token passthrough") is forbidden. stdio servers take credentials from the environment instead.
  • Tool poisoning. A malicious server can put hidden instructions in a tool description, or change its tools later. Only connect to servers you trust, pin versions, review descriptions, and give each server the least access.
  • Local servers must check the Origin header and bind to 127.0.0.1, or a web page can reach them through DNS rebinding.

Interview questions

What is the Vercel AI SDK and why use it instead of calling the provider's API directly?

It is a TypeScript library (the ai package plus provider and UI packages) that gives one API for many models. I use it for four things. First, switching providers is a one-line change, because the call shape is the same. Second, streaming to React works with streamText and useChat without me writing SSE parsing. Third, tools and structured output are typed with Zod. Fourth, it has test helpers, so I can run tests without a key. The trade-off is another dependency that moves fast (it is at version 7, with renames in each major version), so I pin the version and read the migration guide on upgrade. If I only call one model in one place with no streaming, the plain provider SDK is also fine.

What is the difference between generateText and streamText? And what happened to generateObject?

generateText waits and returns the full result. I use it for background jobs, classification and anything the user does not watch. streamText returns at once and gives the answer in pieces, so the user sees the first words quickly. I use it for chat. One more difference: with streamText, errors during streaming do not throw. They arrive as an error part and in onError. generateObject and streamObject still exist but are deprecated. The current way is generateText or streamText with output: Output.object({ schema }). That way structured output and tools work in the same call, and the schema output counts as a step.

What is the difference between ModelMessage and UIMessage?

A ModelMessage is what the model API needs: a role and content. A UIMessage is what my screen and database need: an id, a role and a list of parts (text, tool parts with state, reasoning, files) plus metadata. useChat works with UI messages. On the server I validate them with validateUIMessages and then convert with await convertToModelMessages(...). It is async since version 6, so forgetting await is a real bug. I store UI messages, because converting model messages back to a screen loses information such as the tool state.

Walk me through tool calling. A user asks "what is the weather in Paris?" and your tool exists. What exactly happens?

I define a tool with a description, a Zod inputSchema and an execute function. The SDK sends the prompt and the tool definitions to the model. The model cannot run code. It answers with a tool call such as weather({ city: 'Paris' }). The SDK checks the arguments against my schema, runs execute on my server, and gets the result. Then it makes a second model call with the result added, and the model writes the final answer. The gotcha: by default generateText and streamText stop after one step, so the answer would be empty after the tool runs. I must set stopWhen, for example isStepCount(5). I tested this with a mock: without stopWhen there was 1 step and empty text; with it, 2 steps and the final text.

How would you build a streaming chat in Next.js with the AI SDK?

A POST route handler in app/api/chat/route.ts and a client component with useChat. In the route I authenticate and rate-limit first, then read the messages, run validateUIMessages, drop any system message, call streamText with messages: await convertToModelMessages(...), and return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }). I also set maxDuration, maxOutputTokens, a step limit and abortSignal: req.signal. On the client, useChat gives messages, sendMessage, status, error, stop and regenerate. I keep the input text in my own useState (the old input and handleSubmit were removed in version 5), render message.parts, and show a generic error.

ToolLoopAgent versus a generateText loop versus a fixed workflow. How do you choose?

If the steps are known, I write a fixed workflow: plain code calling generateText several times, with branches and checks. It is cheaper, faster and easy to test. If the model must decide which tools to use and how many times, I use a loop. generateText with stopWhen is fine for one place. ToolLoopAgent packages the model, instructions, tools and limits in one reusable object, with a default of 20 steps, and works with createAgentUIStreamResponse in a route. The trade-off with agents is cost and unpredictability, so I always add limits and tracing. I choose the least free option that does the job.

How do you stop an agent from running forever or costing too much?

I set several limits, because one is not enough. A step limit with stopWhen (the agent default is 20, and I often lower it). maxOutputTokens per call. A timeout object with totalMs, stepMs and toolMs. maxDuration on the route. A rate limit per user, and a daily budget per user in my database using result.usage. In version 7 usage adds up all steps. I also pass abortSignal so a closed tab stops the work. Then I log tokens per step through telemetry or onStepEnd, and alert when the average jumps. A tool that can loop (search, then search again) gets its own call limit in my code.

Security: what can a malicious user do to your chat endpoint, and how do you defend it?

Many things. A user can send a fake system message or a tampered tool part in the history, so I run validateUIMessages with my tools and drop system roles (version 7 also rejects them by default). A user can burn money, so I add auth, rate limits and token limits. A user can try prompt injection to misuse tools, so tools are narrow, ids come from toolsContext (the session) and not from the model, every execute checks ownership, and risky tools need approval. Keys never reach the browser, and errors shown to the user are generic.

How does tool approval work, and when would you use it?

For risky tools such as refunds or deletes I set toolApproval: { refundOrder: 'user-approval' }, or a function that decides from the input, for example only above an amount. When the model calls the tool, the SDK does not run it. It returns a tool-approval-request and stops the loop. In useChat that appears as a tool part with state approval-requested. I show buttons and call addToolApprovalResponse({ id, approved }). With sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, the chat continues by itself. If it is denied, the model gets the denial and answers without the tool. I tell the model not to retry denied actions. It is a helpful control, but I still use least-privilege tools, because people click "approve" too fast. (needsApproval on the tool is the older, deprecated way.)

How do errors, retries and timeouts work?

For generateText, errors are thrown, so I use try/catch and check the type (APICallError has statusCode and isRetryable). Retryable errors are retried up to maxRetries (default 2). In my test a retryable 529 ran 3 times and ended in RetryError, while a 401 ran once. For streamText the status is already 200 when an error happens, so the error goes to onError and into the stream as an error part. I always set onError. Timeouts use timeout (a number, or an object with totalMs, stepMs, chunkMs, toolMs) or abortSignal, and throw a TimeoutError. streamRetries can retry a provider error that appears mid-stream, but text already shown cannot be taken back.

The user presses Stop. What should stop, and what about saving the partial answer?

stop() from useChat only aborts the browser's request. To stop the model call too, my route passes req.signal as abortSignal to streamText. To save the partial answer, I save in onEnd of toUIMessageStream and pass consumeSseStream: consumeStream, so the stream is read to the end even when the client left. streamText also has onAbort, which receives the steps finished so far. Trap: abort and resumable streams do not work together, so a resumable setup needs its own stop endpoint.

How would you persist chat history safely?

I store UIMessage[] per chat, with the owner. The client sends only the new message and the chat id (prepareSendMessagesRequest). The server checks that this user owns the chat, loads history, appends the message, and runs validateUIMessages({ messages, tools, metadataSchema }). If old rows fail validation because tools changed, I catch TypeValidationError and migrate or trim, not crash. I save in onEnd with originalMessages and server-made message ids. The chat id is untrusted input: I check ownership, not just format.

How do you test code that calls an LLM, without an API key?

I use MockLanguageModelV4 from ai/test. For generateText I pass doGenerate as one answer or an array of answers, one per step, so I can script "tool call first, text second". For streaming I use doStream with simulateReadableStream. The mock records doGenerateCalls, so I can assert what was sent, how many steps ran, and that my execute function got the right input. There is also a mock embedding model. These tests check my code. They do not prove that the real model behaves well, so I add a few evals for prompt quality, kept out of the fast unit-test run.

What is language model middleware? Give an example, and what is risky about caching?

Middleware wraps a model with wrapLanguageModel and can change the request (transformParams), the result (wrapGenerate) or the stream (wrapStream). I use it for logging, default settings, guardrails and caching. For a cache I key on the full prompt and settings, look in the store before doGenerate, and save after. For streams I replay saved parts with simulateReadableStream. The risks are stale answers, and leaking one user's answer to another if the key has no user or tenant. I cache only repeatable, non-personal calls, with an expiry.

Edge or Node.js runtime for the AI route?

Node.js, which is the default. Older tutorials put export const runtime = 'edge' on AI routes, but streaming works in Node.js route handlers, and the current Next.js docs say the Edge Runtime is deprecated and to remove the runtime export. Node.js also gives me full libraries (database drivers, MCP clients, OpenTelemetry). What I must set is maxDuration. If streaming works locally but not when deployed, I suspect a proxy that buffers the response and look at headers and the Network tab.

Trap: "I followed a tutorial and maxSteps, parameters and toDataStreamResponse do not exist." What happened?

The tutorial is for version 4. Version 5 renamed parameters to inputSchema, replaced maxSteps with stopWhen, and introduced UI messages with parts and the UI message stream. Version 6 added ToolLoopAgent and tool approval, made convertToModelMessages async, and deprecated generateObject. Version 7 renamed system to instructions, stepCountIs to isStepCount, fullStream to stream, onFinish to onEnd, moved OpenTelemetry to @ai-sdk/otel, made usage count all steps, and dropped CommonJS and Node 20. I check the installed ai version, read that version's migration guide, run the codemod, and never mix code from different versions.

What is MCP, and what problem does it solve?

MCP, the Model Context Protocol, is an open standard for how an AI application talks to an external program that provides tools, resources and prompts. Before it, each app needed its own connector for each service. With MCP, a service writes one server, and any host can use it, like one plug shape. The roles are host (the AI app), client (one connection inside the host) and server. Messages are JSON-RPC, sent over stdio for local servers or Streamable HTTP for remote ones. The newest spec version (2026-07-28) is stateless: no initialize handshake or session id, and each request carries its version and capabilities in _meta, so servers scale easily.

MCP tool versus a normal AI SDK tool versus a REST API. When do you choose which?

A normal AI SDK tool is a function in my own code. It is the simplest, fully typed, and the best choice for my own business logic. An MCP tool lives in a separate server that I (or someone else) run. I choose MCP when the same capability must serve many different AI apps (an IDE, a chat, an agent), when another team owns it, or when a vendor already ships an MCP server. REST is for programs, not models: it has no tool descriptions made for a model and no standard way to list capabilities. The cost of MCP is another moving part and a trust problem, since I run descriptions and code from a server. For a feature inside one app, I use a local tool.

What are the main security risks with MCP, and how do you reduce them?

First, untrusted servers: tool descriptions go into the model prompt, so a bad server can hide instructions there (tool poisoning) or change its tools later. I connect only to trusted servers, pin versions and review the tool list. Second, too much power: a write-capable server plus a model that can be tricked is dangerous, so I use least-privilege credentials and approvals, and I treat readOnlyHint as an untrusted hint unless the server is trusted. Third, auth: remote servers should use OAuth 2.1 with PKCE and tokens bound to the server, and the server must check that a token was meant for it. Forwarding a client's token to another API is forbidden. Fourth, local servers bind to localhost and check Origin and Host against DNS rebinding. The AI SDK client also refuses redirects by default (SSRF).

How do you use MCP tools in an AI SDK app in production? Any gotchas?

I create a client with createMCPClient({ transport: { type: 'http', url } }), call await mcp.tools(), and pass the result as tools. Gotchas: use HTTP in production, because stdio is for local servers. The default is schema discovery, which loads every tool with no types, so in a stable app I pass schemas to load only the tools I need, typed with Zod. Close the client in onEnd or finally. Retries are off by default, and I turn them on only for tools that are safe to repeat. Redirects are blocked by default. I add toolApproval for tools that write. And I avoid opening a new connection on every request when I can reuse one.

Key takeaways

  • The AI SDK is one API for many models, with typed tools, structured output, streaming to React, middleware and mock models. Current major version: 7 (Node.js 22+, ESM only).
  • generateText and streamText stop after one step unless you set stopWhen. ToolLoopAgent defaults to 20 steps.
  • Use generateText with output: Output.object(...). generateObject is deprecated.
  • Two message shapes: UIMessage for screen and database, ModelMessage for the model. Convert with await convertToModelMessages.
  • Treat everything from the browser as untrusted: authenticate, rate-limit, validateUIMessages, drop system roles, take ids from the session through toolsContext.
  • Streaming errors arrive as parts and in onError, not as exceptions. Forward req.signal so Stop really stops the model.
  • Use toolApproval for risky tools, but keep tools narrow too.
  • Test with MockLanguageModelV4. No key needed.
  • MCP is a standard plug for tools and data. The latest spec is stateless. The big risks are untrusted servers, over-powered tools and weak auth.

What's next

Read Backend, Lesson 03: Validation at the Edge again with this lesson in mind. Every model output and every tool input is just another untrusted input that needs a schema.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series