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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/nitro-oauth-module-option.md
Original file line number Diff line number Diff line change
@@ -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' },
},
}),
],
})
```
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 20 additions & 8 deletions packages/nitro-mcp-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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

Expand Down
52 changes: 48 additions & 4 deletions packages/nitro-mcp-toolkit/src/module/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,15 +42,20 @@ 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 {
name: `mcp:${slug}`,
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(
Expand All @@ -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({
Expand All @@ -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) {
Expand Down
73 changes: 71 additions & 2 deletions packages/nitro-mcp-toolkit/src/module/options.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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. */
Expand All @@ -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) } : {}),
}
}
53 changes: 49 additions & 4 deletions packages/nitro-mcp-toolkit/src/module/template.ts
Original file line number Diff line number Diff line change
@@ -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.'

Expand Down Expand Up @@ -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')}
Expand Down
Loading
Loading