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() + }) +})