Per-User Tools

One endpoint, a different toolset per caller.

The safest way to keep a caller away from a tool is not to advertise it. Because handler options can be a function of the event, the toolset can be decided per request. One ordering rule shapes the example: the options function is what produces the auth config, so it runs before the library's own auth check and cannot see its result — verify the caller in a middleware mounted before the handler, and branch on what it stored:

import { H3 } from "h3";
import { defineMcpHandler } from "h3-mcp";
import { readTools, writeTools, adminTools } from "./tools.ts";

const app = new H3();

// Verify before the handler runs. Never reject here: a missing or bad token
// leaves `user` unset, and the handler's `validate` sends the proper 401 challenge.
app.use("/mcp", async (event) => {
  const token = event.req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
  event.context.user = token ? await verifyToken(token) : undefined;
});

app.all(
  "/mcp",
  defineMcpHandler((event) => ({
    name: "my-server",
    version: "1.0.0",

    auth: {
      schemes: ["bearer"],
      // The middleware already checked the signature; checking it twice per
      // request buys nothing, so `validate` only confirms it was there.
      validate: () => event.context.user !== undefined,
    },

    tools: toolsFor(event.context.user?.role), // set above, so it is really there
  })),
);

function toolsFor(role?: string) {
  switch (role) {
    case "admin": {
      return [...readTools, ...writeTools, ...adminTools];
    }
    case "editor": {
      return [...readTools, ...writeTools];
    }
    default: {
      return readTools;
    }
  }
}

The options function runs at most once per request and the result is cached for that event, so toolsFor is not re-evaluated per method call. Why the middleware, rather than a validate that sets event.context.user itself, is spelled out in what auth does not do: by the time validate runs, toolsFor has already been called with nothing.

Important

Hiding a tool is not authorization. tools/call is reachable for anything you return, and nothing stops a client from guessing a name it was never shown. Check permissions inside each handler too — the list is UX, the check is the control.

#Expensive Per-User Definitions

If building a definition requires I/O, make the entry lazy so it only happens when a client actually lists or calls it:

defineMcpHandler((event) => ({
  name: "my-server",
  version: "1.0.0",
  tools: [
    ...readTools,
    async () => {
      const schema = await loadTenantSchema(event.context.user.tenant);
      return defineTool({
        name: "query",
        description: "Query your tenant's data",
        inputSchema: schema,
        handler: async (args) => ({ content: [{ type: "text", text: await runQuery(args) }] }),
      });
    },
  ],
}));
Read more in Lazy definitions.

#Per-Tenant Resources

The same pattern applies to resources — and to the cache scope, which must stay private for anything user-specific:

defineMcpHandler((event) => ({
  name: "my-server",
  version: "1.0.0",
  resources: resourcesFor(event.context.user),
  cache: {
    // discovery is identical for everyone; reads are not
    discover: { ttlMs: 3_600_000, cacheScope: "public" },
    resourceRead: { ttlMs: 5_000, cacheScope: "private" },
  },
}));

#Separate Endpoints Instead

When the split is coarse, two mounts are simpler to reason about than one branching function — and the admin surface is not reachable from the public URL at all:

app.all("/mcp", defineMcpHandler({ name: "public", version: "1.0.0", tools: readTools }));

app.all(
  "/mcp/admin",
  defineMcpHandler({
    name: "admin",
    version: "1.0.0",
    tools: adminTools,
    auth: { tokens: [process.env.ADMIN_TOKEN!] },
    origin: { allow: ["https://admin.example.com"] },
  }),
);

h3-mcp  MCP servers, built on H3.