@ttoss/http-server-mcp 0.17.0 → 0.18.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/README.md CHANGED
@@ -279,6 +279,8 @@ createMcpRouter(mcpServer, {
279
279
  });
280
280
  ```
281
281
 
282
+ The `resource` field in the metadata document is automatically set to `resourceServerUrl + path` (e.g. `https://mcp.example.com/mcp` for the default path). This means MCP clients that follow `resource` to connect will land on the actual MCP endpoint rather than the bare origin.
283
+
282
284
  **With your own auth middleware** — use `createProtectedResourceMetadataMiddleware` as a standalone middleware, mounted _before_ your auth layer so discovery stays unauthenticated:
283
285
 
284
286
  ```typescript
@@ -340,6 +342,26 @@ createMcpRouter(mcpServer, {
340
342
 
341
343
  Both fields are optional. Omitting `resourceMetadataUrl` keeps the bare `Bearer` header, and the `publicMethods` default matches what MCP clients expect for discovery.
342
344
 
345
+ ### Supporting clients that connect to the bare origin
346
+
347
+ Some MCP clients always POST to the bare origin (`/`) regardless of the `resource` value in the metadata. To serve both behaviors from the same router, use the `aliases` option:
348
+
349
+ ```typescript
350
+ createMcpRouter(mcpServer, {
351
+ // The primary endpoint — also the value advertised as `resource` in metadata.
352
+ path: '/mcp',
353
+ // Additionally handle requests at the bare root for clients that ignore `resource`.
354
+ aliases: ['/'],
355
+ auth: {
356
+ verifyToken: async (token) => myVerify(token),
357
+ resourceServerUrl: 'https://mcp.example.com',
358
+ authorizationServerUrl: 'https://auth.example.com',
359
+ },
360
+ });
361
+ ```
362
+
363
+ The discovery endpoint (`/.well-known/oauth-protected-resource`) remains publicly accessible even when `aliases` includes `'/'`.
364
+
343
365
  ## Issuing tokens for MCP clients
344
366
 
345
367
  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-auth`](https://ttoss.dev/docs/modules/packages/http-server-auth) 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.
@@ -355,6 +377,7 @@ Creates a Koa router configured to handle MCP protocol requests.
355
377
  - `server` (`McpServer`) — MCP server instance with registered tools and resources
356
378
  - `options` (`McpRouterOptions`) — Optional configuration
357
379
  - `path` (`string`) — HTTP path for MCP endpoint (default: `'/mcp'`)
380
+ - `aliases` (`string[]`) — Additional paths where the MCP handler is also mounted; use `['/']` to also handle requests at the bare root (default: `[]`)
358
381
  - `sessionIdGenerator` (`() => string`) — Session ID generator for stateful servers (default: `undefined` for stateless)
359
382
  - `apiBaseUrl` (`string`) — Base URL prepended to relative paths in `apiCall`
360
383
  - `getApiHeaders` (`(ctx: Context) => Record<string, string>`) — Return headers to inject into every `apiCall` for this request
@@ -362,7 +385,7 @@ Creates a Koa router configured to handle MCP protocol requests.
362
385
  - `auth.cognitoUserPool` — Cognito user pool config (`userPoolId`, `clientId`, `tokenUse`)
363
386
  - `auth.verifyToken` — Custom async token verifier `(token: string) => Promise<unknown>`
364
387
  - `auth.requiredScopes` — Router-level scope guard; returns 403 if any scope is missing
365
- - `auth.resourceServerUrl` + `auth.authorizationServerUrl` — Enable `/.well-known/oauth-protected-resource`
388
+ - `auth.resourceServerUrl` + `auth.authorizationServerUrl` — Enable `/.well-known/oauth-protected-resource`; the metadata `resource` is set to `resourceServerUrl + path` so clients following `resource` land on the actual MCP endpoint
366
389
  - `auth.publicMethods` — JSON-RPC methods that bypass verification (default `['initialize', 'tools/list']`)
367
390
  - `auth.resourceMetadataUrl` — Emit RFC 9728 `WWW-Authenticate: Bearer resource_metadata="…"` on 401
368
391
 
package/dist/index.cjs CHANGED
@@ -126,7 +126,9 @@ var getIdentity = () => {
126
126
  * Throws if any scope is missing — the MCP SDK catches this and returns a
127
127
  * tool error to the client. Use inside tool handlers for per-tool authorization.
128
128
  *
129
- * Cognito tokens carry scopes as a space-separated string in `payload.scope`.
129
+ * Accepts either `scope: string` (space-separated, standard JWT claim) or
130
+ * `scopes: string[]` from `verifyToken`. If neither is present and `required`
131
+ * is non-empty, throws with a descriptive message instead of a silent 403.
130
132
  *
131
133
  * @example
132
134
  * ```typescript
@@ -137,7 +139,13 @@ var getIdentity = () => {
137
139
  * ```
138
140
  */
139
141
  var checkScopes = required => {
140
- const tokenScopes = (getIdentity()?.scope ?? "").split(" ");
142
+ const identity = getIdentity();
143
+ let scopeString = null;
144
+ if (typeof identity?.scope === "string") scopeString = identity.scope;else if (Array.isArray(identity?.scopes) && identity.scopes.every(s => {
145
+ return typeof s === "string";
146
+ })) scopeString = identity.scopes.join(" ");
147
+ if (scopeString === null && required.length > 0) throw new Error(`verifyToken returned no scope/scopes but requiredScopes is set (${required.join(", ")}). Return either scope: string or scopes: string[] from verifyToken.`);
148
+ const tokenScopes = scopeString ? scopeString.split(" ") : [];
141
149
  if (required.filter(s => {
142
150
  return !tokenScopes.includes(s);
143
151
  }).length > 0) throw new Error(`Insufficient scopes. Required: ${required.join(", ")}`);
@@ -182,6 +190,7 @@ var checkScopes = required => {
182
190
  var createMcpRouter = (server, options = {}) => {
183
191
  const {
184
192
  path = "/mcp",
193
+ aliases = [],
185
194
  sessionIdGenerator,
186
195
  apiBaseUrl,
187
196
  getApiHeaders,
@@ -206,6 +215,7 @@ var createMcpRouter = (server, options = {}) => {
206
215
  return result;
207
216
  };
208
217
  const router = new _ttoss_http_server.Router();
218
+ let authCheck;
209
219
  if (auth) {
210
220
  const verifyToken = buildVerifyToken(auth);
211
221
  const publicMethods = new Set(auth.publicMethods ?? DEFAULT_PUBLIC_METHODS);
@@ -219,20 +229,24 @@ var createMcpRouter = (server, options = {}) => {
219
229
  },
220
230
  resourceMetadataUrl: auth.resourceMetadataUrl
221
231
  });
222
- router.use(path, async (ctx, next) => {
232
+ authCheck = async (ctx, next) => {
223
233
  const method = ctx.request.body?.method;
224
234
  if (publicMethods.has(method ?? "")) {
225
235
  await next();
226
236
  return;
227
237
  }
228
238
  await verify(ctx, next);
229
- });
230
- if (auth.resourceServerUrl && auth.authorizationServerUrl) router.get("/.well-known/oauth-protected-resource", ctx => {
231
- ctx.body = {
232
- resource: auth.resourceServerUrl,
233
- authorization_servers: [auth.authorizationServerUrl]
234
- };
235
- });
239
+ };
240
+ if (auth.resourceServerUrl && auth.authorizationServerUrl) {
241
+ const base = auth.resourceServerUrl.replace(/\/$/, "");
242
+ const resourceUrl = path === "/" ? base : `${base}${path}`;
243
+ router.get("/.well-known/oauth-protected-resource", ctx => {
244
+ ctx.body = {
245
+ resource: resourceUrl,
246
+ authorization_servers: [auth.authorizationServerUrl]
247
+ };
248
+ });
249
+ }
236
250
  }
237
251
  const handleWithContext = async (ctx, body) => {
238
252
  const apiHeaders = getApiHeaders ? getApiHeaders(ctx) : {};
@@ -268,7 +282,7 @@ var createMcpRouter = (server, options = {}) => {
268
282
  }
269
283
  });
270
284
  };
271
- router.post(path, async ctx => {
285
+ const postHandler = async ctx => {
272
286
  try {
273
287
  await handleWithContext(ctx, ctx.request.body);
274
288
  } catch (error) {
@@ -279,8 +293,8 @@ var createMcpRouter = (server, options = {}) => {
279
293
  };
280
294
  }
281
295
  }
282
- });
283
- router.delete(path, async ctx => {
296
+ };
297
+ const deleteHandler = async ctx => {
284
298
  try {
285
299
  await handleWithContext(ctx);
286
300
  } catch (error) {
@@ -291,7 +305,15 @@ var createMcpRouter = (server, options = {}) => {
291
305
  };
292
306
  }
293
307
  }
294
- });
308
+ };
309
+ const allPaths = [path, ...aliases];
310
+ for (const mcpPath of allPaths) if (authCheck) {
311
+ router.post(mcpPath, authCheck, postHandler);
312
+ router.delete(mcpPath, authCheck, deleteHandler);
313
+ } else {
314
+ router.post(mcpPath, postHandler);
315
+ router.delete(mcpPath, deleteHandler);
316
+ }
295
317
  return router;
296
318
  };
297
319
  var rawSchemaRegistryMap = /* @__PURE__ */new WeakMap();
package/dist/index.d.cts CHANGED
@@ -692,7 +692,11 @@ interface McpAuthOptions {
692
692
  * JWTs, opaque tokens). Resolve with the verified payload, or throw to reject.
693
693
  */
694
694
  verifyToken?: (token: string) => Promise<unknown>;
695
- /** Scopes that must all be present on the token's `scope` claim, else `403`. */
695
+ /**
696
+ * Scopes that must all be present on the token, else `403`.
697
+ * `verifyToken` may return either `scope: string` (space-separated) or
698
+ * `scopes: string[]`; both are normalised internally.
699
+ */
696
700
  requiredScopes?: string[];
697
701
  /**
698
702
  * JSON-RPC methods (read from `body.method`) that bypass verification.
@@ -793,7 +797,9 @@ declare const getIdentity: () => unknown;
793
797
  * Throws if any scope is missing — the MCP SDK catches this and returns a
794
798
  * tool error to the client. Use inside tool handlers for per-tool authorization.
795
799
  *
796
- * Cognito tokens carry scopes as a space-separated string in `payload.scope`.
800
+ * Accepts either `scope: string` (space-separated, standard JWT claim) or
801
+ * `scopes: string[]` from `verifyToken`. If neither is present and `required`
802
+ * is non-empty, throws with a descriptive message instead of a silent 403.
797
803
  *
798
804
  * @example
799
805
  * ```typescript
@@ -813,6 +819,17 @@ interface McpRouterOptions {
813
819
  * @default '/mcp'
814
820
  */
815
821
  path?: string;
822
+ /**
823
+ * Additional HTTP paths where the MCP server is also mounted.
824
+ *
825
+ * Useful when MCP clients differ in where they connect after OAuth: some
826
+ * follow the protected-resource `resource` metadata value as the endpoint,
827
+ * others always connect to the bare origin (`/`). Setting `aliases: ['/']`
828
+ * serves both without requiring app-level path rewrites.
829
+ *
830
+ * @example ['/'] // also handle MCP requests at the bare root
831
+ */
832
+ aliases?: string[];
816
833
  /**
817
834
  * Optional session ID generator for stateful MCP servers.
818
835
  * When provided, a single shared transport is created and sessions are tracked.
package/dist/index.d.mts CHANGED
@@ -692,7 +692,11 @@ interface McpAuthOptions {
692
692
  * JWTs, opaque tokens). Resolve with the verified payload, or throw to reject.
693
693
  */
694
694
  verifyToken?: (token: string) => Promise<unknown>;
695
- /** Scopes that must all be present on the token's `scope` claim, else `403`. */
695
+ /**
696
+ * Scopes that must all be present on the token, else `403`.
697
+ * `verifyToken` may return either `scope: string` (space-separated) or
698
+ * `scopes: string[]`; both are normalised internally.
699
+ */
696
700
  requiredScopes?: string[];
697
701
  /**
698
702
  * JSON-RPC methods (read from `body.method`) that bypass verification.
@@ -793,7 +797,9 @@ declare const getIdentity: () => unknown;
793
797
  * Throws if any scope is missing — the MCP SDK catches this and returns a
794
798
  * tool error to the client. Use inside tool handlers for per-tool authorization.
795
799
  *
796
- * Cognito tokens carry scopes as a space-separated string in `payload.scope`.
800
+ * Accepts either `scope: string` (space-separated, standard JWT claim) or
801
+ * `scopes: string[]` from `verifyToken`. If neither is present and `required`
802
+ * is non-empty, throws with a descriptive message instead of a silent 403.
797
803
  *
798
804
  * @example
799
805
  * ```typescript
@@ -813,6 +819,17 @@ interface McpRouterOptions {
813
819
  * @default '/mcp'
814
820
  */
815
821
  path?: string;
822
+ /**
823
+ * Additional HTTP paths where the MCP server is also mounted.
824
+ *
825
+ * Useful when MCP clients differ in where they connect after OAuth: some
826
+ * follow the protected-resource `resource` metadata value as the endpoint,
827
+ * others always connect to the bare origin (`/`). Setting `aliases: ['/']`
828
+ * serves both without requiring app-level path rewrites.
829
+ *
830
+ * @example ['/'] // also handle MCP requests at the bare root
831
+ */
832
+ aliases?: string[];
816
833
  /**
817
834
  * Optional session ID generator for stateful MCP servers.
818
835
  * When provided, a single shared transport is created and sessions are tracked.
package/dist/index.mjs CHANGED
@@ -123,7 +123,9 @@ var getIdentity = () => {
123
123
  * Throws if any scope is missing — the MCP SDK catches this and returns a
124
124
  * tool error to the client. Use inside tool handlers for per-tool authorization.
125
125
  *
126
- * Cognito tokens carry scopes as a space-separated string in `payload.scope`.
126
+ * Accepts either `scope: string` (space-separated, standard JWT claim) or
127
+ * `scopes: string[]` from `verifyToken`. If neither is present and `required`
128
+ * is non-empty, throws with a descriptive message instead of a silent 403.
127
129
  *
128
130
  * @example
129
131
  * ```typescript
@@ -134,7 +136,13 @@ var getIdentity = () => {
134
136
  * ```
135
137
  */
136
138
  var checkScopes = required => {
137
- const tokenScopes = (getIdentity()?.scope ?? "").split(" ");
139
+ const identity = getIdentity();
140
+ let scopeString = null;
141
+ if (typeof identity?.scope === "string") scopeString = identity.scope;else if (Array.isArray(identity?.scopes) && identity.scopes.every(s => {
142
+ return typeof s === "string";
143
+ })) scopeString = identity.scopes.join(" ");
144
+ if (scopeString === null && required.length > 0) throw new Error(`verifyToken returned no scope/scopes but requiredScopes is set (${required.join(", ")}). Return either scope: string or scopes: string[] from verifyToken.`);
145
+ const tokenScopes = scopeString ? scopeString.split(" ") : [];
138
146
  if (required.filter(s => {
139
147
  return !tokenScopes.includes(s);
140
148
  }).length > 0) throw new Error(`Insufficient scopes. Required: ${required.join(", ")}`);
@@ -179,6 +187,7 @@ var checkScopes = required => {
179
187
  var createMcpRouter = (server, options = {}) => {
180
188
  const {
181
189
  path = "/mcp",
190
+ aliases = [],
182
191
  sessionIdGenerator,
183
192
  apiBaseUrl,
184
193
  getApiHeaders,
@@ -203,6 +212,7 @@ var createMcpRouter = (server, options = {}) => {
203
212
  return result;
204
213
  };
205
214
  const router = new Router();
215
+ let authCheck;
206
216
  if (auth) {
207
217
  const verifyToken = buildVerifyToken(auth);
208
218
  const publicMethods = new Set(auth.publicMethods ?? DEFAULT_PUBLIC_METHODS);
@@ -216,20 +226,24 @@ var createMcpRouter = (server, options = {}) => {
216
226
  },
217
227
  resourceMetadataUrl: auth.resourceMetadataUrl
218
228
  });
219
- router.use(path, async (ctx, next) => {
229
+ authCheck = async (ctx, next) => {
220
230
  const method = ctx.request.body?.method;
221
231
  if (publicMethods.has(method ?? "")) {
222
232
  await next();
223
233
  return;
224
234
  }
225
235
  await verify(ctx, next);
226
- });
227
- if (auth.resourceServerUrl && auth.authorizationServerUrl) router.get("/.well-known/oauth-protected-resource", ctx => {
228
- ctx.body = {
229
- resource: auth.resourceServerUrl,
230
- authorization_servers: [auth.authorizationServerUrl]
231
- };
232
- });
236
+ };
237
+ if (auth.resourceServerUrl && auth.authorizationServerUrl) {
238
+ const base = auth.resourceServerUrl.replace(/\/$/, "");
239
+ const resourceUrl = path === "/" ? base : `${base}${path}`;
240
+ router.get("/.well-known/oauth-protected-resource", ctx => {
241
+ ctx.body = {
242
+ resource: resourceUrl,
243
+ authorization_servers: [auth.authorizationServerUrl]
244
+ };
245
+ });
246
+ }
233
247
  }
234
248
  const handleWithContext = async (ctx, body) => {
235
249
  const apiHeaders = getApiHeaders ? getApiHeaders(ctx) : {};
@@ -265,7 +279,7 @@ var createMcpRouter = (server, options = {}) => {
265
279
  }
266
280
  });
267
281
  };
268
- router.post(path, async ctx => {
282
+ const postHandler = async ctx => {
269
283
  try {
270
284
  await handleWithContext(ctx, ctx.request.body);
271
285
  } catch (error) {
@@ -276,8 +290,8 @@ var createMcpRouter = (server, options = {}) => {
276
290
  };
277
291
  }
278
292
  }
279
- });
280
- router.delete(path, async ctx => {
293
+ };
294
+ const deleteHandler = async ctx => {
281
295
  try {
282
296
  await handleWithContext(ctx);
283
297
  } catch (error) {
@@ -288,7 +302,15 @@ var createMcpRouter = (server, options = {}) => {
288
302
  };
289
303
  }
290
304
  }
291
- });
305
+ };
306
+ const allPaths = [path, ...aliases];
307
+ for (const mcpPath of allPaths) if (authCheck) {
308
+ router.post(mcpPath, authCheck, postHandler);
309
+ router.delete(mcpPath, authCheck, deleteHandler);
310
+ } else {
311
+ router.post(mcpPath, postHandler);
312
+ router.delete(mcpPath, deleteHandler);
313
+ }
292
314
  return router;
293
315
  };
294
316
  var rawSchemaRegistryMap = /* @__PURE__ */new WeakMap();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttoss/http-server-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Model Context Protocol (MCP) server integration for @ttoss/http-server",
5
5
  "keywords": [
6
6
  "ai",
@@ -36,8 +36,8 @@
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
37
  "zod": "^4.4.3",
38
38
  "@ttoss/auth-core": "^0.7.0",
39
- "@ttoss/http-server": "^0.7.1",
40
- "@ttoss/http-server-auth": "^0.3.3"
39
+ "@ttoss/http-server-auth": "^0.3.4",
40
+ "@ttoss/http-server": "^0.7.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/koa": "^3.0.3",