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