h3-mcp logoh3-mcp

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.

Read more in Input validation.

#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.

Read more in Errors.

#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" }] };
  },
});
Read more in Request-scoped streaming.

#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:

Read more in Multi round-trip requests.

#Pagination

tools/list is paginated with an opaque cursor automatically — you never handle cursor yourself. Clients that ignore nextCursor simply see the first page.

h3-mcp logo

h3-mcp  MCP servers, built on H3.