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
15 changes: 15 additions & 0 deletions .changeset/nitro-oauth-resource-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"nitro-mcp-toolkit": minor
---

`createMcpOAuth` turns an MCP endpoint into an OAuth 2.1 resource server: JWT access tokens are verified against the issuer's JWKS, and the verified claims land on `event.context.oauth`. `iss` defaults to `authorizationServers` and `aud` to `resource`, so a token minted for another service is refused. It also hands you `metadataHandler` and `metadataPath` for the RFC 9728 protected-resource document, which is what a `401`'s `WWW-Authenticate` points clients at. `createMcpOAuth({ verify })` covers opaque tokens instead. This package does not issue tokens β€” pair it with an authorization server.

```ts
const oauth = createMcpOAuth({
resource: 'https://api.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
jwt: { jwks: 'https://auth.example.com/.well-known/jwks.json' },
})

export default createMcpHandler({ auth: oauth.auth, tools: [whoami] })
```
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

**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.

### MCP Definitions

Use the helper functions:
Expand Down
1 change: 1 addition & 0 deletions apps/nitro-playground/server/mcp/tools/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default defineMcpTool({
protocolVersion: mcp.protocolVersion ?? null,
requestState: mcp.requestState ?? null,
aborted: mcp.signal?.aborted ?? false,
oauth: event.context.oauth ?? null,
}
},
})
45 changes: 27 additions & 18 deletions packages/nitro-mcp-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,33 @@ Enabling `auth` requires at least one of `tokens` or `validate` β€” a config wit

`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.

### OAuth 2.1 resource server

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.

```ts
import { createMcpHandler, createMcpOAuth, defineMcpTool } from 'nitro-mcp-toolkit'

const oauth = createMcpOAuth({
resource: 'https://api.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
jwt: { jwks: 'https://auth.example.com/.well-known/jwks.json' },
})

export default createMcpHandler({
auth: oauth.auth,
tools: [defineMcpTool({ name: 'who', handler: (event) => event.context.oauth?.email })],
})
```

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.

### 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:
Expand All @@ -441,24 +468,6 @@ 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.

### Protected resource metadata

If you act as an OAuth 2.1 resource server, point clients at your [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) metadata document β€” served by your own app, not this package β€” so a `401` is enough to discover your authorization server:

```ts
auth: {
schemes: ['bearer'],
validate: verifyToken,
resourceMetadataUrl: 'https://example.com/.well-known/oauth-protected-resource',
}
```

Every `401` then answers with `WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource"`.

There is no token format, issuer or audience model here β€” `validate` is opaque credential comparison, so **audience validation belongs inside it**: verify that the presented token was issued for this server (its `aud` claim, or the equivalent introspection result) before returning `true`, or a token minted for another service is accepted, the confused-deputy attack the spec's authorization security considerations call out.

In tests, pass the credential as `{ headers }` on `createMcpTestClient` rather than forging the transport's `fetch`.

## Testing

`nitro-mcp-toolkit/testing` connects a real MCP client to your handler in memory. No port, no build, no HTTP server.
Expand Down
1 change: 1 addition & 0 deletions packages/nitro-mcp-toolkit/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineBuildConfig({
'nitro/types',
'h3',
'h3-mcp',
'jose',
'pathe',
'tinyglobby',
'@modelcontextprotocol/client',
Expand Down
1 change: 1 addition & 0 deletions packages/nitro-mcp-toolkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
},
"dependencies": {
"h3-mcp": "0.2.0",
"jose": "^6.1.3",
"pathe": "^2.0.3",
"tinyglobby": "^0.2.17"
},
Expand Down
1 change: 1 addition & 0 deletions packages/nitro-mcp-toolkit/src/runtime/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface McpNotifier {
export type McpEvent = H3Event & {
context: H3Event['context'] & {
mcp: RequestContext & { notify: McpNotifier }
oauth?: import('./oauth.ts').McpOAuthClaims
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/nitro-mcp-toolkit/src/runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/// <reference path="./virtual.d.ts" />

export { createMcpHandler } from './handler.ts'
export {
createMcpOAuth,
authorizationServerMetadataUrl,
protectedResourceMetadataUrl,
} from './oauth.ts'
export { defineMcpPrompt } from './prompt.ts'
export { MODERN_PROTOCOL_VERSION } from './protocol.ts'
export { defineMcpResource } from './resource.ts'
Expand All @@ -21,6 +26,14 @@ export {

export type { McpEvent, McpNotifier } from './context.ts'
export type { McpHandler, McpHandlerOptions } from './handler.ts'
export type {
McpOAuth,
McpOAuthClaims,
McpOAuthJwtOptions,
McpOAuthOptions,
McpOAuthSetup,
McpProtectedResourceMetadata,
} from './oauth.ts'
export type {
McpPromptDefinition,
McpPromptDefinitionWithArguments,
Expand Down
58 changes: 58 additions & 0 deletions packages/nitro-mcp-toolkit/src/runtime/oauth-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])

export function assertAbsoluteHttpUrl(value: string, label: string, allowQuery = false): URL {
let url: URL

try {
url = new URL(value)
} catch {
throw new Error(`[nitro-mcp-toolkit] ${label} is not an absolute URL.`)
}

if (url.username || url.password) {
throw new Error(`[nitro-mcp-toolkit] ${label} cannot include credentials.`)
}

if (url.hash || (!allowQuery && url.search)) {
throw new Error(`[nitro-mcp-toolkit] ${label} cannot include a query or fragment.`)
}

const loopback = LOOPBACK_HOSTS.has(url.hostname)

if (url.protocol === 'http:') {
if (!loopback) {
throw new Error(
`[nitro-mcp-toolkit] ${label} must be HTTPS, except on a loopback host in development.`,
)
}
} else if (url.protocol !== 'https:') {
throw new Error(`[nitro-mcp-toolkit] ${label} must be an http(s) URL.`)
}

return url
}

function wellKnown(kind: string, value: string, label: string): URL {
const url = assertAbsoluteHttpUrl(value, label)
const path = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '')

return new URL(`/.well-known/${kind}${path}`, url.origin)
}

/**
* Insert `/.well-known/oauth-protected-resource` between the origin and the
* resource path, as RFC 9728 specifies for a resource identifier that has a
* path component.
*/
export function protectedResourceMetadataUrl(resource: string): URL {
return wellKnown('oauth-protected-resource', resource, '`resource`')
}

/**
* Insert `/.well-known/oauth-authorization-server` between the origin and the
* issuer path, as RFC 8414 specifies when the issuer identifier has a path
* (Okta custom authorization servers, Auth0 with a custom domain path, …).
*/
export function authorizationServerMetadataUrl(issuer: string): URL {
return wellKnown('oauth-authorization-server', issuer, '`authorizationServer`')
}
Loading