From 6f441025f63a378f6046b724c302a6ea35215ff2 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 3 Sep 2026 16:34:54 +0100 Subject: [PATCH] feat(nitro): gate definitions on oauth scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three helpers take `scopes`. `requireScopes` wraps the handler each `build()` pushes, reads the verified claims off `event.context.oauth`, and throws a JSON-RPC `-32003` naming the scopes that were missing. Scopes come from `scope` (space-delimited, RFC 6749) and `scp` (a string or an array, as Okta and Entra ID send it). It fails closed: a definition that declares scopes on an endpoint with no OAuth has nothing to satisfy them, so every call is refused. The gate is on the call, not the listing, and that is the engine's order rather than a preference — a handler's options resolve before the request is authenticated, so nothing that builds a listing has seen the token yet. A scoped definition therefore stays visible in `tools/list`, with its scopes in `_meta` and on `handler.definitions` so a client can say why a call would fail. When a definition's existence is itself sensitive, it belongs on a second endpoint. --- .changeset/nitro-definition-scopes.md | 15 + AGENTS.md | 2 + .../server/mcp/tools/scoped.ts | 13 + packages/nitro-mcp-toolkit/README.md | 30 +- .../src/runtime/definition.ts | 4 + .../nitro-mcp-toolkit/src/runtime/prompt.ts | 29 +- .../nitro-mcp-toolkit/src/runtime/resource.ts | 23 +- .../nitro-mcp-toolkit/src/runtime/scopes.ts | 57 ++++ .../nitro-mcp-toolkit/src/runtime/tool.ts | 27 +- .../nitro-mcp-toolkit/src/runtime/validate.ts | 10 +- .../nitro-mcp-toolkit/test/scopes.test.ts | 265 ++++++++++++++++++ packages/nitro-mcp-toolkit/test/types.test.ts | 38 ++- 12 files changed, 489 insertions(+), 24 deletions(-) create mode 100644 .changeset/nitro-definition-scopes.md create mode 100644 apps/nitro-playground/server/mcp/tools/scoped.ts create mode 100644 packages/nitro-mcp-toolkit/src/runtime/scopes.ts create mode 100644 packages/nitro-mcp-toolkit/test/scopes.test.ts 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() + }) +})