mcp-expose 1.1.0 → 1.1.2

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 +33 -1
  2. package/README.md +143 -34
  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,36 @@ 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.2] - 2026-09-28
10
+
11
+ ### Documentation
12
+
13
+ - README: every framework guide example (NestJS, Express, Fastify, Koa, Hono, AdonisJS, OpenAPI gateway) now shows
14
+ `transformResponse`; the version compatibility section says what each framework version is verified for (including
15
+ OAuth and `transformResponse`), the `transformResponse` section names the marker for every framework, and
16
+ `ctx.callRoute()` documents each framework's path prefixes.
17
+
18
+ ### Tests
19
+
20
+ - `transformResponse` with `ctx.callRoute()` is checked end to end on every supported framework version (NestJS 10-12,
21
+ Express 3-5, Fastify 3-5, Koa 1-3, Hono 2-4, AdonisJS 6-7, OpenAPI gateway), with and without OAuth.
22
+
23
+ ## [1.1.1] - 2026-09-28
24
+
25
+ ### Added
26
+
27
+ - `transformResponse` hook for route tools, on every marker and adapter, plus a server-level default. It shapes what
28
+ the agent sees from a route's response (hide or format fields, add hints) without turning the route into a custom
29
+ tool. The hook receives the status, headers, body and parsed JSON, the tool arguments and the MCP call context.
30
+ `ctx.callRoute()` enriches the response with other routes of the app, using the same credentials and the same
31
+ middleware, guards and validation. Placeholders are URL-encoded, and `.` / `..` segments are rejected.
32
+ - `ResponseTransform`, `ResponseTransformContext` and `RouteToolResponse` types.
33
+
34
+ ### Fixed
35
+
36
+ - A tool handler or hook that returns plain data with its own `content` array (for example a CMS post) is no longer
37
+ mistaken for an MCP `ToolResult`; only `content` arrays of MCP content items (`{ type: ... }`) are.
38
+
9
39
  ## [1.1.0] - 2026-09-27
10
40
 
11
41
  ### Added
@@ -52,6 +82,8 @@ options, default tool naming) now follows Semantic Versioning.
52
82
  - Path parameters that are empty, `.` or `..` are rejected, so an agent cannot use them to reach routes
53
83
  that were never exposed as tools (for example `/orders/../admin`).
54
84
 
55
- [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.1.0...HEAD
85
+ [Unreleased]: https://github.com/NITINKACHHADIYA/Node-MCP/compare/v1.1.2...HEAD
86
+ [1.1.2]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.2
87
+ [1.1.1]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.1
56
88
  [1.1.0]: https://github.com/NITINKACHHADIYA/Node-MCP/releases/tag/v1.1.0
57
89
  [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
 
@@ -124,7 +125,9 @@ Tools are discovered **lazily on the first MCP request**, so the order you regis
124
125
 
125
126
  mcp-expose supports the **current major version of each framework and the two before it**. Every row
126
127
  below runs in CI as a real project (see [`e2e/`](e2e/README.md)): the packed library is installed from its
127
- tarball and the app is driven by the official MCP SDK client, on Node.js 20, 22 and 24.
128
+ tarball and the app is driven by the official MCP SDK client, on Node.js 20, 22 and 24. Each row is checked for tool
129
+ discovery, auth forwarding, the app's own validation errors, [OAuth](#oauth-sign-in-from-ai-clients) and
130
+ [`transformResponse`](#shaping-and-enriching-responses), with and without the `oauth` option.
128
131
 
129
132
  | Framework | Supported majors | Notes |
130
133
  | --------- | ---------------- | ------------------------------------------------------------------------------------ |
@@ -196,7 +199,16 @@ export class CreateOrderDto {
196
199
  @UseGuards(JwtAuthGuard) // ← still enforced for every tool call
197
200
  export class OrdersController {
198
201
  @Get(':id')
199
- @McpTool({ description: 'Get one order by its id.' })
202
+ @McpTool({
203
+ description: 'Get one order by its id.',
204
+ // Optional: shape what the agent sees. Hide internal fields, add the customer from another route.
205
+ transformResponse: async (res, ctx) => {
206
+ if (!res.ok) return undefined; // keep the default error result
207
+ const { internalNotes, ...order } = res.json as Order;
208
+ const customer = await ctx.callRoute({ path: '/api/customers/:id', params: { id: order.customerId } });
209
+ return { ...order, customer: customer.json };
210
+ },
211
+ })
200
212
  findOne(@Param('id', ParseIntPipe) id: number) { … }
201
213
 
202
214
  @Post()
@@ -270,6 +282,9 @@ app.get(
270
282
  name: 'search_products',
271
283
  description: 'Search the product catalog by name.',
272
284
  query: { type: 'object', properties: { q: { type: 'string' } } },
285
+ // Optional: only send the fields the agent needs
286
+ transformResponse: (res) =>
287
+ res.ok ? (res.json as { items: Product[] }).items.map(({ id, name, price }) => ({ id, name, price })) : undefined,
273
288
  }),
274
289
  searchProducts,
275
290
  );
@@ -334,7 +349,14 @@ app.post(
334
349
  required: ['title'],
335
350
  },
336
351
  },
337
- config: { mcp: { name: 'add_todo', description: 'Add a todo item.' } }, // or `mcp: true`
352
+ config: {
353
+ mcp: {
354
+ name: 'add_todo',
355
+ description: 'Add a todo item.',
356
+ // Optional: shape the response the agent sees
357
+ transformResponse: (res) => (res.ok ? { created: true, id: (res.json as Todo).id } : undefined),
358
+ },
359
+ }, // or `mcp: true`
338
360
  },
339
361
  addTodo,
340
362
  );
@@ -356,7 +378,15 @@ const app = new Koa();
356
378
  const router = new Router({ prefix: '/api' });
357
379
 
358
380
  // Step 1: mark routes
359
- router.get('/weather/:city', mcpTool({ description: 'Current weather for a city.' }), getWeather);
381
+ router.get(
382
+ '/weather/:city',
383
+ mcpTool({
384
+ description: 'Current weather for a city.',
385
+ // Optional: add a hint for the agent
386
+ transformResponse: (res) => (res.ok ? { ...(res.json as object), unit: 'celsius' } : undefined),
387
+ }),
388
+ getWeather,
389
+ );
360
390
 
361
391
  // Step 2: mount the endpoint (before your routers) and list the routers to scan
362
392
  mountMcp(app, { name: 'weather-api', routers: [router] });
@@ -391,7 +421,13 @@ const app = new Hono();
391
421
 
392
422
  app.post(
393
423
  '/notes',
394
- mcpTool({ name: 'create_note', description: 'Save a note.', body: z.object({ text: z.string() }) }),
424
+ mcpTool({
425
+ name: 'create_note',
426
+ description: 'Save a note.',
427
+ body: z.object({ text: z.string() }),
428
+ // Optional: shape the response the agent sees
429
+ transformResponse: (res) => (res.ok ? { saved: true, id: (res.json as Note).id } : undefined),
430
+ }),
395
431
  bearerAuth({ token }),
396
432
  async (c) => c.json(await saveNote(await c.req.json()), 201),
397
433
  );
@@ -418,7 +454,16 @@ const OrdersController = () => import('#controllers/orders_controller');
418
454
  router
419
455
  .group(() => {
420
456
  // Step 1: mark routes. Group prefixes and middleware (auth, throttle) all apply.
421
- router.get('orders/:id', [OrdersController, 'show']).mcp({ description: 'Get an order by id' });
457
+ router.get('orders/:id', [OrdersController, 'show']).mcp({
458
+ description: 'Get an order by id',
459
+ // Optional: add the customer from another route (same auth). Paths include the group prefix.
460
+ transformResponse: async (res, ctx) => {
461
+ if (!res.ok) return undefined;
462
+ const order = res.json as Order;
463
+ const customer = await ctx.callRoute({ path: '/api/v1/customers/:id', params: { id: order.customerId } });
464
+ return { ...order, customer: customer.json };
465
+ },
466
+ });
422
467
 
423
468
  // A VineJS 4 validator (AdonisJS 7) can be passed as the schema directly
424
469
  router
@@ -454,6 +499,8 @@ import { mountMcp } from 'mcp-expose/express';
454
499
  const spec = await fetch('https://api.example.com/openapi.json').then((r) => r.json());
455
500
  const tools = toolsFromOpenApi(spec, createFetchDispatcher({ baseUrl: 'https://api.example.com' }), {
456
501
  include: ({ method }) => method === 'get', // e.g. only read-only operations
502
+ // Optional: shape responses of the generated tools (ctx.tool is the tool name)
503
+ transformResponse: (res, ctx) => (ctx.tool === 'get_user' && res.ok ? redact(res.json) : undefined),
457
504
  });
458
505
 
459
506
  const app = express();
@@ -648,30 +695,91 @@ Sources, from highest to lowest precedence:
648
695
 
649
696
  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
697
 
698
+ ## Shaping and enriching responses
699
+
700
+ By default the agent gets the route's response as it is. Add `transformResponse` to a route tool to change
701
+ what the agent sees: hide internal fields, format values, add hints, or pull in related data from other
702
+ routes. The route stays a route tool; there is no need to rewrite it as a custom tool.
703
+
704
+ ```ts
705
+ @Get(':id')
706
+ @McpTool({
707
+ name: 'get_order',
708
+ transformResponse: async (res, ctx) => {
709
+ if (!res.ok) return undefined; // keep the default error result
710
+ const { internalNotes, ...order } = res.json as Order;
711
+ // Another route of this app, with the same user's credentials, guards and pipes:
712
+ const customer = await ctx.callRoute({ path: '/api/customers/:id', params: { id: order.customerId } });
713
+ return { ...order, total: formatMoney(order.total), customer: customer.ok ? customer.json : null };
714
+ },
715
+ })
716
+ findOne(@Param('id') id: string) { ... }
717
+ ```
718
+
719
+ It works the same with every marker and every supported framework version: `@McpTool({ transformResponse })` (NestJS),
720
+ `mcpTool({ transformResponse })` (Express, Koa, Hono; `mcpToolLegacy()` on Koa 1), `config: { mcp: { transformResponse } }`
721
+ (Fastify), `.mcp({ transformResponse })` (AdonisJS) and `toolsFromOpenApi(doc, dispatch, { transformResponse })`.
722
+
723
+ **What the hook receives**
724
+
725
+ - `res`: `status`, `headers`, the raw `body`, the parsed `json` (for JSON responses) and `ok` (status below 400).
726
+ - `ctx.args`: the tool arguments. `ctx.request`: the internal request (method, path, query, body).
727
+ - `ctx.toolContext`: the MCP request's headers, client IP and, with [OAuth](#oauth-sign-in-from-ai-clients), `auth`.
728
+ - `ctx.defaultResult()`: the result mcp-expose would return without the hook.
729
+ - `ctx.callRoute({ method, path, params, query, body, headers })`: calls another route of the same app.
730
+ - It forwards the same credentials (`Authorization`, cookies) and runs through the same middleware, guards and validation.
731
+ - `path` is the full path as your server sees it, including every prefix: NestJS global prefix and URI version
732
+ (`/api/v1/...`), Express router mount path, Koa router `prefix`, Hono `app.route()` mount, AdonisJS group `.prefix()`.
733
+ `ctx.request.path` shows the full path of the tool's own route. Fill `:id` / `{id}`
734
+ placeholders from `params`: values are URL-encoded, and `.` or `..` are rejected, so agent input can't redirect the call.
735
+
736
+ **What it returns**
737
+
738
+ | Return value | Result |
739
+ | -------------------------- | --------------------------------------------------------------------------------------------------- |
740
+ | a string or any JSON value | Sent to the agent (objects also as `structuredContent`). Still an error result if the route failed. |
741
+ | a full `ToolResult` | Used as is, so you control `isError` (e.g. `{ ...ctx.defaultResult(), content: [...] }`). |
742
+ | `undefined` | The default result. |
743
+ | a thrown error | An error result with the message, which the agent sees. |
744
+
745
+ `maxResponseChars` still applies to whatever the hook returns.
746
+
747
+ **For every tool at once**, set `transformResponse` in the server options, e.g. to strip fields everywhere:
748
+
749
+ ```ts
750
+ mountMcp(app, {
751
+ name: 'shop-api',
752
+ transformResponse: (res) => (res.ok ? omitDeep(res.json, ['internalNotes', 'passwordHash']) : undefined),
753
+ });
754
+ ```
755
+
756
+ A tool's own `transformResponse` replaces the server-level one.
757
+
651
758
  ## Configuration reference
652
759
 
653
760
  ### Server options (all adapters)
654
761
 
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. |
762
+ | Option | Type | Default | Description |
763
+ | ------------------- | --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
764
+ | `name` | `string` | required | Server name shown to clients. |
765
+ | `version` | `string` | `'1.0.0'` | Server version. |
766
+ | `instructions` | `string` | none | Guidance for the model on how to use the tools together. |
767
+ | `path` | `string` | `'/mcp'` | Endpoint path. |
768
+ | `allowedOrigins` | `string[] \| '*'` | `[]` | Browser origins allowed to call the endpoint. Requests without `Origin` (CLIs, IDEs, servers) are always allowed. |
769
+ | `forwardHeaders` | `string[]` | `['authorization','cookie','x-api-key','accept-language']` | Headers copied from the MCP request to the internal API call. |
770
+ | `maxResponseChars` | `number` | `100000` | Longer API responses are truncated before reaching the model. |
771
+ | `oauth` | `OAuthOptions` | none | Protect the endpoint with OAuth 2.1. See [OAuth](#oauth-sign-in-from-ai-clients). |
772
+ | `transformResponse` | `ResponseTransform` | none | Default response hook for route tools without their own. See [Shaping responses](#shaping-and-enriching-responses). |
773
+ | `tools` | `McpToolDefinition[]` | `[]` | Extra hand-written tools. |
774
+ | `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. |
775
+ | `routes` | `{method,path,...}[]` | `[]` | _Express/Koa/Hono._ Expose routes without editing them. |
776
+ | `routers` | see guide | none | _Express:_ `{ '/prefix': router }`. _Koa:_ `[router]`. |
777
+ | `middleware` | `Middleware[]` | `[]` | _Express._ Middleware in front of `/mcp`, such as auth. |
778
+ | `guards` | `CanActivate[]` | `[]` | _NestJS._ Guards on the MCP controller. |
779
+ | `decorators` | `ClassDecorator[]` | `[]` | _NestJS._ Extra decorators on the MCP controller, e.g. `[Public()]` for global guards. |
780
+ | `pathPrefix` | `string` | none | _NestJS._ Extra prefix for tool routes. Global prefix and URI versioning are automatic. |
781
+ | `routeOptions` | `object` | none | _Fastify._ Extra route options for `/mcp`, such as `onRequest` hooks. |
782
+ | `configureRoute` | `(route) => void` | none | _AdonisJS._ Configure the MCP route, e.g. add middleware. |
675
783
 
676
784
  ### Tool options (`@McpTool()`, `mcpTool()`, `config.mcp`, `.mcp()`)
677
785
 
@@ -684,6 +792,7 @@ Your app's own validation always runs as well. The schema tells the agent what t
684
792
  | `annotations` | MCP hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Defaults come from the HTTP method (GET → read-only, DELETE → destructive). |
685
793
  | `headers` | Static headers added to the internal request. |
686
794
  | `scopes` | OAuth scopes the token needs to call this tool (with the `oauth` option). Missing scopes answer `403 insufficient_scope`. |
795
+ | `transformResponse` | Shape or enrich what the agent sees from this route's response. See [Shaping and enriching responses](#shaping-and-enriching-responses). |
687
796
 
688
797
  Each internal request also carries `X-Mcp-Tool: <tool name>`, so you can log or meter agent traffic separately.
689
798
 
@@ -740,7 +849,7 @@ Planned (non-breaking, 1.x minor releases):
740
849
 
741
850
  - Per-user tool lists (hide tools the token's scopes can't call)
742
851
  - Next.js route handlers, Hapi and Elysia adapters
743
- - Structured output schemas, and binary/file responses
852
+ - Structured output schemas (`outputSchema`), and binary/file responses
744
853
  - Streaming long-running responses over SSE
745
854
  - CLI to preview generated tools (`npx mcp-expose inspect`)
746
855
  - 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
  }