diff --git a/.changeset/nitro-oauth-module-option.md b/.changeset/nitro-oauth-module-option.md new file mode 100644 index 0000000..c35ca53 --- /dev/null +++ b/.changeset/nitro-oauth-module-option.md @@ -0,0 +1,20 @@ +--- +"nitro-mcp-toolkit": minor +--- + +`mcp({ oauth })` protects a file-based endpoint without a route file. The module generates the `createMcpOAuth` call, wires it as the handler's `auth`, and mounts the RFC 9728 protected-resource document on the path RFC 9728 derives from `resource` — so a client that gets a `401` can find where to authenticate. `oauth` and `auth` cannot both be set on one endpoint, and a config without a JWKS URL throws at build rather than accepting everything. + +```ts +// nitro.config.ts +export default defineConfig({ + modules: [ + mcp({ + oauth: { + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + jwt: { jwks: 'https://auth.example.com/.well-known/jwks.json' }, + }, + }), + ], +}) +``` diff --git a/AGENTS.md b/AGENTS.md index 01efee3..c0221bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,7 @@ 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. -**OAuth** is a generic resource-server: `createMcpOAuth` verifies JWTs against the issuer's JWKS, lands claims on `event.context.oauth`, and returns the RFC 9728 metadata handler to mount. The package does not mint tokens. Opaque tokens mean `createMcpOAuth({ verify })` in a route file. +**OAuth** is a generic resource-server: `mcp({ oauth: { resource, authorizationServers, jwt } })` or `createMcpOAuth`. JWT verify, claims on `event.context.oauth`, RFC 9728 metadata mounted. The package does not mint tokens. Opaque tokens still mean `createMcpOAuth({ verify })` in a route file. ### MCP Definitions diff --git a/packages/nitro-mcp-toolkit/README.md b/packages/nitro-mcp-toolkit/README.md index d75d6cf..07f4483 100644 --- a/packages/nitro-mcp-toolkit/README.md +++ b/packages/nitro-mcp-toolkit/README.md @@ -425,7 +425,23 @@ Enabling `auth` requires at least one of `tokens` or `validate` — a config wit This package is the **resource server**, not the authorization server. It does not mint tokens, serve a login page, or speak DCR. Pair it with an authorization server — Clerk, Okta, WorkOS, Auth0, or [Better Auth's MCP plugin](https://www.better-auth.com/docs/plugins/mcp). -`createMcpOAuth` verifies JWT access tokens against the issuer's JWKS and lands the claims on `event.context.oauth`. `iss` defaults to `authorizationServers`, `aud` to `resource`. Pass `jwt.audience: false` only when the issuer does not put this MCP URL in the token. +`mcp({ oauth })` is the usual path: JWT access tokens, file-based definitions, RFC 9728 metadata mounted for you. Verified claims land on `event.context.oauth`. `iss` defaults to `authorizationServers`, `aud` to `resource`. Pass `jwt.audience: false` only when the issuer does not put this MCP URL in the token. + +#### Any other JWT issuer + +```ts +mcp({ + oauth: { + resource: 'https://api.example.com/mcp', + authorizationServers: ['https://auth.example.com'], + jwt: { jwks: 'https://auth.example.com/.well-known/jwks.json' }, + }, +}) +``` + +#### Opaque tokens, or extra checks + +`createMcpOAuth({ verify })` in a route file. Audience validation belongs inside `verify` when `jwt` is omitted — otherwise a token minted for another service is accepted. ```ts import { createMcpHandler, createMcpOAuth, defineMcpTool } from 'nitro-mcp-toolkit' @@ -442,15 +458,11 @@ export default createMcpHandler({ }) ``` -Mount `oauth.metadataHandler` on `oauth.metadataPath` to serve the RFC 9728 protected-resource document. Every `401` then carries `WWW-Authenticate: Bearer realm="mcp", resource_metadata="…"` pointing at it, which is how a client discovers where to authenticate. - -#### Opaque tokens, or extra checks - -`createMcpOAuth({ verify })` replaces JWKS verification with your own callback. Audience validation belongs inside `verify` when `jwt` is omitted — otherwise a token minted for another service is accepted. +Mount `oauth.metadataHandler` on `oauth.metadataPath` if you are not using `mcp()`. ### 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. Omit it and that server stays open, exactly like every other `mcp()` option: +`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: ```ts // nitro.config.ts @@ -466,7 +478,7 @@ export default defineConfig({ }) ``` -For a `validate` callback, or anything else that is a live function rather than data, mount `createMcpHandler` yourself in a route file instead — the [Authentication](#authentication) examples above are exactly that. +For a `validate` callback, or anything else that is a live function rather than data, mount `createMcpHandler` yourself in a route file instead — the [Authentication](#authentication) examples above are exactly that. `oauth` on `mcp()` is the exception: JWT verification is generated for you. ## Testing diff --git a/packages/nitro-mcp-toolkit/src/module/index.ts b/packages/nitro-mcp-toolkit/src/module/index.ts index 70beb6e..6a28e74 100644 --- a/packages/nitro-mcp-toolkit/src/module/index.ts +++ b/packages/nitro-mcp-toolkit/src/module/index.ts @@ -3,12 +3,24 @@ import { discoverDefinitions } from './discover.ts' import { resolveModuleOptions } from './options.ts' import { reportDefinitions } from './report.ts' import { registerServer, slugify } from './servers.ts' -import { renderHandler, renderRegistry } from './template.ts' +import { protectedResourceMetadataUrl } from '../runtime/oauth-url.ts' +import { + renderAuthorizationServer, + renderHandler, + renderOAuth, + renderOAuthMetadata, + renderRegistry, +} from './template.ts' import { watchDefinitions } from './watch.ts' import type { McpModuleOptions } from './options.ts' import type { NitroModule } from 'nitro/types' -export type { McpModuleOptions, McpServerOptions, ResolvedMcpModuleOptions } from './options.ts' +export type { + McpModuleOAuthOptions, + McpModuleOptions, + McpServerOptions, + ResolvedMcpModuleOptions, +} from './options.ts' /** * Serve an MCP endpoint from the files under `dir`: every definition in @@ -30,8 +42,10 @@ export type { McpModuleOptions, McpServerOptions, ResolvedMcpModuleOptions } fro * }) * ``` */ +const AS_METADATA = '/.well-known/oauth-authorization-server' + export default function mcp(options: McpModuleOptions = {}): NitroModule { - const { route, dir, server } = resolveModuleOptions(options) + const { route, dir, server, oauth } = resolveModuleOptions(options) const slug = slugify(route) return { @@ -39,6 +53,9 @@ export default function mcp(options: McpModuleOptions = {}): NitroModule { setup(nitro) { const registryId = `#mcp/${slug}/registry` const handlerId = `#mcp/${slug}/handler` + const oauthId = `#mcp/${slug}/oauth` + const metadataId = `#mcp/${slug}/oauth-metadata` + const asId = `#mcp/${slug}/oauth-authorization-server` if (handlerId in nitro.options.virtual) { throw new Error( @@ -51,7 +68,8 @@ export default function mcp(options: McpModuleOptions = {}): NitroModule { nitro.options.virtual[registryId] = async () => renderRegistry(await discoverDefinitions(definitionsDir)) - nitro.options.virtual[handlerId] = () => renderHandler(registryId, server) + nitro.options.virtual[handlerId] = () => + renderHandler(registryId, server, oauth ? oauthId : undefined) registerServer(nitro, { route, slug, handlerId }) nitro.options.handlers.push({ @@ -63,6 +81,32 @@ export default function mcp(options: McpModuleOptions = {}): NitroModule { middleware: false, }) + if (oauth) { + const metadataPath = protectedResourceMetadataUrl(oauth.resource).pathname + + nitro.options.virtual[oauthId] = () => renderOAuth(oauth) + nitro.options.virtual[metadataId] = () => renderOAuthMetadata(oauthId) + nitro.options.handlers.push({ + route: metadataPath, + handler: metadataId, + lazy: true, + middleware: false, + }) + + if ( + oauth.authorizationServer && + !nitro.options.handlers.some((handler) => handler.route === AS_METADATA) + ) { + nitro.options.virtual[asId] = () => renderAuthorizationServer(oauthId) + nitro.options.handlers.push({ + route: AS_METADATA, + handler: asId, + lazy: true, + middleware: false, + }) + } + } + reportDefinitions(nitro, route, definitionsDir) if (nitro.options.dev) { diff --git a/packages/nitro-mcp-toolkit/src/module/options.ts b/packages/nitro-mcp-toolkit/src/module/options.ts index 76841e1..071272f 100644 --- a/packages/nitro-mcp-toolkit/src/module/options.ts +++ b/packages/nitro-mcp-toolkit/src/module/options.ts @@ -1,4 +1,5 @@ import type { Era, Icon } from 'h3-mcp' +import type { McpOAuthSetup } from '../runtime/oauth.ts' /** * What the server advertises and how it answers — everything a definition file @@ -55,6 +56,26 @@ export interface McpServerOptions { } } +/** + * JWT resource-server config for `mcp()`. JSON-serializable, so it can cross + * into generated code. Opaque tokens or extra checks still mean a route file. + */ +export type McpModuleOAuthOptions = McpOAuthSetup + +/** What generated code passes to `createMcpOAuth`. */ +export interface ResolvedMcpOAuthOptions { + resource: string + authorizationServers: string[] + jwt: { + jwks: string + issuer?: string | string[] + audience?: string | string[] | false + authorizedParties?: string[] + } + scopesSupported?: string[] + authorizationServer?: string +} + export interface McpModuleOptions extends McpServerOptions { /** * Where the endpoint is mounted. @@ -69,12 +90,29 @@ export interface McpModuleOptions extends McpServerOptions { * @default 'server/mcp' */ dir?: string + /** + * Protect this endpoint as an OAuth 2.1 resource server: JWT verify against + * a JWKS, RFC 9728 metadata mounted for you. Cannot be combined with `auth`. + * + * @example + * ```ts + * mcp({ + * oauth: { + * resource: 'https://api.example.com/mcp', + * authorizationServers: ['https://auth.example.com'], + * jwt: { jwks: 'https://auth.example.com/.well-known/jwks.json' }, + * }, + * }) + * ``` + */ + oauth?: McpModuleOAuthOptions } export interface ResolvedMcpModuleOptions { route: string dir: string server: McpServerOptions + oauth?: ResolvedMcpOAuthOptions } /** `/Mcp/` and `mcp` alike become `/mcp`, so a route always matches as written. */ @@ -88,8 +126,39 @@ function normalizeRoute(route: string): string { return trimmed.startsWith('/') ? trimmed : `/${trimmed}` } +export function resolveOAuthOptions(oauth: McpModuleOAuthOptions): ResolvedMcpOAuthOptions { + if (oauth.authorizationServers.length === 0) { + throw new Error('[nitro-mcp-toolkit] `oauth.authorizationServers` needs at least one issuer.') + } + + if (!oauth.jwt?.jwks) { + throw new Error( + '[nitro-mcp-toolkit] `oauth` on `mcp()` needs `jwt`. Opaque tokens mean `createMcpOAuth({ verify })` in a route file.', + ) + } + + return { + resource: oauth.resource, + authorizationServers: oauth.authorizationServers, + jwt: oauth.jwt, + ...(oauth.scopesSupported ? { scopesSupported: oauth.scopesSupported } : {}), + ...(oauth.authorizationServer ? { authorizationServer: oauth.authorizationServer } : {}), + } +} + export function resolveModuleOptions(options: McpModuleOptions = {}): ResolvedMcpModuleOptions { - const { route = '/mcp', dir = 'server/mcp', ...server } = options + const { route = '/mcp', dir = 'server/mcp', oauth, ...server } = options - return { route: normalizeRoute(route), dir, server } + if (oauth && server.auth) { + throw new Error( + '[nitro-mcp-toolkit] `oauth` and `auth` cannot both be set. `oauth` already requires a bearer token.', + ) + } + + return { + route: normalizeRoute(route), + dir, + server, + ...(oauth ? { oauth: resolveOAuthOptions(oauth) } : {}), + } } diff --git a/packages/nitro-mcp-toolkit/src/module/template.ts b/packages/nitro-mcp-toolkit/src/module/template.ts index f8440d3..0571b08 100644 --- a/packages/nitro-mcp-toolkit/src/module/template.ts +++ b/packages/nitro-mcp-toolkit/src/module/template.ts @@ -1,6 +1,6 @@ import { DEFINITION_DIRS } from './discover.ts' import type { DefinitionDir, DiscoveredDefinition } from './discover.ts' -import type { McpServerOptions } from './options.ts' +import type { McpServerOptions, ResolvedMcpOAuthOptions } from './options.ts' const BANNER = '// Generated by nitro-mcp-toolkit. Edit the files it lists instead.' @@ -52,15 +52,60 @@ export function renderRegistry(definitions: DiscoveredDefinition[]): string { return `${blocks.join('\n\n')}\n` } +function line(key: string, value: unknown): string { + return ` ${key}: ${JSON.stringify(value)},` +} + +/** The `createMcpOAuth` instance the handler and metadata routes share. */ +export function renderOAuth(oauth: ResolvedMcpOAuthOptions): string { + return `${BANNER} +import { createMcpOAuth } from 'nitro-mcp-toolkit' + +export const oauth = createMcpOAuth({ +${line('resource', oauth.resource)} +${line('authorizationServers', oauth.authorizationServers)} +${oauth.scopesSupported ? `${line('scopesSupported', oauth.scopesSupported)}\n` : ''}${line('jwt', oauth.jwt)}${ + oauth.authorizationServer ? `\n${line('authorizationServer', oauth.authorizationServer)}` : '' + } +}) +` +} + +/** RFC 9728 document, mounted at the path RFC 9728 derives from `resource`. */ +export function renderOAuthMetadata(oauthId: string): string { + return `${BANNER} +import { oauth } from ${JSON.stringify(oauthId)} + +export default oauth.metadataHandler +` +} + +/** RFC 8414 document, proxied from the authorization server. */ +export function renderAuthorizationServer(oauthId: string): string { + return `${BANNER} +import { oauth } from ${JSON.stringify(oauthId)} + +export default oauth.authorizationServerHandler +` +} + /** The route handler: the discovered registry, served on the module's route. */ -export function renderHandler(registryId: string, server: McpServerOptions): string { +export function renderHandler( + registryId: string, + server: McpServerOptions, + oauthId?: string, +): string { const options = Object.entries(server) .filter(([, value]) => value !== undefined) - .map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`) + .map(([key, value]) => line(key, value)) + + if (oauthId) options.push(' auth: oauth.auth,') + + const oauthImport = oauthId ? `import { oauth } from ${JSON.stringify(oauthId)}\n` : '' return `${BANNER} import { createMcpHandler } from 'nitro-mcp-toolkit' -import { prompts, resources, tools } from ${JSON.stringify(registryId)} +${oauthImport}import { prompts, resources, tools } from ${JSON.stringify(registryId)} export default createMcpHandler({ ${[...options, ' tools,', ' resources,', ' prompts,'].join('\n')} diff --git a/packages/nitro-mcp-toolkit/test/module.test.ts b/packages/nitro-mcp-toolkit/test/module.test.ts index 3c1c506..8b6c6b1 100644 --- a/packages/nitro-mcp-toolkit/test/module.test.ts +++ b/packages/nitro-mcp-toolkit/test/module.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { createNitro } from 'nitro/builder' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import mcp from '../src/module/index.ts' +import { resolveOAuthOptions } from '../src/module/options.ts' import { fixtureDir, modules } from './helpers/discovery-fixture.ts' import type { Nitro } from 'nitro/types' @@ -181,6 +182,92 @@ describe('the mcp() module', () => { await guarded.close() }) + it('refuses oauth together with a static token list', () => { + expect(() => + mcp({ + auth: { tokens: ['x'] }, + oauth: { + resource: 'http://localhost:3030/mcp', + authorizationServers: ['http://localhost:3030/api/auth'], + jwt: { jwks: 'http://localhost:3030/api/auth/jwks' }, + }, + }), + ).toThrow(/cannot both be set/) + }) + + it('refuses oauth without a JWKS URL', () => { + expect(() => + resolveOAuthOptions({ + resource: 'http://localhost:3030/mcp', + authorizationServers: ['http://localhost:3030/api/auth'], + jwt: { jwks: '' }, + }), + ).toThrow(/needs `jwt`/) + }) + + it('generates an oauth instance and mounts RFC 9728 metadata', async () => { + const guarded = await createNitro({ + rootDir: fixtureDir, + dev: false, + preset: 'standard', + modules: [ + mcp({ + route: '/oauth/mcp', + dir: 'server/mcp-admin', + name: 'oauth-fixture', + oauth: { + resource: 'http://localhost:3030/oauth/mcp', + authorizationServers: ['http://localhost:3030/api/auth'], + jwt: { + jwks: 'http://localhost:3030/api/auth/jwks', + issuer: ['http://localhost:3030', 'http://localhost:3030/api/auth'], + }, + scopesSupported: ['mcp:read'], + }, + }), + ], + }) + + expect(guarded.options.handlers).toMatchObject([ + { route: '/oauth/mcp', handler: '#mcp/oauth-mcp/handler' }, + { + route: '/.well-known/oauth-protected-resource/oauth/mcp', + handler: '#mcp/oauth-mcp/oauth-metadata', + }, + ]) + + await expect(render(guarded, '#mcp/oauth-mcp/oauth')).resolves.toMatchInlineSnapshot(` + "// Generated by nitro-mcp-toolkit. Edit the files it lists instead. + import { createMcpOAuth } from 'nitro-mcp-toolkit' + + export const oauth = createMcpOAuth({ + resource: "http://localhost:3030/oauth/mcp", + authorizationServers: ["http://localhost:3030/api/auth"], + scopesSupported: ["mcp:read"], + jwt: {"jwks":"http://localhost:3030/api/auth/jwks","issuer":["http://localhost:3030","http://localhost:3030/api/auth"]}, + }) + " + `) + + await expect(render(guarded, '#mcp/oauth-mcp/handler')).resolves.toMatchInlineSnapshot(` + "// Generated by nitro-mcp-toolkit. Edit the files it lists instead. + import { createMcpHandler } from 'nitro-mcp-toolkit' + import { oauth } from "#mcp/oauth-mcp/oauth" + import { prompts, resources, tools } from "#mcp/oauth-mcp/registry" + + export default createMcpHandler({ + name: "oauth-fixture", + auth: oauth.auth, + tools, + resources, + prompts, + }) + " + `) + + await guarded.close() + }) + it('refuses two servers on one route', () => { expect(() => mcp().setup(nitro)).toThrow(/Two MCP servers are mounted on \/mcp/) })