Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/nitro-definition-scopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'nitro-mcp-toolkit': minor
---

`defineMcpTool`, `defineMcpResource` and `defineMcpPrompt` take `scopes`. A call is refused unless the verified access token carries every scope listed, read from `scope` (space-delimited) or `scp` (string or array). A refusal is a JSON-RPC `-32003` naming the missing scopes β€” under HTTP 403 on the modern revision, in the `200` stream a legacy request gets for every error β€” and it fails closed: scopes on an endpoint with no OAuth refuse every call.

```ts
export default defineMcpTool({
scopes: ['todos:write'],
inputSchema: z.object({ id: z.string() }),
handler: ({ id }) => remove(id),
})
```

A scoped definition is still listed, with its scopes in `_meta` and in `handler.definitions`; only the call is gated. Options resolve before a request is authenticated, so nothing that builds a listing has seen the token β€” use a separate endpoint when a tool's existence is itself sensitive.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ The runtime is written against a pinned `h3-mcp` (currently 0.2.0). That release

**`X-MCP-Tools` is the same kind of gate** (`src/runtime/tools-header.ts`): allowlist of tool names, HTTP 400 on unknowns, applied before the engine runs. `handler.definitions` stays the full catalog.

**`scopes` on a definition gates the call, never the listing, and that is a constraint rather than a choice.** `requireScopes` (`src/runtime/scopes.ts`) wraps the handler each of the three `build()` methods pushes, reads `event.context.oauth`, and throws `McpJsonRpcError(-32003, …, { status: 403 })`; `settle()` relays that without turning it into an `isError` result. That `status` only reaches the wire on the modern revision β€” a legacy request gets the same JSON-RPC error inside a `200` stream, as it does for every error β€” so assert on the code, not the status, unless the test pins an era. It fails closed β€” scopes on an endpoint with no OAuth refuse every call. Filtering `tools/list` instead is not available: h3-mcp resolves the handler's options (where the `X-MCP-Tools` filter lives) **before** `checkAuth` populates `event.context.oauth`, and that resolution is synchronous, so no JWT check fits there. The `decorate` plugin hook does run after auth, but only from `handleModernPost`, so it would leave legacy requests ungated while `era` is `dual`. Hiding listings needs a post-auth hook covering both eras upstream in h3-mcp; do not grow a local workaround. Read scopes from `scope` (space-delimited, RFC 6749) and `scp` (string or array, Okta/Entra ID) only β€” `permissions` is Auth0-specific and was left out on purpose.

**Plugins reach a `mcp()` server through `<dir>/plugins.ts`, not through an option** β€” an `ExtensionPlugin` is a live function and `mcp()` options cross into generated code as JSON. `discoverPlugins` returns *every* match so the caller can refuse an ambiguous pair; `onePluginsFile` in `src/module/index.ts` does that where it can name the route, which also keeps the watcher's `served()` from dying on a misconfiguration. The handler virtual is `async` for the same reason the registry is: a file written after `setup` must be seen at rebuild. Adding the convention means four places, not one β€” `discover.ts`, `template.ts` (`renderHandler`'s second argument), `watch.ts` (`couldChangeServed` plus `served`, or creating the file triggers no `rollup:reload`), and `report.ts`.

**OAuth** is a generic resource-server: `mcp({ oauth: { resource, authorizationServers, jwt } })` or `createMcpOAuth`. JWT verify, claims on `event.context.oauth`, RFC 9728 metadata mounted. Connectors (`nitro-mcp-toolkit/oauth/clerk`, `/okta`, `/workos`) return that same options object β€” Clerk infers issuer/JWKS from `CLERK_PUBLISHABLE_KEY` and skips `aud` (client is in `azp`). The package does not mint tokens. Opaque tokens still mean `createMcpOAuth({ verify })` in a route file.
Expand Down
13 changes: 13 additions & 0 deletions apps/nitro-playground/server/mcp/tools/scoped.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineMcpTool } from 'nitro-mcp-toolkit'

/**
* Exercises `scopes`, including the two parts of the contract that surprise
* people: this endpoint has no OAuth, so the guard fails closed and every call
* is refused β€” and the tool is still in `tools/list` regardless, carrying its
* scopes in `_meta`, because options resolve before a request is authenticated.
*/
export default defineMcpTool({
description: 'Refuses unless the access token carries todos:write',
scopes: ['todos:write'],
handler: () => 'never reached on an endpoint without oauth',
})
30 changes: 28 additions & 2 deletions packages/nitro-mcp-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export default defineHandler(() =>
)
```

Each entry carries `kind`, `name`, `title`, `description`, `group`, `tags`, the `uri` of a resource, and the `file` it was discovered in. There is no filtering API on purpose: every field is a plain value, so `Array.filter` covers groups, tags and kinds at once.
Each entry carries `kind`, `name`, `title`, `description`, `group`, `tags`, any `scopes` it requires, the `uri` of a resource, and the `file` it was discovered in. There is no filtering API on purpose: every field is a plain value, so `Array.filter` covers groups, tags and kinds at once.

`mcp` is always typed. Extra names such as `adminMcp` are generated into `node_modules/.nitro/types` when you run `nitro prepare` or `nitro dev`. A handler mounted by hand exposes the same `definitions`, read off your own route.

Expand Down Expand Up @@ -435,7 +435,7 @@ createMcpHandler({

Enabling `auth` requires at least one of `tokens` or `validate` β€” a config with neither throws when the handler is built, rather than accepting everything. A missing or invalid credential gets a `401` with a `www-authenticate` header and no JSON-RPC body, since the request never reached the protocol layer.

`auth` answers "may this caller talk to this endpoint" β€” nothing more. A valid credential still reaches every tool and resource the server declares; per-operation authorization belongs in your `validate` callback (check scopes there) or in your handlers.
`auth` answers "may this caller talk to this endpoint" β€” nothing more. A valid credential otherwise reaches every tool and resource the server declares. To narrow that per operation, declare [scopes](#per-definition-scopes) on the definitions, or check inside your `validate` callback and your handlers.

### OAuth 2.1 resource server

Expand Down Expand Up @@ -526,6 +526,32 @@ export default createMcpHandler({

Mount `oauth.metadataHandler` on `oauth.metadataPath` if you are not using `mcp()`.

### Per-definition scopes

All three helpers take `scopes`. A call is refused unless the access token carries **every** scope listed:

```ts
export default defineMcpTool({
scopes: ['todos:write'],
inputSchema: z.object({ id: z.string() }),
handler: ({ id }) => remove(id),
})
```

The scopes are read off the verified claims on `event.context.oauth`: `scope`, space-delimited as RFC 6749 writes it, and `scp`, which Okta and Entra ID send as a string or an array. A refusal is a JSON-RPC error naming the scopes that were missing β€” under HTTP 403 on the modern revision, and in the `200` stream that a legacy request gets for every error:

```json
{
"code": -32003,
"message": "The tool \"remove-todo\" requires todos:write.",
"data": { "requiredScopes": ["todos:write"], "missingScopes": ["todos:write"] }
}
```

**A scoped definition is still listed.** `tools/list` shows it to every caller, and the scopes come back in its `_meta` so a client can say why a call would fail. Only the call itself is gated. The reason is the engine's order: a handler's options resolve before the request is authenticated, so nothing that builds a listing has seen the token yet. Treat `scopes` as authorization, not as concealment β€” if a tool's _existence_ is sensitive, put it on a second endpoint behind its own `auth`.

It fails closed: a definition that declares `scopes` on an endpoint with no OAuth has no claims to satisfy it, so every call is refused. `handler.definitions` reports the scopes too, so a catalog route can group by them.

### Zero-config: `mcp()`

`mcp()`'s options cross into generated code as JSON, so its `auth` is the JSON-serializable subset of what `createMcpHandler` accepts above β€” a static `tokens` list, no `validate` callback. `oauth` is the other exception: JWT verification is generated for you. Omit both and that server stays open:
Expand Down
4 changes: 4 additions & 0 deletions packages/nitro-mcp-toolkit/src/runtime/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface McpDefinition {
readonly group?: string
/** Free-form labels, advertised in `_meta` for clients to filter on. */
readonly tags?: string[]
/** OAuth scopes the access token must all carry to reach this definition. */
readonly scopes?: string[]
/** Set for discovered definitions; absent for hand-written ones. */
readonly source?: McpDefinitionSource
/**
Expand All @@ -73,6 +75,8 @@ export interface McpDefinitionSummary {
description?: string
group?: string
tags?: string[]
/** OAuth scopes required to reach it, when it declared any. */
scopes?: string[]
/** Resources only: the URI read, or the pattern a template answers. */
uri?: string
/** Path relative to the scanned directory, for discovered definitions. */
Expand Down
29 changes: 21 additions & 8 deletions packages/nitro-mcp-toolkit/src/runtime/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { attachNotify } from './context.ts'
import { requireScopes } from './scopes.ts'
import { resolveMeta } from './validate.ts'
import type { H3Event } from 'h3'
import type { GetPromptResult, Icon, PromptArgument, StandardTypedV1 } from 'h3-mcp'
Expand All @@ -23,6 +24,11 @@ interface McpPromptMetadata {
group?: string
/** Free-form labels, advertised in `_meta` for clients to filter on. */
tags?: string[]
/**
* OAuth scopes the access token must all carry to expand this prompt. It
* still appears in `prompts/list`; an expansion without them is refused.
*/
scopes?: string[]
icons?: Icon[]
}

Expand Down Expand Up @@ -76,7 +82,7 @@ export function defineMcpPrompt(
| McpPromptDefinitionWithArguments
| McpPromptDefinitionWithoutInput,
): McpPrompt {
const { name, title, description, group, tags, icons } = definition
const { name, title, description, group, tags, scopes, icons } = definition

return {
kind: 'prompt',
Expand All @@ -85,22 +91,25 @@ export function defineMcpPrompt(
description,
group,
tags,
scopes,
build(identity, into, notify) {
const advertised = {
name: identity.name,
title: identity.title,
description,
icons,
_meta: resolveMeta(identity.group, tags),
_meta: resolveMeta(identity.group, tags, scopes),
}

if ('inputSchema' in definition && definition.inputSchema) {
const { inputSchema, handler } = definition
into.prompts.push({
...advertised,
arguments: inputSchema,
handler: async (args: StandardTypedV1.InferOutput<Schema>, event: H3Event) =>
toPromptResult(await handler(args, attachNotify(event, notify))),
handler: async (args: StandardTypedV1.InferOutput<Schema>, event: H3Event) => {
requireScopes(event, scopes, 'prompt', identity.name)
return toPromptResult(await handler(args, attachNotify(event, notify)))
},
})
return
}
Expand All @@ -110,17 +119,21 @@ export function defineMcpPrompt(
into.prompts.push({
...advertised,
arguments: args,
handler: async (parsed: Record<string, string>, event: H3Event) =>
toPromptResult(await handler(parsed, attachNotify(event, notify))),
handler: async (parsed: Record<string, string>, event: H3Event) => {
requireScopes(event, scopes, 'prompt', identity.name)
return toPromptResult(await handler(parsed, attachNotify(event, notify)))
},
})
return
}

const { handler } = definition
into.prompts.push({
...advertised,
handler: async (event: H3Event) =>
toPromptResult(await handler(attachNotify(event, notify))),
handler: async (event: H3Event) => {
requireScopes(event, scopes, 'prompt', identity.name)
return toPromptResult(await handler(attachNotify(event, notify)))
},
})
},
}
Expand Down
23 changes: 17 additions & 6 deletions packages/nitro-mcp-toolkit/src/runtime/resource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineResourceTemplate } from 'h3-mcp'
import { attachNotify } from './context.ts'
import { requireScopes } from './scopes.ts'
import { resolveMeta } from './validate.ts'
import type { H3Event } from 'h3'
import type {
Expand Down Expand Up @@ -30,6 +31,11 @@ interface McpResourceMetadata {
group?: string
/** Free-form labels, advertised in `_meta` for clients to filter on. */
tags?: string[]
/**
* OAuth scopes the access token must all carry to read this resource. It
* still appears in `resources/list`; a read without them is refused.
*/
scopes?: string[]
mimeType?: string
icons?: Icon[]
/** Advertised to clients so they may cache the read. */
Expand Down Expand Up @@ -83,7 +89,7 @@ export function defineMcpResource(definition: McpResourceTemplateDefinition): Mc
export function defineMcpResource(
definition: McpResourceDefinition | McpResourceTemplateDefinition,
): McpResource {
const { name, title, description, group, tags, mimeType, icons, cache } = definition
const { name, title, description, group, tags, scopes, mimeType, icons, cache } = definition
const isStaticUri = isStatic(definition)

return {
Expand All @@ -93,6 +99,7 @@ export function defineMcpResource(
description,
group,
tags,
scopes,
uri: isStaticUri ? definition.uri : definition.uriTemplate,
build(identity, into, notify) {
const advertised = {
Expand All @@ -102,16 +109,18 @@ export function defineMcpResource(
mimeType,
icons,
cache,
_meta: resolveMeta(identity.group, tags),
_meta: resolveMeta(identity.group, tags, scopes),
}

if (isStaticUri) {
const { uri: staticUri, handler } = definition
into.resources.push({
...advertised,
uri: staticUri,
handler: async (url: URL, event: H3Event) =>
toReadResult(url, await handler(url, attachNotify(event, notify))),
handler: async (url: URL, event: H3Event) => {
requireScopes(event, scopes, 'resource', identity.name)
return toReadResult(url, await handler(url, attachNotify(event, notify)))
},
})
return
}
Expand All @@ -124,8 +133,10 @@ export function defineMcpResource(
uriTemplate,
list,
complete,
handler: async (url: URL, variables: Record<string, string>, event: H3Event) =>
toReadResult(url, await handler(url, variables, attachNotify(event, notify))),
handler: async (url: URL, variables: Record<string, string>, event: H3Event) => {
requireScopes(event, scopes, 'resource', identity.name)
return toReadResult(url, await handler(url, variables, attachNotify(event, notify)))
},
}),
)
},
Expand Down
57 changes: 57 additions & 0 deletions packages/nitro-mcp-toolkit/src/runtime/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { McpJsonRpcError } from 'h3-mcp'
import type { H3Event } from 'h3'
import type { McpDefinition } from './definition.ts'
import type { McpOAuthClaims } from './oauth.ts'

/**
* Implementation-defined server error, from JSON-RPC 2.0's `-32000`…`-32099`
* range. The `403` rides along as `status`, which is what RFC 6750 calls for.
*/
const INSUFFICIENT_SCOPE = -32003

function spaceDelimited(value: unknown): string[] {
return typeof value === 'string' ? value.split(' ').filter((scope) => scope !== '') : []
}

/**
* The scopes a verified access token carries: `scope` as RFC 6749 writes it,
* space-delimited, plus `scp` β€” the same thing under Okta and Entra ID, which
* may send it as an array.
*/
export function grantedScopes(claims: McpOAuthClaims | undefined): Set<string> {
if (!claims) return new Set()

const scp = Array.isArray(claims.scp)
? claims.scp.filter((scope): scope is string => typeof scope === 'string')
: spaceDelimited(claims.scp)

return new Set([...spaceDelimited(claims.scope), ...scp])
}

/**
* Refuse the call when the token lacks any scope the definition declares.
*
* This runs at call time rather than filtering the listings: the engine
* resolves a handler's options before it authenticates, so no listing can see
* the claims. It fails closed β€” a definition declaring scopes on an endpoint
* with no OAuth has nothing to satisfy it.
*/
export function requireScopes(
event: H3Event,
scopes: string[] | undefined,
kind: McpDefinition['kind'],
name: string,
): void {
if (!scopes?.length) return

const granted = grantedScopes(event.context.oauth)
const missing = scopes.filter((scope) => !granted.has(scope))

if (missing.length === 0) return

throw new McpJsonRpcError(
INSUFFICIENT_SCOPE,
`The ${kind} ${JSON.stringify(name)} requires ${missing.join(', ')}.`,
{ status: 403, data: { requiredScopes: scopes, missingScopes: missing } },
)
}
27 changes: 23 additions & 4 deletions packages/nitro-mcp-toolkit/src/runtime/tool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { McpJsonRpcError } from 'h3-mcp'
import { attachNotify } from './context.ts'
import { isInputRequired, toCallToolResult, toErrorResult } from './results.ts'
import { requireScopes } from './scopes.ts'
import { resolveMeta } from './validate.ts'
import type { H3Event } from 'h3'
import type {
Expand Down Expand Up @@ -36,6 +37,16 @@ interface McpToolMetadata {
group?: string
/** Free-form labels, advertised in `_meta` for clients to filter on. */
tags?: string[]
/**
* OAuth scopes the access token must all carry to call this tool. The tool
* still appears in `tools/list`; a call without them is refused.
*
* @example
* ```ts
* defineMcpTool({ scopes: ['todos:write'], handler: … })
* ```
*/
scopes?: string[]
annotations?: ToolAnnotations
icons?: Icon[]
}
Expand Down Expand Up @@ -100,7 +111,8 @@ export function defineMcpTool(
| McpToolDefinition<Schema, Schema | undefined>
| McpToolDefinitionWithoutInput<Schema | undefined>,
): McpTool {
const { name, title, description, group, tags, annotations, icons, outputSchema } = definition
const { name, title, description, group, tags, scopes, annotations, icons, outputSchema } =
definition
const hasOutputSchema = outputSchema !== undefined

return {
Expand All @@ -110,6 +122,7 @@ export function defineMcpTool(
description,
group,
tags,
scopes,
build(identity, into, notify) {
const advertised = {
name: identity.name,
Expand All @@ -118,7 +131,7 @@ export function defineMcpTool(
outputSchema,
annotations,
icons,
_meta: resolveMeta(identity.group, tags),
_meta: resolveMeta(identity.group, tags, scopes),
}

if (definition.inputSchema) {
Expand All @@ -127,7 +140,10 @@ export function defineMcpTool(
...advertised,
inputSchema,
handler: (args: StandardTypedV1.InferOutput<Schema>, event: H3Event) =>
settle(() => handler(args, attachNotify(event, notify)), hasOutputSchema),
settle(() => {
requireScopes(event, scopes, 'tool', identity.name)
return handler(args, attachNotify(event, notify))
}, hasOutputSchema),
})
return
}
Expand All @@ -136,7 +152,10 @@ export function defineMcpTool(
into.tools.push({
...advertised,
handler: (event: H3Event) =>
settle(() => handler(attachNotify(event, notify)), hasOutputSchema),
settle(() => {
requireScopes(event, scopes, 'tool', identity.name)
return handler(attachNotify(event, notify))
}, hasOutputSchema),
})
},
}
Expand Down
Loading
Loading