MCP
This module lets an AI agent work in your app for a signed-in user: list their tickets,
submit a form or, for staff, manage events. Your app serves one Model Context Protocol (MCP)
endpoint at /mcp. Each agent sees only the tools its token allows, and other people's personal data stays hidden. Use
it when users should reach your app through an agent. For scripts that call your HTTP routes directly, an
API token is enough.
The package is @frigear-nu/mcp, built on @nuxtjs/mcp-toolkit.
What you get
- Server routes (on by default): the MCP endpoint at
/mcp. Agents connect with an API token, an access token or a session, as Access control explains. - Tools from other modules (opt-in per module): each adds its tools when its
settings.mcpis on. - Composables and helpers (on by default): definers for your own tools, each limited to the permission it declares.
- Agent instructions and an audit log (on by default): the agent is told its limits, and every call is logged without its arguments or result.
@nuxtjs/mcp-toolkit (>=0.24.0) and Module Kit.
You add: nothing.
Optional: the modules whose tools you want, listed in Quick setup.Quick setup
Install MCP
Events, Forms, OAuth Server, Payments and Subscriptions install MCP for you. Users installs it when
users.settings.mcp is on. To use it on its own:
pnpm add @frigear-nu/mcp
Turn on the tools you want
Each module adds its tools only when its settings.mcp is true. The default is false.
mcpModules, not mcp. The mcp key belongs to @nuxtjs/mcp-toolkit.export default defineNuxtConfig({
users: { settings: { mcp: true } },
events: { settings: { mcp: true } },
forms: { settings: { mcp: true } },
payments: { settings: { mcp: true } },
subscriptions: { settings: { mcp: true } },
oauthServer: { settings: { mcp: true } },
})
| Module | Tools |
|---|---|
events | Events, tickets and staff catalogue tools. See MCP tools. |
forms | Forms, submitting and staff tools. See MCP tools. |
oauthServer | my-oauth-applications, with the permission oauth:read. |
users | get-my-profile, with the permission profile:read. |
payments | my-payments, with the permission payments:read. |
subscriptions | my-subscriptions, with the permission subscriptions:read. |
Connect an agent
Create an API token with the scopes the agent needs. A tool appears only when the token carries its permission and the
user still holds it through their roles. Point the agent's MCP client at https://<your app>/mcp, with the
header Authorization: Bearer fnu_….
tickets:read, the agent lists list-events, get-event and
my-tickets, and no staff tools.Options
Options go under the mcpModules key.
| Option | Type | Default | Purpose |
|---|---|---|---|
route | string | /mcp | The route of the MCP endpoint. It sets the toolkit's mcp.route. |
The module sets the toolkit's mcp.instructions, unless your app sets its own. They tell the agent that it acts as the
user, that personal data needs a *:pii permission, that some settings change only in the admin area, and how money is
written. The module also turns on Nitro's asyncContext, which the definers use to reach the request.
Server
Add tools, resources and prompts to MCP from a module. This section is for module authors.
Rules every tool follows
| Rule | What it means |
|---|---|
| Permission | Every definition declares one permission. The caller never sees a tool they are not allowed, and a direct call is refused. The check is allows on every listing and authorize on every call, so a token needs a scope that covers the permission and the user must hold it through a role. |
| Staff tools | They use a staff permission such as events:manage, so roles, policies and the authorization:before hook apply on every listing and call. |
| "My" tools | They take no user id. They act on the signed-in user (ctx.user). |
| Personal data | Staff tools return ids, statuses and counts. Names, emails and answers need a personally identifiable information (PII) permission, *:pii, which no wildcard grants. For now these permissions gate MCP tool output only: the admin HTTP API of the same data (attendees, submissions) is gated by the module's :manage permission. |
| Money | Amounts are whole currency units next to a currency code: 52.2 with "USD" is 52.20 dollars. |
| No exfiltration | Where form submissions are delivered can be neither read nor changed through MCP. |
| Session-only areas | User administration and OAuth application administration are not exposed. |
| Unscoped definitions | A definition not made with the scoped definers is dropped. |
| Unserializable input | A tool whose input cannot become JSON Schema is dropped and logged. |
| Failing checks | A permission check that throws hides the tool, and a call is refused. The module fails closed. |
Convert at the edge with fromCents and toCents from
@frigear-nu/mcp/utils, which know that JPY has no decimals, and describe money fields with MONEY_NOTICE.
Add tools from a module
Register the tools from the module's setup(), only when your app turned them on, as the built-in modules do:
import { registerAccess } from '@frigear-nu/auth/utils'
import { addMcpDefinitions } from '@frigear-nu/mcp/utils'
import { notesAccess } from './runtime/access' // defineAccess('notes', { permissions: { read, manage, pii }, scopes: { notes } })
// moduleDependencies: { '@frigear-nu/mcp': { version: '>=0.0.1' } }
registerAccess(nuxt, notesAccess) // notes:read (implicit), notes:manage, notes:pii and the `notes` scope
if (options.settings?.mcp === true) addMcpDefinitions(nuxt, resolver.resolve('./runtime/server/mcp'))
addMcpDefinitions(nuxt, dir) takes a folder laid out like a project's server/mcp, with optional tools/,
resources/ and prompts/. A subfolder of tools/ becomes a group: tools/admin/… is the group admin.
Define a tool
Three auto-imported definers take the toolkit's options plus permission, and pass a ScopedMcpContext to the
handler. For money and credential actions, use a sessionOnly permission, so a token never reaches the tool.
| Definer | Handler signature |
|---|---|
defineScopedMcpTool | (args, ctx: ScopedMcpContext, extra) => result |
defineScopedMcpResource | (ctx: ScopedMcpContext, ...toolkitResourceArgs) => result |
defineScopedMcpPrompt | (args, ctx: ScopedMcpContext) => result |
interface ScopedAccess {
/**
* Required: a definition without a permission is never exposed. The caller must be allowed it on every listing and
* call. Whether it is session only, and who holds it, comes from its `addPermission` definition.
*/
permission: string
}
interface ScopedMcpContext {
event: H3Event
principal: AuthPrincipal
/** The authenticated user. Use this; never take a user id as an argument. */
user: AuthUser
/** Whether the caller is allowed `permission` (e.g. an opt-in `*:pii`), optionally for a record. Async. */
can: (permission: string, record?: unknown) => Promise<boolean>
}
export default defineScopedMcpTool({
description: 'List my notes',
permission: 'notes:read',
handler: (_args, { user }) => listNotes(user.id),
})
A staff tool declares a staff permission, and withholds personal data unless the caller also holds the PII permission.
ctx.can is asynchronous, so await it:
export default defineScopedMcpTool({
description: 'List every note (staff)',
permission: 'notes:manage',
handler: async (_args, ctx) => {
const allow = await ctx.can('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 }
},
})
These helpers are auto-imported too:
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, permission, fieldsLabel) // message for the agent when data was withheld; undefined when allowed
z.coerce.date() cannot become JSON Schema, and such a tool is dropped. Parse with your REST schema inside the handler.@frigear-nu/mcp/utils also exports permissionTags, SCOPED_META_KEY and the ScopedAccess,
RedactOptions and McpDefinitionKind types.
Hooks
MCP adds one build-time hook and listens to two Nitro hooks of the toolkit.
| Hook | When | Payload |
|---|---|---|
mcp:definitions:paths (Nuxt) | Build time, while definition folders are collected. addMcpDefinitions pushes to it. | { tools, resources, prompts, handlers: string[] } |
mcp:config:resolved (Nitro, toolkit) | Per request. The module drops unscoped or non-serializable definitions here. | { config, event } |
mcp:tool:called (Nitro, toolkit) | After every tool call. The module logs the call. | { name, result, durationMs, event } |
Next steps
- Account – the page where users create the API tokens agents connect with.
- Access control – how permissions, scopes, policies and the
authorization:beforehook combine. - Authorization – roles, and which role holds the permissions your tools declare.
- Extension points – how MCP's registry fits next to menus, widgets and tabs.