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 with a mandatory prefix — namespace/name, reverse DNS recommended (com.example/thing).

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

The map is echoed verbatim on server/discover, 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:

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:

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, MCP Apps and 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.

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, MCP Apps and 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.

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

h3-mcp  MCP servers, built on H3.