MCP Guards, Prompts & Resources
Everything beyond tools is a hook — the package ships no opinion about billing, storage, or auditing. beforeCall gates a call before it runs, onCall audits it after, a per-call meta scratchpad ties the two to the handler, per-tool scope gating fails closed, and optional prompts and resources round out the capability set.
#Per-Call Guards
Two symmetric hooks wrap every tools/call:
| Hook | Receives | Purpose |
|---|---|---|
beforeCall | ({ args, caller, meta, name }) | Refuse a single tools/call before it runs (credits exhausted, rate limited). Return { block } to short-circuit; return nothing to proceed. |
onCall | ({ args, caller, meta, name, ok }) | Fired after every tools/call for auditing — ok reflects whether the result was an error, and meta carries anything the handler wrote. |
mcpServer<Caller>({
// ...endpoint config as before
// Refuse a single call before it runs (credits, rate limit). The
// message comes back as an isError tool result the model can relay
// to the user — not a crash, not a transport error.
beforeCall: async ({ caller }) =>
(await outOfCredits(caller))
? { block: 'Out of credits this cycle.' }
: undefined,
// Audit every call. `meta` carries whatever the tool handler wrote.
onCall: ({ caller, name, ok, meta }) =>
recordCall({ caller, name, ok, touched: meta.touched })
});block string comes back as an isError tool result — a paused or rate-limited notice the model can relay to the user — not a transport error that kills the conversation.#The meta Scratchpad
Each tools/call gets a fresh meta object shared between tools, beforeCall, and onCall. A tool handler can record what it touched, and your audit hook can read it back:
// Each tools/call gets a fresh meta object shared between tools,
// beforeCall, and onCall. A tool handler can record what it touched,
// and your audit hook can read it back:
tools: ({ caller, meta }) =>
buildAdminTools(caller, (memberId) => {
meta.touched = memberId;
}),
onCall: ({ meta, name, ok }) =>
ledger.write({ tool: name, ok, member: meta.touched })#Scope Gating
When authorize returns the caller's scopes, any tool that declares a scope is only listed on tools/list and callable on tools/call when the caller holds it. Tools without a scope are always available.
// authorize returns the caller's scopes alongside the caller...
authorize: async (request) => {
const token = await verifyBearer({
request,
issuer: 'https://your.app',
requiredScope: 'mcp',
verify: (jwt) => verifyJwt(jwt, publicJwk)
});
if ('error' in token) return { ok: false, reason: token.error };
return {
caller: { userId: token.subject },
ok: true,
scopes: token.scopes // parsed from the token's space-separated scope
};
},
// ...and a tool with a `scope` is only listed and callable when the
// caller holds it. Tools without a scope are always available.
tools: () => ({
delete_member: {
description: 'Remove a member account.',
inputSchema: { type: 'object' },
scope: 'mcp:admin', // hidden from callers without this scope
handler: async (args) => removeMember(args)
}
})Unknown tool — indistinguishable from a tool that doesn't exist.#Prompts
Server-side prompts are recipes the client shows in its picker. Declare the definitions once and implement one get function; the package serves prompts/list (paginated) and prompts/get from them.
mcpServer<Caller>({
// Server-side prompts: recipes the client shows in its picker.
prompts: {
definitions: {
daily_briefing: {
title: 'Daily briefing',
description: 'Summarise what changed since yesterday.',
arguments: [
{ description: 'Team to focus on', name: 'team', required: false }
]
}
},
// Build the prompt text for prompts/get. Return null to fail the
// request ("Prompt failed to build").
get: async ({ name, args, caller }) => buildPromptText(name, args, caller)
}
});get receives { args, caller, name } and returns the prompt text, delivered to the client as a single user message with one text content block. Returning null fails the request; an unknown prompt name is an invalid-params error.
#Resources
Readable resources are two functions: list returns the McpResource entries visible to this caller (served paginated on resources/list), and read resolves one uri to its text — or null, which answers Unknown resource.
mcpServer<Caller>({
// Readable resources, served on resources/list and resources/read.
resources: {
mimeType: 'text/markdown', // the default
list: ({ caller }) => listResources(caller),
read: ({ caller, uri }) => readResource(caller, uri) // string | null
}
});resources/read answers with the text and the configured mimeType, which defaults to text/markdown.
#Capability Advertisement
The capabilities advertised on initialize are derived from what the config actually provides — a client never sees a prompts or resources capability this endpoint can't serve. instructions and serverInfo ride along in the same response.
| Capability | Advertised when | Flags |
|---|---|---|
tools | Always | { listChanged: false } |
prompts | promptsconfigured | { listChanged: false } |
resources | resourcesconfigured | { listChanged: false, subscribe: false } |
listPageSize (default 50) paginates tools/list, prompts/list, and resources/list alike, with an opaque cursor — a malformed or foreign cursor reads as page zero rather than erroring, as the spec treats cursors as opaque.