Tools
Functions a model can call.
A tool is a name, an optional input schema, and a handler. Clients discover them with tools/list and invoke them with tools/call, on both eras.
import { defineMcpTool } from "h3-mcp";
const searchTool = defineMcpTool({
name: "search",
description: "Search documents by query",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "number" },
},
required: ["query"],
},
handler: async (args, event) => ({
content: [{ type: "text", text: `Results for: ${args.query}` }],
}),
});#Handler Signature
The signature depends on whether the tool takes input:
// With `inputSchema` — (args, event)
defineMcpTool({
name: "echo",
inputSchema: { type: "object", properties: { message: { type: "string" } } },
handler: async (args, event) => ({ content: [{ type: "text", text: args.message as string }] }),
});
// Without `inputSchema` — (event)
defineMcpTool({
name: "status",
description: "Get server status",
handler: async (event) => ({ content: [{ type: "text", text: "OK" }] }),
});#Standard Schema
Standard JSON Schema implementors — Zod v4.2+, Valibot v1.2+, ArkType v2.1.28+ — can be passed straight through as inputSchema. 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 { defineMcpTool } from "h3-mcp";
import { z } from "zod";
const createUserTool = defineMcpTool({
name: "create-user",
description: "Create a new user account",
inputSchema: z.object({
name: z.string().describe("Full name of the user"),
email: z.string().email(),
role: z.enum(["admin", "member"]).optional(),
}),
handler: async (args) => {
// args: { name: string; email: string; role?: "admin" | "member" }
return { content: [{ type: "text", text: `Created user ${args.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
Plain JSON Schema inputSchema 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.
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" },
},
],
};#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 = defineMcpTool({
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,
};
},
});#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:
handler: async (args) => {
if (!args.data) {
return {
isError: true,
content: [{ type: "text", text: "Validation failed: data is empty" }],
};
}
return { content: [{ type: "text", text: "Valid" }] };
};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 = defineMcpTool({
name: "delete-item",
description: "Delete an item by ID",
annotations: {
title: "Delete item",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
handler: async (args) => ({ content: [{ type: "text", text: `Deleted ${args.id}` }] }),
});They are hints, not enforcement — never rely on a client honoring them.
#Icons and Task Support
defineMcpTool({
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: "..." }] }),
});#Long-Running Tools
Cooperate with cancellation via event.context.mcp.signal, and report progress with event.context.mcp.progress():
const importTool = defineMcpTool({
name: "import",
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" }] };
},
});#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.