Your Vercel AI SDK agent demos beautifully. You wire up `streamText`, pass a couple of tools, and the model answers questions about your products like it actually knows them. Then it ships, a real user asks "which of the waterproof jackets are in stock under $200", and the agent confidently recommends a discontinued SKU at the wrong price. The model didn't lie on purpose. It just never saw your real catalog. It pattern-matched from training data and a vague tool description, because that is all you gave it.
The fix is to put real, current, structured content in front of the model on every turn, and that is where Sanity Context comes in. Sanity Context is Sanity's agent-facing product. Its primary surface today is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search across a Sanity dataset. For unstructured sources like PDFs, support transcripts, and marketing pages, Knowledge Bases turns the mess into well-ordered documents an agent can actually navigate.
This article stays on the Vercel AI SDK side of the problem first: why tool descriptions and stuffed system prompts drift, how the SDK's `tools` and MCP support actually work, and then how to attach Context MCP so your agent reads from live content instead of guessing.
Why your agent hallucinates even with tools attached
The Vercel AI SDK makes tool calling almost too easy. You hand `generateText` or `streamText` a `tools` object, the model decides when to call them, and the SDK runs the loop. The trap is that the model only knows what your tool's `description` and Zod schema tell it. If the description says 'look up product information' and the model already has a plausible-looking answer from pretraining, it often skips the tool entirely. No call, no fresh data, a fluent wrong answer.
The second failure mode is the stuffed system prompt. To stop the skipping, teams paste their whole catalog or a giant FAQ into the system message. This works for ten products and falls apart at five hundred. You blow the context window, latency climbs, and the model still anchors on whatever appeared first in the blob. Worse, the blob is a snapshot. The moment marketing updates a price or pulls a product, your prompt is stale and there is no review step that would have caught it.
The third is silent staleness in a custom sync. A common setup pulls content into a vector DB or a JSON file at build time, then queries that copy at runtime. Now you maintain a pipeline, an embedding job, and a cache invalidation story, and when the agent returns the wrong thing you cannot tell whether the model failed or the sync did. The honest framing: the model is rarely the problem. Retrieval is. The agent answered correctly given what it saw, and what it saw was wrong. Fixing accuracy means fixing what the model reads per turn, not swapping models or rewriting the system prompt for the fifth time.
How tool calls and MCP actually work in the Vercel AI SDK
Before reaching for any external service, it helps to be precise about the SDK's two ways of giving a model access to data. The first is a local `tool()`: you define a Zod input schema and an `execute` function, and the SDK exposes it to the model. The model emits a tool call, the SDK runs your `execute`, feeds the result back, and continues. You own the function body, so you own the query. This is the right place to run a typed content query.
The second, newer path is MCP. The Vercel AI SDK can connect to a Model Context Protocol server and pull its tool definitions in at runtime via `experimental_createMCPClient`. Instead of you hand-writing each tool, the server advertises them, the SDK turns them into tools the model can call, and you get schema-aware capabilities without maintaining the wrappers yourself. For a read-only content source this is the shorter path, because the server already knows its own schema and query language.
The key knob for stopping hallucination is `stopWhen` combined with `stepCount`. By default a single call returns after one model response. To let the agent call a tool, read the result, and then answer, you need multiple steps. Set `stopWhen: stepCountIs(5)` (or similar) so the loop runs: model decides to query, tool returns real data, model writes the final answer grounded in that data. Without multi-step, the model often narrates that it 'would look this up' and then guesses anyway. None of this fixes the root cause if the tool returns garbage, but it does guarantee the model gets a chance to read before it speaks.
A local tool that returns typed content
A strict description plus a typed schema nudges the model to call the tool instead of guessing.
import { generateText, tool, stepCountIs } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const result = await generateText({
model: openai('gpt-4o'),
stopWhen: stepCountIs(5),
tools: {
findProducts: tool({
description: 'Search the live product catalog. Always call this before answering questions about products, prices, or stock.',
inputSchema: z.object({
query: z.string(),
maxPrice: z.number().optional(),
}),
execute: async ({ query, maxPrice }) => {
// runs against live content, not a snapshot
return await queryCatalog(query, maxPrice);
},
}),
},
prompt: 'Which waterproof jackets are in stock under $200?',
});Where the data behind the tool should come from
So your `execute` function needs to return real, current product data. The lazy version reads a JSON file or hits a vector DB you populated at build time. Both reintroduce the staleness and the second-source-of-truth problems from the first section. What you actually want is to query the system editors already use, at request time, with the structure intact.
This is the gap Sanity Context fills for an agent-frameworks setup. The agent does not talk to a backend you have to babysit. It calls Context MCP, the hosted read-only MCP endpoint, and gets schema-aware tools: it can read your schema, run GROQ queries, traverse references, and optionally do semantic search, all against the live dataset. For structured content like a product catalog or an article archive, GROQ retrieval is the right tool, because the question almost always has structural predicates the model needs honored exactly: in stock, under a price, of a category, currently published.
Worth being blunt about what production looks like here, because most RAG advice gets it backwards. The heavy majority of agent calls against a Sanity dataset are structured: GROQ queries and schema lookups. Semantic search is a small slice, and embeddings are opt-in and off by default. Most projects shipping on Context MCP never turn them on. The discipline is hybrid retrieval, but hybrid means structural predicates plus keyword matching plus optional embeddings, not embeddings everywhere. If your agent's failures are 'wrong price, wrong stock, wrong variant', those are structural failures, and a pure vector search would not have fixed them anyway. Vector search is one ingredient, not a synonym for good retrieval.
Embeddings are not the default fix
Attaching Context MCP to your agent loop
The fastest way in is to connect the Vercel AI SDK directly to Context MCP. You create an MCP client pointed at the hosted endpoint, pull its tools, and merge them into the `tools` object you already pass to `generateText` or `streamText`. The model now has schema-aware content tools without you writing a single GROQ string or maintaining a sync job. Because the endpoint is read-only, there is no risk of an agent mutating your dataset through it; writes in Sanity go through a separate path, Agent Actions, not MCP. That read-only constraint is a feature when you are wiring up an autonomous loop, not a limitation.
In practice you keep the MCP tools alongside your own local tools. Use Context MCP for everything content-shaped, keep a local tool for anything app-specific like creating an order or hitting your billing API. The model picks per turn. Remember to close the MCP client when the request finishes so you do not leak connections in a long-running server.
Wire Context MCP into streamText
Pull schema-aware content tools from the hosted read-only endpoint and merge them into the agent loop.
import { streamText, experimental_createMCPClient } from 'ai';
import { openai } from '@ai-sdk/openai';
const mcpClient = await experimental_createMCPClient({
transport: {
type: 'sse',
url: 'https://mcp.sanity.io/v1/...',
headers: { Authorization: `Bearer ${process.env.SANITY_MCP_TOKEN}` },
},
});
const contextTools = await mcpClient.tools();
try {
const result = streamText({
model: openai('gpt-4o'),
tools: { ...contextTools },
prompt: 'Which waterproof jackets are in stock under $200?',
});
for await (const part of result.textStream) {
process.stdout.write(part);
}
} finally {
await mcpClient.close();
}When you want full control: a typed GROQ tool
MCP is the default path, but some teams want to own the exact query, shape the result, and strip fields the model does not need to see. In that case write a thin local `tool()` whose `execute` runs a GROQ query against your dataset with `createClient` from `next-sanity`. You decide the projection, so the model gets a tight, token-cheap result instead of a fat document.
This is also where you make a deliberate retrieval choice. For a question like 'in stock under $200', the filtering is pure structure: predicates inside the `*[ ... ]` query against `price`, `inStock`, and `category`. No embeddings involved. When a question genuinely needs fuzzy meaning, GROQ supports hybrid scoring in the same query: `text::semanticSimilarity($queryText)` for semantic relevance, `[field] match text::query($queryText)` for BM25 keyword matching, combined through `score(...)` and read back with `order(_score desc)`. The structural predicates stay where they belong, inside the filter, not folded into the score. That single-query hybrid is the canonical Sanity pattern: structural correctness first, relevance ranking second, optional semantics on top only when you have turned them on.
The payoff for control is that you can pin exactly what the model reads. Return `name`, `price`, and `inStock`, not the entire product document with its draft fields and internal notes. Fewer tokens, less to misread, and a result you can log verbatim for debugging.
A local GROQ tool with next-sanity
Structural predicates live inside the filter; the projection keeps the result token-cheap.
import { tool } from 'ai';
import { createClient } from 'next-sanity';
import { z } from 'zod';
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false,
});
export const findProducts = tool({
description: 'Search the live product catalog before answering about products, prices, or stock.',
inputSchema: z.object({
query: z.string(),
maxPrice: z.number().optional(),
}),
execute: async ({ query, maxPrice }) =>
sanity.fetch(
`*[_type == "product" && inStock == true && (!defined($maxPrice) || price <= $maxPrice) && title match $q]{ title, price, inStock }`,
{ q: `${query}*`, maxPrice: maxPrice ?? null },
),
});Debugging accuracy: trace the retrieval, not just the model
When a grounded agent still returns something wrong, the instinct is to blame the model or tweak the system prompt. Resist it. In a tool-calling loop the failure almost always traces back to retrieval, and the Vercel AI SDK gives you the data to prove it. Every result from `generateText` includes the `steps` array: each tool call, its arguments, and the value it returned. Log those. When you see the model called `findProducts` with `maxPrice: 200` and the tool returned an empty array, you know the bug is in the query or the data, not the LLM.
This is the right thing to send to your observability stack too. Log what the agent saw, the tool input and the raw GROQ result or MCP response, alongside what it said. A trace that only captures the final text tells you the answer was wrong but not why. A trace that captures the retrieval shows you the discontinued SKU was still marked `inStock: true` in the dataset, which is an editorial fix, not a prompt fix.
That editorial angle is the last piece worth naming. The reason structured content sits well under an agent is that Sanity is the Content Operating System for the AI era, the intelligent backend where prices, stock, brand voice, and approved answers are versioned, reviewed, and corrected by humans before they reach the model. When the trace points at bad data, an editor fixes it in the Studio, and Content Releases lets that fix ship through a review step instead of a hotfixed JSON file. The agent reads the corrected content on the next turn, with no redeploy and no second source of truth to keep in sync.