h3-mcp logoh3-mcp

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 — after auth has run:

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

const app = new H3();

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

    auth: {
      schemes: ["bearer"],
      validate: async (auth, event) => {
        const user = await verifyToken(auth.token);
        if (!user) return false;
        event.context.user = user; // available below and in every handler
        return true;
      },
    },

    tools: toolsFor(event.context.user?.role),
  })),
);

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.

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 defineMcpTool({
        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 logo

h3-mcp  MCP servers, built on H3.