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.
- package/CHANGELOG.md +19 -1
- package/README.md +102 -13
- package/dist/adonisjs/index.cjs +238 -0
- package/dist/adonisjs/index.cjs.map +1 -1
- package/dist/adonisjs/index.d.cts +2 -1
- package/dist/adonisjs/index.d.ts +2 -1
- package/dist/adonisjs/index.js +238 -0
- package/dist/adonisjs/index.js.map +1 -1
- package/dist/express/index.cjs +236 -1
- package/dist/express/index.cjs.map +1 -1
- package/dist/express/index.d.cts +4 -2
- package/dist/express/index.d.ts +4 -2
- package/dist/express/index.js +236 -1
- package/dist/express/index.js.map +1 -1
- package/dist/fastify/index.cjs +245 -0
- package/dist/fastify/index.cjs.map +1 -1
- package/dist/fastify/index.d.cts +2 -1
- package/dist/fastify/index.d.ts +2 -1
- package/dist/fastify/index.js +245 -0
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.cjs +239 -0
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.d.cts +2 -1
- package/dist/hono/index.d.ts +2 -1
- package/dist/hono/index.js +239 -0
- package/dist/hono/index.js.map +1 -1
- package/dist/index.cjs +243 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +239 -0
- package/dist/index.js.map +1 -1
- package/dist/koa/index.cjs +238 -0
- package/dist/koa/index.cjs.map +1 -1
- package/dist/koa/index.d.cts +2 -1
- package/dist/koa/index.d.ts +2 -1
- package/dist/koa/index.js +238 -0
- package/dist/koa/index.js.map +1 -1
- package/dist/nestjs/index.cjs +253 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +21 -7
- package/dist/nestjs/index.d.ts +21 -7
- package/dist/nestjs/index.js +254 -2
- package/dist/nestjs/index.js.map +1 -1
- package/dist/oauth/index.cjs +214 -0
- package/dist/oauth/index.cjs.map +1 -0
- package/dist/oauth/index.d.cts +69 -0
- package/dist/oauth/index.d.ts +69 -0
- package/dist/oauth/index.js +177 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth-8RmQamGP.d.cts +75 -0
- package/dist/oauth-8RmQamGP.d.ts +75 -0
- package/dist/{server-fzkb-wJo.d.ts → server-BFQ_Hdh9.d.cts} +27 -0
- package/dist/{server-fzkb-wJo.d.cts → server-BRw1E2xy.d.ts} +27 -0
- 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.
|
|
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. [
|
|
48
|
-
8. [
|
|
49
|
-
9. [
|
|
50
|
-
10. [
|
|
51
|
-
11. [
|
|
52
|
-
12. [
|
|
53
|
-
13. [
|
|
54
|
-
14. [
|
|
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
|
-
-
|
|
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
|
|
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
|
|
package/dist/adonisjs/index.cjs
CHANGED
|
@@ -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");
|