mcp-expose 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +102 -13
  3. package/dist/adonisjs/index.cjs +238 -0
  4. package/dist/adonisjs/index.cjs.map +1 -1
  5. package/dist/adonisjs/index.d.cts +2 -1
  6. package/dist/adonisjs/index.d.ts +2 -1
  7. package/dist/adonisjs/index.js +238 -0
  8. package/dist/adonisjs/index.js.map +1 -1
  9. package/dist/express/index.cjs +236 -1
  10. package/dist/express/index.cjs.map +1 -1
  11. package/dist/express/index.d.cts +4 -2
  12. package/dist/express/index.d.ts +4 -2
  13. package/dist/express/index.js +236 -1
  14. package/dist/express/index.js.map +1 -1
  15. package/dist/fastify/index.cjs +245 -0
  16. package/dist/fastify/index.cjs.map +1 -1
  17. package/dist/fastify/index.d.cts +2 -1
  18. package/dist/fastify/index.d.ts +2 -1
  19. package/dist/fastify/index.js +245 -0
  20. package/dist/fastify/index.js.map +1 -1
  21. package/dist/hono/index.cjs +239 -0
  22. package/dist/hono/index.cjs.map +1 -1
  23. package/dist/hono/index.d.cts +2 -1
  24. package/dist/hono/index.d.ts +2 -1
  25. package/dist/hono/index.js +239 -0
  26. package/dist/hono/index.js.map +1 -1
  27. package/dist/index.cjs +243 -0
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -2
  30. package/dist/index.d.ts +5 -2
  31. package/dist/index.js +239 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/koa/index.cjs +238 -0
  34. package/dist/koa/index.cjs.map +1 -1
  35. package/dist/koa/index.d.cts +2 -1
  36. package/dist/koa/index.d.ts +2 -1
  37. package/dist/koa/index.js +238 -0
  38. package/dist/koa/index.js.map +1 -1
  39. package/dist/nestjs/index.cjs +253 -1
  40. package/dist/nestjs/index.cjs.map +1 -1
  41. package/dist/nestjs/index.d.cts +21 -7
  42. package/dist/nestjs/index.d.ts +21 -7
  43. package/dist/nestjs/index.js +254 -2
  44. package/dist/nestjs/index.js.map +1 -1
  45. package/dist/oauth/index.cjs +214 -0
  46. package/dist/oauth/index.cjs.map +1 -0
  47. package/dist/oauth/index.d.cts +69 -0
  48. package/dist/oauth/index.d.ts +69 -0
  49. package/dist/oauth/index.js +177 -0
  50. package/dist/oauth/index.js.map +1 -0
  51. package/dist/oauth-8RmQamGP.d.cts +75 -0
  52. package/dist/oauth-8RmQamGP.d.ts +75 -0
  53. package/dist/{server-fzkb-wJo.d.ts → server-BFQ_Hdh9.d.cts} +27 -0
  54. package/dist/{server-fzkb-wJo.d.cts → server-BRw1E2xy.d.ts} +27 -0
  55. package/package.json +21 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.1.0] - 2026-09-27
10
+
11
+ ### Added
12
+
13
+ - **OAuth 2.1 support** following the MCP authorization spec, on every adapter, enabled with the new `oauth` server option:
14
+ - Protected Resource Metadata (RFC 9728) served at `/.well-known/oauth-protected-resource[/<path>]`, public and CORS-enabled.
15
+ - `401` / `403` responses with `WWW-Authenticate` challenges (`resource_metadata`, `scope`, `invalid_token`,
16
+ `insufficient_scope`), so MCP clients discover the authorization server and sign the user in by themselves.
17
+ - Audience-checked token verification with `requiredScopes`; the verified token is available to tools as `ctx.auth`.
18
+ - Per-tool `scopes` with `403 insufficient_scope` step-up.
19
+ - New `mcp-expose/oauth` entry point: `jwtVerifier()` (JWKS, via the optional `jose` peer dependency),
20
+ `introspectionVerifier()` (RFC 7662) and `discoverAuthorizationServer()`.
21
+ - NestJS: `decorators` option to add decorators (such as `Public()`) to the MCP controller.
22
+ - E2E: every scenario (each supported framework major) also runs in OAuth mode with `jwtVerifier`, and a new
23
+ scenario has the official MCP SDK client register dynamically, sign in with authorization code + PKCE and a
24
+ resource indicator, and step up scopes.
25
+
9
26
  ## [1.0.0] - 2026-09-25
10
27
 
11
28
  First stable release. The public API (`mcp-expose` and `mcp-expose/<framework>` exports, tool and server
@@ -35,5 +52,6 @@ options, default tool naming) now follows Semantic Versioning.
35
52
  - Path parameters that are empty, `.` or `..` are rejected, so an agent cannot use them to reach routes
36
53
  that were never exposed as tools (for example `/orders/../admin`).
37
54
 
38
- [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.0.0...HEAD
55
+ [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.1.0...HEAD
56
+ [1.1.0]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.0
39
57
  [1.0.0]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.0.0
package/README.md CHANGED
@@ -32,6 +32,7 @@ router.get('/orders/:id', [OrdersController, 'show']).use(middleware.auth()).mcp
32
32
  - Zero runtime dependencies. Dual ESM/CJS. Node.js 20, 22 and 24, plus Bun, Deno and Workers for Hono.
33
33
  - Supports the current **and the two previous major versions** of every framework, verified end to end.
34
34
  - Speaks MCP Streamable HTTP (protocol `2024-11-05` → `2025-11-25`), stateless, so it scales horizontally and runs serverless.
35
+ - Built-in [OAuth 2.1](#oauth-sign-in-from-ai-clients): AI clients discover your identity provider and sign the user in by themselves (Auth0, Okta, Keycloak, Entra ID, Clerk, Cognito, ...).
35
36
 
36
37
  ---
37
38
 
@@ -44,14 +45,15 @@ router.get('/orders/:id', [OrdersController, 'show']).use(middleware.auth()).mcp
44
45
  5. [Framework guides](#framework-guides)
45
46
  - [NestJS](#nestjs) · [Express](#express) · [Fastify](#fastify) · [Koa](#koa) · [Hono](#hono) · [AdonisJS](#adonisjs) · [Any API via OpenAPI](#any-api-via-openapi-standalone-gateway)
46
47
  6. [Connect an AI client](#connect-an-ai-client)
47
- 7. [Defining tool inputs (schemas)](#defining-tool-inputs-schemas)
48
- 8. [Configuration reference](#configuration-reference)
49
- 9. [Custom (non-HTTP) tools](#custom-non-http-tools)
50
- 10. [Security checklist](#security-checklist)
51
- 11. [Writing tools agents use well](#writing-tools-agents-use-well)
52
- 12. [Limitations and roadmap](#limitations-and-roadmap)
53
- 13. [Development](#development)
54
- 14. [Versioning and support](#versioning-and-support)
48
+ 7. [OAuth: sign in from AI clients](#oauth-sign-in-from-ai-clients)
49
+ 8. [Defining tool inputs (schemas)](#defining-tool-inputs-schemas)
50
+ 9. [Configuration reference](#configuration-reference)
51
+ 10. [Custom (non-HTTP) tools](#custom-non-http-tools)
52
+ 11. [Security checklist](#security-checklist)
53
+ 12. [Writing tools agents use well](#writing-tools-agents-use-well)
54
+ 13. [Limitations and roadmap](#limitations-and-roadmap)
55
+ 14. [Development](#development)
56
+ 15. [Versioning and support](#versioning-and-support)
55
57
 
56
58
  ---
57
59
 
@@ -466,7 +468,8 @@ This also works with `@nestjs/swagger`, `@fastify/swagger`, tsoa and hono-openap
466
468
  ## Connect an AI client
467
469
 
468
470
  Start your app, then point a client at `http://localhost:3000/mcp`. Pass the same credentials a normal API
469
- client would use. They are forwarded to your routes.
471
+ client would use. They are forwarded to your routes. (With the [`oauth` option](#oauth-sign-in-from-ai-clients)
472
+ you skip the header: the client signs the user in by itself.)
470
473
 
471
474
  **Claude Code**
472
475
 
@@ -534,6 +537,89 @@ curl -s localhost:3000/mcp -H 'content-type: application/json' \
534
537
 
535
538
  ---
536
539
 
540
+ ## OAuth: sign in from AI clients
541
+
542
+ Pasting a token into a client config works for development. For real users, turn on the `oauth` option:
543
+ mcp-expose then implements the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
544
+ so Claude, Cursor, VS Code and other clients open your normal login page, get a token for the user, and
545
+ send it on every call. Nothing is pasted by hand.
546
+
547
+ ```
548
+ AI client ── POST /mcp (no token) ─────────────▶ 401 WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"
549
+ AI client ── GET /.well-known/oauth-protected-resource/mcp ─▶ { resource, authorization_servers: ["https://login.example.com"] }
550
+ AI client ◀──── login in the browser (your IdP: Auth0, Okta, Keycloak, Entra ID, Clerk, Cognito, ...) ────▶ access token
551
+ AI client ── POST /mcp Authorization: Bearer <token> ─▶ mcp-expose checks the token ─▶ your route (token forwarded, guards run)
552
+ ```
553
+
554
+ mcp-expose is the **resource server**. Your identity provider stays the **authorization server**, so user
555
+ accounts, login pages and MFA stay where they are.
556
+
557
+ ### 1. Configure it
558
+
559
+ ```ts
560
+ import { jwtVerifier } from 'mcp-expose/oauth'; // npm install jose
561
+
562
+ const oauth = {
563
+ // The public URL of your MCP endpoint. Tokens must be issued for exactly this audience.
564
+ resource: 'https://api.example.com/mcp',
565
+ authorizationServers: ['https://example.auth0.com/'],
566
+ requiredScopes: ['mcp:tools'], // optional: needed for any MCP access
567
+ verifyToken: jwtVerifier({ issuer: 'https://example.auth0.com/' }),
568
+ };
569
+ ```
570
+
571
+ Pass it as `oauth` to any adapter:
572
+
573
+ ```ts
574
+ McpModule.forRoot({ name: 'shop-api', oauth, decorators: [Public()] }); // NestJS (see note below)
575
+ mountMcp(app, { name: 'shop-api', oauth }); // Express, Koa, Hono, AdonisJS
576
+ await app.register(fastifyMcp, { name: 'shop-api', oauth }); // Fastify
577
+ ```
578
+
579
+ That's it. The adapter:
580
+
581
+ - serves the Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) at `/.well-known/oauth-protected-resource/mcp` (and `/.well-known/oauth-protected-resource`), public and CORS-enabled, outside any global prefix, guard or MCP middleware;
582
+ - answers requests without a valid token with `401` and a `WWW-Authenticate` challenge that starts the client's login;
583
+ - rejects tokens that were not issued for this resource (audience check), which blocks token passthrough from other services;
584
+ - forwards the verified `Authorization` header to your routes, so your guards see the same user;
585
+ - exposes the verified token to custom tools as `ctx.auth` (`subject`, `scopes`, `clientId`, `claims`).
586
+
587
+ ### 2. Per-tool scopes (step-up)
588
+
589
+ ```ts
590
+ @Delete(':id')
591
+ @McpTool({ name: 'cancel_order', scopes: ['orders:write'] })
592
+ cancel(@Param('id') id: string) { ... }
593
+ ```
594
+
595
+ A token without `orders:write` gets `403 insufficient_scope` naming the scopes it needs (plus the ones it already has, so the new token doesn't lose access). MCP clients then ask
596
+ the user to approve the extra access and retry. All tool scopes are advertised in `scopes_supported`.
597
+ `scopes` works on every marker (`mcpTool()`, `config.mcp`, `.mcp()`, `defineTool()`).
598
+
599
+ ### Token verifiers
600
+
601
+ | Verifier | Use it for |
602
+ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
603
+ | `jwtVerifier({ issuer })` | JWT access tokens. Keys come from the issuer's JWKS (found through its metadata, or set `jwksUri` / `jwks`). Checks signature (asymmetric algorithms only), `iss`, `aud`, `exp`, `nbf`. Needs `jose`. |
604
+ | `introspectionVerifier({ issuer, clientId, clientSecret })` | Opaque tokens, via [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) introspection (Keycloak, Okta, ORY, Authlete, ...). Checks `active`, `aud` and `iss`, and caches results for 60 s. |
605
+ | `verifyToken: async (token, { resource }) => AuthInfo \| undefined` | Anything else, such as your existing session or API-key lookup. Return `undefined` to reject the token. |
606
+
607
+ `jwtVerifier` checks that `aud` equals `resource` by default. If your provider uses another audience
608
+ identifier (for example an Auth0 API identifier), set `audience`.
609
+
610
+ ### Provider notes
611
+
612
+ - **The authorization server must support the MCP client flow:** authorization code with PKCE, and ideally
613
+ dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) or client ID metadata documents,
614
+ so clients can register themselves. Many providers support this (for example Auth0, Keycloak, WorkOS and Stytch); with others, pre-register the client and give users its client ID.
615
+ - **Your routes must accept the same token.** It is forwarded unchanged. If your API normally checks a different
616
+ audience, allow the MCP resource too, or set `forwardHeaders` to drop `authorization` and authorise
617
+ with a custom tool using `ctx.auth`.
618
+ - **NestJS global guards** (an `APP_GUARD` that requires a session) run before the MCP controller and would answer 401 without the
619
+ OAuth challenge. Pass your "public" decorator with `decorators: [Public()]` so the guard lets MCP requests through;
620
+ mcp-expose checks the token itself. Likewise, don't also put auth `middleware`, `guards`, `routeOptions` hooks or `configureRoute` middleware on the MCP endpoint.
621
+ - **Behind a proxy**, `resource` must be the public URL the client connects to (`https://...`), not the internal one.
622
+
537
623
  ## Defining tool inputs (schemas)
538
624
 
539
625
  The agent sees **one flat object** of arguments. mcp-expose maps each argument back to the right place:
@@ -575,12 +661,14 @@ Your app's own validation always runs as well. The schema tells the agent what t
575
661
  | `allowedOrigins` | `string[] \| '*'` | `[]` | Browser origins allowed to call the endpoint. Requests without `Origin` (CLIs, IDEs, servers) are always allowed. |
576
662
  | `forwardHeaders` | `string[]` | `['authorization','cookie','x-api-key','accept-language']` | Headers copied from the MCP request to the internal API call. |
577
663
  | `maxResponseChars` | `number` | `100000` | Longer API responses are truncated before reaching the model. |
664
+ | `oauth` | `OAuthOptions` | none | Protect the endpoint with OAuth 2.1. See [OAuth](#oauth-sign-in-from-ai-clients). |
578
665
  | `tools` | `McpToolDefinition[]` | `[]` | Extra hand-written tools. |
579
666
  | `baseUrl` | `string` | loopback | _Express/Koa/Nest/Adonis._ Where internal calls go. Set it for HTTPS with self-signed certs, unix sockets, or a separate API host. |
580
667
  | `routes` | `{method,path,...}[]` | `[]` | _Express/Koa/Hono._ Expose routes without editing them. |
581
668
  | `routers` | see guide | none | _Express:_ `{ '/prefix': router }`. _Koa:_ `[router]`. |
582
669
  | `middleware` | `Middleware[]` | `[]` | _Express._ Middleware in front of `/mcp`, such as auth. |
583
670
  | `guards` | `CanActivate[]` | `[]` | _NestJS._ Guards on the MCP controller. |
671
+ | `decorators` | `ClassDecorator[]` | `[]` | _NestJS._ Extra decorators on the MCP controller, e.g. `[Public()]` for global guards. |
584
672
  | `pathPrefix` | `string` | none | _NestJS._ Extra prefix for tool routes. Global prefix and URI versioning are automatic. |
585
673
  | `routeOptions` | `object` | none | _Fastify._ Extra route options for `/mcp`, such as `onRequest` hooks. |
586
674
  | `configureRoute` | `(route) => void` | none | _AdonisJS._ Configure the MCP route, e.g. add middleware. |
@@ -595,6 +683,7 @@ Your app's own validation always runs as well. The schema tells the agent what t
595
683
  | `input` / `params` / `query` / `body` | Schemas, see [above](#defining-tool-inputs-schemas). |
596
684
  | `annotations` | MCP hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Defaults come from the HTTP method (GET → read-only, DELETE → destructive). |
597
685
  | `headers` | Static headers added to the internal request. |
686
+ | `scopes` | OAuth scopes the token needs to call this tool (with the `oauth` option). Missing scopes answer `403 insufficient_scope`. |
598
687
 
599
688
  Each internal request also carries `X-Mcp-Tool: <tool name>`, so you can log or meter agent traffic separately.
600
689
 
@@ -616,12 +705,12 @@ const convert = defineTool({
616
705
  mountMcp(app, { name: 'shop-api', tools: [convert] });
617
706
  ```
618
707
 
619
- Handlers can return a string, any JSON value, or a full MCP `ToolResult`. `ctx.headers` holds the MCP request's headers, so you can authenticate there too.
708
+ Handlers can return a string, any JSON value, or a full MCP `ToolResult`. `ctx.headers` holds the MCP request's headers, so you can authenticate there too. With the `oauth` option, `ctx.auth` holds the verified token (`subject`, `scopes`, `clientId`, `claims`).
620
709
 
621
710
  ## Security checklist
622
711
 
623
712
  - **Opt-in only.** Nothing is exposed unless you mark it. Review marked routes the way you review a public API, because an LLM can call them with any arguments.
624
- - **Use per-user credentials.** Clients send their own token, the token is forwarded, and your guards authorise the call. Avoid one shared super-token.
713
+ - **Use per-user credentials.** Clients send their own token, the token is forwarded, and your guards authorise the call. Avoid one shared super-token. For remote servers, prefer the [`oauth` option](#oauth-sign-in-from-ai-clients): users sign in through your identity provider, tokens are audience-checked, and sensitive tools can require extra `scopes`.
625
714
  - **Protect discovery too** if tool names are sensitive (`guards`, `middleware`, `routeOptions`).
626
715
  - **Rate limits and IPs:** the agent IP is sent as `X-Forwarded-For`. For loopback adapters, trust loopback only: Express `app.set('trust proxy', 'loopback')`, Koa `app.proxy = true` behind a proxy that overwrites the header, Nest (Express) `app.set('trust proxy', 'loopback')`. Fastify `inject()` sets the IP directly.
627
716
  - **Browser access:** keep `allowedOrigins` empty unless a browser app must call `/mcp` directly.
@@ -649,7 +738,7 @@ Current scope (1.x):
649
738
 
650
739
  Planned (non-breaking, 1.x minor releases):
651
740
 
652
- - OAuth 2.1 protected-resource metadata (RFC 9728) helpers for remote MCP auth, and per-user tool lists
741
+ - Per-user tool lists (hide tools the token's scopes can't call)
653
742
  - Next.js route handlers, Hapi and Elysia adapters
654
743
  - Structured output schemas, and binary/file responses
655
744
  - Streaming long-running responses over SSE
@@ -663,7 +752,7 @@ Contributions are welcome. See [Development](#development).
663
752
  ```bash
664
753
  npm install
665
754
  npm test # vitest: core + all six adapters (real servers, real HTTP)
666
- npm run test:e2e # pack → install into 18 fresh framework projects → official MCP SDK client
755
+ npm run test:e2e # pack → install into 19 fresh framework projects → official MCP SDK client (with and without OAuth)
667
756
  npm run typecheck
668
757
  npm run build # ESM + CJS + .d.ts into dist/
669
758
 
@@ -369,6 +369,7 @@ function createRouteTool(route, opts, dispatch, serverOpts = {}) {
369
369
  ...defaultAnnotations(method),
370
370
  ...opts.annotations
371
371
  },
372
+ scopes: opts.scopes,
372
373
  validate: toValidator(opts.input),
373
374
  async handler(args, ctx) {
374
375
  const missing = opts.input ? void 0 : checkRequired(schema, args);
@@ -417,6 +418,115 @@ function createRouteTool(route, opts, dispatch, serverOpts = {}) {
417
418
  }
418
419
  __name(createRouteTool, "createRouteTool");
419
420
 
421
+ // src/core/oauth.ts
422
+ var PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource";
423
+ function protectedResourceMetadataPaths(resource) {
424
+ const pathname = new URL(resource).pathname.replace(/\/+$/, "");
425
+ return pathname ? [
426
+ PROTECTED_RESOURCE_WELL_KNOWN + pathname,
427
+ PROTECTED_RESOURCE_WELL_KNOWN
428
+ ] : [
429
+ PROTECTED_RESOURCE_WELL_KNOWN
430
+ ];
431
+ }
432
+ __name(protectedResourceMetadataPaths, "protectedResourceMetadataPaths");
433
+ function protectedResourceMetadataUrl(resource) {
434
+ return new URL(protectedResourceMetadataPaths(resource)[0], resource).href;
435
+ }
436
+ __name(protectedResourceMetadataUrl, "protectedResourceMetadataUrl");
437
+ function validateOAuthOptions(o) {
438
+ let url;
439
+ try {
440
+ url = new URL(o.resource);
441
+ } catch {
442
+ throw new Error(`mcp-expose: oauth.resource must be an absolute URL, got ${JSON.stringify(o.resource)}`);
443
+ }
444
+ if (url.hash) throw new Error("mcp-expose: oauth.resource must not contain a fragment");
445
+ if (!o.authorizationServers?.length) throw new Error("mcp-expose: oauth.authorizationServers must not be empty");
446
+ if (typeof o.verifyToken !== "function") throw new Error("mcp-expose: oauth.verifyToken is required");
447
+ }
448
+ __name(validateOAuthOptions, "validateOAuthOptions");
449
+ var quote = /* @__PURE__ */ __name((v) => `"${v.replace(/["\\]/g, "\\$&")}"`, "quote");
450
+ function bearerChallenge(resource, params = {}) {
451
+ const parts = [];
452
+ if (params.error) parts.push(`error=${quote(params.error)}`);
453
+ if (params.errorDescription) parts.push(`error_description=${quote(params.errorDescription)}`);
454
+ if (params.scope?.length) parts.push(`scope=${quote(params.scope.join(" "))}`);
455
+ parts.push(`resource_metadata=${quote(protectedResourceMetadataUrl(resource))}`);
456
+ return `Bearer ${parts.join(", ")}`;
457
+ }
458
+ __name(bearerChallenge, "bearerChallenge");
459
+ function bearerToken(headers) {
460
+ const m = /^Bearer[ ]+([^\s,]+)\s*$/i.exec(headers.authorization ?? "");
461
+ return m?.[1];
462
+ }
463
+ __name(bearerToken, "bearerToken");
464
+ async function authenticate(o, headers) {
465
+ const token = bearerToken(headers);
466
+ if (!token) {
467
+ return {
468
+ ok: false,
469
+ status: 401,
470
+ error: "unauthorized",
471
+ description: "Authorization required",
472
+ challenge: bearerChallenge(o.resource, {
473
+ scope: o.requiredScopes
474
+ })
475
+ };
476
+ }
477
+ let auth;
478
+ try {
479
+ auth = await o.verifyToken(token, {
480
+ resource: o.resource,
481
+ headers
482
+ });
483
+ } catch {
484
+ auth = void 0;
485
+ }
486
+ if (!auth) return deny(o, 401, "invalid_token", "The access token is invalid");
487
+ if (auth.expiresAt !== void 0 && auth.expiresAt * 1e3 <= Date.now()) {
488
+ return deny(o, 401, "invalid_token", "The access token has expired");
489
+ }
490
+ const missing = missingScopes(auth, o.requiredScopes);
491
+ if (missing.length) {
492
+ const scope = [
493
+ .../* @__PURE__ */ new Set([
494
+ ...auth.scopes ?? [],
495
+ ...o.requiredScopes ?? []
496
+ ])
497
+ ];
498
+ return deny(o, 403, "insufficient_scope", `Missing scope: ${missing.join(" ")}`, scope);
499
+ }
500
+ return {
501
+ ok: true,
502
+ auth: {
503
+ ...auth,
504
+ scopes: auth.scopes ?? []
505
+ }
506
+ };
507
+ }
508
+ __name(authenticate, "authenticate");
509
+ function deny(o, status, error, description, scope) {
510
+ return {
511
+ ok: false,
512
+ status,
513
+ error,
514
+ description,
515
+ challenge: bearerChallenge(o.resource, {
516
+ error,
517
+ errorDescription: description,
518
+ scope
519
+ })
520
+ };
521
+ }
522
+ __name(deny, "deny");
523
+ function missingScopes(auth, required) {
524
+ if (!required?.length) return [];
525
+ const granted = new Set(auth?.scopes ?? []);
526
+ return required.filter((s) => !granted.has(s));
527
+ }
528
+ __name(missingScopes, "missingScopes");
529
+
420
530
  // src/core/server.ts
421
531
  var SUPPORTED_PROTOCOL_VERSIONS = [
422
532
  "2025-11-25",
@@ -451,6 +561,7 @@ var McpServer = class {
451
561
  loaders = [];
452
562
  constructor(options) {
453
563
  this.options = options;
564
+ if (options.oauth) validateOAuthOptions(options.oauth);
454
565
  }
455
566
  /** Register a tool. Throws on duplicate names. */
456
567
  addTool(tool) {
@@ -585,6 +696,79 @@ var McpServer = class {
585
696
  return (allowed ?? []).includes(origin);
586
697
  }
587
698
  /**
699
+ * Paths on which adapters serve the OAuth Protected Resource Metadata
700
+ * (empty when `oauth` is not configured). They must be public: no auth middleware.
701
+ */
702
+ get oauthMetadataPaths() {
703
+ return this.options.oauth ? protectedResourceMetadataPaths(this.options.oauth.resource) : [];
704
+ }
705
+ /** The OAuth Protected Resource Metadata document (RFC 9728). */
706
+ async protectedResourceMetadata() {
707
+ const o = this.options.oauth;
708
+ if (!o) throw new Error("mcp-expose: the oauth option is not configured");
709
+ await this.load();
710
+ const scopes = o.scopesSupported ?? [
711
+ .../* @__PURE__ */ new Set([
712
+ ...o.requiredScopes ?? [],
713
+ ...[
714
+ ...this.tools.values()
715
+ ].flatMap((t) => t.scopes ?? [])
716
+ ])
717
+ ];
718
+ return {
719
+ resource: o.resource,
720
+ authorization_servers: o.authorizationServers,
721
+ ...scopes.length ? {
722
+ scopes_supported: scopes
723
+ } : {},
724
+ bearer_methods_supported: [
725
+ "header"
726
+ ],
727
+ ...o.resourceName ? {
728
+ resource_name: o.resourceName
729
+ } : {},
730
+ ...o.resourceDocumentation ? {
731
+ resource_documentation: o.resourceDocumentation
732
+ } : {},
733
+ ...o.metadata
734
+ };
735
+ }
736
+ /** HTTP handler for the metadata paths. Public and CORS-enabled so browser-based clients can read it. */
737
+ async handleMetadataHttp(req) {
738
+ const cors = {
739
+ "access-control-allow-origin": "*",
740
+ "access-control-allow-methods": "GET, OPTIONS"
741
+ };
742
+ const method = req.method.toUpperCase();
743
+ if (!this.options.oauth) return {
744
+ status: 404,
745
+ headers: {}
746
+ };
747
+ if (method === "OPTIONS") return {
748
+ status: 204,
749
+ headers: {
750
+ ...cors,
751
+ "access-control-allow-headers": "*"
752
+ }
753
+ };
754
+ if (method !== "GET" && method !== "HEAD") return {
755
+ status: 405,
756
+ headers: {
757
+ allow: "GET, OPTIONS"
758
+ }
759
+ };
760
+ const body = JSON.stringify(await this.protectedResourceMetadata());
761
+ return {
762
+ status: 200,
763
+ headers: {
764
+ ...cors,
765
+ "content-type": "application/json",
766
+ "cache-control": "public, max-age=3600"
767
+ },
768
+ body: method === "HEAD" ? void 0 : body
769
+ };
770
+ }
771
+ /**
588
772
  * Framework-neutral Streamable HTTP handler. Adapters convert their request
589
773
  * into McpHttpRequest, call this, and write the McpHttpResponse back.
590
774
  */
@@ -599,6 +783,15 @@ var McpServer = class {
599
783
  body: JSON.stringify(fail(null, INVALID_REQUEST, "Origin not allowed"))
600
784
  };
601
785
  }
786
+ const oauth = this.options.oauth;
787
+ if (oauth) {
788
+ const outcome = await authenticate(oauth, req.headers);
789
+ if (!outcome.ok) return authError(outcome);
790
+ ctx = {
791
+ ...ctx,
792
+ auth: outcome.auth
793
+ };
794
+ }
602
795
  if (req.method.toUpperCase() !== "POST") {
603
796
  return {
604
797
  status: 405,
@@ -619,6 +812,27 @@ var McpServer = class {
619
812
  };
620
813
  }
621
814
  }
815
+ if (oauth) {
816
+ await this.load();
817
+ const calls = Array.isArray(body) ? body : [
818
+ body
819
+ ];
820
+ for (const m of calls) {
821
+ if (m?.method !== "tools/call") continue;
822
+ const tool = this.tools.get(m.params?.name);
823
+ const missing = missingScopes(ctx.auth, tool?.scopes);
824
+ if (missing.length) {
825
+ const scope = [
826
+ .../* @__PURE__ */ new Set([
827
+ ...ctx.auth?.scopes ?? [],
828
+ ...oauth.requiredScopes ?? [],
829
+ ...tool?.scopes ?? []
830
+ ])
831
+ ];
832
+ return authError(deny(oauth, 403, "insufficient_scope", `Tool "${tool.name}" needs scope: ${missing.join(" ")}`, scope));
833
+ }
834
+ }
835
+ }
622
836
  const response = await this.handleMessage(body, ctx);
623
837
  if (response === null) return {
624
838
  status: 202,
@@ -631,6 +845,20 @@ var McpServer = class {
631
845
  };
632
846
  }
633
847
  };
848
+ function authError(o) {
849
+ return {
850
+ status: o.status,
851
+ headers: {
852
+ "content-type": "application/json",
853
+ "www-authenticate": o.challenge
854
+ },
855
+ body: JSON.stringify({
856
+ error: o.error,
857
+ error_description: o.description
858
+ })
859
+ };
860
+ }
861
+ __name(authError, "authError");
634
862
 
635
863
  // src/adonisjs/index.ts
636
864
  var marked = /* @__PURE__ */ new Map();
@@ -697,6 +925,16 @@ function mountMcp(router, options) {
697
925
  });
698
926
  route.as("mcp_expose.endpoint");
699
927
  options.configureRoute?.(route);
928
+ server.oauthMetadataPaths.forEach((p, i) => {
929
+ router.any(p, async (ctx) => {
930
+ const out = await server.handleMetadataHttp({
931
+ method: ctx.request.method()
932
+ });
933
+ ctx.response.status(out.status);
934
+ for (const [k, v] of Object.entries(out.headers)) ctx.response.header(k, v);
935
+ ctx.response.send(out.body ?? "");
936
+ }).as(`mcp_expose.oauth_metadata_${i}`);
937
+ });
700
938
  return server;
701
939
  }
702
940
  __name(mountMcp, "mountMcp");