MCP
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.
@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).
| Option | Type | Default | Purpose |
|---|---|---|---|
route | string | the toolkit default, /mcp | Route 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:manageplus theevents:authorizehost hook,forms:manageplusforms: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
*:piiscope (events:pii,forms:pii) on top of the manage scope. Responses carry anoticeexplaining what was withheld. - Money is in whole currency units. Amounts in tool inputs and outputs are major units next to a currency code (
52.2with"USD"is 52.20 US dollars, never5220), so an agent cannot mistake cents for dollars. Tools convert at the edge withfromCents/toCentsfrom@frigear-nu/mcp/utils(exponent-aware: JPY has no decimals, KWD has three); the database and REST API keep minor units. Describe money fields withMONEY_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 of | Tool | Scopes |
|---|---|---|
events | list-events, get-event, my-tickets | tickets:read |
events (staff) | list-all-events, get-event-definition, create-event, update-event, update-ticket-type | events:manage |
events (staff) | list-attendees | events:manage (+ events:pii for names and emails) |
forms | list-forms, get-form, submit-form | forms:submit |
forms (staff) | list-all-forms, get-form-definition, create-form, update-form | forms:manage |
forms (staff) | list-submissions, get-submission | forms:manage (+ forms:pii for answers) |
oauthServer | my-oauth-applications | oauth:read |
users | get-my-profile | profile:read |
payments | my-payments | payments:read |
subscriptions | my-subscriptions | subscriptions: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
}
| Definer | Handler 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
| Hook | When | Payload |
|---|---|---|
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) |