Tools
Functions a model can call.
A tool is a name, an optional input schema, an optional output schema, and a handler. Clients discover them with tools/list and invoke them with tools/call, on both eras.
import { z } from "zod";
import { defineTool } from "h3-mcp";
const searchTool = defineTool({
name: "search",
description: "Search documents by query",
inputSchema: z.object({
query: z.string().describe("Full-text search query"),
limit: z.int().max(50).default(10).describe("Max results to return"),
}),
outputSchema: z.object({
hits: z.array(z.object({ id: z.string(), title: z.string() })),
}),
handler: async ({ query, limit }, event) => {
const hits = await search(query, limit);
return {
content: [{ type: "text", text: `${hits.length} results for "${query}"` }],
structuredContent: { hits },
};
},
});The first two tabs are the same tool twice: a schema library describes the tool to the client and checks both ends of the call at runtime. The third describes it just as well and enforces nothing — that difference is the subject of the next two sections, and it is why the examples below use Zod.
#Handler Signature
The signature depends on whether the tool takes input:
// With `inputSchema` — (args, event)
defineTool({
name: "echo",
inputSchema: z.object({ message: z.string() }),
handler: async ({ message }, event) => ({ content: [{ type: "text", text: message }] }),
});
// Without `inputSchema` — (event)
defineTool({
name: "status",
description: "Get server status",
handler: async (event) => ({ content: [{ type: "text", text: "OK" }] }),
});args is typed from the schema. A plain JSON Schema object carries no type to infer, so it widens to Record<string, unknown> and every field needs narrowing before use — never an as string cast, which asserts what the wire never promised.
#Standard Schema
Standard JSON Schema implementors — Zod v4.2+, ArkType v2.1.28+, Valibot through toStandardJsonSchema() — can be passed straight through as inputSchema and outputSchema. They are resolved to plain JSON Schema when tools/list serializes them, and validated at runtime before your handler runs, so args is both typed and checked:
import { defineTool } from "h3-mcp";
import { z } from "zod";
const createUserTool = defineTool({
name: "create-user",
description: "Create a new user account",
inputSchema: z.object({
name: z.string().describe("Full name of the user"),
email: z.email(),
role: z.enum(["admin", "member"]).optional(),
}),
outputSchema: z.object({ id: z.string(), name: z.string() }),
handler: async (args) => {
// args: { name: string; email: string; role?: "admin" | "member" }
const user = await db.createUser(args);
return {
content: [{ type: "text", text: `Created user ${user.name}` }],
structuredContent: { id: user.id, name: user.name },
};
},
});Invalid arguments never reach the handler. How the failure is reported is version-aware, per SEP-1303: clients on ≤2025-06-18 get a JSON-RPC error, clients on ≥2025-11-25 get a tool result with isError: true so the model can correct itself.
Note
A plain JSON Schema — as inputSchema or as outputSchema — is advertised to clients but not validated at runtime; there is no validator to call. Use a Standard Schema implementor if you want server-side enforcement, or validate inside the handler.
Warning
A bare Valibot schema is the one trap here. Valibot keeps JSON Schema conversion in a companion package for bundle-size reasons, so v.object({…}) carries ~standard.validate but no ~standard.jsonSchema. Hand one over directly and it is validated correctly while tools/list advertises Valibot's internal object — { kind, type, entries, … } — in place of a schema. Wrap it in toStandardJsonSchema() from @valibot/to-json-schema v1.5+ and both halves work, as in the tab above.
You can also pre-convert with z.toJSONSchema(schema) for explicit control. For Zod v3, use zod-to-json-schema.
#Results
A tool returns content — an array of content blocks:
// Text
return { content: [{ type: "text", text: "Hello" }] };
// Image or audio (base64)
return { content: [{ type: "image", data: "iVBOR...", mimeType: "image/png" }] };
// Embedded resource
return {
content: [
{
type: "resource",
resource: { uri: "app:///report.md", mimeType: "text/markdown", text: "# Report" },
},
],
};Note
A server that embeds resource content in tool results should also implement the resources capability, so a client can fetch the same URI on its own. h3-mcp cannot enforce this: capabilities are derived from your resources / resourceTemplates definitions, and nothing inspects what a handler returns. If your tools embed resources, declare the matching resource (or a resource template covering the URI space) yourself.
#Structured Output
Declare an outputSchema and return structuredContent alongside the human-readable blocks. Since 2026-07-28, structuredContent may be any JSON value, not only an object:
const weatherTool = defineTool({
name: "weather",
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ tempC: z.number(), summary: z.string() }),
handler: async ({ city }) => {
const data = await getWeather(city);
return {
content: [{ type: "text", text: `${data.summary}, ${data.tempC}°C` }],
structuredContent: data,
};
},
});outputSchema is checked, not merely advertised — a result the tool's own published contract rejects does not reach the client:
- a non-error result with no
structuredContentis rejected; structuredContentis validated when the schema carries a validator (a Standard Schema implementor). A plain JSON Schema object does not, so only presence is checked.
Both failures are -32602 on every era, mirroring the official SDK. The message is static: the offending value is your own server's, and a validator's issue text can quote fragments of it back, so none of it goes on the wire. Test your handlers against their schemas.
Results that are not final output are left alone: an isError result and an MRTR input_required result carry no structured payload to check.
The playground's own weather tool is this example, running — call it and the panel shows both halves of the result, the text content and the validated structuredContent:
Note
This is a behavior change: a tool that declared an outputSchema and returned no structuredContent used to be served as-is. Two details worth knowing. Presence is tested against undefined, so a falsy structuredContent (0, false, null) is a value, not an omission. And a schema that transforms — z.stringbool(), .default(), a .pipe() chain — is advertised from its output type but validated on its input side, because Standard Schema exposes a single input → output validator (the official SDK has the same asymmetry). Declare output schemas as the shape your handler actually returns and the two coincide.
#Errors
A tool that fails in a way the model should see returns isError: true — this is a successful JSON-RPC response carrying a failed tool call:
defineTool({
name: "publish",
inputSchema: z.object({ rows: z.array(z.string()) }),
outputSchema: z.object({ published: z.number() }),
// A schema says the call was *well-formed*, not that it made sense. An empty
// batch is valid input and a failed operation, so it is the model's problem,
// not the client's — and an `isError` result is never judged against
// `outputSchema`, so it carries no `structuredContent`.
handler: async ({ rows }) => {
if (rows.length === 0) {
return {
isError: true,
content: [{ type: "text", text: "Nothing to publish: `rows` was empty." }],
};
}
await publish(rows);
return {
content: [{ type: "text", text: `Published ${rows.length} rows` }],
structuredContent: { published: rows.length },
};
},
});Throwing, by contrast, is a protocol error and its message is not shown to the client on the modern era.
#Annotations
Annotations are behavior hints clients use to decide whether to ask for confirmation:
const deleteTool = defineTool({
name: "delete-item",
description: "Delete an item by ID",
annotations: {
title: "Delete item",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
inputSchema: z.object({ id: z.string().describe("Item to delete") }),
outputSchema: z.object({ deleted: z.string() }),
handler: async ({ id }) => {
await db.delete(id);
return {
content: [{ type: "text", text: `Deleted ${id}` }],
structuredContent: { deleted: id },
};
},
});They are hints, not enforcement — never rely on a client honoring them.
#Scopes
Set scopes to require OAuth scopes before a tool runs. A caller missing a required scope gets HTTP 403 with an insufficient_scope challenge. If auth is disabled, calling a scoped tool returns 500 without running the handler. The scopes field is not included in tools/list.
defineTool({
name: "delete-item",
scopes: ["items:write"],
handler: async ({ id }) => {
/* runs after the items:write scope check */
},
});#Icons and Task Support
defineTool({
name: "render",
icons: [{ src: "https://example.com/render.svg", mimeType: "image/svg+xml", theme: "dark" }],
execution: { taskSupport: "optional" }, // "required" | "optional" | "forbidden"
handler: async () => ({ content: [{ type: "text", text: "..." }] }),
});"optional" and "forbidden" are advisory: they are advertised in tools/list and never change dispatch, because the server alone decides per request whether to answer with a task. "required" does change dispatch — a tools/call from a client that did not declare the tasks extension is rejected with -32021 before the handler runs. It needs the tasks plugin installed ({ extensionPlugins: [mcpTasks()] }); without it there are no tasks/* methods to serve such a tool, and the call fails as the misconfiguration it is.
#Long-Running Tools
Cooperate with cancellation via event.context.mcp.signal, and report progress with event.context.mcp.progress():
const importTool = defineTool({
name: "import",
outputSchema: z.object({ imported: z.number() }),
handler: async (event) => {
const mcp = event.context.mcp!;
for (let i = 1; i <= 10; i++) {
if (mcp.signal?.aborted) {
return { isError: true, content: [{ type: "text", text: "Cancelled" }] };
}
mcp.progress?.(i, 10, `row ${i}`);
await importRow(i);
}
return {
content: [{ type: "text", text: "Imported 10 rows" }],
structuredContent: { imported: 10 },
};
},
});#Asking the Client for Input
A tool that needs the user to answer something returns an interim input_required result instead of calling the client itself:
#Pagination
tools/list is paginated with an opaque cursor automatically — you never handle cursor yourself. Clients that ignore nextCursor simply see the first page.