@ttoss/http-server-mcp 0.15.0 → 0.16.1
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/README.md +21 -78
- package/dist/index.cjs +20 -478
- package/dist/index.d.cts +23 -361
- package/dist/index.d.mts +23 -361
- package/dist/index.mjs +21 -476
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ const data = await apiCall('GET', 'https://partner.api.com/data', {
|
|
|
129
129
|
|
|
130
130
|
## Authentication
|
|
131
131
|
|
|
132
|
-
`createMcpRouter` supports OAuth 2.0 Bearer token authentication via the `auth` option.
|
|
132
|
+
`createMcpRouter` supports OAuth 2.0 Bearer token authentication via the `auth` option. Incoming MCP requests must include a valid `Authorization: Bearer <token>` header — invalid or missing tokens receive a `401 Unauthorized` response. The MCP lifecycle methods `initialize` and `tools/list` are exempt by default so clients can discover the server before authenticating (see [Public methods and discovery](#public-methods-and-discovery)).
|
|
133
133
|
|
|
134
134
|
```mermaid
|
|
135
135
|
sequenceDiagram
|
|
@@ -315,74 +315,34 @@ app.use(createMcpRouter(mcpServer).routes());
|
|
|
315
315
|
|
|
316
316
|
The `WWW-Authenticate: Bearer resource_metadata="…"` header is how MCP clients bootstrap OAuth discovery after their first unauthorized request.
|
|
317
317
|
|
|
318
|
-
|
|
318
|
+
### Public methods and discovery
|
|
319
319
|
|
|
320
|
-
The
|
|
320
|
+
The two behaviors the [MCP authorization spec](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/) requires for client bootstrapping are built into the `auth` option, so you no longer need the hand-rolled middleware shown above:
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
- **`publicMethods`** — JSON-RPC methods that bypass verification, read from the request body's `method` field. Defaults to `['initialize', 'tools/list']` so clients can discover the server before authenticating. Pass `[]` to require a token for every method, or a custom list to change the exempt set.
|
|
323
|
+
- **`resourceMetadataUrl`** — when set, a `401` responds with `WWW-Authenticate: Bearer resource_metadata="<resourceMetadataUrl>"` (RFC 9728) instead of a bare `Bearer`, pointing MCP clients at the protected-resource metadata document. When omitted, the header falls back to `Bearer`.
|
|
323
324
|
|
|
324
325
|
```typescript
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
expiresIn: 3600,
|
|
337
|
-
}),
|
|
338
|
-
// App-owned login/consent — show your own UI, then approve
|
|
339
|
-
onAuthorize: async ({ ctx, request }) => {
|
|
340
|
-
const session = await getSession(ctx);
|
|
341
|
-
if (!session) {
|
|
342
|
-
ctx.redirect(`/login?return_to=${encodeURIComponent(ctx.url)}`);
|
|
343
|
-
return { approved: false };
|
|
344
|
-
}
|
|
345
|
-
return { approved: true, subject: session.userId, scopes: request.scopes };
|
|
346
|
-
},
|
|
347
|
-
// App-owned refresh validation — required to enable the refresh_token grant
|
|
348
|
-
onRefreshToken: async ({ refreshToken }) => {
|
|
349
|
-
const claims = await verifyRefreshToken(refreshToken);
|
|
350
|
-
return claims ? { subject: claims.sub, scopes: claims.scopes } : undefined;
|
|
326
|
+
createMcpRouter(mcpServer, {
|
|
327
|
+
auth: {
|
|
328
|
+
cognitoUserPool: { userPoolId: '...', clientId: '...' },
|
|
329
|
+
// Serve the metadata document (unauthenticated)...
|
|
330
|
+
resourceServerUrl: 'https://mcp.example.com',
|
|
331
|
+
authorizationServerUrl:
|
|
332
|
+
'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx',
|
|
333
|
+
// ...and point 401s at it for auto-discovery.
|
|
334
|
+
resourceMetadataUrl:
|
|
335
|
+
'https://mcp.example.com/.well-known/oauth-protected-resource',
|
|
336
|
+
// publicMethods defaults to ['initialize', 'tools/list'].
|
|
351
337
|
},
|
|
352
|
-
scopesSupported: ['mcp:access'],
|
|
353
338
|
});
|
|
354
|
-
|
|
355
|
-
const app = new App();
|
|
356
|
-
app.use(bodyParser());
|
|
357
|
-
app.use(authServer.routes());
|
|
358
339
|
```
|
|
359
340
|
|
|
360
|
-
|
|
341
|
+
Both fields are optional. Omitting `resourceMetadataUrl` keeps the bare `Bearer` header, and the `publicMethods` default matches what MCP clients expect for discovery.
|
|
361
342
|
|
|
362
|
-
|
|
363
|
-
| ----------------------------------------- | --------- | ---------------------------------------------------- |
|
|
364
|
-
| `/.well-known/oauth-authorization-server` | RFC 8414 | Authorization server metadata discovery |
|
|
365
|
-
| `/.well-known/oauth-protected-resource` | RFC 9728 | Protected-resource metadata (when `resource` is set) |
|
|
366
|
-
| `/authorize` | OAuth 2.1 | Authorization endpoint — **PKCE (S256) required** |
|
|
367
|
-
| `/token` | OAuth 2.1 | `authorization_code` + `refresh_token` grants |
|
|
368
|
-
| `/register` | RFC 7591 | Dynamic Client Registration |
|
|
343
|
+
## Issuing tokens for MCP clients
|
|
369
344
|
|
|
370
|
-
The
|
|
371
|
-
|
|
372
|
-
The pluggable stores let you keep persistence in your own datastore:
|
|
373
|
-
|
|
374
|
-
```typescript
|
|
375
|
-
const clientStore: ClientStore = {
|
|
376
|
-
get: (clientId) => db.clients.findById(clientId),
|
|
377
|
-
register: (client) => db.clients.put(client),
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
const authCodeStore: AuthCodeStore = {
|
|
381
|
-
save: (code) => db.codes.put(code), // set a TTL matching authorizationCodeTtl
|
|
382
|
-
get: (code) => db.codes.findById(code),
|
|
383
|
-
delete: (code) => db.codes.remove(code),
|
|
384
|
-
};
|
|
385
|
-
```
|
|
345
|
+
The `auth` option above covers the **resource-server** half of MCP authorization — it verifies tokens issued by an external authorization server (Cognito, Auth0, …). To make your own first-party server _issue_ the tokens an MCP client runs the full OAuth flow against, add the [`@ttoss/http-server-oauth`](https://ttoss.dev/docs/modules/packages/http-server-oauth) plugin's `oauthServer()` and pair it with `createMcpRouter({ auth: { verifyToken } })` so one deployment both issues and verifies tokens. See the [OAuth Authorization Server](https://ttoss.dev/docs/engineering/guidelines/oauth-authorization-server) guideline.
|
|
386
346
|
|
|
387
347
|
## API Reference
|
|
388
348
|
|
|
@@ -403,28 +363,11 @@ Creates a Koa router configured to handle MCP protocol requests.
|
|
|
403
363
|
- `auth.verifyToken` — Custom async token verifier `(token: string) => Promise<unknown>`
|
|
404
364
|
- `auth.requiredScopes` — Router-level scope guard; returns 403 if any scope is missing
|
|
405
365
|
- `auth.resourceServerUrl` + `auth.authorizationServerUrl` — Enable `/.well-known/oauth-protected-resource`
|
|
366
|
+
- `auth.publicMethods` — JSON-RPC methods that bypass verification (default `['initialize', 'tools/list']`)
|
|
367
|
+
- `auth.resourceMetadataUrl` — Emit RFC 9728 `WWW-Authenticate: Bearer resource_metadata="…"` on 401
|
|
406
368
|
|
|
407
369
|
**Returns:** `Router` — Koa router instance
|
|
408
370
|
|
|
409
|
-
### `createMcpAuthServer(options)`
|
|
410
|
-
|
|
411
|
-
Creates an OAuth 2.1 Authorization Server (`/authorize`, `/token`, `/register`, and discovery metadata) for MCP clients. See [OAuth 2.1 Authorization Server](#oauth-21-authorization-server).
|
|
412
|
-
|
|
413
|
-
**Parameters (`McpAuthServerOptions`):**
|
|
414
|
-
|
|
415
|
-
- `issuer` (`string`) — The authorization server's issuer identifier (its base URL); used to build absolute endpoint URLs in the discovery document
|
|
416
|
-
- `clientStore` (`ClientStore`) — `{ get(clientId), register(client) }` for dynamic clients
|
|
417
|
-
- `authCodeStore` (`AuthCodeStore`) — `{ save(code), get(code), delete(code) }` for short-lived authorization codes (codes are single-use)
|
|
418
|
-
- `issueTokens` (`({ subject, scopes, client }) => IssuedTokens`) — App-owned token minting; returns `{ accessToken, refreshToken?, expiresIn?, scope? }`
|
|
419
|
-
- `onAuthorize` (`({ ctx, client, request }) => OnAuthorizeResult`) — App-owned login/consent; return `{ approved: true, subject, scopes? }` to issue a code, or take over the response and return `{ approved: false }`
|
|
420
|
-
- `onRefreshToken` (`({ refreshToken, client, scopes }) => { subject, scopes } | undefined`, optional) — App-owned refresh-token validation; required to enable the `refresh_token` grant
|
|
421
|
-
- `scopesSupported` (`string[]`, optional) — Advertised in discovery metadata as `scopes_supported`
|
|
422
|
-
- `resource` (`string`, optional) — When set, also serves `/.well-known/oauth-protected-resource`
|
|
423
|
-
- `authorizationCodeTtl` (`number`, optional) — Authorization code lifetime in seconds (default: `600`)
|
|
424
|
-
- `endpoints` (`{ authorize?, token?, register? }`, optional) — Override the default endpoint paths
|
|
425
|
-
|
|
426
|
-
**Returns:** `Router` — Koa router instance exposing `routes()` / `allowedMethods()`
|
|
427
|
-
|
|
428
371
|
### `apiCall(method, url, options?)`
|
|
429
372
|
|
|
430
373
|
Generic HTTP helper for use inside MCP tool handlers.
|
package/dist/index.cjs
CHANGED
|
@@ -4,383 +4,14 @@ Object.defineProperty(exports, Symbol.toStringTag, {
|
|
|
4
4
|
});
|
|
5
5
|
let node_async_hooks = require("node:async_hooks");
|
|
6
6
|
let _modelcontextprotocol_sdk_server_streamableHttp_js = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
7
|
-
let _ttoss_auth_core_amazon_cognito = require("@ttoss/auth-core/amazon-cognito");
|
|
8
7
|
let _ttoss_http_server = require("@ttoss/http-server");
|
|
8
|
+
let _ttoss_http_server_oauth = require("@ttoss/http-server-oauth");
|
|
9
9
|
let zod = require("zod");
|
|
10
|
-
let node_crypto = require("node:crypto");
|
|
11
10
|
let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
12
11
|
|
|
13
|
-
//#region src/authServerHandlers.ts
|
|
14
|
-
var base64UrlEncode = buffer => {
|
|
15
|
-
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
16
|
-
};
|
|
17
|
-
/** Generates a URL-safe random token of the given byte length. */
|
|
18
|
-
var generateToken = (bytes = 32) => {
|
|
19
|
-
return base64UrlEncode((0, node_crypto.randomBytes)(bytes));
|
|
20
|
-
};
|
|
21
|
-
var sha256Base64Url = input => {
|
|
22
|
-
return base64UrlEncode((0, node_crypto.createHash)("sha256").update(input).digest());
|
|
23
|
-
};
|
|
24
|
-
/** Verifies a PKCE `code_verifier` against a stored S256 `code_challenge`. */
|
|
25
|
-
var verifyPkce = (codeVerifier, codeChallenge) => {
|
|
26
|
-
return sha256Base64Url(codeVerifier) === codeChallenge;
|
|
27
|
-
};
|
|
28
|
-
/** Joins an issuer base URL with a path, collapsing any duplicate slash. */
|
|
29
|
-
var joinUrl = (issuer, path) => {
|
|
30
|
-
return `${issuer.replace(/\/$/, "")}${path}`;
|
|
31
|
-
};
|
|
32
|
-
/** Returns the value if it is a string, otherwise `undefined`. */
|
|
33
|
-
var asString = value => {
|
|
34
|
-
return typeof value === "string" ? value : void 0;
|
|
35
|
-
};
|
|
36
|
-
var parseScopes = scope => {
|
|
37
|
-
const value = asString(scope);
|
|
38
|
-
return value ? value.split(" ").filter(Boolean) : [];
|
|
39
|
-
};
|
|
40
|
-
/** Writes a spec-compliant OAuth error response (RFC 6749 §5.2). */
|
|
41
|
-
var sendOAuthError = (ctx, status, error, description) => {
|
|
42
|
-
ctx.status = status;
|
|
43
|
-
ctx.body = {
|
|
44
|
-
error,
|
|
45
|
-
error_description: description
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
var buildTokenResponse = (tokens, scopes) => {
|
|
49
|
-
return {
|
|
50
|
-
access_token: tokens.accessToken,
|
|
51
|
-
token_type: "Bearer",
|
|
52
|
-
...(tokens.expiresIn !== void 0 ? {
|
|
53
|
-
expires_in: tokens.expiresIn
|
|
54
|
-
} : {}),
|
|
55
|
-
...(tokens.refreshToken !== void 0 ? {
|
|
56
|
-
refresh_token: tokens.refreshToken
|
|
57
|
-
} : {}),
|
|
58
|
-
scope: tokens.scope ?? scopes.join(" ")
|
|
59
|
-
};
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Authenticates the client at the token endpoint via `client_secret_basic`
|
|
63
|
-
* (Authorization header), `client_secret_post` (body), or `none` (public,
|
|
64
|
-
* PKCE-only). Returns the client, or `undefined` when authentication fails.
|
|
65
|
-
*/
|
|
66
|
-
var authenticateClient = async (ctx, body, clientStore) => {
|
|
67
|
-
let clientId = asString(body.client_id);
|
|
68
|
-
let clientSecret = asString(body.client_secret);
|
|
69
|
-
const authHeader = ctx.headers.authorization;
|
|
70
|
-
if (authHeader?.startsWith("Basic ")) {
|
|
71
|
-
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
72
|
-
const separatorIndex = decoded.indexOf(":");
|
|
73
|
-
if (separatorIndex !== -1) {
|
|
74
|
-
clientId = decodeURIComponent(decoded.slice(0, separatorIndex));
|
|
75
|
-
clientSecret = decodeURIComponent(decoded.slice(separatorIndex + 1));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (!clientId) return;
|
|
79
|
-
const client = await clientStore.get(clientId);
|
|
80
|
-
if (!client) return;
|
|
81
|
-
if (client.client_secret && client.client_secret !== clientSecret) return;
|
|
82
|
-
return client;
|
|
83
|
-
};
|
|
84
|
-
/** Handles `grant_type=authorization_code` token requests (with PKCE verify). */
|
|
85
|
-
var handleAuthorizationCodeGrant = async (ctx, body, options) => {
|
|
86
|
-
const {
|
|
87
|
-
clientStore,
|
|
88
|
-
authCodeStore,
|
|
89
|
-
issueTokens
|
|
90
|
-
} = options;
|
|
91
|
-
const client = await authenticateClient(ctx, body, clientStore);
|
|
92
|
-
if (!client) {
|
|
93
|
-
sendOAuthError(ctx, 401, "invalid_client", "client authentication failed");
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const code = asString(body.code);
|
|
97
|
-
if (!code) {
|
|
98
|
-
sendOAuthError(ctx, 400, "invalid_request", "code is required");
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const stored = await authCodeStore.get(code);
|
|
102
|
-
await authCodeStore.delete(code);
|
|
103
|
-
if (!stored) {
|
|
104
|
-
sendOAuthError(ctx, 400, "invalid_grant", "invalid or expired authorization code");
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (stored.expiresAt < Date.now()) {
|
|
108
|
-
sendOAuthError(ctx, 400, "invalid_grant", "authorization code expired");
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (stored.clientId !== client.client_id) {
|
|
112
|
-
sendOAuthError(ctx, 400, "invalid_grant", "authorization code was not issued to this client");
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (stored.redirectUri !== asString(body.redirect_uri)) {
|
|
116
|
-
sendOAuthError(ctx, 400, "invalid_grant", "redirect_uri mismatch");
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
const codeVerifier = asString(body.code_verifier);
|
|
120
|
-
if (!codeVerifier) {
|
|
121
|
-
sendOAuthError(ctx, 400, "invalid_request", "code_verifier is required");
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
if (!verifyPkce(codeVerifier, stored.codeChallenge)) {
|
|
125
|
-
sendOAuthError(ctx, 400, "invalid_grant", "PKCE verification failed");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
ctx.body = buildTokenResponse(await issueTokens({
|
|
129
|
-
subject: stored.subject,
|
|
130
|
-
scopes: stored.scopes,
|
|
131
|
-
client
|
|
132
|
-
}), stored.scopes);
|
|
133
|
-
};
|
|
134
|
-
/** Handles `grant_type=refresh_token` token requests. */
|
|
135
|
-
var handleRefreshTokenGrant = async (ctx, body, options) => {
|
|
136
|
-
const {
|
|
137
|
-
clientStore,
|
|
138
|
-
issueTokens,
|
|
139
|
-
onRefreshToken
|
|
140
|
-
} = options;
|
|
141
|
-
const client = await authenticateClient(ctx, body, clientStore);
|
|
142
|
-
if (!client) {
|
|
143
|
-
sendOAuthError(ctx, 401, "invalid_client", "client authentication failed");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (!onRefreshToken) {
|
|
147
|
-
sendOAuthError(ctx, 400, "unsupported_grant_type", "refresh_token grant is not supported");
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
const refreshToken = asString(body.refresh_token);
|
|
151
|
-
if (!refreshToken) {
|
|
152
|
-
sendOAuthError(ctx, 400, "invalid_request", "refresh_token is required");
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
const result = await onRefreshToken({
|
|
156
|
-
refreshToken,
|
|
157
|
-
client,
|
|
158
|
-
scopes: parseScopes(body.scope)
|
|
159
|
-
});
|
|
160
|
-
if (!result) {
|
|
161
|
-
sendOAuthError(ctx, 400, "invalid_grant", "invalid refresh token");
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
ctx.body = buildTokenResponse(await issueTokens({
|
|
165
|
-
subject: result.subject,
|
|
166
|
-
scopes: result.scopes,
|
|
167
|
-
client
|
|
168
|
-
}), result.scopes);
|
|
169
|
-
};
|
|
170
|
-
/**
|
|
171
|
-
* Handles the authorization request after `client_id` and `redirect_uri` have
|
|
172
|
-
* been validated: enforces PKCE, runs the consent hook, and issues a code.
|
|
173
|
-
*/
|
|
174
|
-
var handleAuthorize = async (ctx, options, redirectUri) => {
|
|
175
|
-
const {
|
|
176
|
-
clientStore,
|
|
177
|
-
authCodeStore,
|
|
178
|
-
onAuthorize
|
|
179
|
-
} = options;
|
|
180
|
-
const ttl = options.authorizationCodeTtl ?? 600;
|
|
181
|
-
const clientId = asString(ctx.query.client_id);
|
|
182
|
-
const client = await clientStore.get(clientId);
|
|
183
|
-
const state = asString(ctx.query.state);
|
|
184
|
-
const redirectError = (error, description) => {
|
|
185
|
-
const url = new URL(redirectUri);
|
|
186
|
-
url.searchParams.set("error", error);
|
|
187
|
-
url.searchParams.set("error_description", description);
|
|
188
|
-
if (state) url.searchParams.set("state", state);
|
|
189
|
-
ctx.redirect(url.toString());
|
|
190
|
-
};
|
|
191
|
-
if (asString(ctx.query.response_type) !== "code") {
|
|
192
|
-
redirectError("unsupported_response_type", "response_type must be code");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const codeChallenge = asString(ctx.query.code_challenge);
|
|
196
|
-
if (!codeChallenge) {
|
|
197
|
-
redirectError("invalid_request", "code_challenge is required (PKCE)");
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const codeChallengeMethod = asString(ctx.query.code_challenge_method) ?? "plain";
|
|
201
|
-
if (codeChallengeMethod !== "S256") {
|
|
202
|
-
redirectError("invalid_request", "code_challenge_method must be S256");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const scopes = parseScopes(ctx.query.scope);
|
|
206
|
-
const result = await onAuthorize({
|
|
207
|
-
ctx,
|
|
208
|
-
client,
|
|
209
|
-
request: {
|
|
210
|
-
clientId,
|
|
211
|
-
redirectUri,
|
|
212
|
-
scopes,
|
|
213
|
-
state,
|
|
214
|
-
codeChallenge,
|
|
215
|
-
codeChallengeMethod
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
if (!result.approved) return;
|
|
219
|
-
const grantedScopes = result.scopes ?? scopes;
|
|
220
|
-
const code = generateToken();
|
|
221
|
-
await authCodeStore.save({
|
|
222
|
-
code,
|
|
223
|
-
clientId,
|
|
224
|
-
redirectUri,
|
|
225
|
-
codeChallenge,
|
|
226
|
-
scopes: grantedScopes,
|
|
227
|
-
subject: result.subject,
|
|
228
|
-
expiresAt: Date.now() + ttl * 1e3
|
|
229
|
-
});
|
|
230
|
-
const url = new URL(redirectUri);
|
|
231
|
-
url.searchParams.set("code", code);
|
|
232
|
-
if (state) url.searchParams.set("state", state);
|
|
233
|
-
ctx.redirect(url.toString());
|
|
234
|
-
};
|
|
235
|
-
/** Handles Dynamic Client Registration (RFC 7591) requests. */
|
|
236
|
-
var handleRegister = async (ctx, clientStore) => {
|
|
237
|
-
const metadata = ctx.request.body ?? {};
|
|
238
|
-
const redirectUris = metadata.redirect_uris;
|
|
239
|
-
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every(uri => {
|
|
240
|
-
return typeof uri === "string";
|
|
241
|
-
})) {
|
|
242
|
-
sendOAuthError(ctx, 400, "invalid_redirect_uri", "redirect_uris is required and must be an array of strings");
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const tokenAuthMethod = asString(metadata.token_endpoint_auth_method) ?? "client_secret_basic";
|
|
246
|
-
const isPublic = tokenAuthMethod === "none";
|
|
247
|
-
const client = {
|
|
248
|
-
...metadata,
|
|
249
|
-
client_id: generateToken(16),
|
|
250
|
-
redirect_uris: redirectUris,
|
|
251
|
-
token_endpoint_auth_method: tokenAuthMethod,
|
|
252
|
-
grant_types: metadata.grant_types ?? ["authorization_code", "refresh_token"],
|
|
253
|
-
response_types: metadata.response_types ?? ["code"],
|
|
254
|
-
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
255
|
-
};
|
|
256
|
-
if (!isPublic) {
|
|
257
|
-
client.client_secret = generateToken(32);
|
|
258
|
-
client.client_secret_expires_at = 0;
|
|
259
|
-
}
|
|
260
|
-
await clientStore.register(client);
|
|
261
|
-
ctx.status = 201;
|
|
262
|
-
ctx.body = client;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
//#endregion
|
|
266
|
-
//#region src/authServer.ts
|
|
267
|
-
/**
|
|
268
|
-
* Creates transport-agnostic OAuth 2.1 Authorization Server primitives for MCP.
|
|
269
|
-
*
|
|
270
|
-
* Mounts the authorization endpoint (`/authorize`, PKCE S256 required), token
|
|
271
|
-
* endpoint (`/token`, `authorization_code` + `refresh_token` grants), Dynamic
|
|
272
|
-
* Client Registration (`/register`, RFC 7591), and AS metadata discovery
|
|
273
|
-
* (`/.well-known/oauth-authorization-server`, RFC 8414). When `resource` is
|
|
274
|
-
* set, it also serves the protected-resource metadata (RFC 9728).
|
|
275
|
-
*
|
|
276
|
-
* ttoss owns only the protocol mechanics — the app supplies its own stores,
|
|
277
|
-
* token signing/verification, and login/consent UI through the option hooks, so
|
|
278
|
-
* the user model, JWT/IAM, and authentication never leave the consuming app.
|
|
279
|
-
*
|
|
280
|
-
* @param options - Authorization server configuration and pluggable hooks.
|
|
281
|
-
* @returns A Koa `Router` exposing `routes()` / `allowedMethods()`.
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* ```typescript
|
|
285
|
-
* import { App, bodyParser } from '@ttoss/http-server';
|
|
286
|
-
* import { createMcpAuthServer } from '@ttoss/http-server-mcp';
|
|
287
|
-
*
|
|
288
|
-
* const authServer = createMcpAuthServer({
|
|
289
|
-
* issuer: 'https://api.soat.dev',
|
|
290
|
-
* clientStore,
|
|
291
|
-
* authCodeStore,
|
|
292
|
-
* issueTokens: async ({ subject, scopes }) => ({
|
|
293
|
-
* accessToken: signJwt({ sub: subject, scope: scopes.join(' ') }),
|
|
294
|
-
* refreshToken: createRefreshToken(subject),
|
|
295
|
-
* expiresIn: 3600,
|
|
296
|
-
* }),
|
|
297
|
-
* onAuthorize: async ({ ctx }) => {
|
|
298
|
-
* const session = await getSession(ctx);
|
|
299
|
-
* if (!session) {
|
|
300
|
-
* ctx.redirect('/login');
|
|
301
|
-
* return { approved: false };
|
|
302
|
-
* }
|
|
303
|
-
* return { approved: true, subject: session.userId };
|
|
304
|
-
* },
|
|
305
|
-
* scopesSupported: ['mcp:access'],
|
|
306
|
-
* });
|
|
307
|
-
*
|
|
308
|
-
* const app = new App();
|
|
309
|
-
* app.use(bodyParser());
|
|
310
|
-
* app.use(authServer.routes());
|
|
311
|
-
* ```
|
|
312
|
-
*/
|
|
313
|
-
var createMcpAuthServer = options => {
|
|
314
|
-
const {
|
|
315
|
-
issuer,
|
|
316
|
-
clientStore,
|
|
317
|
-
scopesSupported,
|
|
318
|
-
resource
|
|
319
|
-
} = options;
|
|
320
|
-
const authorizePath = options.endpoints?.authorize ?? "/authorize";
|
|
321
|
-
const tokenPath = options.endpoints?.token ?? "/token";
|
|
322
|
-
const registerPath = options.endpoints?.register ?? "/register";
|
|
323
|
-
const router = new _ttoss_http_server.Router();
|
|
324
|
-
router.get("/.well-known/oauth-authorization-server", ctx => {
|
|
325
|
-
ctx.body = {
|
|
326
|
-
issuer,
|
|
327
|
-
authorization_endpoint: joinUrl(issuer, authorizePath),
|
|
328
|
-
token_endpoint: joinUrl(issuer, tokenPath),
|
|
329
|
-
registration_endpoint: joinUrl(issuer, registerPath),
|
|
330
|
-
response_types_supported: ["code"],
|
|
331
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
332
|
-
code_challenge_methods_supported: ["S256"],
|
|
333
|
-
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
334
|
-
...(scopesSupported ? {
|
|
335
|
-
scopes_supported: scopesSupported
|
|
336
|
-
} : {})
|
|
337
|
-
};
|
|
338
|
-
});
|
|
339
|
-
if (resource) router.get("/.well-known/oauth-protected-resource", ctx => {
|
|
340
|
-
ctx.body = {
|
|
341
|
-
resource,
|
|
342
|
-
authorization_servers: [issuer]
|
|
343
|
-
};
|
|
344
|
-
});
|
|
345
|
-
router.get(authorizePath, async ctx => {
|
|
346
|
-
const clientId = asString(ctx.query.client_id);
|
|
347
|
-
if (!clientId) {
|
|
348
|
-
sendOAuthError(ctx, 400, "invalid_request", "client_id is required");
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const client = await clientStore.get(clientId);
|
|
352
|
-
if (!client) {
|
|
353
|
-
sendOAuthError(ctx, 400, "invalid_client", "unknown client_id");
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
const redirectUri = asString(ctx.query.redirect_uri);
|
|
357
|
-
if (!redirectUri || !client.redirect_uris.includes(redirectUri)) {
|
|
358
|
-
sendOAuthError(ctx, 400, "invalid_request", "redirect_uri is missing or not registered for this client");
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
await handleAuthorize(ctx, options, redirectUri);
|
|
362
|
-
});
|
|
363
|
-
router.post(tokenPath, async ctx => {
|
|
364
|
-
const body = ctx.request.body ?? {};
|
|
365
|
-
const grantType = asString(body.grant_type);
|
|
366
|
-
if (grantType === "authorization_code") {
|
|
367
|
-
await handleAuthorizationCodeGrant(ctx, body, options);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (grantType === "refresh_token") {
|
|
371
|
-
await handleRefreshTokenGrant(ctx, body, options);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
sendOAuthError(ctx, 400, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token");
|
|
375
|
-
});
|
|
376
|
-
router.post(registerPath, async ctx => {
|
|
377
|
-
await handleRegister(ctx, clientStore);
|
|
378
|
-
});
|
|
379
|
-
return router;
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
//#endregion
|
|
383
12
|
//#region src/index.ts
|
|
13
|
+
/** MCP lifecycle/discovery methods reachable before a client authenticates. */
|
|
14
|
+
var DEFAULT_PUBLIC_METHODS = ["initialize", "tools/list"];
|
|
384
15
|
var requestContextStore = new node_async_hooks.AsyncLocalStorage();
|
|
385
16
|
/**
|
|
386
17
|
* Generic HTTP helper for use inside MCP tool handlers.
|
|
@@ -496,52 +127,6 @@ var checkScopes = required => {
|
|
|
496
127
|
return !tokenScopes.includes(s);
|
|
497
128
|
}).length > 0) throw new Error(`Insufficient scopes. Required: ${required.join(", ")}`);
|
|
498
129
|
};
|
|
499
|
-
var buildTokenVerifier = auth => {
|
|
500
|
-
if (auth.cognitoUserPool) {
|
|
501
|
-
const v = _ttoss_auth_core_amazon_cognito.CognitoJwtVerifier.create({
|
|
502
|
-
tokenUse: "access",
|
|
503
|
-
...auth.cognitoUserPool
|
|
504
|
-
});
|
|
505
|
-
return t => {
|
|
506
|
-
return v.verify(t);
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
if (auth.verifyToken) return auth.verifyToken;
|
|
510
|
-
throw new Error("McpAuthOptions requires either cognitoUserPool or verifyToken");
|
|
511
|
-
};
|
|
512
|
-
var registerAuthRoutes = (router, path, auth, tokenVerifier) => {
|
|
513
|
-
router.use(path, async (ctx, next) => {
|
|
514
|
-
const authHeader = ctx.headers.authorization;
|
|
515
|
-
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
516
|
-
let identity;
|
|
517
|
-
try {
|
|
518
|
-
identity = await tokenVerifier(token);
|
|
519
|
-
} catch {
|
|
520
|
-
ctx.status = 401;
|
|
521
|
-
ctx.set("WWW-Authenticate", "Bearer");
|
|
522
|
-
ctx.body = "Unauthorized";
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
if (auth.requiredScopes?.length) {
|
|
526
|
-
const tokenScopes = (identity?.scope ?? "").split(" ");
|
|
527
|
-
if (auth.requiredScopes.filter(s => {
|
|
528
|
-
return !tokenScopes.includes(s);
|
|
529
|
-
}).length > 0) {
|
|
530
|
-
ctx.status = 403;
|
|
531
|
-
ctx.body = "Forbidden";
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
ctx.state.identity = identity;
|
|
536
|
-
await next();
|
|
537
|
-
});
|
|
538
|
-
if (auth.resourceServerUrl && auth.authorizationServerUrl) router.get("/.well-known/oauth-protected-resource", ctx => {
|
|
539
|
-
ctx.body = {
|
|
540
|
-
resource: auth.resourceServerUrl,
|
|
541
|
-
authorization_servers: [auth.authorizationServerUrl]
|
|
542
|
-
};
|
|
543
|
-
});
|
|
544
|
-
};
|
|
545
130
|
/**
|
|
546
131
|
* Creates a Koa router configured to handle MCP protocol requests
|
|
547
132
|
*
|
|
@@ -606,7 +191,23 @@ var createMcpRouter = (server, options = {}) => {
|
|
|
606
191
|
return result;
|
|
607
192
|
};
|
|
608
193
|
const router = new _ttoss_http_server.Router();
|
|
609
|
-
if (auth)
|
|
194
|
+
if (auth) {
|
|
195
|
+
const {
|
|
196
|
+
resourceServerUrl,
|
|
197
|
+
authorizationServerUrl,
|
|
198
|
+
...verifyOptions
|
|
199
|
+
} = auth;
|
|
200
|
+
router.use(path, (0, _ttoss_http_server_oauth.oauthVerify)({
|
|
201
|
+
...verifyOptions,
|
|
202
|
+
publicMethods: verifyOptions.publicMethods ?? DEFAULT_PUBLIC_METHODS
|
|
203
|
+
}));
|
|
204
|
+
if (resourceServerUrl && authorizationServerUrl) router.get("/.well-known/oauth-protected-resource", ctx => {
|
|
205
|
+
ctx.body = {
|
|
206
|
+
resource: resourceServerUrl,
|
|
207
|
+
authorization_servers: [authorizationServerUrl]
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
610
211
|
const handleWithContext = async (ctx, body) => {
|
|
611
212
|
const apiHeaders = getApiHeaders ? getApiHeaders(ctx) : {};
|
|
612
213
|
const identity = ctx.state.identity;
|
|
@@ -751,62 +352,6 @@ var registerToolFromSchema = (server, params) => {
|
|
|
751
352
|
});
|
|
752
353
|
}
|
|
753
354
|
};
|
|
754
|
-
/**
|
|
755
|
-
* Returns the `WWW-Authenticate` header value for a 401 response on a
|
|
756
|
-
* protected resource, following the MCP auth spec requirement that
|
|
757
|
-
* unauthorized responses advertise the resource metadata URL so MCP
|
|
758
|
-
* clients can bootstrap OAuth discovery.
|
|
759
|
-
*
|
|
760
|
-
* Use this in your own auth middleware when you are not using the built-in
|
|
761
|
-
* `auth` option on `createMcpRouter`.
|
|
762
|
-
*
|
|
763
|
-
* @example
|
|
764
|
-
* ```typescript
|
|
765
|
-
* import { getWwwAuthenticateHeader } from '@ttoss/http-server-mcp';
|
|
766
|
-
*
|
|
767
|
-
* // Inside a Koa middleware
|
|
768
|
-
* ctx.status = 401;
|
|
769
|
-
* ctx.set('WWW-Authenticate', getWwwAuthenticateHeader({ resource: 'https://mcp.example.com' }));
|
|
770
|
-
* ```
|
|
771
|
-
*/
|
|
772
|
-
var getWwwAuthenticateHeader = args => {
|
|
773
|
-
return `Bearer resource_metadata="${`${args.resource.replace(/\/$/, "")}/.well-known/oauth-protected-resource`}"`;
|
|
774
|
-
};
|
|
775
|
-
/**
|
|
776
|
-
* Creates a standalone Koa middleware that serves
|
|
777
|
-
* `GET /.well-known/oauth-protected-resource` (RFC 9728) without requiring
|
|
778
|
-
* the built-in `auth` option on `createMcpRouter`.
|
|
779
|
-
*
|
|
780
|
-
* Mount this **before** your own auth middleware so the discovery endpoint
|
|
781
|
-
* remains unauthenticated (MCP clients fetch it before they have a token).
|
|
782
|
-
*
|
|
783
|
-
* @example
|
|
784
|
-
* ```typescript
|
|
785
|
-
* import Koa from 'koa';
|
|
786
|
-
* import { createProtectedResourceMetadataMiddleware } from '@ttoss/http-server-mcp';
|
|
787
|
-
*
|
|
788
|
-
* const app = new Koa();
|
|
789
|
-
* app.use(
|
|
790
|
-
* createProtectedResourceMetadataMiddleware({
|
|
791
|
-
* resource: 'https://mcp.example.com',
|
|
792
|
-
* authorizationServers: ['https://api.example.com'],
|
|
793
|
-
* })
|
|
794
|
-
* );
|
|
795
|
-
* app.use(myOwnAuthMiddleware);
|
|
796
|
-
* ```
|
|
797
|
-
*/
|
|
798
|
-
var createProtectedResourceMetadataMiddleware = args => {
|
|
799
|
-
return async (ctx, next) => {
|
|
800
|
-
if (ctx.method === "GET" && ctx.path === "/.well-known/oauth-protected-resource") {
|
|
801
|
-
ctx.body = {
|
|
802
|
-
resource: args.resource,
|
|
803
|
-
authorization_servers: args.authorizationServers
|
|
804
|
-
};
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
await next();
|
|
808
|
-
};
|
|
809
|
-
};
|
|
810
355
|
|
|
811
356
|
//#endregion
|
|
812
357
|
Object.defineProperty(exports, 'McpServer', {
|
|
@@ -817,11 +362,8 @@ Object.defineProperty(exports, 'McpServer', {
|
|
|
817
362
|
});
|
|
818
363
|
exports.apiCall = apiCall;
|
|
819
364
|
exports.checkScopes = checkScopes;
|
|
820
|
-
exports.createMcpAuthServer = createMcpAuthServer;
|
|
821
365
|
exports.createMcpRouter = createMcpRouter;
|
|
822
|
-
exports.createProtectedResourceMetadataMiddleware = createProtectedResourceMetadataMiddleware;
|
|
823
366
|
exports.getIdentity = getIdentity;
|
|
824
|
-
exports.getWwwAuthenticateHeader = getWwwAuthenticateHeader;
|
|
825
367
|
exports.registerToolFromSchema = registerToolFromSchema;
|
|
826
368
|
Object.defineProperty(exports, 'z', {
|
|
827
369
|
enumerable: true,
|