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

```ts [zod]
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 },
    };
  },
});
```

```ts [valibot]
import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";
import { defineTool } from "h3-mcp";

const searchTool = defineTool({
  name: "search",
  description: "Search documents by query",
  // Valibot keeps JSON Schema conversion in a companion package, so a schema
  // is wrapped rather than passed bare — see the warning under Standard Schema.
  inputSchema: toStandardJsonSchema(
    v.object({
      query: v.pipe(v.string(), v.description("Full-text search query")),
      limit: v.optional(
        v.pipe(v.number(), v.integer(), v.maxValue(50), v.description("Max results to return")),
        10,
      ),
    }),
  ),
  outputSchema: toStandardJsonSchema(
    v.object({ hits: v.array(v.object({ id: v.string(), title: v.string() })) }),
  ),
  handler: async ({ query, limit }, event) => {
    const hits = await search(query, limit);
    return {
      content: [{ type: "text", text: `${hits.length} results for "${query}"` }],
      structuredContent: { hits },
    };
  },
});
```

```ts [json schema]
import { defineTool } from "h3-mcp";

const searchTool = defineTool({
  name: "search",
  description: "Search documents by query",
  inputSchema: {
    type: "object",
    properties: {
      query: { type: "string", description: "Full-text search query" },
      limit: { type: "integer", maximum: 50, default: 10, description: "Max results to return" },
    },
    required: ["query"],
  },
  outputSchema: {
    type: "object",
    properties: {
      hits: {
        type: "array",
        items: {
          type: "object",
          properties: { id: { type: "string" }, title: { type: "string" } },
          required: ["id", "title"],
        },
      },
    },
    required: ["hits"],
  },
  // Nothing here runs: `args` is `Record<string, unknown>`, the advertised
  // `default` is never applied, and only the *presence* of `structuredContent`
  // is checked. Narrow it yourself.
  handler: async (args, event) => {
    const hits = await search(String(args.query ?? ""), Number(args.limit ?? 10));
    return {
      content: [{ type: "text", text: `${hits.length} results` }],
      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:

```ts
// 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](https://standardschema.dev/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:

```ts
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](https://modelcontextprotocol.io/specification/2026-07-28): 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`](https://www.npmjs.com/package/@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`](https://github.com/StefanTerdell/zod-to-json-schema).

:read-more{to="/security/validation" title="Input validation"}

## Results

A tool returns `content` — an array of content blocks:

```ts
// 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:

```ts [zod]
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,
    };
  },
});
```

```ts [valibot]
const weatherTool = defineTool({
  name: "weather",
  inputSchema: toStandardJsonSchema(v.object({ city: v.string() })),
  outputSchema: toStandardJsonSchema(v.object({ tempC: v.number(), summary: v.string() })),
  handler: async ({ city }) => {
    const data = await getWeather(city);
    return {
      content: [{ type: "text", text: `${data.summary}, ${data.tempC}°C` }],
      structuredContent: data,
    };
  },
});
```

```ts [json schema]
const weatherTool = defineTool({
  name: "weather",
  inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  outputSchema: {
    type: "object",
    properties: { tempC: { type: "number" }, summary: { type: "string" } },
    required: ["tempC", "summary"],
  },
  handler: async (args) => {
    const data = await getWeather(String(args.city ?? ""));
    return {
      content: [{ type: "text", text: `${data.summary}, ${data.tempC}°C` }],
      // Present, so it ships — whether or not it matches the schema above.
      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 `structuredContent` is rejected;
- `structuredContent` is 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`](#errors) result and an [MRTR](/protocols/modern/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`:

::playground{item="tools/weather" label="tools/weather — structured output"}
::

> [!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:

```ts
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.

:read-more{to="/guide/errors" title="Errors"}

## Annotations

Annotations are behavior hints clients use to decide whether to ask for confirmation:

```ts
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`.

```ts
defineTool({
  name: "delete-item",
  scopes: ["items:write"],
  handler: async ({ id }) => {
    /* runs after the items:write scope check */
  },
});
```

:read-more{to="/security/auth#scopes" title="Scopes"}

## Icons and Task Support

```ts
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](/protocols/modern/tasks). `"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()`:

```ts
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 },
    };
  },
});
```

:read-more{to="/protocols/modern/streaming" title="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{to="/protocols/modern/mrtr" title="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.
