mcp-expose 1.1.0 → 1.1.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +88 -28
  3. package/dist/adonisjs/index.cjs +128 -6
  4. package/dist/adonisjs/index.cjs.map +1 -1
  5. package/dist/adonisjs/index.d.cts +1 -1
  6. package/dist/adonisjs/index.d.ts +1 -1
  7. package/dist/adonisjs/index.js +128 -6
  8. package/dist/adonisjs/index.js.map +1 -1
  9. package/dist/express/index.cjs +128 -6
  10. package/dist/express/index.cjs.map +1 -1
  11. package/dist/express/index.d.cts +1 -1
  12. package/dist/express/index.d.ts +1 -1
  13. package/dist/express/index.js +128 -6
  14. package/dist/express/index.js.map +1 -1
  15. package/dist/fastify/index.cjs +128 -6
  16. package/dist/fastify/index.cjs.map +1 -1
  17. package/dist/fastify/index.d.cts +1 -1
  18. package/dist/fastify/index.d.ts +1 -1
  19. package/dist/fastify/index.js +128 -6
  20. package/dist/fastify/index.js.map +1 -1
  21. package/dist/hono/index.cjs +128 -6
  22. package/dist/hono/index.cjs.map +1 -1
  23. package/dist/hono/index.d.cts +1 -1
  24. package/dist/hono/index.d.ts +1 -1
  25. package/dist/hono/index.js +128 -6
  26. package/dist/hono/index.js.map +1 -1
  27. package/dist/index.cjs +128 -31
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +7 -5
  30. package/dist/index.d.ts +7 -5
  31. package/dist/index.js +128 -31
  32. package/dist/index.js.map +1 -1
  33. package/dist/koa/index.cjs +128 -6
  34. package/dist/koa/index.cjs.map +1 -1
  35. package/dist/koa/index.d.cts +1 -1
  36. package/dist/koa/index.d.ts +1 -1
  37. package/dist/koa/index.js +128 -6
  38. package/dist/koa/index.js.map +1 -1
  39. package/dist/nestjs/index.cjs +128 -6
  40. package/dist/nestjs/index.cjs.map +1 -1
  41. package/dist/nestjs/index.d.cts +1 -1
  42. package/dist/nestjs/index.d.ts +1 -1
  43. package/dist/nestjs/index.js +128 -6
  44. package/dist/nestjs/index.js.map +1 -1
  45. package/dist/{server-BFQ_Hdh9.d.cts → server--mQVQq3A.d.cts} +55 -2
  46. package/dist/{server-BRw1E2xy.d.ts → server-BPDTsT2o.d.ts} +55 -2
  47. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ 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.1] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - `transformResponse` hook for route tools, on every marker and adapter, plus a server-level default. It shapes what
14
+ the agent sees from a route's response (hide or format fields, add hints) without turning the route into a custom
15
+ tool. The hook receives the status, headers, body and parsed JSON, the tool arguments and the MCP call context.
16
+ `ctx.callRoute()` enriches the response with other routes of the app, using the same credentials and the same
17
+ middleware, guards and validation. Placeholders are URL-encoded, and `.` / `..` segments are rejected.
18
+ - `ResponseTransform`, `ResponseTransformContext` and `RouteToolResponse` types.
19
+
20
+ ### Fixed
21
+
22
+ - A tool handler or hook that returns plain data with its own `content` array (for example a CMS post) is no longer
23
+ mistaken for an MCP `ToolResult`; only `content` arrays of MCP content items (`{ type: ... }`) are.
24
+
9
25
  ## [1.1.0] - 2026-09-27
10
26
 
11
27
  ### Added
@@ -52,6 +68,7 @@ options, default tool naming) now follows Semantic Versioning.
52
68
  - Path parameters that are empty, `.` or `..` are rejected, so an agent cannot use them to reach routes
53
69
  that were never exposed as tools (for example `/orders/../admin`).
54
70
 
55
- [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.1.0...HEAD
71
+ [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.1.1...HEAD
72
+ [1.1.1]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.1
56
73
  [1.1.0]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.0
57
74
  [1.0.0]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.0.0
package/README.md CHANGED
@@ -47,13 +47,14 @@ router.get('/orders/:id', [OrdersController, 'show']).use(middleware.auth()).mcp
47
47
  6. [Connect an AI client](#connect-an-ai-client)
48
48
  7. [OAuth: sign in from AI clients](#oauth-sign-in-from-ai-clients)
49
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)
50
+ 9. [Shaping and enriching responses](#shaping-and-enriching-responses)
51
+ 10. [Configuration reference](#configuration-reference)
52
+ 11. [Custom (non-HTTP) tools](#custom-non-http-tools)
53
+ 12. [Security checklist](#security-checklist)
54
+ 13. [Writing tools agents use well](#writing-tools-agents-use-well)
55
+ 14. [Limitations and roadmap](#limitations-and-roadmap)
56
+ 15. [Development](#development)
57
+ 16. [Versioning and support](#versioning-and-support)
57
58
 
58
59
  ---
59
60
 
@@ -648,30 +649,88 @@ Sources, from highest to lowest precedence:
648
649
 
649
650
  Your app's own validation always runs as well. The schema tells the agent what to send, and your API decides what it accepts.
650
651
 
652
+ ## Shaping and enriching responses
653
+
654
+ By default the agent gets the route's response as it is. Add `transformResponse` to a route tool to change
655
+ what the agent sees: hide internal fields, format values, add hints, or pull in related data from other
656
+ routes. The route stays a route tool; there is no need to rewrite it as a custom tool.
657
+
658
+ ```ts
659
+ @Get(':id')
660
+ @McpTool({
661
+ name: 'get_order',
662
+ transformResponse: async (res, ctx) => {
663
+ if (!res.ok) return undefined; // keep the default error result
664
+ const { internalNotes, ...order } = res.json as Order;
665
+ // Another route of this app, with the same user's credentials, guards and pipes:
666
+ const customer = await ctx.callRoute({ path: '/api/customers/:id', params: { id: order.customerId } });
667
+ return { ...order, total: formatMoney(order.total), customer: customer.ok ? customer.json : null };
668
+ },
669
+ })
670
+ findOne(@Param('id') id: string) { ... }
671
+ ```
672
+
673
+ It works the same with every marker: `mcpTool({ transformResponse })`, `config: { mcp: { transformResponse } }`,
674
+ `.mcp({ transformResponse })`, and `toolsFromOpenApi(doc, dispatch, { transformResponse })`.
675
+
676
+ **What the hook receives**
677
+
678
+ - `res`: `status`, `headers`, the raw `body`, the parsed `json` (for JSON responses) and `ok` (status below 400).
679
+ - `ctx.args`: the tool arguments. `ctx.request`: the internal request (method, path, query, body).
680
+ - `ctx.toolContext`: the MCP request's headers, client IP and, with [OAuth](#oauth-sign-in-from-ai-clients), `auth`.
681
+ - `ctx.defaultResult()`: the result mcp-expose would return without the hook.
682
+ - `ctx.callRoute({ method, path, params, query, body, headers })`: calls another route of the same app.
683
+ - It forwards the same credentials (`Authorization`, cookies) and runs through the same middleware, guards and validation.
684
+ - `path` is the full path as your server sees it, including any global prefix or router mount path. Fill `:id` / `{id}`
685
+ placeholders from `params`: values are URL-encoded, and `.` or `..` are rejected, so agent input can't redirect the call.
686
+
687
+ **What it returns**
688
+
689
+ | Return value | Result |
690
+ | -------------------------- | --------------------------------------------------------------------------------------------------- |
691
+ | a string or any JSON value | Sent to the agent (objects also as `structuredContent`). Still an error result if the route failed. |
692
+ | a full `ToolResult` | Used as is, so you control `isError` (e.g. `{ ...ctx.defaultResult(), content: [...] }`). |
693
+ | `undefined` | The default result. |
694
+ | a thrown error | An error result with the message, which the agent sees. |
695
+
696
+ `maxResponseChars` still applies to whatever the hook returns.
697
+
698
+ **For every tool at once**, set `transformResponse` in the server options, e.g. to strip fields everywhere:
699
+
700
+ ```ts
701
+ mountMcp(app, {
702
+ name: 'shop-api',
703
+ transformResponse: (res) => (res.ok ? omitDeep(res.json, ['internalNotes', 'passwordHash']) : undefined),
704
+ });
705
+ ```
706
+
707
+ A tool's own `transformResponse` replaces the server-level one.
708
+
651
709
  ## Configuration reference
652
710
 
653
711
  ### Server options (all adapters)
654
712
 
655
- | Option | Type | Default | Description |
656
- | ------------------ | --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
657
- | `name` | `string` | required | Server name shown to clients. |
658
- | `version` | `string` | `'1.0.0'` | Server version. |
659
- | `instructions` | `string` | none | Guidance for the model on how to use the tools together. |
660
- | `path` | `string` | `'/mcp'` | Endpoint path. |
661
- | `allowedOrigins` | `string[] \| '*'` | `[]` | Browser origins allowed to call the endpoint. Requests without `Origin` (CLIs, IDEs, servers) are always allowed. |
662
- | `forwardHeaders` | `string[]` | `['authorization','cookie','x-api-key','accept-language']` | Headers copied from the MCP request to the internal API call. |
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). |
665
- | `tools` | `McpToolDefinition[]` | `[]` | Extra hand-written tools. |
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. |
667
- | `routes` | `{method,path,...}[]` | `[]` | _Express/Koa/Hono._ Expose routes without editing them. |
668
- | `routers` | see guide | none | _Express:_ `{ '/prefix': router }`. _Koa:_ `[router]`. |
669
- | `middleware` | `Middleware[]` | `[]` | _Express._ Middleware in front of `/mcp`, such as auth. |
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. |
672
- | `pathPrefix` | `string` | none | _NestJS._ Extra prefix for tool routes. Global prefix and URI versioning are automatic. |
673
- | `routeOptions` | `object` | none | _Fastify._ Extra route options for `/mcp`, such as `onRequest` hooks. |
674
- | `configureRoute` | `(route) => void` | none | _AdonisJS._ Configure the MCP route, e.g. add middleware. |
713
+ | Option | Type | Default | Description |
714
+ | ------------------- | --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
715
+ | `name` | `string` | required | Server name shown to clients. |
716
+ | `version` | `string` | `'1.0.0'` | Server version. |
717
+ | `instructions` | `string` | none | Guidance for the model on how to use the tools together. |
718
+ | `path` | `string` | `'/mcp'` | Endpoint path. |
719
+ | `allowedOrigins` | `string[] \| '*'` | `[]` | Browser origins allowed to call the endpoint. Requests without `Origin` (CLIs, IDEs, servers) are always allowed. |
720
+ | `forwardHeaders` | `string[]` | `['authorization','cookie','x-api-key','accept-language']` | Headers copied from the MCP request to the internal API call. |
721
+ | `maxResponseChars` | `number` | `100000` | Longer API responses are truncated before reaching the model. |
722
+ | `oauth` | `OAuthOptions` | none | Protect the endpoint with OAuth 2.1. See [OAuth](#oauth-sign-in-from-ai-clients). |
723
+ | `transformResponse` | `ResponseTransform` | none | Default response hook for route tools without their own. See [Shaping responses](#shaping-and-enriching-responses). |
724
+ | `tools` | `McpToolDefinition[]` | `[]` | Extra hand-written tools. |
725
+ | `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. |
726
+ | `routes` | `{method,path,...}[]` | `[]` | _Express/Koa/Hono._ Expose routes without editing them. |
727
+ | `routers` | see guide | none | _Express:_ `{ '/prefix': router }`. _Koa:_ `[router]`. |
728
+ | `middleware` | `Middleware[]` | `[]` | _Express._ Middleware in front of `/mcp`, such as auth. |
729
+ | `guards` | `CanActivate[]` | `[]` | _NestJS._ Guards on the MCP controller. |
730
+ | `decorators` | `ClassDecorator[]` | `[]` | _NestJS._ Extra decorators on the MCP controller, e.g. `[Public()]` for global guards. |
731
+ | `pathPrefix` | `string` | none | _NestJS._ Extra prefix for tool routes. Global prefix and URI versioning are automatic. |
732
+ | `routeOptions` | `object` | none | _Fastify._ Extra route options for `/mcp`, such as `onRequest` hooks. |
733
+ | `configureRoute` | `(route) => void` | none | _AdonisJS._ Configure the MCP route, e.g. add middleware. |
675
734
 
676
735
  ### Tool options (`@McpTool()`, `mcpTool()`, `config.mcp`, `.mcp()`)
677
736
 
@@ -684,6 +743,7 @@ Your app's own validation always runs as well. The schema tells the agent what t
684
743
  | `annotations` | MCP hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Defaults come from the HTTP method (GET → read-only, DELETE → destructive). |
685
744
  | `headers` | Static headers added to the internal request. |
686
745
  | `scopes` | OAuth scopes the token needs to call this tool (with the `oauth` option). Missing scopes answer `403 insufficient_scope`. |
746
+ | `transformResponse` | Shape or enrich what the agent sees from this route's response. See [Shaping and enriching responses](#shaping-and-enriching-responses). |
687
747
 
688
748
  Each internal request also carries `X-Mcp-Tool: <tool name>`, so you can log or meter agent traffic separately.
689
749
 
@@ -740,7 +800,7 @@ Planned (non-breaking, 1.x minor releases):
740
800
 
741
801
  - Per-user tool lists (hide tools the token's scopes can't call)
742
802
  - Next.js route handlers, Hapi and Elysia adapters
743
- - Structured output schemas, and binary/file responses
803
+ - Structured output schemas (`outputSchema`), and binary/file responses
744
804
  - Streaming long-running responses over SSE
745
805
  - CLI to preview generated tools (`npx mcp-expose inspect`)
746
806
  - Resources from GET routes, and prompts
@@ -98,6 +98,38 @@ function loopbackBaseUrl(req) {
98
98
  }
99
99
  __name(loopbackBaseUrl, "loopbackBaseUrl");
100
100
 
101
+ // src/core/result.ts
102
+ function isToolResult(value) {
103
+ const content = value?.content;
104
+ return !!value && typeof value === "object" && Array.isArray(content) && content.length > 0 && content.every((c) => !!c && typeof c === "object" && typeof c.type === "string");
105
+ }
106
+ __name(isToolResult, "isToolResult");
107
+ function toToolResult(value) {
108
+ if (isToolResult(value)) return value;
109
+ if (typeof value === "string") return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: value
114
+ }
115
+ ]
116
+ };
117
+ const text = JSON.stringify(value ?? null);
118
+ const result = {
119
+ content: [
120
+ {
121
+ type: "text",
122
+ text
123
+ }
124
+ ]
125
+ };
126
+ if (value && typeof value === "object" && !Array.isArray(value)) {
127
+ result.structuredContent = value;
128
+ }
129
+ return result;
130
+ }
131
+ __name(toToolResult, "toToolResult");
132
+
101
133
  // src/core/schema.ts
102
134
  function isStandard(s) {
103
135
  return typeof s === "object" && s !== null && "~standard" in s;
@@ -318,9 +350,7 @@ function splitArgs(args, mapping) {
318
350
  }
319
351
  __name(splitArgs, "splitArgs");
320
352
  function responseToResult(res, maxChars) {
321
- let text = res.body;
322
- if (text.length > maxChars) text = `${text.slice(0, maxChars)}
323
- \u2026[truncated ${text.length - maxChars} chars]`;
353
+ const text = truncate(res.body, maxChars);
324
354
  const isError = res.status >= 400;
325
355
  const result = {
326
356
  content: [
@@ -345,6 +375,58 @@ ${text}` : text || `HTTP ${res.status}`
345
375
  return result;
346
376
  }
347
377
  __name(responseToResult, "responseToResult");
378
+ function safeDecode(segment) {
379
+ try {
380
+ return decodeURIComponent(segment);
381
+ } catch {
382
+ return segment;
383
+ }
384
+ }
385
+ __name(safeDecode, "safeDecode");
386
+ function truncate(text, maxChars) {
387
+ return text.length > maxChars ? `${text.slice(0, maxChars)}
388
+ \u2026[truncated ${text.length - maxChars} chars]` : text;
389
+ }
390
+ __name(truncate, "truncate");
391
+ function toRouteToolResponse(res) {
392
+ let json;
393
+ if ((res.headers["content-type"] ?? "").includes("json") && res.body) {
394
+ try {
395
+ json = JSON.parse(res.body);
396
+ } catch {
397
+ }
398
+ }
399
+ return {
400
+ ...res,
401
+ json,
402
+ ok: res.status < 400
403
+ };
404
+ }
405
+ __name(toRouteToolResponse, "toRouteToolResponse");
406
+ function transformedResult(value, res, maxChars) {
407
+ const explicit = isToolResult(value);
408
+ const result = toToolResult(value);
409
+ if (!explicit && !res.ok) {
410
+ result.isError = true;
411
+ delete result.structuredContent;
412
+ }
413
+ let truncated = false;
414
+ const content = result.content.map((c) => {
415
+ if (c.type !== "text" || c.text.length <= maxChars) return c;
416
+ truncated = true;
417
+ return {
418
+ ...c,
419
+ text: truncate(c.text, maxChars)
420
+ };
421
+ });
422
+ if (!truncated) return result;
423
+ const { structuredContent: _dropped, ...rest } = result;
424
+ return {
425
+ ...rest,
426
+ content
427
+ };
428
+ }
429
+ __name(transformedResult, "transformedResult");
348
430
  function pickForwardHeaders(ctx, names) {
349
431
  const out = {};
350
432
  for (const name of names) {
@@ -360,6 +442,7 @@ function createRouteTool(route, opts, dispatch, serverOpts = {}) {
360
442
  const { schema, mapping } = buildInputSchema(route, opts);
361
443
  const forward = serverOpts.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
362
444
  const maxChars = serverOpts.maxResponseChars ?? 1e5;
445
+ const transform = opts.transformResponse ?? serverOpts.transformResponse;
363
446
  return {
364
447
  name,
365
448
  title: opts.title,
@@ -405,14 +488,53 @@ function createRouteTool(route, opts, dispatch, serverOpts = {}) {
405
488
  ]
406
489
  };
407
490
  }
408
- const res = await dispatch({
491
+ const request = {
409
492
  method,
410
493
  path,
411
494
  query,
412
495
  body,
413
496
  headers
414
- }, ctx);
415
- return responseToResult(res, maxChars);
497
+ };
498
+ const res = await dispatch(request, ctx);
499
+ if (!transform) return responseToResult(res, maxChars);
500
+ const { "content-type": _ct, ...baseHeaders } = headers;
501
+ const hookCtx = {
502
+ tool: name,
503
+ args,
504
+ request,
505
+ toolContext: ctx,
506
+ defaultResult: /* @__PURE__ */ __name(() => responseToResult(res, maxChars), "defaultResult"),
507
+ async callRoute(r) {
508
+ if (!r.path.startsWith("/")) {
509
+ throw new Error(`callRoute: path must start with "/", got ${JSON.stringify(r.path)}`);
510
+ }
511
+ const params = r.params ?? {};
512
+ const missing2 = pathParams(r.path).filter((p) => params[p] === void 0 && !r.path.includes(`:${p}?`));
513
+ if (missing2.length) throw new Error(`callRoute: missing params for ${r.path}: ${missing2.join(", ")}`);
514
+ const subPath = interpolatePath(r.path, params);
515
+ if (subPath.split("/").some((seg) => [
516
+ ".",
517
+ ".."
518
+ ].includes(safeDecode(seg)))) {
519
+ throw new Error(`callRoute: path must not contain "." or ".." segments: ${JSON.stringify(subPath)}`);
520
+ }
521
+ const sub = {
522
+ method: (r.method ?? "GET").toUpperCase(),
523
+ path: subPath,
524
+ query: r.query ?? {},
525
+ body: r.body,
526
+ headers: {
527
+ ...baseHeaders,
528
+ ...r.headers
529
+ }
530
+ };
531
+ if (sub.body !== void 0) sub.headers["content-type"] = "application/json";
532
+ return toRouteToolResponse(await dispatch(sub, ctx));
533
+ }
534
+ };
535
+ const response = toRouteToolResponse(res);
536
+ const value = await transform(response, hookCtx);
537
+ return value === void 0 ? responseToResult(res, maxChars) : transformedResult(value, response, maxChars);
416
538
  }
417
539
  };
418
540
  }