Tool Call Execution
By the Syncfusion DevTutorials team · Last updated
The steps of an MCP tool call: catalog, inspection, selection, approval, tools/call and results, with content blocks, outputSchema and structuredContent, isError, progress and cancellation.
Think about ordering at a busy café with a menu board. You read the menu and choose. The cashier checks that the item exists and, if needed, asks "Are you sure? It's spicy." The kitchen makes it. Then you get a tray: a drink, a sandwich, maybe a receipt with a link to the café's app.
A tool call follows a similar path. The host reads the menu (tools/list). The model requests a tool. The host checks the request and, when its policy requires it, asks the user. The server does the work. The result comes back as a "tray" of content blocks.
The main idea: a typical tool call follows these steps: list, inspect, select, approve when needed, call, and read the result. Each step has a job, and the host decides which tools to show and what checks or approval its policy requires.
A Real-World Example
Maya asks the assistant: "When will A-1002 arrive?"
- When it started, the host got the tool catalog with
tools/list. - It gave the model the read-only tools that suit this chat.
- The model asked for
get_order_statuswith{"order_id": "A-1002"}. - The host checked the tool exists, the arguments fit the schema, and the tool is read-only. No approval needed.
- The client sent
tools/call. - The server returned text for the model, structured data for the app, and a link to the full order record.
- The model answered: "Your air purifier is being prepared and should arrive on 18 October."
In simple words: the host runs a careful checklist around every tool call.
What You Will Learn
In this lesson, you will learn:
- How a host reads and prepares the tool catalog.
- How the host selects, checks and approves a call.
- The kinds of content blocks a result can hold.
- How
outputSchemaandstructuredContentgive programs reliable data. - How
isErrorresults feed back to the model. - Where progress and cancellation fit in.
The MCP Lab has a new tool, get_order_status, with an output schema. When you call it, the Explorer shows its structuredContent and a button to read the linked resource.
The typical steps of a tool call. The host owns the checks and any required approval. The server owns the work and the result.
Before a model can use a tool, the host must know which tools are available. The tool catalog is the host's source of information about what a server can do and what inputs each tool expects.
The Catalog
tools/list
Before a model can request a tool, it needs to know which tools exist and what inputs they accept. The host gets this information from the tool catalog.
The client asks the server for its tools once, keeps the list, and asks again when it hears notifications/tools/list_changed. Long lists may come in pages: the result then has a nextCursor, and the client asks again with that cursor until there is none.
Getting a list of tools is only the first step. Before the host gives those tools to a model, it often reviews, filters, and adapts them for its own environment and policies.
A raw MCP catalog is not always the same format expected by a model provider. Hosts often transform, filter, and review tools before exposing them to the model.
Catalog Parsing and Schema Inspection
Before showing tools to the model, the host prepares them:
- Convert: model APIs want tools in their own format, usually a name, a description and a parameters schema. The host copies them from the MCP catalog.
- Inspect: the host reads each schema and annotation. It can refuse tools with broken schemas and plan which ones need approval under its policy. Annotations are hints, not proof that a tool is safe.
- Select: the host doesn't have to show every tool. It can show only tools that suit this chat or this user, like read-only tools for a reporting assistant.
The example below filters on the readOnlyHint annotation to demonstrate selection. In a real host, do not treat an annotation as a permission grant; apply the host's own policy and trust decisions.
Prepare the catalog for the model
// Step 1: turn the tools/list catalog into the "tools" list a model API expects.
const catalog = [
{
name: "lookup_order",
description: "Get one order.",
inputSchema: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
annotations: { readOnlyHint: true },
},
{
name: "cancel_order",
description: "Cancel one order.",
inputSchema: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
annotations: { destructiveHint: true },
},
{
name: "export_report",
description: "Build a report.",
inputSchema: {
type: "object",
properties: { rows: { type: "integer", minimum: 1 } },
required: ["rows"],
},
annotations: { readOnlyHint: true },
},
];
// The host can also leave tools out. Here: show only read-only tools in this chat.
const forModel = catalog
.filter((t) => t.annotations?.readOnlyHint === true)
.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }));
console.log(
forModel
.map((t) => `${t.name}(${Object.keys(t.parameters.properties).join(", ")})`)
.join("\n"),
);
console.log("hidden:", catalog.length - forModel.length);
Expected output:
lookup_order(order_id)
export_report(rows)
hidden: 1
Showing a focused set of tools can make the available choices easier to understand. Give the model the tools that fit the task instead of including unrelated ones.
Once the model can see available tools, it may decide that a tool is needed to answer a question. However, choosing a tool does not mean the tool gets executed immediately. The host still needs to validate the request and apply its approval rules.
Selection and Approval
The Model Selects
The model reads the question and the tool list, and asks for a tool by name with arguments. It may also answer without a tool. Only the model's request comes from the model. Everything after that is the host's decision.
The Host Checks
A model can make mistakes. It may choose the wrong tool, omit required arguments, or request an operation that should not be allowed in the current conversation. For that reason, hosts validate requests before sending them to the server.
Before sending a request, a host can check:
- Does a tool with this name exist?
- Do the arguments fit the input schema?
- Is the tool allowed in this chat?
- Does it need the user's approval?
If the arguments are wrong, the host can send the problem back to the model without calling the server. The server must still validate every request it receives; it cannot rely on the host's check.
Check a tool request
// Step 2: the model asked for a tool. The host checks it before anything runs.
const catalog = {
lookup_order: { annotations: { readOnlyHint: true }, required: ["order_id"] },
create_ticket: {
annotations: { readOnlyHint: false, destructiveHint: false },
required: ["summary", "priority"],
},
};
function decide(call) {
const tool = catalog[call.name];
if (!tool) {
return "reject: no such tool";
}
const missing = tool.required.filter((k) => !(k in call.arguments));
if (missing.length) {
return "send back to the model: missing " + missing.join(", ");
}
if (tool.annotations.readOnlyHint) {
return "run now";
}
return "ask the user: " + call.name + " " + JSON.stringify(call.arguments);
}
console.log(decide({ name: "lookup_order", arguments: { order_id: "A-1002" } }));
console.log(
decide({
name: "create_ticket",
arguments: { summary: "Lamp broken", priority: "high" },
}),
);
console.log(decide({ name: "create_ticket", arguments: { summary: "Lamp broken" } }));
console.log(decide({ name: "refund_all", arguments: {} }));
Expected output:
run now
ask the user: create_ticket {"summary":"Lamp broken","priority":"high"}
send back to the model: missing priority
reject: no such tool
User Approval
Approval is most commonly used for actions that create, update, delete, send, purchase, or otherwise affect real data. Read-only operations often run immediately, while actions with consequences usually require confirmation.
When its policy calls for confirmation, the host should show the user what is about to happen, including the tool name and arguments. The user can approve, edit or refuse. Approval rules belong to the host; MCP does not itself require a confirmation step for every tool that changes data.
After the host has accepted a tool request, it can send that request to the server. The server performs the work and returns a result that the host and model can use.
tools/call and Its Result
The Request
tools/call sends the tool name and the arguments. Over HTTP, the client also adds the Mcp-Name header with the tool name.
Not every tool returns the same kind of data. Some return text, others return images, audio, or references to resources. MCP represents all of these using content blocks.
Content Blocks
The result's content is a list of blocks. One result can mix several kinds:
| Block type | What it holds |
|---|---|
text | Plain text for the model |
image | Base64 image data with a mimeType |
audio | Base64 audio data with a mimeType |
resource_link | A pointer to a resource: a uri, a name, a mimeType. The client reads it only if needed |
resource | An embedded resource: the resource's contents, right inside the result |
In practice, text is the most common block type. Images and audio are used for media results, while resource_link and resource are useful when a tool needs to expose additional data.
Handle each block type
// Step 3: read a tool result. It can hold several kinds of content blocks.
const result = {
content: [
{ type: "text", text: "Order A-1001 is shipped." },
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
{ type: "audio", data: "UklGRg==", mimeType: "audio/wav" },
{
type: "resource_link",
uri: "orders://A-1001",
name: "Order A-1001",
mimeType: "application/json",
},
{
type: "resource",
resource: { uri: "kb://shipping", mimeType: "text/plain", text: "3 to 6 days." },
},
],
resultType: "complete",
};
for (const block of result.content) {
switch (block.type) {
case "text":
console.log("text ->", block.text);
break;
case "image":
case "audio":
console.log(block.type.padEnd(8), "->", block.mimeType, "as base64");
break;
case "resource_link":
console.log("link ->", block.uri, "(read it later if needed)");
break;
case "resource":
console.log("embedded ->", block.resource.uri + ":", block.resource.text);
break;
}
}
Expected output:
text -> Order A-1001 is shipped.
image -> image/png as base64
audio -> audio/wav as base64
link -> orders://A-1001 (read it later if needed)
embedded -> kb://shipping: 3 to 6 days.
A resource_link can keep results small. Instead of including a huge record, the tool points to where it can be read. The host can decide whether that resource is needed.
Think of a resource_link as receiving a file path rather than the entire file. The tool tells the host where the data lives, and the host can decide whether retrieving it is worth the extra cost.
The content blocks above are useful for presenting information. However, applications sometimes need data in a predictable format that code can process without guessing.
Structured Results
Text is easy for a model to read, but harder for a program to use reliably. For example, a program can reliably read structuredContent.status to check whether an order is shipped. Extracting the same value from a paragraph of text requires guessing and may fail.
If a tool declares an outputSchema, its result must include structuredContent, a JSON object that matches that schema. A text block with the same data is useful for compatibility with clients that primarily consume text, but it does not replace structuredContent.
The official SDK used in this course checks structuredContent against the declared outputSchema before sending the result. If the data is missing or does not match, that SDK reports a tool error instead. This validation behavior is an SDK implementation detail, not a check performed automatically by every client.
Check structured content
// A small teaching example for the fields below, not a full JSON Schema validator.
const outputSchema = {
type: "object",
properties: {
order_id: { type: "string" },
status: { type: "string", enum: ["processing", "shipped", "delivered"] },
eta: { type: "string" },
},
required: ["order_id", "status", "eta"],
};
function matches(schema, value) {
for (const key of schema.required) {
if (!(key in value)) {
return `missing ${key}`;
}
}
for (const [key, rule] of Object.entries(schema.properties)) {
if (key in value && typeof value[key] !== rule.type) {
return `${key} is not a ${rule.type}`;
}
if (rule.enum && !rule.enum.includes(value[key])) {
return `${key} is not one of ${rule.enum.join("/")}`;
}
}
return "ok";
}
const result = {
structuredContent: { order_id: "A-1002", status: "processing", eta: "2026-10-18" },
content: [
{
type: "text",
text: '{"order_id":"A-1002","status":"processing","eta":"2026-10-18"}',
},
],
};
console.log("check:", matches(outputSchema, result.structuredContent));
console.log(
"a program can now read:",
result.structuredContent.status,
"until",
result.structuredContent.eta,
);
console.log(
"bad one:",
matches(outputSchema, { order_id: "A-1", status: "lost", eta: "?" }),
);
Expected output:
check: ok
a program can now read: processing until 2026-10-18
bad one: status is not one of processing/shipped/delivered
Not every failure is the same. Sometimes the request itself is invalid, which is a protocol error. Other times the request is valid but the requested work cannot be completed. That second case is a tool error.
Not every tool execution succeeds. Some requests are valid but still cannot be completed because of missing data, unavailable records, or business rules.
isError
When a tool runs but cannot complete its task, such as when an order does not exist, its result can set isError: true and include a helpful message in content. The host can pass that result back to the model, which may correct the call, ask the user for more information, or explain the problem. A protocol-level error is different: the request itself could not be processed as a valid call.
A tool error, then a retry
// The whole execution, with a stand-in server and a stand-in model.
const server = {
call(name, args) {
if (name !== "lookup_order") {
return { error: { code: -32602, message: `Tool ${name} not found` } };
}
if (args.order_id !== "A-1002") {
return {
result: {
content: [{ type: "text", text: "No order " + args.order_id }],
isError: true,
},
};
}
return {
result: {
content: [
{ type: "text", text: "A-1002: Air purifier, processing, arrives 2026-10-18" },
],
},
};
},
};
const modelWants = [
{ name: "lookup_order", arguments: { order_id: "A-1020" } },
{ name: "lookup_order", arguments: { order_id: "A-1002" } },
];
for (let turn = 0; turn < modelWants.length; turn++) {
const call = modelWants[turn];
const reply = server.call(call.name, call.arguments);
if (reply.error) {
console.log("protocol error, stop:", reply.error.message);
break;
}
const text = reply.result.content.map((c) => c.text).join(" ");
if (reply.result.isError) {
console.log(`turn ${turn + 1}: tool error -> back to the model: "${text}"`);
continue;
}
console.log(`turn ${turn + 1}: success -> model writes the answer from: "${text}"`);
break;
}
Expected output:
turn 1: tool error -> back to the model: "No order A-1020"
turn 2: success -> model writes the answer from: "A-1002: Air purifier, processing, arrives 2026-10-18"
A tool result becomes part of the model's context. Text from outside, such as an email or web page, may contain instructions aimed at the model. Treat that text as untrusted data, not as instructions to follow. Merely labeling it as data does not make it safe; keep approval and access rules in host code.
Many tool calls finish almost instantly, but some take longer. Large reports, long-running searches, or external operations may need a way to report progress and allow cancellation.
Some tools respond in seconds, while others may generate reports, search large data sets, or wait for external systems. Long-running work benefits from progress updates and a way to stop execution when it is no longer needed.
Progress and Cancellation
Long tool calls can use the progress and cancellation features from Chapter 2:
- The client adds a
progressToken, and the server streamsnotifications/progressbefore the result. - To cancel, the client closes that request's stream over Streamable HTTP. With stdio, it sends
notifications/cancelledwith the request ID.
A host should show progress to the user, offer a Stop button for slow tools, and set a time limit, so a stuck tool can't freeze the chat.
Common mistakes
- Treating the model's tool request as approved. The model only asks. The host decides.
- Throwing protocol errors for tool failures. A missing order is
isError: true, so the model can recover. - Ignoring
nextCursor. Large catalogs come in pages. Keep asking until there is no cursor. - Ignoring non-text blocks. A host that only reads
textloses images, links and embedded resources. - No time limit. A slow tool without progress or a timeout looks like a frozen app.
Exercises
Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.
What to Remember
- A typical tool call goes: list, inspect, select, approve when needed, call, read the result.
- The host converts and filters the catalog, applies its checks, and asks the user when its policy requires approval.
- Results hold content blocks:
text,image,audio,resource_linkand embeddedresource. - With an
outputSchema, return matchingstructuredContent; a text copy can help text-focused clients. isError: truegoes back to the model so it can recover.- Long calls may use progress and cancellation; hosts should also set a time limit. A cancellation request does not guarantee the work stopped.
Next: Tool Schema Validation, checking arguments and results on both sides.