Your Mistral agent calls a tool named getProduct, the model fills in productId with a value it inferred from the conversation, and the function returns null because that ID never existed. The model apologizes, invents a plausible spec sheet, and your support thread now contains a confidently wrong answer. Function calling routed correctly. The arguments were garbage, and nothing in the loop knew the shape of your actual content.
Sanity Context fixes the argument-grounding problem at the source. 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. Knowledge Bases is the second surface, for unstructured sources like PDFs, websites, and support data. Instead of letting Mistral guess argument values against a free-form prompt, the model queries real, typed content and gets back documents that conform to a published schema.
This article works through Mistral's function calling and structured output APIs first: how the tool loop runs, where arguments drift, and how json_schema response formats tighten the model's output. Then it shows the retrieval patterns that keep those calls grounded in content that actually exists.
How Mistral runs the function calling loop
Mistral's tool use follows the same multi-turn pattern across Large, Small, and the open models. You pass a `tools` array of JSON-schema function definitions and `tool_choice: "auto"`. The model responds with a `tool_calls` array instead of content, you execute each call locally, append the result as a `tool` role message keyed by `tool_call_id`, and call the API again so the model can read the result and answer.
The contract is strict on one point: every `tool_call_id` the model emits must come back with a matching `tool` message before the next completion, or the API rejects the request with a 400. Mismatched or missing IDs are the most common reason a working demo breaks the moment a model decides to call two tools in one turn.
Where this loop goes wrong is not the routing. Mistral is reliable at picking getProduct over searchOrders. The failure is in the arguments. The model fills `productId` from whatever it saw in the conversation, and if the user said 'the blue running shoe' the model invents an ID, or reuses one from an earlier turn, or guesses a format. Your function dutifully runs the query, finds nothing, and returns an empty result that the model then papers over with a fluent guess.
A Mistral tool-use round trip
The model returns tool_calls; you parse arguments and execute the function yourself.
from mistralai import Mistral
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
tools = [{
"type": "function",
"function": {
"name": "get_product",
"description": "Fetch a product by its catalog ID",
"parameters": {
"type": "object",
"properties": {
"product_id": {"type": "string"}
},
"required": ["product_id"],
},
},
}]
messages = [{"role": "user", "content": "What does the blue running shoe cost?"}]
resp = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools,
tool_choice="auto",
)
call = resp.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments)
# args["product_id"] is whatever the model guessed; it may not existTighten outputs with json_schema response formats
Before reaching for retrieval, pin down the part you control: the model's output shape. Mistral supports structured outputs through `response_format`, either a loose `{"type": "json_object"}` or, better, a strict `json_schema` that the model is constrained to satisfy. With a schema attached, the model cannot return a free-form paragraph where you expected a price and a currency code. It returns the fields you declared, typed.
This matters for two reasons. First, it removes a whole class of parsing failures where you `json.loads` the response and crash on trailing prose. Second, it lets you separate two concerns that developers usually tangle together: extracting structure from text, and grounding that structure in real data. Structured outputs solve the first. They do nothing for the second. A schema-conformant response can still contain a `product_id` that was never in your catalog, because the schema constrains the SHAPE, not the VALUE.
The useful mental model: `json_schema` guarantees the envelope, not the contents. If you only need the model to format what it already knows, structured outputs are enough. The moment the correctness of a value depends on data the model has never seen, formatting will not save you. You need the model to read that data during the loop, which is where a content-aware tool comes in.
Constrain Mistral output to a JSON schema
json_schema fixes the envelope. It does not check that the values map to real content.
resp = client.chat.complete(
model="mistral-large-latest",
messages=[
{"role": "system", "content": "Extract the product reference."},
{"role": "user", "content": "I want the blue running shoe in size 10"},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "product_query",
"schema": {
"type": "object",
"properties": {
"color": {"type": "string"},
"category": {"type": "string"},
"size": {"type": "integer"},
},
"required": ["color", "category"],
"additionalProperties": False,
},
},
},
)
# Reliable shape: {"color": "blue", "category": "running shoe", "size": 10}
# Still NOT grounded: no guarantee such a product existsConnect Mistral to typed content over Context MCP
The cleanest fix is to stop asking the model to supply `product_id` from imagination and instead give it a tool that searches real content. With Sanity Context, the fastest way in is Context MCP, a hosted read-only MCP endpoint. Your agent loop attaches it as an MCP server and gets schema-aware tools out of the box: the model can read the dataset's schema, run GROQ queries, and traverse references without you hand-writing each function.
The read-only constraint is the point. The model can query the catalog, follow a reference from a product to its brand, and read publication state, but it cannot mutate anything. Writes go through Agent Actions, not MCP, so an over-eager tool call can't corrupt content. For a function-calling loop this is exactly the boundary you want: rich reads, no destructive surprises.
In practice the model's flow changes. Instead of inventing `product_id`, it calls a query tool with the structured fields you extracted earlier (`color`, `category`), gets back the actual matching documents from your dataset, and answers from those. When zero documents match, the tool returns an empty array, which is a far better signal for the model than a fabricated ID returning null. The model can say 'I don't see a blue running shoe in the catalog' instead of inventing one.
Attach Context MCP to a Mistral tool loop
The agent attaches the hosted MCP endpoint and gets read-only, schema-aware content tools.
import { Mistral } from "@mistralai/mistralai";
import { experimental_createMCPClient } from "ai";
const mcp = await experimental_createMCPClient({
transport: {
type: "sse",
url: "https://<projectId>.api.sanity.io/context/mcp",
},
});
// Schema-aware tools exposed by Context MCP: schema reads,
// GROQ query execution, and reference traversal.
const tools = await mcp.tools();
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
const resp = await client.chat.complete({
model: "mistral-large-latest",
messages: [
{ role: "user", content: "What does the blue running shoe cost?" },
],
tools, // the model now queries real content instead of guessing IDs
toolChoice: "auto",
});Most of these calls are structured, not semantic
There is a reflex, when you hear 'ground the model in content,' to reach for embeddings and a vector database. For typed catalog content that is usually the wrong first move. Across production traffic on Context MCP, the heavy majority of calls are structured: GROQ queries and schema lookups, with a compressed initial context behind them. Semantic search is a small slice, opt-in, off by default, and most projects never turn it on.
The reason is that the questions a function-calling agent asks usually have structural answers. 'The blue running shoe' is a filter on `color` and `category`. 'Orders from last week' is a date-range predicate. 'In-stock variants' is a check on a stock field. None of that needs an embedding. A vector search would actually do worse here, because it would return things that are semantically near 'blue running shoe' but ignore the hard constraints the user stated.
So the routing rule is simple. Structured content, your catalog, your articles with a schema, goes through GROQ retrieval. Unstructured content, PDFs, marketing pages, support transcripts, goes through Knowledge Bases, which turns those messy sources into ordered documents with a clear table of contents. High-volume machine-generated corpora that need no editorial governance can still live in a dedicated vector DB. The discipline is hybrid, but hybrid means structured predicates plus keyword match plus optional embeddings, not embeddings everywhere.
Embeddings are not the default answer
When you do need meaning, use hybrid GROQ retrieval
Some queries genuinely are about meaning. 'Something comfortable for long-distance trail runs' has no clean predicate. This is where you reach past pure structure, but you do not abandon it. The pattern is one GROQ query that combines a structural filter, a keyword match, and a semantic similarity score, then orders by the combined score. Structural predicates live inside the `*[ ... ]` filter and narrow the candidate set. Inside `score()`, `text::query()` contributes BM25 keyword relevance and `text::semanticSimilarity()` contributes meaning, and `order(_score desc)` ranks the result.
Note what `text::semanticSimilarity()` takes: the query TEXT, not a precomputed embedding field. Sanity handles the embedding behind the scenes when semantic search is enabled on the dataset. You pass the user's phrasing through, you keep your hard filters as ordinary predicates, and you get back ranked, typed documents your Mistral loop can answer from. The structural filter still does most of the work; the semantic score only reorders within the candidates that already passed your constraints.
Hybrid retrieval in a single GROQ query
Structural predicates filter; score() blends keyword and semantic relevance; order ranks by _score.
*[_type == "product" && category == $category && inStock == true]
| score(
boost(brand->name match text::query($queryText), 2),
text::semanticSimilarity($queryText)
)
| order(_score desc) [0...5] {
_id, title, price, "brand": brand->name, _score
}Where this fits in the broader Sanity picture
Stepping back from the loop: the reason this grounding works is that the content the model reads is the same content humans model, edit, and govern. Sanity is the Content Operating System for the AI era, the intelligent backend for teams building AI content operations at scale. The schema your Mistral agent reads over Context MCP is the schema your editors author against, so there is no separate 'AI copy' of the catalog drifting out of sync with production.
That shared foundation is what separates this from bolting a vector index onto a content export. When an editor unpublishes a product, the MCP query stops returning it, because publication state is a real field the read-only endpoint respects. When the team prepares a seasonal catalog change, Content Releases lets them stage and preview it before it goes live, and the agent reads the live version until the release ships. Governance is not a wrapper you build around the model. It is the substrate the model queries.
The provider stays independent in all of this. You can run Mistral Large for the reasoning turns and a smaller open model for cheap extraction, swap to a different provider tomorrow, and the retrieval layer does not change. What changes per turn is what the model SEES, and that is the thing worth controlling.