AbsoluteJS

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:

HookReceivesPurpose
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.
TS
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 })
});
A block is a message, not a crash
The 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:

TS
// 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.

TS
// 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)
  }
})
Fails closed, hides completely
A scoped tool is hidden when the caller's scopes are unknown, and calling a hidden tool answers 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.

titleThe display name the client shows in its picker.
descriptionWhat the prompt does.
argumentsoptionalA list of { name, description, required? } entries advertised on prompts/list.
TS
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.

uriThe identifier a client passes to resources/read.
nameThe display name.
descriptionoptionalWhat the resource holds.
mimeTypeoptionalThe content type.
TS
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.

CapabilityAdvertised whenFlags
toolsAlways{ listChanged: false }
promptspromptsconfigured{ listChanged: false }
resourcesresourcesconfigured{ listChanged: false, subscribe: false }
One page size for every list
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.