
# Standard Schema

> Typed, validated tool inputs and outputs, from any implementor.

Hand a [Standard JSON Schema](https://standardschema.dev/json-schema) implementor to `inputSchema` or `outputSchema` and you get three things at once: JSON Schema for clients, a TypeScript type for your handler, and runtime validation at the boundary. The same tool, in each of the three:

::code-group

```ts [zod]
import { z } from "zod";
import { defineTool } from "h3-mcp";

export const createIssue = defineTool({
  name: "create-issue",
  description: "Open an issue in the tracker",
  inputSchema: z.object({
    title: z.string().min(1).max(200).describe("One-line summary"),
    body: z.string().optional(),
    labels: z.array(z.enum(["bug", "feature", "docs"])).default([]),
    priority: z.int().min(1).max(5).default(3),
  }),
  outputSchema: z.object({ number: z.number(), url: z.string() }),
  handler: async (args) => {
    // args: { title: string; body?: string; labels: (...)[]; priority: number }
    const issue = await tracker.create(args);
    return {
      content: [{ type: "text", text: `Created #${issue.number}` }],
      structuredContent: { number: issue.number, url: issue.url },
    };
  },
});
```

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

export const createIssue = defineTool({
  name: "create-issue",
  description: "Open an issue in the tracker",
  // Valibot's conversion lives in a companion package, so the schema is wrapped
  // rather than handed over bare — see the warning below.
  inputSchema: toStandardJsonSchema(
    v.object({
      title: v.pipe(
        v.string(),
        v.minLength(1),
        v.maxLength(200),
        v.description("One-line summary"),
      ),
      body: v.optional(v.string()),
      priority: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(5)), 3),
    }),
  ),
  outputSchema: toStandardJsonSchema(v.object({ number: v.number(), url: v.string() })),
  handler: async (args) => {
    const issue = await tracker.create(args);
    return {
      content: [{ type: "text", text: `Created #${issue.number}` }],
      structuredContent: { number: issue.number, url: issue.url },
    };
  },
});
```

```ts [arktype]
import { type } from "arktype";
import { defineTool } from "h3-mcp";

export const createIssue = defineTool({
  name: "create-issue",
  description: "Open an issue in the tracker",
  inputSchema: type({
    title: type("1 <= string <= 200").describe("One-line summary"),
    "body?": "string",
    "priority?": "1 <= number.integer <= 5",
  }),
  outputSchema: type({ number: "number", url: "string" }),
  handler: async (args) => {
    const issue = await tracker.create(args);
    return {
      content: [{ type: "text", text: `Created #${issue.number}` }],
      structuredContent: { number: issue.number, url: issue.url },
    };
  },
});
```

::

Minimum versions: Zod v4.2+, ArkType v2.1.28+, Valibot v1.2+ with `@valibot/to-json-schema` v1.5+ — earlier releases do not implement Standard JSON Schema.

> [!WARNING]
> **Valibot is the one that needs wrapping.** Its conversion lives in the companion package for bundle-size reasons, so a bare `v.object({…})` carries `~standard.validate` but no `~standard.jsonSchema`: handed over directly it validates correctly while `tools/list` advertises Valibot's internal object in place of a schema. `toStandardJsonSchema()` returns something that implements both halves, which is what the tab above passes.

## Descriptions Are the Interface

The schema is what the model reads to decide how to call your tool. `.describe()` is not decoration:

```ts
inputSchema: z.object({
  query: z.string().describe("Full-text search query. Supports quotes for exact phrases."),
  since: z.string().datetime().describe("ISO 8601 timestamp. Only results after this time."),
  limit: z
    .number()
    .int()
    .max(50)
    .default(10)
    .describe("Max results. Keep small; results are verbose."),
});
```

## Output Schemas

`outputSchema` works the same way and pairs with `structuredContent`:

```ts
defineTool({
  name: "weather",
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({
    tempC: z.number(),
    conditions: z.enum(["clear", "cloudy", "rain", "snow"]),
  }),
  handler: async ({ city }) => {
    const data = await getWeather(city);
    return {
      content: [{ type: "text", text: `${data.conditions}, ${data.tempC}°C` }],
      structuredContent: data,
    };
  },
});
```

Always return `content` as well — a client that ignores structured output still needs something to show.

## Explicit Conversion

If you would rather control the emitted JSON Schema, convert it yourself:

```ts
const schema = z.object({ city: z.string() });

defineTool({
  name: "weather",
  inputSchema: z.toJSONSchema(schema),
  handler: async (args) => {
    const { city } = schema.parse(args); // now validate explicitly
    return { content: [{ type: "text", text: city }] };
  },
});
```

> [!NOTE]
> Converting up front means you also opt out of automatic validation — plain JSON Schema is advertised, not enforced. Parse inside the handler, as above. For Zod v3, use [`zod-to-json-schema`](https://github.com/StefanTerdell/zod-to-json-schema).

## Validation Failures

Nothing invalid reaches the handler. On `≥2025-11-25` the client receives a tool result with `isError: true` (so the model can fix its call); on `≤2025-06-18` it receives a JSON-RPC error.

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