diff --git a/.changeset/nitro-definition-scopes.md b/.changeset/nitro-definition-scopes.md
new file mode 100644
index 0000000..a79e35c
--- /dev/null
+++ b/.changeset/nitro-definition-scopes.md
@@ -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.
diff --git a/AGENTS.md b/AGENTS.md
index 3e57b44..c641ecd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 `
/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.
diff --git a/apps/nitro-playground/server/mcp/tools/scoped.ts b/apps/nitro-playground/server/mcp/tools/scoped.ts
new file mode 100644
index 0000000..87c97f6
--- /dev/null
+++ b/apps/nitro-playground/server/mcp/tools/scoped.ts
@@ -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',
+})
diff --git a/packages/nitro-mcp-toolkit/README.md b/packages/nitro-mcp-toolkit/README.md
index 9946ce3..6f3050e 100644
--- a/packages/nitro-mcp-toolkit/README.md
+++ b/packages/nitro-mcp-toolkit/README.md
@@ -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.
@@ -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
@@ -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:
diff --git a/packages/nitro-mcp-toolkit/src/runtime/definition.ts b/packages/nitro-mcp-toolkit/src/runtime/definition.ts
index 9935d37..ca05aef 100644
--- a/packages/nitro-mcp-toolkit/src/runtime/definition.ts
+++ b/packages/nitro-mcp-toolkit/src/runtime/definition.ts
@@ -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
/**
@@ -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. */
diff --git a/packages/nitro-mcp-toolkit/src/runtime/prompt.ts b/packages/nitro-mcp-toolkit/src/runtime/prompt.ts
index dc922d0..89863a0 100644
--- a/packages/nitro-mcp-toolkit/src/runtime/prompt.ts
+++ b/packages/nitro-mcp-toolkit/src/runtime/prompt.ts
@@ -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'
@@ -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[]
}
@@ -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',
@@ -85,13 +91,14 @@ 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) {
@@ -99,8 +106,10 @@ export function defineMcpPrompt(
into.prompts.push({
...advertised,
arguments: inputSchema,
- handler: async (args: StandardTypedV1.InferOutput, event: H3Event) =>
- toPromptResult(await handler(args, attachNotify(event, notify))),
+ handler: async (args: StandardTypedV1.InferOutput, event: H3Event) => {
+ requireScopes(event, scopes, 'prompt', identity.name)
+ return toPromptResult(await handler(args, attachNotify(event, notify)))
+ },
})
return
}
@@ -110,8 +119,10 @@ export function defineMcpPrompt(
into.prompts.push({
...advertised,
arguments: args,
- handler: async (parsed: Record, event: H3Event) =>
- toPromptResult(await handler(parsed, attachNotify(event, notify))),
+ handler: async (parsed: Record, event: H3Event) => {
+ requireScopes(event, scopes, 'prompt', identity.name)
+ return toPromptResult(await handler(parsed, attachNotify(event, notify)))
+ },
})
return
}
@@ -119,8 +130,10 @@ export function defineMcpPrompt(
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)))
+ },
})
},
}
diff --git a/packages/nitro-mcp-toolkit/src/runtime/resource.ts b/packages/nitro-mcp-toolkit/src/runtime/resource.ts
index eed510b..011dd41 100644
--- a/packages/nitro-mcp-toolkit/src/runtime/resource.ts
+++ b/packages/nitro-mcp-toolkit/src/runtime/resource.ts
@@ -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 {
@@ -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. */
@@ -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 {
@@ -93,6 +99,7 @@ export function defineMcpResource(
description,
group,
tags,
+ scopes,
uri: isStaticUri ? definition.uri : definition.uriTemplate,
build(identity, into, notify) {
const advertised = {
@@ -102,7 +109,7 @@ export function defineMcpResource(
mimeType,
icons,
cache,
- _meta: resolveMeta(identity.group, tags),
+ _meta: resolveMeta(identity.group, tags, scopes),
}
if (isStaticUri) {
@@ -110,8 +117,10 @@ export function defineMcpResource(
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
}
@@ -124,8 +133,10 @@ export function defineMcpResource(
uriTemplate,
list,
complete,
- handler: async (url: URL, variables: Record, event: H3Event) =>
- toReadResult(url, await handler(url, variables, attachNotify(event, notify))),
+ handler: async (url: URL, variables: Record, event: H3Event) => {
+ requireScopes(event, scopes, 'resource', identity.name)
+ return toReadResult(url, await handler(url, variables, attachNotify(event, notify)))
+ },
}),
)
},
diff --git a/packages/nitro-mcp-toolkit/src/runtime/scopes.ts b/packages/nitro-mcp-toolkit/src/runtime/scopes.ts
new file mode 100644
index 0000000..3a55a50
--- /dev/null
+++ b/packages/nitro-mcp-toolkit/src/runtime/scopes.ts
@@ -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 {
+ 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 } },
+ )
+}
diff --git a/packages/nitro-mcp-toolkit/src/runtime/tool.ts b/packages/nitro-mcp-toolkit/src/runtime/tool.ts
index 5e2b2bc..6c212ce 100644
--- a/packages/nitro-mcp-toolkit/src/runtime/tool.ts
+++ b/packages/nitro-mcp-toolkit/src/runtime/tool.ts
@@ -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 {
@@ -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[]
}
@@ -100,7 +111,8 @@ export function defineMcpTool(
| McpToolDefinition
| McpToolDefinitionWithoutInput,
): 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 {
@@ -110,6 +122,7 @@ export function defineMcpTool(
description,
group,
tags,
+ scopes,
build(identity, into, notify) {
const advertised = {
name: identity.name,
@@ -118,7 +131,7 @@ export function defineMcpTool(
outputSchema,
annotations,
icons,
- _meta: resolveMeta(identity.group, tags),
+ _meta: resolveMeta(identity.group, tags, scopes),
}
if (definition.inputSchema) {
@@ -127,7 +140,10 @@ export function defineMcpTool(
...advertised,
inputSchema,
handler: (args: StandardTypedV1.InferOutput, event: H3Event) =>
- settle(() => handler(args, attachNotify(event, notify)), hasOutputSchema),
+ settle(() => {
+ requireScopes(event, scopes, 'tool', identity.name)
+ return handler(args, attachNotify(event, notify))
+ }, hasOutputSchema),
})
return
}
@@ -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),
})
},
}
diff --git a/packages/nitro-mcp-toolkit/src/runtime/validate.ts b/packages/nitro-mcp-toolkit/src/runtime/validate.ts
index 06ad6b9..0224f4a 100644
--- a/packages/nitro-mcp-toolkit/src/runtime/validate.ts
+++ b/packages/nitro-mcp-toolkit/src/runtime/validate.ts
@@ -41,10 +41,15 @@ function duplicates(values: string[]): string[] {
export function resolveMeta(
group: string | undefined,
tags: string[] | undefined,
+ scopes?: string[],
): Record | undefined {
- if (!group && !tags?.length) return undefined
+ if (!group && !tags?.length && !scopes?.length) return undefined
- return { ...(group ? { group } : {}), ...(tags?.length ? { tags } : {}) }
+ return {
+ ...(group ? { group } : {}),
+ ...(tags?.length ? { tags } : {}),
+ ...(scopes?.length ? { scopes } : {}),
+ }
}
function isResource(definition: McpDefinition): definition is McpResource {
@@ -64,6 +69,7 @@ export function summarize(registrations: readonly McpRegistration[]): McpDefinit
...(definition.description ? { description: definition.description } : {}),
...(identity.group ? { group: identity.group } : {}),
...(definition.tags?.length ? { tags: [...definition.tags] } : {}),
+ ...(definition.scopes?.length ? { scopes: [...definition.scopes] } : {}),
...(isResource(definition) ? { uri: definition.uri } : {}),
...(definition.source ? { file: definition.source.file } : {}),
}))
diff --git a/packages/nitro-mcp-toolkit/test/scopes.test.ts b/packages/nitro-mcp-toolkit/test/scopes.test.ts
new file mode 100644
index 0000000..743a034
--- /dev/null
+++ b/packages/nitro-mcp-toolkit/test/scopes.test.ts
@@ -0,0 +1,265 @@
+import { describe, expect, it } from 'vitest'
+import {
+ MODERN_PROTOCOL_VERSION,
+ createMcpHandler,
+ defineMcpPrompt,
+ defineMcpResource,
+ defineMcpTool,
+} from '../src/runtime/index.ts'
+import { grantedScopes } from '../src/runtime/scopes.ts'
+import { createMcpTestClient } from '../src/testing/index.ts'
+import type { McpPrompt, McpResource, McpTool } from '../src/runtime/index.ts'
+
+/**
+ * Stands in for `createMcpOAuth` without a JWKS: the bearer token is read as
+ * the granted scope list, and the claims land where a verified token puts them.
+ */
+function servingWithScopes(...definitions: (McpTool | McpResource | McpPrompt)[]) {
+ return createMcpHandler({
+ name: 'test',
+ version: '1.0.0',
+ auth: {
+ schemes: ['bearer'],
+ validate: (credential, event) => {
+ event.context.oauth = { sub: 'user_1', scope: credential.token }
+ return true
+ },
+ },
+ tools: definitions.filter((definition): definition is McpTool => definition.kind === 'tool'),
+ resources: definitions.filter(
+ (definition): definition is McpResource => definition.kind === 'resource',
+ ),
+ prompts: definitions.filter(
+ (definition): definition is McpPrompt => definition.kind === 'prompt',
+ ),
+ })
+}
+
+function asUser(handler: ReturnType, scope: string) {
+ return createMcpTestClient(handler, { headers: { authorization: `Bearer ${scope}` } })
+}
+
+const writeTodos = defineMcpTool({
+ name: 'remove-todo',
+ scopes: ['todos:write'],
+ handler: () => 'removed',
+})
+
+describe('grantedScopes', () => {
+ it('reads the space-delimited scope claim RFC 6749 defines', () => {
+ expect(grantedScopes({ scope: 'openid todos:read todos:write' })).toEqual(
+ new Set(['openid', 'todos:read', 'todos:write']),
+ )
+ })
+
+ it('reads scp as an array, which Okta and Entra ID send', () => {
+ expect(grantedScopes({ scp: ['todos:read', 'todos:write'] })).toEqual(
+ new Set(['todos:read', 'todos:write']),
+ )
+ })
+
+ it('reads scp as a string too', () => {
+ expect(grantedScopes({ scp: 'todos:read todos:write' })).toEqual(
+ new Set(['todos:read', 'todos:write']),
+ )
+ })
+
+ it('merges both claims when a token carries them', () => {
+ expect(grantedScopes({ scope: 'openid', scp: ['todos:read'] })).toEqual(
+ new Set(['openid', 'todos:read']),
+ )
+ })
+
+ it('grants nothing without claims, and ignores padding', () => {
+ expect(grantedScopes(undefined)).toEqual(new Set())
+ expect(grantedScopes({})).toEqual(new Set())
+ expect(grantedScopes({ scope: ' todos:read todos:write ' })).toEqual(
+ new Set(['todos:read', 'todos:write']),
+ )
+ })
+
+ it('ignores non-string entries in scp rather than granting them', () => {
+ expect(grantedScopes({ scp: ['todos:read', 7, null] })).toEqual(new Set(['todos:read']))
+ })
+})
+
+describe('a tool declaring scopes', () => {
+ it('runs for a token carrying every one of them', async () => {
+ await using client = await asUser(servingWithScopes(writeTodos), 'openid todos:write')
+
+ const result = await client.callTool({ name: 'remove-todo', arguments: {} })
+
+ expect(result.content).toEqual([{ type: 'text', text: 'removed' }])
+ })
+
+ it('refuses a token that carries none of them', async () => {
+ await using client = await asUser(servingWithScopes(writeTodos), 'openid')
+
+ // `-32003` is JSON-RPC's implementation-defined server range; the `403` it
+ // carries is the HTTP status the engine reports it under.
+ await expect(client.callTool({ name: 'remove-todo', arguments: {} })).rejects.toThrow(
+ /"code":-32003.*requires todos:write/,
+ )
+ })
+
+ it('names only the scopes actually missing', async () => {
+ const strict = defineMcpTool({
+ name: 'audit',
+ scopes: ['todos:read', 'todos:write'],
+ handler: () => 'audited',
+ })
+
+ await using client = await asUser(servingWithScopes(strict), 'todos:read')
+
+ await expect(client.callTool({ name: 'audit', arguments: {} })).rejects.toThrow(
+ '"missingScopes":["todos:write"]',
+ )
+ })
+
+ // The token is what carries scopes, so a server with no OAuth cannot satisfy
+ // one — declaring scopes there is a misconfiguration, not an open door.
+ it('refuses when the endpoint has no OAuth at all', async () => {
+ await using client = await createMcpTestClient(
+ createMcpHandler({ name: 'test', tools: [writeTodos] }),
+ )
+
+ await expect(client.callTool({ name: 'remove-todo', arguments: {} })).rejects.toThrow(
+ /requires todos:write/,
+ )
+ })
+
+ it('leaves a tool that declares none alone', async () => {
+ const open = defineMcpTool({ name: 'ping', handler: () => 'pong' })
+
+ await using client = await asUser(servingWithScopes(open), 'openid')
+
+ const result = await client.callTool({ name: 'ping', arguments: {} })
+
+ expect(result.content).toEqual([{ type: 'text', text: 'pong' }])
+ })
+
+ // The engine resolves a handler's options before it authenticates, so no
+ // listing can see the claims: the tool is advertised and the call is refused.
+ it('is still advertised, with its scopes in _meta', async () => {
+ await using client = await asUser(servingWithScopes(writeTodos), 'openid')
+
+ const { tools } = await client.listTools()
+
+ expect(tools).toHaveLength(1)
+ expect(tools[0]?._meta).toEqual({ scopes: ['todos:write'] })
+ })
+})
+
+describe('a resource or prompt declaring scopes', () => {
+ it('refuses a read without them', async () => {
+ const secret = defineMcpResource({
+ name: 'secret',
+ uri: 'app://secret',
+ scopes: ['files:read'],
+ handler: () => 'classified',
+ })
+
+ await using client = await asUser(servingWithScopes(secret), 'openid')
+
+ await expect(client.readResource({ uri: 'app://secret' })).rejects.toThrow(
+ /requires files:read/,
+ )
+ })
+
+ it('refuses a templated read without them', async () => {
+ const perSlug = defineMcpResource({
+ name: 'doc',
+ uriTemplate: 'app://docs/{slug}',
+ scopes: ['files:read'],
+ handler: () => 'classified',
+ })
+
+ await using client = await asUser(servingWithScopes(perSlug), 'openid')
+
+ await expect(client.readResource({ uri: 'app://docs/intro' })).rejects.toThrow(
+ /requires files:read/,
+ )
+ })
+
+ it('refuses an expansion without them', async () => {
+ const review = defineMcpPrompt({
+ name: 'review',
+ scopes: ['code:read'],
+ handler: () => 'Review it.',
+ })
+
+ await using client = await asUser(servingWithScopes(review), 'openid')
+
+ await expect(client.getPrompt({ name: 'review' })).rejects.toThrow(/requires code:read/)
+ })
+
+ it('allows the read once the token carries them', async () => {
+ const secret = defineMcpResource({
+ name: 'secret',
+ uri: 'app://secret',
+ scopes: ['files:read'],
+ handler: () => 'classified',
+ })
+
+ await using client = await asUser(servingWithScopes(secret), 'files:read')
+
+ const { contents } = await client.readResource({ uri: 'app://secret' })
+
+ expect(contents[0]).toMatchObject({ text: 'classified' })
+ })
+})
+
+describe('the status a refusal comes back under', () => {
+ // The `403` only reaches the wire on the modern revision. A legacy request
+ // carries the same error inside a `200` stream, as it does for every error.
+ it('answers a modern request with HTTP 403', async () => {
+ const handler = servingWithScopes(writeTodos)
+ const envelope = 'io.modelcontextprotocol/'
+
+ const response = await handler.fetch(
+ new Request('http://localhost/mcp', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'application/json, text/event-stream',
+ 'mcp-protocol-version': MODERN_PROTOCOL_VERSION,
+ 'mcp-method': 'tools/call',
+ 'mcp-name': 'remove-todo',
+ authorization: 'Bearer openid',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: {
+ name: 'remove-todo',
+ arguments: {},
+ _meta: {
+ [`${envelope}protocolVersion`]: MODERN_PROTOCOL_VERSION,
+ [`${envelope}clientCapabilities`]: {},
+ },
+ },
+ }),
+ }),
+ )
+
+ expect(response.status).toBe(403)
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: -32003, data: { missingScopes: ['todos:write'] } },
+ })
+ })
+})
+
+describe('what a handler reports about scopes', () => {
+ it('carries them in definitions, beside the tags', () => {
+ const handler = servingWithScopes(
+ writeTodos,
+ defineMcpTool({ name: 'ping', tags: ['public'], handler: () => 'pong' }),
+ )
+
+ expect(handler.definitions).toEqual([
+ { kind: 'tool', name: 'remove-todo', scopes: ['todos:write'] },
+ { kind: 'tool', name: 'ping', tags: ['public'] },
+ ])
+ })
+})
diff --git a/packages/nitro-mcp-toolkit/test/types.test.ts b/packages/nitro-mcp-toolkit/test/types.test.ts
index f8620ce..d0a5b7f 100644
--- a/packages/nitro-mcp-toolkit/test/types.test.ts
+++ b/packages/nitro-mcp-toolkit/test/types.test.ts
@@ -1,11 +1,20 @@
import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod'
-import { defineMcpTool } from '../src/runtime/index.ts'
+import { defineMcpPrompt, defineMcpResource, defineMcpTool } from '../src/runtime/index.ts'
import type { CallToolResult } from '../src/runtime/index.ts'
// Type-only: the module exists once a build generates it, never here.
import type generated from '#mcp/admin-mcp/handler'
import type { mcp } from 'nitro-mcp-toolkit/servers'
-import type { ExtensionPlugin, McpEvent, McpHandler, McpToolReturn } from '../src/runtime/index.ts'
+import type {
+ ExtensionPlugin,
+ McpDefinitionSummary,
+ McpEvent,
+ McpHandler,
+ McpPrompt,
+ McpResource,
+ McpTool,
+ McpToolReturn,
+} from '../src/runtime/index.ts'
const output = z.object({ bmi: z.number() })
@@ -75,3 +84,28 @@ describe('the plugins convention', () => {
expectTypeOf<[{ settings: () => Record }]>().not.toExtend()
})
})
+
+describe('scope typing', () => {
+ it('takes scopes on every kind of definition', () => {
+ expectTypeOf(
+ defineMcpTool({ name: 'remove', scopes: ['todos:write'], handler: () => 'ok' }),
+ ).toEqualTypeOf()
+
+ expectTypeOf(
+ defineMcpResource({
+ name: 'secret',
+ uri: 'app://secret',
+ scopes: ['files:read'],
+ handler: () => 'ok',
+ }),
+ ).toEqualTypeOf()
+
+ expectTypeOf(
+ defineMcpPrompt({ name: 'review', scopes: ['code:read'], handler: () => 'ok' }),
+ ).toEqualTypeOf()
+ })
+
+ it('reports them on what a handler says it serves', () => {
+ expectTypeOf().toEqualTypeOf()
+ })
+})