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) }] }),
});
},
],
}));#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"] },
}),
);