Modules

MCP

Modules contribute MCP tools that are only listed and callable within the scopes the caller holds, without leaking other people's personal data.

A Nuxt module on top of @nuxtjs/mcp-toolkit that serves one MCP endpoint (/mcp) for everybody. What a client sees in tools/list depends on who is calling: an ordinary user sees their own tools, and staff tools appear only when the host's policy lets that user in. Clients authenticate with a user API token (Authorization: Bearer fnu_…), an OAuth access token or a cookie session; see Auth.

Requires@frigear-nu/auth and @nuxtjs/mcp-toolkit (>=0.24.0); both are installed automatically. Events, Forms, OAuth Server and Subscriptions always depend on this module; Users installs it only when users.settings.mcp is on.

Quick setup

Exposing tools is opt-in per module: a module contributes its MCP tools only when its settings.mcp is true (default false).

export default defineNuxtConfig({
  users: { settings: { mcp: true } },
  events: { settings: { mcp: true } },
  forms: { settings: { mcp: true } },
  subscriptions: { settings: { mcp: true } },
  oauthServer: { settings: { mcp: true } },
})

Options

The config key is mcpModules, not mcp (that belongs to the toolkit).

OptionTypeDefaultPurpose
routestringthe toolkit default, /mcpRoute of the MCP endpoint (forwarded to the toolkit's mcp.route).

The module also sets the toolkit's standing mcp.instructions (unless the host provides its own), which explain to the agent that it acts as the signed-in user, that other people's personal data needs a *:pii scope, and that some settings (such as where form submissions are delivered) can only be changed in the admin UI, and that money is in whole currency units. It turns on Nitro's asyncContext, which the scoped definers use to reach the request.

Rules every tool follows

  • Scopes. Every definition declares the scopes it needs (registered with addAuthScope). A token without them never sees the tool, and a direct call is refused.
  • Admin tools use the module's own gate. events:manage plus the events:authorize host hook, forms:manage plus forms:authorize. Both must pass on every listing and every call.
  • "My" tools take no user id. They act on the authenticated user (ctx.user).
  • Personal data of others is opt-in. Staff tools return ids, statuses and counts. Names, emails, form answers and file names need a *:pii scope (events:pii, forms:pii) on top of the manage scope. Responses carry a notice explaining what was withheld.
  • Money is in whole currency units. Amounts in tool inputs and outputs are major units next to a currency code (52.2 with "USD" is 52.20 US dollars, never 5220), so an agent cannot mistake cents for dollars. Tools convert at the edge with fromCents / toCents from @frigear-nu/mcp/utils (exponent-aware: JPY has no decimals, KWD has three); the database and REST API keep minor units. Describe money fields with MONEY_NOTICE.
  • No exfiltration levers. Where form submissions are delivered (delivery: recipients, webhooks) cannot be read or changed through MCP.
  • Session-only areas stay out. User administration and OAuth application administration are not exposed.
  • Fail closed. A definition not created with the scoped definers is dropped at runtime, a tool whose input cannot be described as JSON Schema is dropped (and logged) instead of breaking the listing, and a failing policy hook hides the tool.

Exposed tools

settings.mcp ofToolScopes
eventslist-events, get-event, my-ticketstickets:read
events (staff)list-all-events, get-event-definition, create-event, update-event, update-ticket-typeevents:manage
events (staff)list-attendeesevents:manage (+ events:pii for names and emails)
formslist-forms, get-form, submit-formforms:submit
forms (staff)list-all-forms, get-form-definition, create-form, update-formforms:manage
forms (staff)list-submissions, get-submissionforms:manage (+ forms:pii for answers)
oauthServermy-oauth-applicationsoauth:read
usersget-my-profileprofile:read
paymentsmy-paymentspayments:read
subscriptionsmy-subscriptionssubscriptions:read

submit-form always submits as the caller and works for forms open to everyone or to signed-in users; forms that need their own token or password are refused.

Server

Add tools to a module from its setup():

import { addMcpDefinitions } from '@frigear-nu/mcp/utils'

// moduleDependencies: { '@frigear-nu/mcp': { version: '>=0.0.1' } }
addAuthScope(nuxt, { name: 'notes:read', description: 'Read your notes' })
// Opt-in: only register when the host enabled it, as the built-in modules do.
if (options.settings?.mcp === true) addMcpDefinitions(nuxt, resolver.resolve('./runtime/server/mcp'))

addMcpDefinitions(nuxt, dir) takes a directory laid out like a project's server/mcp: optional tools/, resources/ and prompts/ folders. Subfolders of tools/ become groups (tools/admin/… is group admin).

The auto-imported definers share the access options and pass a ScopedMcpContext to the handler:

interface ScopedAccess {
  /** Required: a definition without scopes is never exposed. */
  scopes: string[]
  /** Never exposed to API tokens or OAuth tokens. */
  sessionOnly?: boolean
  /** Extra check on every listing and call, typically the module's `*:authorize` hook. Identify the caller from `principal.user.id`. */
  gate?: (event: H3Event, principal: AuthPrincipal) => boolean | Promise<boolean>
}

interface ScopedMcpContext {
  event: H3Event
  principal: AuthPrincipal
  /** The authenticated user. Use this; never take a user id as an argument. */
  user: AuthUser
  /** Whether the credential carries `scope` (e.g. an opt-in `*:pii` scope). */
  hasScope: (scope: string) => boolean
}
DefinerHandler signature
defineScopedMcpTool(args, ctx: ScopedMcpContext, extra) => result
defineScopedMcpResource(ctx: ScopedMcpContext, ...toolkitResourceArgs) => result
defineScopedMcpPrompt(args, ctx: ScopedMcpContext) => result

Each takes the toolkit's own definition options plus ScopedAccess.

// runtime/server/mcp/tools/my-notes.ts
export default defineScopedMcpTool({
  description: 'List my notes',
  scopes: ['notes:read'],
  handler: (_args, { user }) => listNotes(user.id),
})
// runtime/server/mcp/tools/admin/list-all-notes.ts
export default defineScopedMcpTool({
  description: 'List every note (staff)',
  scopes: ['notes:manage'],
  gate: canManageNotes, // e.g. your `notes:authorize` hook
  handler: (_args, ctx) => {
    const allow = ctx.hasScope('notes:pii')
    const notes = listAllNotes().map(note => redactPii(pick(note, ['id', 'text', 'ownerEmail']), { allow, fields: ['ownerEmail'] }))
    return { notice: piiNotice(allow, 'notes:pii', 'Owner emails'), notes }
  },
})

Helpers (also auto-imported):

pick(row, ['id', 'text']) // an explicit allow-list, so a new column is never exposed by accident
redactPii(row, { allow, fields }) // drops `fields` unless `allow`
piiNotice(allowed, scope, fieldsLabel) // message for the agent when data was withheld; undefined when allowed

Return explicit allow-listed fields and never a raw database row. Define input schemas with plain types: zod transforms and z.coerce.date() cannot be sent to clients as JSON Schema, so parse with your REST schema inside the handler instead.

@frigear-nu/mcp/utils also exports isPrincipalAllowed, scopeTags, SCOPED_META_KEY and the ScopedAccess, RedactOptions and McpDefinitionKind types.

Hooks

HookWhenPayload
mcp:definitions:paths (Nuxt)Build time, collecting definition folders{ tools, resources, prompts, handlers: string[] }; addMcpDefinitions pushes to it
mcp:config:resolved (Nitro, from the toolkit)The resolved config is built per request{ config, event }; the module filters out unscoped or non-serializable definitions here
mcp:tool:called (Nitro, from the toolkit)After every tool call{ name, result, durationMs, event }; the module logs who called which tool (never arguments or results)
Copyright © 2026