
# Extensions

> Advertise capabilities beyond the core spec.

Extensions let a server declare optional, namespaced functionality in `ServerCapabilities.extensions`. Identifiers follow the [`_meta` key naming rules](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta) with a mandatory prefix — `namespace/name`, reverse DNS recommended (`com.example/thing`).

```ts
defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  extensions: { "io.modelcontextprotocol/tasks": {} },
  tools: [/* ... */],
});
```

The map is echoed verbatim on [`server/discover`](/protocols/modern/discovery), so the value can carry extension-specific configuration rather than just `{}`. Identifiers are checked before they are used: a malformed one fails every modern request rather than advertising an extension no client could ever negotiate.

## Negotiation

Extensions are negotiated by advertisement only: if one side lacks an extension, the other must fall back to core behavior or return an error. Never assume a client understands one because it connected.

A client declares its own in `_meta["io.modelcontextprotocol/clientCapabilities"].extensions`. `h3-mcp` validates that map at the transport boundary (bounded in size and identifier length; an over-sized map is rejected with `-32602` rather than truncated) and intersects it with what the server advertised. The result is on the request context:

```ts
defineTool({
  name: "demo",
  handler: (event) => {
    const mcp = event.context.mcp;

    // Negotiated: `id` → the *server's* advertised settings. The client's own
    // settings for the same id stay on `mcp.clientCapabilities.extensions`.
    const negotiated = mcp?.extensions?.["com.example/demo"];
    if (!negotiated) {
      return { content: [{ type: "text", text: "core behavior" }] };
    }

    return { content: [{ type: "text", text: "extension behavior" }] };
  },
});
```

When a handler cannot revert to core behavior, assert instead — `requireExtension` throws `-32021` (`MissingRequiredClientCapability`) with the missing identifiers under `data.requiredCapabilities.extensions`:

```ts
event.context.mcp?.requireExtension?.("com.example/demo");
```

Assert only what the server itself advertises. `-32021` tells the client to declare the extension and retry, and negotiation only ever intersects the advertised map — so asserting an identifier missing from `extensions` would loop the client forever. That case is reported as the configuration bug it is (an internal error naming the identifier) rather than blamed on the client.

Both are modern-era only, so use optional calls if the same handler is also served on the legacy era, where a request has nothing to negotiate with.

> [!NOTE]
> `extensions` is capability plumbing: declaring an identifier here advertises it and nothing more. Only advertise what you actually serve. `h3-mcp` implements three — [Tasks](/protocols/modern/tasks), [MCP Apps](/protocols/modern/apps) and [Skills](/protocols/modern/skills) — and all ship as plugins: `{ extensionPlugins: [mcpTasks(), mcpUI({ apps }), mcpSkills({ skills })] }` adds their identifiers to this map for you. Every plugin merges into this map **by identifier**, so advertising your own extensions alongside an installed one is safe.

### Reading what the client declared

An extension's settings object is per-extension schema on **both** sides, and some extensions put the data on the client's side. MCP Apps is the example: the server advertises `{}`, while the client declares the content types it can render, and the server is expected to test that value.

```ts
const ui = event.context.mcp?.clientExtensionSettings?.("io.modelcontextprotocol/ui");
const renders = Array.isArray(ui?.mimeTypes) && ui.mimeTypes.includes("text/html;profile=mcp-app");
```

This fails closed on the negotiated map — an identifier this server never advertised reads as `undefined` no matter what the client sent — but what it returns past that is the client's own unvalidated JSON. Shape-check it at the point of use, as above.

## Writing a plugin

An extension is packaged as a plugin and installed at construction — the same seam [Tasks](/protocols/modern/tasks), [MCP Apps](/protocols/modern/apps) and [Skills](/protocols/modern/skills) ship through. `defineMcpHandler`'s second argument takes the list.

A plugin does not have to serve methods. Tasks uses `setup`/`methods`/`results`; MCP Apps uses none of them, reaching the wire entirely through `decorate` — the hook that wraps the tool, resource and prompt definitions a request serves; Skills uses `methods` and `decorate` together, plus `cacheable`. Use whichever subset your extension needs.

```ts
import { defineMcpHandler, mcpMethod, parseParams, requireParamString } from "h3-mcp/modern";
import type { ExtensionPlugin } from "h3-mcp/modern";

// A plugin carries its own configuration — there is no handler option for an
// extension the core does not implement.
function notes(options: { enabled?: boolean } = {}): ExtensionPlugin<Map<string, string>> {
  let store: Map<string, string> | undefined;

  return {
    id: "com.example/notes",
    // Advertised under `capabilities.extensions[id]`, or `undefined` to stay quiet.
    settings: () => (options.enabled === false ? undefined : { version: 1 }),
    // Long-lived, per-handler state. Never at module scope.
    setup: () => {
      store = new Map();
    },
    // Per-request state, readable anywhere from the event with
    // `pluginState(event, plugin)` — including from a public helper of your
    // own that only takes an `H3Event`. Returning `undefined` means "off for
    // this request".
    initEvent: () => (options.enabled === false ? undefined : store),
    methods: (ctx) => ({
      "com.example/notes.get": (req, event) =>
        mcpMethod(ctx, req, event, () => {
          const id = requireParamString(parseParams(req.params), "id");
          event.context.mcp?.requireExtension?.("com.example/notes");
          return { note: store!.get(id) ?? null };
        }),
    }),
    // Optional: claim a `resultType` of your own. A handler may then return
    // `{ resultType: "com.example/notes.pending", … }`, and this validator is
    // what judges it. A `resultType` no installed plugin claims is rejected.
    results: {
      "com.example/notes.pending": (result) => {
        if (typeof result.noteId !== "string") {
          throw new Error("A pending note result must carry a noteId");
        }
      },
    },
    // Optional: name the methods whose `complete` results carry caching hints
    // (`ttlMs` / `cacheScope`), the way `tools/list` does. The transport strips
    // both from every other plugin method, so a result that extends the base
    // schema's `CacheableResult` has to be listed here to keep them.
    cacheable: ["com.example/notes.get"],
    // Optional: stamp the extension's own `_meta` on every tool, without the
    // server author repeating it on each definition.
    decorate: (definitions) => ({
      ...definitions,
      tools: async () =>
        (await definitions.tools())?.map((tool) => ({
          ...tool,
          _meta: { ...tool._meta, "com.example/notes": { version: 1 } },
        })),
    }),
  };
}

export default defineMcpHandler(options, { extensionPlugins: [notes()] });
```

The hooks run in a fixed order: `settings` and `resolveOptions` on every option resolution, `setup`, `methods` and `results` once at construction, and per request `initEvent` then `decorate` — the last two after the request's extensions have been negotiated, so both can read `event.context.mcp`.

`settings` and `resolveOptions` receive the `H3Event` **only** when your handler options are themselves a function of it, since that is the one configuration resolved per request. Gate what you _serve_ in `initEvent`, which always sees the request.

Seven things are rejected at mount rather than silently: a duplicate plugin `id`, an `id` outside the `namespace/name` grammar, a method name a core method already serves, two plugins serving one method, a `resultType` the core owns (`complete`, `input_required`), two plugins claiming one `resultType`, and a `cacheable` method the plugin does not serve. A plugin **adds** methods; it cannot intercept or wrap one. One plugin instance belongs to one handler — call the factory again for a second.

Definitions handed to `decorate` must be copied, not mutated: return new arrays and new objects, since the ones you receive are the server author's own and outlive the request.

:read-more{to="/api/exports#plugin-toolkit" title="Plugin toolkit"}

Extensions are a modern-era concept: the field is deliberately absent from the legacy `initialize` response, which defines no negotiation semantics for it.
