From 26360e9cfacf14a59d8bd14a604d217723837f62 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:01:53 +0200 Subject: [PATCH 1/7] feat(plugin-result-handles): fold large tool answers into a preview, a shape and a handle that result_query reads New package @frontmcp/plugin-result-handles. On the fitAnswer stage, before the answer budget, an answer whose data is over thresholdBytes is kept in a createStorage() store with a TTL, and the client gets { preview, shape, handle } in its place. result_query reads slices of it by a dot path or a JSON pointer, an offset and limit or a page, and fields. - A handle is a random UUID bound to the call's verified session and its authenticated caller; any other session or caller, an expired handle and an unknown one all get RESULT_HANDLE_NOT_FOUND. - foldPolicy(toolName, answer, context) decides which answers fold. - A tool with an output schema folds only when the schema admits the folded form (foldedAnswerSchema in a union); otherwise it is sent whole and logged once. - Options are validated by Zod; invalid ones throw ResultHandlesConfigurationError from init(). CallToolFlow.isClientCall() is now public, so a hook on the answer stages can skip in-process calls as they do. --- README.md | 1 + .../e2e/result-handles.e2e.spec.ts | 98 ++++ apps/e2e/demo-e2e-answer-shaping/project.json | 16 +- .../src/apps/results/index.ts | 20 + .../apps/results/tools/export-orders.tool.ts | 14 + .../src/apps/results/tools/orders.ts | 13 + .../apps/results/tools/strict-orders.tool.ts | 16 + apps/e2e/demo-e2e-answer-shaping/src/main.ts | 3 +- .../e2e/dts-consumer.e2e.spec.ts | 2 +- .../fixture/answer-shaping-must-compile.ts | 14 +- libs/sdk/src/tool/flows/call-tool.flow.ts | 2 +- .../catalog/frontmcp-development/SKILL.md | 4 +- .../references/create-plugin-hooks.md | 2 + .../references/create-plugin.md | 4 +- .../references/official-plugins.md | 26 +- .../references/result-handles-plugin.md | 180 ++++++++ libs/skills/catalog/skills-manifest.json | 7 +- plugins/plugin-result-handles/README.md | 170 +++++++ plugins/plugin-result-handles/jest.config.ts | 51 +++ plugins/plugin-result-handles/package.json | 63 +++ plugins/plugin-result-handles/project.json | 95 ++++ .../src/__tests__/folded-answer.spec.ts | 102 +++++ .../src/__tests__/result-handle.store.spec.ts | 89 ++++ .../__tests__/result-handles.options.spec.ts | 57 +++ .../__tests__/result-handles.plugin.spec.ts | 430 ++++++++++++++++++ .../src/__tests__/result-query.spec.ts | 84 ++++ .../src/folded-answer.ts | 93 ++++ plugins/plugin-result-handles/src/index.ts | 20 + .../src/result-handle-store.factory.ts | 17 + .../src/result-handle.store.ts | 69 +++ .../src/result-handles.errors.ts | 51 +++ .../src/result-handles.options.ts | 95 ++++ .../src/result-handles.plugin.ts | 144 ++++++ .../src/result-handles.symbols.ts | 10 + .../src/result-query.tool.ts | 75 +++ .../plugin-result-handles/src/result-query.ts | 122 +++++ plugins/plugin-result-handles/tsconfig.json | 16 + .../plugin-result-handles/tsconfig.lib.json | 19 + .../plugin-result-handles/tsconfig.spec.json | 12 + tsconfig.base.json | 1 + yarn.lock | 13 + 41 files changed, 2308 insertions(+), 12 deletions(-) create mode 100644 apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts create mode 100644 apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-orders.tool.ts create mode 100644 apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/orders.ts create mode 100644 apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/strict-orders.tool.ts create mode 100644 libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md create mode 100644 plugins/plugin-result-handles/README.md create mode 100644 plugins/plugin-result-handles/jest.config.ts create mode 100644 plugins/plugin-result-handles/package.json create mode 100644 plugins/plugin-result-handles/project.json create mode 100644 plugins/plugin-result-handles/src/__tests__/folded-answer.spec.ts create mode 100644 plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts create mode 100644 plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts create mode 100644 plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts create mode 100644 plugins/plugin-result-handles/src/__tests__/result-query.spec.ts create mode 100644 plugins/plugin-result-handles/src/folded-answer.ts create mode 100644 plugins/plugin-result-handles/src/index.ts create mode 100644 plugins/plugin-result-handles/src/result-handle-store.factory.ts create mode 100644 plugins/plugin-result-handles/src/result-handle.store.ts create mode 100644 plugins/plugin-result-handles/src/result-handles.errors.ts create mode 100644 plugins/plugin-result-handles/src/result-handles.options.ts create mode 100644 plugins/plugin-result-handles/src/result-handles.plugin.ts create mode 100644 plugins/plugin-result-handles/src/result-handles.symbols.ts create mode 100644 plugins/plugin-result-handles/src/result-query.tool.ts create mode 100644 plugins/plugin-result-handles/src/result-query.ts create mode 100644 plugins/plugin-result-handles/tsconfig.json create mode 100644 plugins/plugin-result-handles/tsconfig.lib.json create mode 100644 plugins/plugin-result-handles/tsconfig.spec.json diff --git a/README.md b/README.md index dd2557d97..912e3de60 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Internal, published so the above resolve: [`protocol`](libs/protocol), [`di`](li | -------------------------------------------------------------------- | ----------------------------------------------- | | [`@frontmcp/plugin-cache`](plugins/plugin-cache) | Cache tool results with a TTL | | [`@frontmcp/plugin-remember`](plugins/plugin-remember) | Per-session memory (`this.remember`) | +| [`@frontmcp/plugin-result-handles`](plugins/plugin-result-handles) | Fold large answers into a handle to query | | [`@frontmcp/plugin-approval`](plugins/plugin-approval) | Human approval gates before a tool runs | | [`@frontmcp/plugin-codecall`](plugins/plugin-codecall) | Let the model compose tool calls as code | | [`@frontmcp/plugin-dashboard`](plugins/plugin-dashboard) | Built-in web dashboard | diff --git a/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts b/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts new file mode 100644 index 000000000..d939b29ce --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts @@ -0,0 +1,98 @@ +/** E2E tests for @frontmcp/plugin-result-handles: fold before the budget, query slices, session binding, TTL. */ +import { expect, test } from '@frontmcp/testing'; + +const TTL_SECONDS = 4; + +interface FoldedAnswer { + preview: { orders: Array<{ id: number }>; total: number }; + shape: { type: string; keys: Record }; + handle: string; +} + +test.describe('Result handles E2E', () => { + test.use({ + server: 'apps/e2e/demo-e2e-answer-shaping/src/main.ts', + project: 'demo-e2e-answer-shaping', + publicMode: true, + }); + + test('folds a large answer into a preview, a shape and a handle, before the budget', async ({ mcp }) => { + const result = await mcp.tools.call('export-orders', { count: 500 }); + expect(result).toBeSuccessful(); + + const folded = result.raw.structuredContent as unknown as FoldedAnswer; + expect(folded.preview.orders.map((order) => order.id)).toEqual([0, 1, 2]); + expect(folded.preview.total).toBe(500); + expect(folded.shape.keys['orders']).toMatchObject({ type: 'array', length: 500 }); + expect(folded.handle).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(result.raw._meta?.['frontmcp/result-handle']).toMatchObject({ + handle: folded.handle, + ttlSeconds: TTL_SECONDS, + }); + expect(result.raw._meta?.['frontmcp/budget']).toBeUndefined(); + }); + + test('result_query reads slices of the folded answer', async ({ mcp }) => { + const folded = await mcp.tools.call('export-orders', { count: 500 }); + const { handle } = folded.raw.structuredContent as unknown as FoldedAnswer; + + const range = await mcp.tools.call('result_query', { + handle, + path: 'orders', + offset: 100, + limit: 5, + fields: ['id', 'status'], + }); + expect(range.raw.structuredContent).toEqual({ + path: 'orders', + value: [100, 101, 102, 103, 104].map((id) => ({ id, status: id % 2 === 0 ? 'open' : 'shipped' })), + total: 500, + offset: 100, + nextOffset: 105, + }); + + const lastPage = await mcp.tools.call('result_query', { handle, path: '/orders', page: 50, limit: 10 }); + expect(lastPage.raw.structuredContent).toMatchObject({ offset: 490, total: 500 }); + expect(lastPage.raw.structuredContent?.['nextOffset']).toBeUndefined(); + + const note = await mcp.tools.call('result_query', { handle, path: '$.orders[7].note', limit: 12 }); + expect(note.raw.structuredContent).toMatchObject({ value: 'Order 7 note' }); + }); + + test('another session gets "not found", never the data', async ({ mcp, server }) => { + const folded = await mcp.tools.call('export-orders', { count: 500 }); + const { handle } = folded.raw.structuredContent as unknown as FoldedAnswer; + + const other = await server.createClient(); + try { + const result = await other.tools.call('result_query', { handle, path: 'orders', limit: 1 }); + expect(result.isError).toBe(true); + expect(result.raw._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); + expect(JSON.stringify(result.raw)).not.toContain('Order 0 note'); + } finally { + await other.disconnect(); + } + }); + + test('the handle expires after its TTL', async ({ mcp }) => { + const folded = await mcp.tools.call('export-orders', { count: 500 }); + const { handle } = folded.raw.structuredContent as unknown as FoldedAnswer; + + await new Promise((resolve) => setTimeout(resolve, (TTL_SECONDS + 1) * 1000)); + + const result = await mcp.tools.call('result_query', { handle, path: 'total' }); + expect(result.isError).toBe(true); + expect(result.raw._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); + }); + + test('a tool whose output schema cannot admit the folded form is left to the budget', async ({ mcp }) => { + const result = await mcp.tools.call('strict-orders', { count: 500 }); + expect(result).toBeSuccessful(); + + const answer = result.raw.structuredContent as { orders: Array<{ id: number }>; total: number }; + expect(answer.total).toBe(500); + expect(answer.orders[answer.orders.length - 1].id).toBe(499); + expect(result.raw._meta?.['frontmcp/result-handle']).toBeUndefined(); + expect(result.raw._meta?.['frontmcp/budget']).toBeDefined(); + }); +}); diff --git a/apps/e2e/demo-e2e-answer-shaping/project.json b/apps/e2e/demo-e2e-answer-shaping/project.json index 317fbebc8..17a4dc202 100644 --- a/apps/e2e/demo-e2e-answer-shaping/project.json +++ b/apps/e2e/demo-e2e-answer-shaping/project.json @@ -39,7 +39,13 @@ "options": { "jestConfig": "apps/e2e/demo-e2e-answer-shaping/jest.e2e.config.ts", "passWithNoTests": true - } + }, + "dependsOn": [ + { + "projects": ["plugin-result-handles", "observability"], + "target": "build" + } + ] }, "test:e2e": { "executor": "@nx/jest:jest", @@ -48,7 +54,13 @@ "jestConfig": "apps/e2e/demo-e2e-answer-shaping/jest.e2e.config.ts", "runInBand": true, "passWithNoTests": true - } + }, + "dependsOn": [ + { + "projects": ["plugin-result-handles", "observability"], + "target": "build" + } + ] } } } diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts new file mode 100644 index 000000000..46592d59d --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts @@ -0,0 +1,20 @@ +import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; +import { App } from '@frontmcp/sdk'; + +import ExportOrdersTool from './tools/export-orders.tool'; +import StrictOrdersTool from './tools/strict-orders.tool'; + +@App({ + id: 'results', + name: 'Results', + tools: [ExportOrdersTool, StrictOrdersTool], + plugins: [ + ResultHandlesPlugin.init({ + thresholdBytes: 8 * 1024, + ttlSeconds: 4, + preview: { items: 3, chars: 80 }, + storage: { type: 'memory' }, + }), + ], +}) +export class ResultsApp {} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-orders.tool.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-orders.tool.ts new file mode 100644 index 000000000..788322549 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-orders.tool.ts @@ -0,0 +1,14 @@ +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { orders } from './orders'; + +@Tool({ + name: 'export-orders', + description: 'Every order, with no output schema: a large answer folds', + inputSchema: { count: z.number().int().min(1).max(5000).default(500) }, +}) +export default class ExportOrdersTool extends ToolContext { + async execute({ count }: { count: number }) { + return { orders: orders(count), total: count }; + } +} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/orders.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/orders.ts new file mode 100644 index 000000000..2c2674af2 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/orders.ts @@ -0,0 +1,13 @@ +import { z } from '@frontmcp/sdk'; + +export const orderSchema = z.object({ id: z.number(), customer: z.string(), status: z.string(), note: z.string() }); + +/** About 135 KB for 500 orders: over the result-handles threshold and the server's 64 KB answer budget. */ +export function orders(count: number) { + return Array.from({ length: count }, (_, id) => ({ + id, + customer: `Customer ${id}`, + status: id % 2 === 0 ? 'open' : 'shipped', + note: `Order ${id} note `.padEnd(200, '.'), + })); +} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/strict-orders.tool.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/strict-orders.tool.ts new file mode 100644 index 000000000..1478e5de7 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/strict-orders.tool.ts @@ -0,0 +1,16 @@ +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { orders, orderSchema } from './orders'; + +/** Its output schema has no room for the folded form, so its large answers go to the budget instead. */ +@Tool({ + name: 'strict-orders', + description: 'Every order, with an output schema the folded form does not match', + inputSchema: { count: z.number().int().min(1).max(5000).default(500) }, + outputSchema: { orders: z.array(orderSchema), total: z.number() }, +}) +export default class StrictOrdersTool extends ToolContext { + async execute({ count }: { count: number }) { + return { orders: orders(count), total: count }; + } +} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/main.ts b/apps/e2e/demo-e2e-answer-shaping/src/main.ts index 64adf3194..ac0e69982 100644 --- a/apps/e2e/demo-e2e-answer-shaping/src/main.ts +++ b/apps/e2e/demo-e2e-answer-shaping/src/main.ts @@ -1,12 +1,13 @@ import { FrontMcp, LogLevel } from '@frontmcp/sdk'; import { ReportsApp } from './apps/reports'; +import { ResultsApp } from './apps/results'; const port = parseInt(process.env['PORT'] ?? '3210', 10); @FrontMcp({ info: { name: 'Demo E2E Answer Shaping', version: '0.1.0' }, - apps: [ReportsApp], + apps: [ReportsApp, ResultsApp], logging: { level: LogLevel.Warn }, http: { port, forwardHeaders: ['x-acme-*'] }, auth: { diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index 61694c042..a24aa7c2c 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -163,7 +163,7 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('tool-aliases-must-compile.ts'))).toEqual([]); }); - it('types output.budget, clients profiles, http.forwardHeaders and the budget note', () => { + it('types output.budget, clients profiles, http.forwardHeaders, the budget note and isClientCall() on the call-tool hook ctx', () => { expect(describeEach(inFixtureFile('answer-shaping-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/answer-shaping-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/answer-shaping-must-compile.ts index d4017d4af..2552bbd93 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/answer-shaping-must-compile.ts +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/answer-shaping-must-compile.ts @@ -4,11 +4,14 @@ import { App, BUDGET_META_KEY, FrontMcp, + Plugin, Tool, ToolContext, + ToolHook, z, type BudgetNote, type ClientProfile, + type FlowCtxOf, type OutputBudget, } from '@frontmcp/sdk'; @@ -22,7 +25,16 @@ export class EventsTool extends ToolContext { } } -@App({ name: 'Events', tools: [EventsTool], output: { budget } }) +// A hook before the budget reads whether a client made the call, as the answer stages do. +@Plugin({ name: 'answer-tagger' }) +export class AnswerTagger { + @ToolHook.Will('fitAnswer') + tag(ctx: FlowCtxOf<'tools:call-tool'>): void { + if (ctx.isClientCall() && ctx.state.output) ctx.respond({ ...ctx.state.output, _meta: { tagged: true } }); + } +} + +@App({ name: 'Events', tools: [EventsTool], plugins: [AnswerTagger], output: { budget } }) export class EventsApp {} @FrontMcp({ diff --git a/libs/sdk/src/tool/flows/call-tool.flow.ts b/libs/sdk/src/tool/flows/call-tool.flow.ts index 9dbc64742..77f39da0d 100644 --- a/libs/sdk/src/tool/flows/call-tool.flow.ts +++ b/libs/sdk/src/tool/flows/call-tool.flow.ts @@ -1733,7 +1733,7 @@ export default class CallToolFlow extends FlowBase { } /** Whether a client called the tool, rather than a tool, agent or job in process (`this.callTool()`). */ - private isClientCall(): boolean { + isClientCall(): boolean { const callerCtx = this.input.ctx as { internalCall?: boolean; agentPrivateCall?: boolean } | undefined; return !(callerCtx?.internalCall || callerCtx?.agentPrivateCall || getRunningTool()); } diff --git a/libs/skills/catalog/frontmcp-development/SKILL.md b/libs/skills/catalog/frontmcp-development/SKILL.md index 222cf4385..8d9580ac6 100644 --- a/libs/skills/catalog/frontmcp-development/SKILL.md +++ b/libs/skills/catalog/frontmcp-development/SKILL.md @@ -77,6 +77,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( | Overview of all official adapters | `official-adapters` | Router to all adapter types; adapter vs plugin comparison | | Integrate an external API via OpenAPI spec | `openapi-adapter` | OpenapiAdapter with auth, polling, filtering, transforms, format resolution, $ref security | | Use official plugins (caching, remember, feature flags) | `official-plugins` | Built-in plugins for caching, session memory, approval, and feature flags (dashboard is beta) | +| Fold large tool answers into a handle the model reads in slices | `result-handles-plugin` | Preview, shape and handle in place of a large answer; `result_query`; `foldedAnswerSchema` | | Connect to an external data source via a custom adapter | `create-adapter` | Create custom adapters for external data sources | | Configure LLM settings for an agent component | `create-agent-llm-config` | Configure LLM settings for agent components | | Add will/did/around/finally lifecycle hooks to a plugin | `create-plugin-hooks` | Add lifecycle hooks to plugins (will/did/around/finally) | @@ -93,6 +94,7 @@ This is a router skill. The "steps" here are how to choose the right reference ( 8. **`create-skill`** / **`create-skill-with-tools`** — Author your own skills (meta) 9. **`official-adapters`** / **`openapi-adapter`** — Integrate external APIs via OpenAPI specs 10. **`official-plugins`** — Add caching, session memory, feature flags, and more +11. **`result-handles-plugin`** — Let the model page through large tool answers ## Cross-Cutting Patterns @@ -285,4 +287,4 @@ when a server has been configured to host this skill. ## Reference - [FrontMCP Overview](https://frontmcp.dev/reference) -- Related skills: [`create-tool`](../create-tool/SKILL.md) (top-level), `create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-skill`, `create-skill-with-tools`, `decorators-guide`, `official-adapters`, `openapi-adapter`, `official-plugins` +- Related skills: [`create-tool`](../create-tool/SKILL.md) (top-level), `create-resource`, `create-prompt`, `create-agent`, `create-provider`, `create-job`, `create-workflow`, `create-skill`, `create-skill-with-tools`, `decorators-guide`, `official-adapters`, `openapi-adapter`, `official-plugins`, `result-handles-plugin` diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md index f98645e16..dec1cd7be 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md @@ -98,6 +98,8 @@ To keep the flow's own step from running: Each answer the flow sends, the tool's from `finalize` or a hook's `ctx.respond(result)` (an `@Around('finalize')` hook that replaces it after `next()` included), becomes `ctx.state.output` in `tools:call-tool` and `resources:read-resource`. The stages after `finalize` work on it: `fitAnswer` (`output.budget`), `shapeAnswer` (`clients` profiles) and `meterAnswer` (the `/metrics` answer cost). Hook `Did('meterAnswer')` to see the answer the client receives. +These stages shape only a client's answer, not the one a tool gets from `this.callTool()`; a hook on them checks the same with `ctx.isClientCall()`. A `Will('fitAnswer')` hook that answers with `ctx.respond(result)` replaces the answer before the budget fits it, as `@frontmcp/plugin-result-handles` does to fold a large answer (see `result-handles-plugin`). + ## Hook Order Hooks on one stage run in `priority` order, **lowest first**; the default is `0`, and a higher number does not run earlier. A stage runs its `@Will` hooks, then its steps (the flow's own step and the `@Stage` hooks) wrapped by its `@Around` hooks, then its `@Did` hooks, then its `@Finally` hooks, each list in `priority` order. diff --git a/libs/skills/catalog/frontmcp-development/references/create-plugin.md b/libs/skills/catalog/frontmcp-development/references/create-plugin.md index 4873320d5..f3488f94e 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/create-plugin.md @@ -5,7 +5,7 @@ description: Build plugins with providers, context extensions, lifecycle hooks, # Create a FrontMCP Plugin -This skill covers building custom plugins for FrontMCP and using all 7 official plugins. Plugins are modular units that extend server behavior through providers, context extensions, lifecycle hooks, and contributed tools/resources/prompts. +This skill covers building custom plugins for FrontMCP and using all 8 official plugins. Plugins are modular units that extend server behavior through providers, context extensions, lifecycle hooks, and contributed tools/resources/prompts. ## When to Use This Skill @@ -580,7 +580,7 @@ export { MyServiceToken } from './my-plugin.symbols'; ## Official Plugins -For official plugin installation, configuration, and examples, see the **official-plugins** skill. FrontMCP provides 7 official plugins: CodeCall, Remember, Approval, Cache, Feature Flags, Dashboard, and WebMCP. Install individually or via `@frontmcp/plugins` (meta-package). +For official plugin installation, configuration, and examples, see the **official-plugins** skill. FrontMCP provides 8 official plugins: CodeCall, Remember, Approval, Cache, Feature Flags, Dashboard, WebMCP, and Result Handles. Install individually or via `@frontmcp/plugins` (meta-package). ## Recommended Folder Structure diff --git a/libs/skills/catalog/frontmcp-development/references/official-plugins.md b/libs/skills/catalog/frontmcp-development/references/official-plugins.md index 208dcb7c0..63be957bc 100644 --- a/libs/skills/catalog/frontmcp-development/references/official-plugins.md +++ b/libs/skills/catalog/frontmcp-development/references/official-plugins.md @@ -1,11 +1,11 @@ --- name: official-plugins -description: Guide to the 7 official plugins for discovery, memory, auth, caching, flags, monitoring, and WebMCP +description: Guide to the 8 official plugins for discovery, memory, auth, caching, flags, monitoring, WebMCP, and large answers --- # Official FrontMCP Plugins -FrontMCP ships 7 official plugins that extend server behavior with cross-cutting concerns: semantic tool discovery, session memory, authorization workflows, result caching, feature gating, visual monitoring, and exposing in-browser tools to browser agents (WebMCP). Install individually or via `@frontmcp/plugins` (meta-package re-exporting cache, codecall, and remember). +FrontMCP ships 8 official plugins that extend server behavior with cross-cutting concerns: semantic tool discovery, session memory, authorization workflows, result caching, feature gating, visual monitoring, exposing in-browser tools to browser agents (WebMCP), and folding large tool answers into a handle the model reads in slices. Install individually or via `@frontmcp/plugins` (meta-package re-exporting cache, codecall, and remember). > **Note:** The Dashboard plugin (`@frontmcp/plugin-dashboard`) is currently in **beta** and may not work correctly in all environments. It is not recommended for production use at this time. @@ -17,6 +17,7 @@ FrontMCP ships 7 official plugins that extend server behavior with cross-cutting - Adding session memory, tool caching, or authorization workflows to an existing server - Integrating feature flag services (LaunchDarkly, Split.io, Unleash) to gate tools at runtime - Exposing the tools of a FrontMCP server running in the browser to in-browser agents through WebMCP (`document.modelContext`) +- Keeping large tool answers out of the model's context, readable in slices through `result_query` (see `result-handles-plugin`) ### Recommended @@ -1172,6 +1173,27 @@ void registerWebMcpTools(resolveDocumentModelContext(), tools, (modelContext) => - `registerWebMcpTools(modelContext, tools, loadServer, { exposedTo? })` registers the list at once. The first call loads the server once (concurrent first calls share the load) and runs through the plugin; a failed load rejects that call (a server it built is disposed, the listed tools stay) and the next call retries. Then the plugin follows the server's tool changes; a listed tool the server lacks is unregistered. No `modelContext` → does nothing. - `@frontmcp/plugin-webmcp/register` never imports `@frontmcp/sdk` (a few KB, CJS and ESM). +## 8. Result Handles Plugin (`@frontmcp/plugin-result-handles`) + +Folds a tool answer over `thresholdBytes` (16 KB by default) into `{ preview, shape, handle }` and adds the +`result_query` tool, which reads slices of the rest by path, range or page, and fields. A handle is a random UUID bound +to the call's session and authenticated caller; another session or caller gets `RESULT_HANDLE_NOT_FOUND`. It runs on +the `fitAnswer` stage, before `output.budget`. A tool with an output schema folds only when the schema admits the +folded form (`z.union([schema, foldedAnswerSchema])`). + +```typescript +import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; + +@App({ + name: 'Orders', + tools: [ExportOrdersTool], + plugins: [ResultHandlesPlugin.init({ thresholdBytes: 32 * 1024, ttlSeconds: 600, storage: { type: 'auto' } })], +}) +class OrdersApp {} +``` + +Every option, the folded form, `result_query` and its errors: see `result-handles-plugin`. + ## Common Patterns | Pattern | Correct | Incorrect | Why | diff --git a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md new file mode 100644 index 000000000..7bd6c7f94 --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -0,0 +1,180 @@ +--- +name: result-handles-plugin +description: Fold large tool answers into a preview, a shape and a handle, and let the model read slices with result_query +--- + +# Result Handles Plugin + +`@frontmcp/plugin-result-handles` keeps a tool answer that is too large to send whole on the server. The client gets +`{ preview, shape, handle }` in its place, and the model reads the parts it needs with the `result_query` tool. A +handle works only for the session and the caller it was issued to, until its TTL runs out. + +## When to Use This Skill + +### Must Use + +- A tool returns large lists or documents (exports, search results, logs) that would flood the model's context +- You want the model to page through a large answer instead of receiving it cut by the answer budget + +### Recommended + +- Combining with `output.budget`: the plugin folds first, and the budget fits what is still too large +- Tools whose answers vary in size: small answers are sent whole, only large ones fold + +### Skip When + +- The answer must reach the client whole (a widget renders it, or another system parses it): a UI answer is never folded +- You only need to cut answers to a size, with no way back to the rest (use `output.budget`, see `decorators-guide`) +- You want to cache answers between calls (see `official-plugins`, the Cache plugin) + +> **Decision:** Use this plugin when a tool's answer is often larger than the model should read at once, and the model +> can find what it needs by path, range or page. + +## Prerequisites + +- `@frontmcp/sdk` and `@frontmcp/plugin-result-handles` on the same version +- A shared store (Redis, Vercel KV, Upstash or SQLite) when the server runs on more than one instance + +## Steps + +### Step 1: Install the plugin + +```bash +npm install @frontmcp/plugin-result-handles +``` + +```typescript +import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; +import { App } from '@frontmcp/sdk'; + +@App({ + name: 'Orders', + tools: [ExportOrdersTool], + plugins: [ + ResultHandlesPlugin.init({ + thresholdBytes: 32 * 1024, // answers over 32 KB fold (default 16 KB) + ttlSeconds: 600, // a handle can be read for 10 minutes (default) + preview: { items: 5, chars: 500 }, // the default + storage: { type: 'redis', redis: { url: process.env.REDIS_URL } }, // default { type: 'auto' } + }), + ], +}) +class OrdersApp {} +``` + +On an app, the plugin folds that app's tools; on `@FrontMcp({ plugins })`, every app's. It adds the `result_query` +tool. `storage` goes to `createStorage()` from `@frontmcp/utils`; `{ type: 'sqlite', sqlite: { path } }` needs +`@frontmcp/storage-sqlite`. + +### Step 2: Let tools with an output schema fold + +A client checks `structuredContent` against the tool's listed `outputSchema`, so a tool with one folds only when its +schema admits the folded form. Make `foldedAnswerSchema` a member of a union: + +```typescript +import { foldedAnswerSchema } from '@frontmcp/plugin-result-handles'; +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +const reportSchema = z.object({ orders: z.array(orderSchema), total: z.number() }); + +@Tool({ + name: 'export_orders', + inputSchema: { status: z.string() }, + outputSchema: z.union([reportSchema, foldedAnswerSchema]), +}) +class ExportOrdersTool extends ToolContext { + async execute({ status }: { status: string }) { + const orders = await this.get(OrdersService).list(status); + return { orders, total: orders.length }; + } +} +``` + +A tool with no output schema always admits it. A JSON Schema output schema (an OpenAPI or remote tool's) must validate +it. A tool whose schema does not admit it is sent whole, and the plugin logs one warning naming the tool. + +### Step 3: Choose what folds (optional) + +```typescript +ResultHandlesPlugin.init({ + foldPolicy: (toolName, answer, { bytes, fullName, request }) => toolName !== 'download_invoice', +}); +``` + +The policy is asked only for answers over the threshold. `answer` is the answer's `structuredContent`, or its text. + +## What the Model Sees + +```json +{ + "preview": { "orders": [{ "id": 0, "status": "open" }], "total": 500 }, + "shape": { + "type": "object", + "keys": { + "orders": { + "type": "array", + "length": 500, + "items": { "type": "object", "keys": { "id": { "type": "number" } } } + }, + "total": { "type": "number" } + } + }, + "handle": "3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60" +} +``` + +This is the answer's `structuredContent` and its text, followed by a text line naming `result_query`, the handle and +the TTL. `_meta['frontmcp/result-handle']` holds `{ handle, bytes, ttlSeconds }`. The preview keeps the first `items` +of each list, the first `chars` of each string and the first 50 keys of each object; the shape lists the keys, types, +list lengths and string lengths, and a list's first item. + +`result_query` arguments: + +| Argument | Meaning | +| -------- | ----------------------------------------------------------------------------------------------------- | +| `handle` | The folded answer's handle (required) | +| `path` | `$.orders[2].note`, `orders.2.note` or the JSON pointer `/orders/2/note`; the whole answer by default | +| `offset` | First list item, or first character of a string | +| `limit` | List items (default 20) or characters (default 2000) | +| `page` | A page of `limit` items, from 1, in place of `offset` | +| `fields` | Keep only these keys of the object, or of each object of the list | + +It answers `{ path, value, total?, offset?, nextOffset? }`. + +## Common Patterns + +| Pattern | Correct | Incorrect | Why | +| --------------------------- | ------------------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------- | +| Output schema of a big tool | `z.union([reportSchema, foldedAnswerSchema])` | `reportSchema` alone | A schema without the folded form never folds; the answer is sent whole | +| Several instances | `storage: { type: 'redis', redis: { url } }` | `storage: { type: 'memory' }` | A handle saved on one instance is "not found" on another | +| With an answer budget | `output.budget` above `thresholdBytes` | A budget below the threshold | The plugin folds first; a budget below the folded size cuts the preview | +| Reading back in a tool | `this.callTool('export_orders')` gets the whole answer | Expecting a handle from `this.callTool()` | Only a client's call folds | + +## Verification Checklist + +### Configuration + +- [ ] `thresholdBytes` is at least 1024 and `ttlSeconds` at least 1 (otherwise `init()` throws `ResultHandlesConfigurationError`) +- [ ] Tools with an output schema have `foldedAnswerSchema` in a union +- [ ] A shared `storage` when the server runs on several instances + +### Runtime + +- [ ] A large answer comes back with `structuredContent.handle` and `_meta['frontmcp/result-handle']` +- [ ] `result_query` with that handle reads a slice +- [ ] Another session gets `RESULT_HANDLE_NOT_FOUND` + +## Troubleshooting + +| Problem | Cause | Solution | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Warning: "does not admit the folded form" | The tool's output schema rejects `{ preview, shape, handle }` | Add `foldedAnswerSchema` to the schema as a union member | +| A large answer is not folded | Not text only, an error result, a widget answer, an in-process call, or a stateless anonymous call | Fold only applies to a client's text answer with a session or a signed-in caller | +| `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, or another instance with memory storage | Call the tool again; use a shared store across instances | +| `RESULT_SLICE_TOO_LARGE` | The slice is over `thresholdBytes` | Read fewer items, fewer `fields`, or a deeper `path` | +| `RESULT_PATH_NOT_FOUND` | The path is malformed or names nothing | Follow the keys the `shape` lists | + +## Reference + +- [Result handles plugin](https://frontmcp.dev/reference/plugins/result-handles) +- Related skills: `official-plugins`, `create-plugin-hooks` (the `fitAnswer` stage), `decorators-guide` (`output.budget`) diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index 952658064..50b6d42f8 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -2274,7 +2274,7 @@ }, { "name": "official-plugins", - "description": "Guide to the 7 official plugins for discovery, memory, auth, caching, flags, monitoring, and WebMCP", + "description": "Guide to the 8 official plugins for discovery, memory, auth, caching, flags, monitoring, WebMCP, and large answers", "examples": [ { "name": "cache-and-feature-flags", @@ -2391,6 +2391,11 @@ ] } ] + }, + { + "name": "result-handles-plugin", + "description": "Fold large tool answers into a preview, a shape and a handle, and let the model read slices with result_query", + "examples": [] } ] }, diff --git a/plugins/plugin-result-handles/README.md b/plugins/plugin-result-handles/README.md new file mode 100644 index 000000000..45403ad32 --- /dev/null +++ b/plugins/plugin-result-handles/README.md @@ -0,0 +1,170 @@ +# @frontmcp/plugin-result-handles + +A FrontMCP plugin for tools whose answers are too large to send whole. An answer over a size threshold is kept on the +server, and the model gets a preview, a shape and a handle in its place. It then reads only the parts it needs with the +`result_query` tool: a path, a range or a page, and the fields it wants. A handle works only for the session and the +caller it was issued to, and only until its TTL runs out. + +[![npm](https://img.shields.io/npm/v/@frontmcp/plugin-result-handles.svg)](https://www.npmjs.com/package/@frontmcp/plugin-result-handles) [![License](https://img.shields.io/npm/l/@frontmcp/plugin-result-handles.svg)](https://github.com/agentfront/frontmcp/blob/main/LICENSE) + +## Install + +```bash +npm install @frontmcp/plugin-result-handles @frontmcp/sdk +``` + +Requires Node.js 24 or later. Keep `@frontmcp/sdk` on the same version as this package. + +## Usage + +Register the plugin on the app whose answers it should fold, or on `@FrontMcp({ plugins })` for every app: + +```ts +import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; +import { App } from '@frontmcp/sdk'; + +@App({ + id: 'orders', + name: 'Orders', + tools: [ExportOrdersTool], + plugins: [ResultHandlesPlugin.init({ thresholdBytes: 32 * 1024, preview: { items: 2 } })], +}) +export class OrdersApp {} +``` + +An answer of 500 orders then reaches the client as: + +```json +{ + "preview": { + "orders": [ + { "id": 0, "status": "open" }, + { "id": 1, "status": "shipped" } + ], + "total": 500 + }, + "shape": { + "type": "object", + "keys": { + "orders": { + "type": "array", + "length": 500, + "items": { + "type": "object", + "keys": { "id": { "type": "number" }, "status": { "type": "string", "length": 4 } } + } + }, + "total": { "type": "number" } + } + }, + "handle": "3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60" +} +``` + +as `structuredContent` and as its text, followed by a line telling the model how to read the rest. The result's +`_meta['frontmcp/result-handle']` holds `{ handle, bytes, ttlSeconds }`. + +## What folds + +The plugin runs on the `fitAnswer` stage of `tools/call`, before the answer budget (`output.budget`), so the budget +sees the folded answer. An answer folds when all of these hold: + +- a client called the tool: a tool calling another with `this.callTool()` gets the whole answer; +- the answer is text only: `structuredContent`, or one text content. Images, audio, resources, several texts, error + results and answers that render a widget are left alone; +- its data, `structuredContent` or the text, is larger than `thresholdBytes` as UTF-8 JSON; +- the call has a verified session or an authenticated caller. A stateless anonymous call is left alone, since nobody + could read its handle back; +- `foldPolicy(toolName, answer, context)` returns `true`; +- the tool's output schema admits the folded form (below). + +## Tools with an output schema + +A client checks `structuredContent` against the `outputSchema` it was listed, so a tool with an output schema folds +only when that schema admits `{ preview, shape, handle }`. Add `foldedAnswerSchema` to it as a union member: + +```ts +import { foldedAnswerSchema } from '@frontmcp/plugin-result-handles'; + +const reportSchema = z.object({ orders: z.array(orderSchema), total: z.number() }); + +@Tool({ name: 'export_orders', inputSchema: {}, outputSchema: z.union([reportSchema, foldedAnswerSchema]) }) +export class ExportOrdersTool extends ToolContext { + /* … */ +} +``` + +A Zod schema must accept the folded form with no undeclared key; a JSON Schema (an OpenAPI or remote tool's) must +validate it. A tool whose schema doesn't admit it is sent whole, and the plugin logs a warning naming the tool once. + +## `result_query` + +| Argument | Description | +| -------- | -------------------------------------------------------------------------------------------------------------------- | +| `handle` | The handle of the folded answer. Required. | +| `path` | A dot path (`$.orders[2].note`, `orders.2.note`) or a JSON pointer (`/orders/2/note`). The whole answer if left out. | +| `offset` | Of a list, the first item to read; of a string, the first character. | +| `limit` | How many items (20 by default) or characters (2000 by default) to read. | +| `page` | A page of `limit` items, from 1, in place of `offset`. | +| `fields` | Keep only these keys of the object, or of each object in the list. | + +It answers `{ path, value, total?, offset?, nextOffset? }`: `total` is the length of the list or string, and +`nextOffset` is set while there is more to read. Its errors carry a remedy for the model: + +| Code | When | +| ------------------------- | ------------------------------------------------------------------------------------------- | +| `RESULT_HANDLE_NOT_FOUND` | The handle expired, was never issued, or belongs to another session or caller. | +| `RESULT_PATH_NOT_FOUND` | The path isn't a dot path or a JSON pointer, or names nothing in the answer. | +| `RESULT_SLICE_TOO_LARGE` | The slice is larger than `thresholdBytes`: read fewer items, fewer fields or a deeper path. | +| `INVALID_INPUT` | Both `page` and `offset` were given. | + +## Who can read a handle + +A handle is a random UUID, never derived from the answer. It's bound to the call's verified session and, unless the +caller is anonymous, to its authenticated subject. A different session or a different caller gets +`RESULT_HANDLE_NOT_FOUND`, the same answer as for a handle that never existed, and never the data. + +## Options + +| Option | Default | Description | +| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `thresholdBytes` | `16384` | Answers larger than this fold, and no `result_query` slice is larger. At least 1024. | +| `ttlSeconds` | `600` | How long a folded answer can be read. | +| `preview` | `{ items: 5, chars: 500 }` | The first `items` of each list and `chars` of each string the preview keeps. | +| `foldPolicy` | every answer | `(toolName, answer, { bytes, fullName, request }) => boolean`: which answers over the threshold fold. | +| `storage` | `{ type: 'auto' }` | Where folded answers are kept, passed to `createStorage()` from `@frontmcp/utils`: memory, Redis, Vercel KV, Upstash or SQLite. | +| `namespace` | `'result-handles'` | The key prefix of folded answers in the storage. | + +Invalid options throw a `ResultHandlesConfigurationError` from `init()`, naming each problem. + +With `storage: { type: 'auto' }` the store is Redis, Vercel KV or Upstash when their environment variables are set, +and memory otherwise. Memory keeps answers in one process, so use a shared store when the server runs on several +instances: + +```ts +ResultHandlesPlugin.init({ storage: { type: 'redis', redis: { url: process.env.REDIS_URL } } }); +ResultHandlesPlugin.init({ storage: { type: 'sqlite', sqlite: { path: '/var/lib/my-server/results.sqlite' } } }); +``` + +SQLite needs `@frontmcp/storage-sqlite`. + +## Docs + +- [Result handles plugin reference](https://frontmcp.dev/reference/plugins/result-handles) +- [Plugins and adapters](https://frontmcp.dev/reference/plugins): where to register a plugin + +## Related packages + +- [`@frontmcp/sdk`](https://www.npmjs.com/package/@frontmcp/sdk): the framework this plugin extends, and the answer + budget (`output.budget`) that runs after it +- [`@frontmcp/utils`](https://www.npmjs.com/package/@frontmcp/utils): `createStorage()`, which builds the store + +## Contributing + +Source: [`plugins/plugin-result-handles`](https://github.com/agentfront/frontmcp/tree/main/plugins/plugin-result-handles). +Issues and pull requests are welcome; see +[CONTRIBUTING.md](https://github.com/agentfront/frontmcp/blob/main/CONTRIBUTING.md). + +## License + +Apache-2.0. See [LICENSE](https://github.com/agentfront/frontmcp/blob/main/LICENSE). diff --git a/plugins/plugin-result-handles/jest.config.ts b/plugins/plugin-result-handles/jest.config.ts new file mode 100644 index 000000000..3538e2e1a --- /dev/null +++ b/plugins/plugin-result-handles/jest.config.ts @@ -0,0 +1,51 @@ +module.exports = { + displayName: 'plugin-result-handles', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transformIgnorePatterns: ['node_modules/(?!(jose|@noble/hashes|@noble/ciphers)/)'], + moduleNameMapper: { + ...require('../../jest.imports-mapper'), + '^@frontmcp/sdk$': '/../../libs/sdk/src/index.ts', + '^@frontmcp/utils$': '/../../libs/utils/src/index.ts', + }, + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + dynamicImport: true, + decorators: true, + }, + transform: { + decoratorMetadata: true, + legacyDecorator: true, + }, + keepClassNames: true, + externalHelpers: true, + loose: true, + }, + module: { + type: 'es6', + }, + sourceMaps: true, + swcrc: false, + }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/unit/plugin-result-handles', + coverageReporters: ['text', 'lcov'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'], + coverageThreshold: { + global: { + branches: 90, + functions: 95, + lines: 95, + statements: 95, + }, + }, + setupFilesAfterEnv: ['reflect-metadata'], +}; diff --git a/plugins/plugin-result-handles/package.json b/plugins/plugin-result-handles/package.json new file mode 100644 index 000000000..247a0be27 --- /dev/null +++ b/plugins/plugin-result-handles/package.json @@ -0,0 +1,63 @@ +{ + "name": "@frontmcp/plugin-result-handles", + "version": "1.10.16", + "description": "Result handles plugin for FrontMCP: folds large tool answers into a preview, a shape and a handle, and lets the model read the rest with result_query", + "author": "AgentFront ", + "license": "Apache-2.0", + "engines": { + "node": ">=24.0.0" + }, + "keywords": [ + "frontmcp", + "mcp", + "model-context-protocol", + "result-handles", + "large-results", + "pagination", + "plugin", + "agentfront" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/agentfront/frontmcp.git", + "directory": "plugins/plugin-result-handles" + }, + "bugs": { + "url": "https://github.com/agentfront/frontmcp/issues" + }, + "homepage": "https://frontmcp.dev/reference/plugins/result-handles", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "type": "commonjs", + "main": "./dist/index.js", + "module": "./dist/esm/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/esm/index.mjs" + } + } + }, + "dependencies": { + "@frontmcp/utils": "1.10.16" + }, + "peerDependencies": { + "@frontmcp/lazy-zod": "1.10.16", + "@frontmcp/sdk": "1.10.16" + }, + "devDependencies": { + "@frontmcp/sdk": "1.10.16", + "reflect-metadata": "^0.2.2" + } +} diff --git a/plugins/plugin-result-handles/project.json b/plugins/plugin-result-handles/project.json new file mode 100644 index 000000000..25b234de2 --- /dev/null +++ b/plugins/plugin-result-handles/project.json @@ -0,0 +1,95 @@ +{ + "name": "plugin-result-handles", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "plugins/plugin-result-handles/src", + "projectType": "library", + "tags": ["scope:plugins", "scope:publishable", "versioning:synchronized"], + "targets": { + "build-cjs": { + "executor": "@nx/esbuild:esbuild", + "dependsOn": ["^build"], + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "plugins/plugin-result-handles/dist", + "main": "plugins/plugin-result-handles/src/index.ts", + "tsConfig": "plugins/plugin-result-handles/tsconfig.lib.json", + "format": ["cjs"], + "declaration": true, + "declarationRootDir": "plugins/plugin-result-handles/src", + "bundle": true, + "thirdParty": false, + "platform": "node", + "assets": [ + "plugins/plugin-result-handles/README.md", + "plugins/plugin-result-handles/CHANGELOG.md", + "LICENSE", + "plugins/plugin-result-handles/package.json" + ], + "esbuildOptions": { + "minifyWhitespace": true, + "outExtension": { + ".js": ".js" + }, + "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "@frontmcp/utils", "zod", "reflect-metadata"] + } + } + }, + "build-esm": { + "executor": "@nx/esbuild:esbuild", + "dependsOn": ["build-cjs"], + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "plugins/plugin-result-handles/dist/esm", + "main": "plugins/plugin-result-handles/src/index.ts", + "tsConfig": "plugins/plugin-result-handles/tsconfig.lib.json", + "format": ["esm"], + "declaration": false, + "bundle": true, + "thirdParty": false, + "platform": "node", + "esbuildOptions": { + "minifyWhitespace": true, + "outExtension": { + ".js": ".mjs" + }, + "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "@frontmcp/utils", "zod", "reflect-metadata"] + } + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": ["build-cjs", "build-esm"], + "options": { + "commands": [ + "node scripts/strip-dist-from-pkg.js plugins/plugin-result-handles/dist/package.json", + "node scripts/inject-esm-require-banner.js plugins/plugin-result-handles/dist/esm", + "node scripts/escape-non-ascii.mjs plugins/plugin-result-handles/dist" + ], + "parallel": false + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/unit/plugin-result-handles"], + "options": { + "jestConfig": "plugins/plugin-result-handles/jest.config.ts", + "passWithNoTests": true + } + }, + "publish": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "npm publish plugins/plugin-result-handles/dist --access public --registry=https://registry.npmjs.org/" + } + }, + "publish-alpha": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "bash scripts/publish-alpha.sh {projectRoot}/dist", + "cwd": "{workspaceRoot}" + } + } + } +} diff --git a/plugins/plugin-result-handles/src/__tests__/folded-answer.spec.ts b/plugins/plugin-result-handles/src/__tests__/folded-answer.spec.ts new file mode 100644 index 000000000..8e6265e16 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/folded-answer.spec.ts @@ -0,0 +1,102 @@ +import { z } from '@frontmcp/lazy-zod'; + +import { foldedAnswerSchema, previewOf, shapeOf } from '../folded-answer'; + +const limits = { items: 3, chars: 10 }; + +function nested(depth: number): unknown { + return depth === 0 ? 'leaf' : { next: nested(depth - 1) }; +} + +describe('previewOf', () => { + it('keeps the first list items and the first characters of strings, at every level', () => { + const answer = { + rows: Array.from({ length: 10 }, (_, id) => ({ id, body: `row ${id} `.padEnd(40, '.') })), + title: 'Quarterly revenue report', + total: 10, + final: true, + note: null, + }; + + expect(previewOf(answer, limits)).toEqual({ + rows: [ + { id: 0, body: 'row 0 ....' }, + { id: 1, body: 'row 1 ....' }, + { id: 2, body: 'row 2 ....' }, + ], + title: 'Quarterly ', + total: 10, + final: true, + note: null, + }); + }); + + it('leaves short lists and strings whole', () => { + expect(previewOf(['a', 'b'], limits)).toEqual(['a', 'b']); + expect(previewOf('short', limits)).toBe('short'); + }); + + it('never splits a surrogate pair', () => { + expect(previewOf('abcdefghi\u{1F600}xyz', limits)).toBe('abcdefghi'); + }); + + it('keeps the first 50 keys of an object', () => { + const wide = Object.fromEntries(Array.from({ length: 60 }, (_, index) => [`key${index}`, index])); + const preview = previewOf(wide, limits) as Record; + expect(Object.keys(preview)).toHaveLength(50); + expect(preview['key49']).toBe(49); + }); + + it('empties lists and objects nested deeper than 8 levels', () => { + expect(JSON.stringify(previewOf(nested(10), limits))).toBe( + '{"next":{"next":{"next":{"next":{"next":{"next":{"next":{"next":{}}}}}}}}}', + ); + expect(previewOf([[[[[[[[[1]]]]]]]]], limits)).toEqual([[[[[[[[[]]]]]]]]]); + }); +}); + +describe('shapeOf', () => { + it('describes keys, types and list and string lengths', () => { + const answer = { rows: [{ id: 1, title: 'ab' }], total: 1, missing: null, flag: true, empty: [] }; + + expect(shapeOf(answer)).toEqual({ + type: 'object', + keys: { + rows: { + type: 'array', + length: 1, + items: { type: 'object', keys: { id: { type: 'number' }, title: { type: 'string', length: 2 } } }, + }, + total: { type: 'number' }, + missing: { type: 'null' }, + flag: { type: 'boolean' }, + empty: { type: 'array', length: 0 }, + }, + }); + }); + + it('lists the first 50 keys and counts the rest', () => { + const wide = Object.fromEntries(Array.from({ length: 60 }, (_, index) => [`key${index}`, index])); + const shape = shapeOf(wide); + expect(Object.keys(shape.keys ?? {})).toHaveLength(50); + expect(shape.moreKeys).toBe(10); + }); + + it('stops describing below 8 levels', () => { + let shape = shapeOf(nested(10)); + for (let level = 0; level < 8; level += 1) shape = shape.keys?.['next'] ?? { type: 'null' }; + expect(shape).toEqual({ type: 'object' }); + expect(JSON.stringify(shapeOf([[[[[[[[[1]]]]]]]]]))).toContain('{"type":"array","length":1}'); + }); +}); + +describe('foldedAnswerSchema', () => { + it('admits a folded answer as a member of a union output schema', () => { + const report = z.object({ rows: z.array(z.number()), total: z.number() }); + const foldable = z.union([report, foldedAnswerSchema]); + const folded = { preview: { rows: [1] }, shape: { type: 'object' }, handle: 'b9f2' }; + + expect(foldable.parse(folded)).toEqual(folded); + expect(report.safeParse(folded).success).toBe(false); + }); +}); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts new file mode 100644 index 000000000..2d791646f --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts @@ -0,0 +1,89 @@ +import { createMemoryStorage } from '@frontmcp/utils'; + +import { createResultHandleStore } from '../result-handle-store.factory'; +import { ResultHandleStore, type ResultOwner } from '../result-handle.store'; + +const alice = { session: 'session-1', caller: 'alice' }; +const answer = { rows: [1, 2, 3] }; + +describe('ResultHandleStore', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + async function memoryStore(ttlSeconds = 60) { + return createResultHandleStore({ storage: { type: 'memory' }, namespace: 'result-handles', ttlSeconds }); + } + + async function saved(store: ResultHandleStore, owner: ResultOwner): Promise { + const handle = store.newHandle(); + await store.save(handle, answer, owner); + return handle; + } + + it('saves an answer under a random handle that only its owner reads back', async () => { + const store = await memoryStore(); + const handle = await saved(store, alice); + + expect(handle).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(store.newHandle()).not.toBe(handle); + expect(await store.read(handle, alice)).toEqual({ value: answer }); + }); + + it.each([ + ['another session', { session: 'session-2', caller: 'alice' }], + ['another caller', { session: 'session-1', caller: 'mallory' }], + ['an anonymous caller', { session: 'session-1' }], + ['no session', { caller: 'alice' }], + ])('reads nothing for %s', async (_, owner) => { + const store = await memoryStore(); + const handle = await saved(store, alice); + expect(await store.read(handle, owner)).toBeUndefined(); + }); + + it('binds an answer to the caller alone when the call had no session', async () => { + const store = await memoryStore(); + const handle = await saved(store, { caller: 'alice' }); + expect(await store.read(handle, { caller: 'alice' })).toEqual({ value: answer }); + expect(await store.read(handle, { caller: 'bob' })).toBeUndefined(); + }); + + it('reads nothing for an unknown or malformed handle', async () => { + const store = await memoryStore(); + expect(await store.read('3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60', alice)).toBeUndefined(); + expect(await store.read('*', alice)).toBeUndefined(); + }); + + it('forgets an answer after its TTL', async () => { + jest.useFakeTimers({ now: new Date('2026-11-02T10:00:00Z') }); + const store = await memoryStore(2); + const handle = await saved(store, alice); + + jest.advanceTimersByTime(1000); + expect(await store.read(handle, alice)).toEqual({ value: answer }); + jest.advanceTimersByTime(1500); + expect(await store.read(handle, alice)).toBeUndefined(); + }); + + it('reads nothing from a key that does not hold a stored answer', async () => { + const root = createMemoryStorage(); + await root.connect(); + const storage = root.namespace('result-handles'); + const store = new ResultHandleStore(storage, 60); + const handle = '3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60'; + + await storage.set(handle, 'not json'); + expect(await store.read(handle, alice)).toBeUndefined(); + await storage.set(handle, JSON.stringify({ owner: 'alice', value: 1 })); + expect(await store.read(handle, alice)).toBeUndefined(); + }); + + it('disconnects its storage at shutdown', async () => { + const storage = createMemoryStorage(); + const disconnect = jest.spyOn(storage.root, 'disconnect'); + const store = new ResultHandleStore(storage.namespace('result-handles'), 60); + + await store.onShutdown(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts new file mode 100644 index 000000000..1568681e0 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts @@ -0,0 +1,57 @@ +import { ResultHandlesConfigurationError } from '../result-handles.errors'; +import { parseResultHandlesOptions } from '../result-handles.options'; +import ResultHandlesPlugin from '../result-handles.plugin'; + +describe('parseResultHandlesOptions', () => { + it('fills in the defaults', () => { + const options = parseResultHandlesOptions({}); + expect(options).toMatchObject({ + thresholdBytes: 16 * 1024, + ttlSeconds: 600, + preview: { items: 5, chars: 500 }, + storage: { type: 'auto' }, + namespace: 'result-handles', + }); + expect(options.foldPolicy('any_tool', {}, { bytes: 1, fullName: 'app:any_tool', request: {} as never })).toBe(true); + }); + + it('keeps the options it is given', () => { + const foldPolicy = () => false; + const options = parseResultHandlesOptions({ + thresholdBytes: 4096, + ttlSeconds: 30, + preview: { items: 2 }, + foldPolicy, + storage: { type: 'memory' }, + namespace: 'answers', + }); + expect(options).toEqual({ + thresholdBytes: 4096, + ttlSeconds: 30, + preview: { items: 2, chars: 500 }, + foldPolicy, + storage: { type: 'memory' }, + namespace: 'answers', + }); + }); + + it.each([ + [{ thresholdBytes: 100 }, 'thresholdBytes: must be a whole number of at least 1024'], + [{ thresholdBytes: 2048.5 }, 'thresholdBytes: must be a whole number of at least 1024'], + [{ ttlSeconds: 0 }, 'ttlSeconds: must be a whole number of seconds, at least 1'], + [{ preview: { items: 0 } }, 'preview.items: must be a whole number, at least 1'], + [{ preview: { chars: -1 } }, 'preview.chars: must be a whole number, at least 1'], + [{ foldPolicy: 'always' }, 'foldPolicy: must be a function (toolName, answer, context) => boolean'], + [{ storage: 'memory' }, 'storage: must be a storage config, such as { type: "redis", redis: { url } }'], + [{ namespace: '' }, 'namespace: must not be empty'], + [{ tresholdBytes: 4096 }, 'Unrecognized key: "tresholdBytes"'], + ])('refuses %j', (input, message) => { + expect(() => parseResultHandlesOptions(input as never)).toThrow(ResultHandlesConfigurationError); + expect(() => parseResultHandlesOptions(input as never)).toThrow(message); + }); + + it('refuses invalid options when the plugin is created', () => { + expect(() => ResultHandlesPlugin.init({ thresholdBytes: 10 })).toThrow(ResultHandlesConfigurationError); + expect(() => new ResultHandlesPlugin({ ttlSeconds: -5 })).toThrow(ResultHandlesConfigurationError); + }); +}); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts new file mode 100644 index 000000000..47a91f59f --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -0,0 +1,430 @@ +import 'reflect-metadata'; + +import { z } from '@frontmcp/lazy-zod'; +import { + App, + connect, + LogLevel, + LogTransport, + LogTransportInterface, + tool, + Tool, + ToolContext, + type CallToolResult, + type DirectClient, + type FlowCtxOf, + type LogRecord, +} from '@frontmcp/sdk'; + +import { foldedAnswerSchema, type FoldedAnswer } from '../folded-answer'; +import { RESULT_HANDLE_META_KEY, type FoldContext } from '../result-handles.options'; +import ResultHandlesPlugin from '../result-handles.plugin'; + +const warnings: string[] = []; + +@LogTransport({ name: 'ResultHandlesWarnings', description: 'Keeps warnings for assertions' }) +class WarningCapture extends LogTransportInterface { + log(record: LogRecord): void { + if (record.level === LogLevel.Warn) warnings.push(record.message); + } +} + +const rowSchema = z.object({ id: z.number(), title: z.string(), body: z.string() }); + +function rows(count: number) { + return Array.from({ length: count }, (_, id) => ({ + id, + title: `Row ${id}`, + body: `Body of row ${id} `.padEnd(200, '.'), + })); +} + +const bytesOf = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).byteLength; + +@Tool({ name: 'export_rows', inputSchema: { count: z.number() } }) +class ExportRowsTool extends ToolContext { + async execute({ count }: { count: number }) { + return { rows: rows(count), total: count }; + } +} + +@Tool({ name: 'unfolded_rows', inputSchema: { count: z.number() } }) +class UnfoldedRowsTool extends ExportRowsTool {} + +@Tool({ + name: 'typed_rows', + inputSchema: { count: z.number() }, + outputSchema: z.union([z.object({ rows: z.array(rowSchema), total: z.number() }), foldedAnswerSchema]), +}) +class TypedRowsTool extends ExportRowsTool {} + +@Tool({ + name: 'strict_rows', + inputSchema: { count: z.number() }, + outputSchema: { rows: z.array(rowSchema), total: z.number() }, +}) +class StrictRowsTool extends ExportRowsTool {} + +@Tool({ name: 'long_text', inputSchema: {} }) +class LongTextTool extends ToolContext { + async execute() { + return { content: [{ type: 'text', text: '[lorem] ipsum '.repeat(1000) }] }; + } +} + +@Tool({ name: 'rows_text', inputSchema: {} }) +class RowsTextTool extends ToolContext { + async execute() { + return { content: [{ type: 'text', text: JSON.stringify(rows(100)) }] }; + } +} + +@Tool({ name: 'two_texts', inputSchema: {} }) +class TwoTextsTool extends ToolContext { + async execute() { + const text = JSON.stringify(rows(50)); + return { + content: [ + { type: 'text', text }, + { type: 'text', text }, + ], + }; + } +} + +@Tool({ name: 'chart', inputSchema: {} }) +class ChartTool extends ToolContext { + async execute() { + return { content: [{ type: 'image', data: 'a'.repeat(10_000), mimeType: 'image/png' }] }; + } +} + +@Tool({ name: 'failing_rows', inputSchema: {} }) +class FailingRowsTool extends ToolContext { + async execute() { + return { content: [{ type: 'text', text: JSON.stringify(rows(100)) }], isError: true }; + } +} + +@Tool({ name: 'row_count', inputSchema: {} }) +class RowCountTool extends ToolContext { + async execute() { + const inner = await this.callTool('export_rows', { count: 100 }); + const answer = inner.structuredContent as { rows?: unknown[]; handle?: string }; + return { folded: answer.handle !== undefined, rows: answer.rows?.length }; + } +} + +/** A tool with a JSON Schema `outputSchema`, as the OpenAPI adapter and remote apps build them. */ +function apiTool(name: string, rawOutputSchema: Record) { + const metadata = { name, inputSchema: {}, rawOutputSchema }; + return tool(metadata as unknown as Parameters[0])(async () => ({ data: rows(100) })); +} + +const OpenApiRowsTool = apiTool('open_api_rows', { type: 'object', properties: { data: {} } }); +const ClosedApiRowsTool = apiTool('closed_api_rows', { + type: 'object', + properties: { data: {} }, + additionalProperties: false, +}); +const BrokenApiRowsTool = apiTool('broken_api_rows', { type: 'object', $ref: '#/$defs/missing' }); + +const foldPolicy = jest.fn((toolName: string, _answer: unknown, _context: FoldContext) => toolName !== 'unfolded_rows'); + +@App({ + id: 'exports', + name: 'Exports', + tools: [ + ExportRowsTool, + UnfoldedRowsTool, + TypedRowsTool, + StrictRowsTool, + LongTextTool, + RowsTextTool, + TwoTextsTool, + ChartTool, + FailingRowsTool, + RowCountTool, + OpenApiRowsTool, + ClosedApiRowsTool, + BrokenApiRowsTool, + ], + plugins: [ + ResultHandlesPlugin.init({ + thresholdBytes: 4096, + ttlSeconds: 60, + preview: { items: 2, chars: 40 }, + storage: { type: 'memory' }, + foldPolicy, + }), + ], +}) +class ExportsApp {} + +const serverConfig = { + info: { name: 'result-handles', version: '1.0.0' }, + apps: [ExportsApp], + logging: { level: LogLevel.Warn, enableConsole: false, transports: [WarningCapture] }, +}; + +const clients: DirectClient[] = []; + +async function open(session?: { id: string; user?: { sub: string } }): Promise { + const client = await connect(serverConfig, session && { session }); + clients.push(client); + return client; +} + +async function call(client: DirectClient, name: string, args: Record = {}) { + return (await client.callTool(name, args)) as CallToolResult; +} + +function textAt(result: CallToolResult, index: number): string { + const block = result.content[index]; + return block?.type === 'text' ? block.text : ''; +} + +describe('ResultHandlesPlugin', () => { + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); + warnings.length = 0; + foldPolicy.mockClear(); + jest.restoreAllMocks(); + }); + + describe('folding', () => { + it('folds a large answer into a preview, a shape and a handle', async () => { + const client = await open(); + const result = await call(client, 'export_rows', { count: 200 }); + const folded = result.structuredContent as unknown as FoldedAnswer; + const bytes = bytesOf({ rows: rows(200), total: 200 }); + + expect(folded.preview).toEqual({ + rows: [ + { id: 0, title: 'Row 0', body: 'Body of row 0 ..........................' }, + { id: 1, title: 'Row 1', body: 'Body of row 1 ..........................' }, + ], + total: 200, + }); + expect(folded.shape).toEqual({ + type: 'object', + keys: { + rows: { + type: 'array', + length: 200, + items: { + type: 'object', + keys: { + id: { type: 'number' }, + title: { type: 'string', length: 5 }, + body: { type: 'string', length: 200 }, + }, + }, + }, + total: { type: 'number' }, + }, + }); + expect(folded.handle).toMatch(/^[0-9a-f-]{36}$/); + expect(JSON.parse(textAt(result, 0))).toEqual(folded); + expect(textAt(result, 1)).toBe( + `This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. ` + + `Read the rest with result_query and handle "${folded.handle}" ` + + '(a path, then offset and limit or a page, and fields) within 60 seconds.', + ); + expect(result._meta?.[RESULT_HANDLE_META_KEY]).toEqual({ handle: folded.handle, bytes, ttlSeconds: 60 }); + }); + + it('leaves an answer within the threshold alone', async () => { + const client = await open(); + const result = await call(client, 'export_rows', { count: 2 }); + expect(result.structuredContent).toEqual({ rows: rows(2), total: 2 }); + expect(result._meta?.[RESULT_HANDLE_META_KEY]).toBeUndefined(); + expect(foldPolicy).not.toHaveBeenCalled(); + }); + + it('asks the fold policy with the tool name, the answer and the context', async () => { + const client = await open({ id: 'policy-session' }); + const unfolded = await call(client, 'unfolded_rows', { count: 100 }); + await call(client, 'export_rows', { count: 100 }); + + expect(unfolded.structuredContent).toEqual({ rows: rows(100), total: 100 }); + expect(foldPolicy).toHaveBeenCalledTimes(2); + const [toolName, answer, context] = foldPolicy.mock.calls[1]; + expect(toolName).toBe('export_rows'); + expect(answer).toEqual({ rows: rows(100), total: 100 }); + expect(context).toMatchObject({ bytes: bytesOf(answer), fullName: 'exports:export_rows' }); + expect(context.request.sessionId).toBe('policy-session'); + }); + + it('folds an answer of one text, its preview the first characters', async () => { + const client = await open(); + const result = await call(client, 'long_text'); + const folded = result.structuredContent as unknown as FoldedAnswer; + expect(folded.preview).toBe('[lorem] ipsum [lorem] ipsum [lorem] ipsu'); + expect(folded.shape).toEqual({ type: 'string', length: 14_000 }); + }); + + it('folds an answer of one JSON text as JSON', async () => { + const client = await open(); + const result = await call(client, 'rows_text'); + const folded = result.structuredContent as unknown as FoldedAnswer; + expect(folded.preview).toEqual(rows(2).map((row) => ({ ...row, body: row.body.slice(0, 40) }))); + expect(folded.shape).toMatchObject({ type: 'array', length: 100 }); + }); + + it('folds a tool whose output schema admits the folded form', async () => { + const client = await open(); + const result = await call(client, 'typed_rows', { count: 200 }); + expect(Object.keys(result.structuredContent ?? {})).toEqual(['preview', 'shape', 'handle']); + }); + + it('leaves a tool whose output schema cannot admit the folded form alone, and says so once', async () => { + const client = await open(); + const first = await call(client, 'strict_rows', { count: 200 }); + await call(client, 'strict_rows', { count: 200 }); + + expect((first.structuredContent as { rows: unknown[] }).rows).toHaveLength(200); + expect(warnings).toEqual([ + `Tool "exports:strict_rows" answered ${bytesOf({ rows: rows(200), total: 200 })} bytes, over the ` + + '4096-byte fold threshold, but its outputSchema does not admit the folded form { preview, shape, handle }, ' + + 'so its answers are sent whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.', + ]); + }); + + it('folds a tool whose JSON Schema output schema validates the folded form, and leaves the others alone', async () => { + const client = await open(); + const openSchema = await call(client, 'open_api_rows'); + const closed = await call(client, 'closed_api_rows'); + const broken = await call(client, 'broken_api_rows'); + + expect(Object.keys(openSchema.structuredContent ?? {})).toEqual(['preview', 'shape', 'handle']); + expect((closed.structuredContent as { data: unknown[] }).data).toHaveLength(100); + expect((broken.structuredContent as { data: unknown[] }).data).toHaveLength(100); + expect(warnings.map((warning) => warning.slice(0, 32))).toEqual([ + 'Tool "exports:closed_api_rows" a', + 'Tool "exports:broken_api_rows" a', + ]); + }); + + it('leaves media, several-text, error and in-process answers alone', async () => { + const client = await open(); + expect((await call(client, 'chart')).content[0]?.type).toBe('image'); + expect((await call(client, 'two_texts')).content).toHaveLength(2); + const failing = await call(client, 'failing_rows'); + expect(failing.isError).toBe(true); + expect(JSON.parse(textAt(failing, 0))).toHaveLength(100); + expect((await call(client, 'row_count')).structuredContent).toEqual({ folded: false, rows: 100 }); + }); + }); + + describe('result_query', () => { + async function foldedHandle(client: DirectClient): Promise { + const result = await call(client, 'export_rows', { count: 200 }); + return (result.structuredContent as unknown as FoldedAnswer).handle; + } + + it('reads slices of the stored answer by path, range, page and fields', async () => { + const client = await open(); + const handle = await foldedHandle(client); + + const range = await call(client, 'result_query', { handle, path: 'rows', offset: 150, limit: 2, fields: ['id'] }); + expect(range.structuredContent).toEqual({ + path: 'rows', + value: [{ id: 150 }, { id: 151 }], + total: 200, + offset: 150, + nextOffset: 152, + }); + + const page = await call(client, 'result_query', { + handle, + path: '/rows', + page: 20, + limit: 10, + fields: ['title'], + }); + expect((page.structuredContent as { value: unknown[] }).value).toHaveLength(10); + expect(page.structuredContent).toMatchObject({ offset: 190 }); + + const body = await call(client, 'result_query', { handle, path: '$.rows[3].body', offset: 0, limit: 14 }); + expect(body.structuredContent).toMatchObject({ value: 'Body of row 3 ', total: 200 }); + }); + + it('refuses a slice over the threshold, saying how to narrow it', async () => { + const client = await open(); + const handle = await foldedHandle(client); + + const result = await call(client, 'result_query', { handle, path: 'rows', limit: 100 }); + expect(result.isError).toBe(true); + expect(result._meta?.['code']).toBe('RESULT_SLICE_TOO_LARGE'); + expect(textAt(result, 0)).toContain('Read fewer items, fewer fields or a deeper path'); + }); + + it('answers a path with no value with RESULT_PATH_NOT_FOUND', async () => { + const client = await open(); + const handle = await foldedHandle(client); + + const result = await call(client, 'result_query', { handle, path: 'rows[999]' }); + expect(result.isError).toBe(true); + expect(result._meta?.['code']).toBe('RESULT_PATH_NOT_FOUND'); + }); + + it('answers another session with "not found", never the data', async () => { + const owner = await open({ id: 'owner-session' }); + const other = await open({ id: 'other-session' }); + const handle = await foldedHandle(owner); + + const result = await call(other, 'result_query', { handle, path: 'rows', limit: 1 }); + expect(result.isError).toBe(true); + expect(result._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); + expect(JSON.stringify(result)).not.toContain('Body of row'); + }); + + it('answers another caller in the same session with "not found"', async () => { + const alice = await open({ id: 'shared-session', user: { sub: 'alice' } }); + const mallory = await open({ id: 'shared-session', user: { sub: 'mallory' } }); + const handle = await foldedHandle(alice); + + expect((await call(alice, 'result_query', { handle, path: 'total' })).structuredContent).toMatchObject({ + value: 200, + }); + const result = await call(mallory, 'result_query', { handle, path: 'total' }); + expect(result._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); + }); + + it('answers "not found" once the handle outlives its TTL', async () => { + const client = await open(); + const handle = await foldedHandle(client); + const now = Date.now(); + jest.spyOn(Date, 'now').mockReturnValue(now + 61_000); + + const result = await call(client, 'result_query', { handle, path: 'total' }); + expect(result._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); + expect(textAt(result, 0)).toContain('Call the tool again for a new handle'); + }); + }); + + describe('a call nobody could read back', () => { + it('is left alone', async () => { + const plugin = new ResultHandlesPlugin({ thresholdBytes: 1024 }); + const output = { content: [{ type: 'text', text: JSON.stringify(rows(50)) }] }; + const respond = jest.fn(); + const flowCtx = { + state: { + tool: { fullName: 'exports:export_rows' }, + toolContext: { + tryGetContext: () => ({ verifiedSessionId: undefined }), + auth: { isAnonymous: true, user: { sub: 'anon:1' } }, + }, + output, + }, + toolName: 'export_rows', + isClientCall: () => true, + respond, + } as unknown as FlowCtxOf<'tools:call-tool'>; + + await plugin.foldAnswer(flowCtx); + expect(respond).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts new file mode 100644 index 000000000..381480262 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts @@ -0,0 +1,84 @@ +import { InvalidInputError } from '@frontmcp/sdk'; + +import { ResultPathNotFoundError } from '../result-handles.errors'; +import { parseResultPath, queryResult } from '../result-query'; + +const result = { + rows: Array.from({ length: 45 }, (_, id) => ({ id, title: `Row ${id}`, tags: ['a', 'b'] })), + log: 'x'.repeat(2500), + 'odd.key': { 'a/b': { 'c~d': 1 } }, + total: 45, +}; + +describe('parseResultPath', () => { + it.each([ + ['', []], + ['$', []], + ['rows', ['rows']], + ['$.rows[2].title', ['rows', 2, 'title']], + ['rows.2.title', ['rows', '2', 'title']], + ['$["odd.key"]["a/b"]', ['odd.key', 'a/b']], + ['/rows/2/title', ['rows', '2', 'title']], + ['/odd.key/a~1b/c~0d', ['odd.key', 'a/b', 'c~d']], + ['/', ['']], + ])('reads %j', (path, segments) => { + expect(parseResultPath(path)).toEqual(segments); + }); + + it.each(['rows..title', 'rows[', 'rows[x]', '$rows', 'rows["a]'])('refuses %j', (path) => { + expect(() => parseResultPath(path)).toThrow(ResultPathNotFoundError); + }); +}); + +describe('queryResult', () => { + it('reads the first 20 items of a list by default, with its length and the next offset', () => { + const slice = queryResult(result, { path: 'rows' }); + expect(slice.path).toBe('rows'); + expect(slice.value).toHaveLength(20); + expect(slice).toMatchObject({ total: 45, offset: 0, nextOffset: 20 }); + }); + + it('reads a range of a list', () => { + const slice = queryResult(result, { path: '$.rows', offset: 5, limit: 3 }); + expect((slice.value as Array<{ id: number }>).map((row) => row.id)).toEqual([5, 6, 7]); + expect(slice).toMatchObject({ total: 45, offset: 5, nextOffset: 8 }); + }); + + it('reads a page of limit items, the last one with no next offset', () => { + const slice = queryResult(result, { path: '/rows', page: 3, limit: 20 }); + expect((slice.value as Array<{ id: number }>).map((row) => row.id)).toEqual([40, 41, 42, 43, 44]); + expect(slice.offset).toBe(40); + expect(slice.nextOffset).toBeUndefined(); + }); + + it('refuses a page and an offset together', () => { + expect(() => queryResult(result, { path: 'rows', page: 2, offset: 4 })).toThrow(InvalidInputError); + }); + + it('keeps only the listed fields of each object in a list, and of an object', () => { + const slice = queryResult(result, { path: 'rows', limit: 2, fields: ['id', 'missing'] }); + expect(slice.value).toEqual([{ id: 0 }, { id: 1 }]); + expect(queryResult(result, { path: 'rows[1]', fields: ['title'] }).value).toEqual({ title: 'Row 1' }); + expect(queryResult(result, { path: 'rows[1].tags', fields: ['title'] }).value).toEqual(['a', 'b']); + }); + + it('reads the characters of a string by range, 2000 by default', () => { + expect(queryResult(result, { path: 'log' })).toMatchObject({ total: 2500, offset: 0, nextOffset: 2000 }); + const tail = queryResult(result, { path: 'log', offset: 2490 }); + expect(tail.value).toBe('x'.repeat(10)); + expect(tail.nextOffset).toBeUndefined(); + }); + + it('reads an object or a value whole, with the whole result at $', () => { + expect(queryResult(result, { path: '$["odd.key"]' }).value).toEqual({ 'a/b': { 'c~d': 1 } }); + expect(queryResult(result, { path: 'total' })).toEqual({ path: 'total', value: 45 }); + expect(queryResult({ a: 1 }, {})).toEqual({ path: '$', value: { a: 1 } }); + }); + + it.each(['missing', 'rows[45]', 'rows.first', 'total.value', 'rows[0].__proto__'])( + 'answers a path with no value (%j) with ResultPathNotFoundError', + (path) => { + expect(() => queryResult(result, { path })).toThrow(ResultPathNotFoundError); + }, + ); +}); diff --git a/plugins/plugin-result-handles/src/folded-answer.ts b/plugins/plugin-result-handles/src/folded-answer.ts new file mode 100644 index 000000000..177327fd5 --- /dev/null +++ b/plugins/plugin-result-handles/src/folded-answer.ts @@ -0,0 +1,93 @@ +import { z } from '@frontmcp/lazy-zod'; + +/** The keys, types and lengths of an answer, so a model knows what to ask `result_query` for. */ +export interface AnswerShape { + type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'; + /** A list's items, or a string's characters. */ + length?: number; + /** An object's first 50 keys. */ + keys?: Record; + /** The keys of an object past the first 50. */ + moreKeys?: number; + /** A list's first item. */ + items?: AnswerShape; +} + +/** What a folded answer sends in place of the answer. */ +export interface FoldedAnswer { + /** The start of the answer: the first items of each list and the first characters of each string. */ + preview: unknown; + shape: AnswerShape; + /** Reads the rest through `result_query`. */ + handle: string; +} + +/** The folded form; a tool with an output schema folds only as `z.union([schema, foldedAnswerSchema])`. */ +export const foldedAnswerSchema = z.object({ + preview: z.unknown().describe('The start of the answer: the first items of each list and characters of each string'), + shape: z.record(z.string(), z.unknown()).describe("The answer's keys, types and lengths"), + handle: z.string().describe('Read the rest of the answer with result_query and this handle'), +}); + +/** How many keys of an object the preview and the shape keep. */ +const MAX_KEYS = 50; +/** How deep the preview and the shape go; deeper lists and objects are left empty, or undescribed. */ +const MAX_DEPTH = 8; + +export interface PreviewLimits { + items: number; + chars: number; +} + +const encoder = new TextEncoder(); + +/** The size of a JSON value: UTF-8 bytes of its JSON. */ +export function jsonBytes(value: unknown): number { + return encoder.encode(JSON.stringify(value) ?? '').byteLength; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** The first `length` UTF-16 units of `text`, one fewer when that would split a surrogate pair. */ +export function textPrefix(text: string, length: number): string { + if (text.length <= length) return text; + const lastCode = text.charCodeAt(length - 1); + return text.slice(0, lastCode >= 0xd800 && lastCode <= 0xdbff ? length - 1 : length); +} + +/** The start of a JSON value: the first `items` of each list and `chars` of each string, the first 50 keys of each object. */ +export function previewOf(value: unknown, limits: PreviewLimits, depth = 0): unknown { + if (typeof value === 'string') return textPrefix(value, limits.chars); + if (Array.isArray(value)) { + if (depth >= MAX_DEPTH) return []; + return value.slice(0, limits.items).map((item) => previewOf(item, limits, depth + 1)); + } + if (isRecord(value)) { + if (depth >= MAX_DEPTH) return {}; + const entries = Object.entries(value).slice(0, MAX_KEYS); + return Object.fromEntries(entries.map(([key, item]) => [key, previewOf(item, limits, depth + 1)])); + } + return value; +} + +/** The shape of a JSON value: its type, the length of each list and string, and the keys of each object. */ +export function shapeOf(value: unknown, depth = 0): AnswerShape { + if (value === null) return { type: 'null' }; + if (typeof value === 'string') return { type: 'string', length: value.length }; + if (Array.isArray(value)) { + const shape: AnswerShape = { type: 'array', length: value.length }; + if (value.length > 0 && depth < MAX_DEPTH) shape.items = shapeOf(value[0], depth + 1); + return shape; + } + if (isRecord(value)) { + if (depth >= MAX_DEPTH) return { type: 'object' }; + const keys = Object.keys(value); + const described = keys.slice(0, MAX_KEYS).map((key) => [key, shapeOf(value[key], depth + 1)]); + const shape: AnswerShape = { type: 'object', keys: Object.fromEntries(described) }; + if (keys.length > MAX_KEYS) shape.moreKeys = keys.length - MAX_KEYS; + return shape; + } + return { type: typeof value === 'number' ? 'number' : 'boolean' }; +} diff --git a/plugins/plugin-result-handles/src/index.ts b/plugins/plugin-result-handles/src/index.ts new file mode 100644 index 000000000..9e95888d2 --- /dev/null +++ b/plugins/plugin-result-handles/src/index.ts @@ -0,0 +1,20 @@ +export { default, default as ResultHandlesPlugin } from './result-handles.plugin'; +export { default as ResultQueryTool, RESULT_QUERY_TOOL_NAME } from './result-query.tool'; +export { foldedAnswerSchema, type AnswerShape, type FoldedAnswer } from './folded-answer'; +export { + MIN_THRESHOLD_BYTES, + RESULT_HANDLE_META_KEY, + type FoldContext, + type FoldPolicy, + type ResultHandlesPluginOptions, + type ResultHandlesPluginOptionsInput, +} from './result-handles.options'; +export { ResultHandleStore, type ResultOwner } from './result-handle.store'; +export { createResultHandleStore, type ResultHandleStoreOptions } from './result-handle-store.factory'; +export { ResultHandlesConfigToken, ResultHandleStoreToken } from './result-handles.symbols'; +export { + ResultHandleNotFoundError, + ResultHandlesConfigurationError, + ResultPathNotFoundError, + ResultSliceTooLargeError, +} from './result-handles.errors'; diff --git a/plugins/plugin-result-handles/src/result-handle-store.factory.ts b/plugins/plugin-result-handles/src/result-handle-store.factory.ts new file mode 100644 index 000000000..bae6a5f70 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handle-store.factory.ts @@ -0,0 +1,17 @@ +import { createStorage, type StorageConfig } from '@frontmcp/utils'; + +import { ResultHandleStore } from './result-handle.store'; + +export interface ResultHandleStoreOptions { + /** Passed to `createStorage()`: memory, Redis, Vercel KV, Upstash or SQLite. */ + storage: StorageConfig; + /** The key prefix of folded answers. */ + namespace: string; + ttlSeconds: number; +} + +/** A connected {@link ResultHandleStore} on the storage `options.storage` describes. */ +export async function createResultHandleStore(options: ResultHandleStoreOptions): Promise { + const storage = await createStorage(options.storage); + return new ResultHandleStore(storage.namespace(options.namespace), options.ttlSeconds); +} diff --git a/plugins/plugin-result-handles/src/result-handle.store.ts b/plugins/plugin-result-handles/src/result-handle.store.ts new file mode 100644 index 000000000..85d0c59b3 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handle.store.ts @@ -0,0 +1,69 @@ +import { z } from '@frontmcp/lazy-zod'; +import type { OnShutdown } from '@frontmcp/sdk'; +import { randomUUID, type NamespacedStorage } from '@frontmcp/utils'; + +/** Who may read a folded answer: the session and the authenticated caller it was folded for. */ +export interface ResultOwner { + /** The verified session; undefined on a transport without sessions. */ + session?: string; + /** The authenticated subject; undefined for an anonymous caller. */ + caller?: string; +} + +const HANDLE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const storedResultSchema = z.object({ + owner: z.object({ session: z.string().optional(), caller: z.string().optional() }).strict(), + value: z.unknown(), +}); + +/** The caller of a tool or a hook's call: its verified session and, unless anonymous, its subject. */ +export function resultOwnerOf(context: { + tryGetContext(): { verifiedSessionId?: string } | undefined; + auth: { isAnonymous: boolean; user: { sub?: string } }; +}): ResultOwner { + const session = context.tryGetContext()?.verifiedSessionId; + const caller = context.auth.isAnonymous ? undefined : context.auth.user.sub; + return { ...(session && { session }), ...(caller && { caller }) }; +} + +/** Folded answers, each under a random handle, readable by its owner until its TTL runs out. */ +export class ResultHandleStore implements OnShutdown { + constructor( + private readonly storage: NamespacedStorage, + private readonly ttlSeconds: number, + ) {} + + /** A new handle: a random UUID, which says nothing about the answer it will hold. */ + newHandle(): string { + return randomUUID(); + } + + /** Keep `value` under `handle` for `owner` until the TTL runs out. */ + async save(handle: string, value: unknown, owner: ResultOwner): Promise { + await this.storage.set(handle, JSON.stringify({ owner, value }), { ttlSeconds: this.ttlSeconds }); + } + + /** The value under `handle`, or undefined when it expired, never existed, or `owner` is not its owner. */ + async read(handle: string, owner: ResultOwner): Promise<{ value: unknown } | undefined> { + if (!HANDLE.test(handle)) return undefined; + const stored = await this.storage.get(handle); + if (stored === null) return undefined; + const record = storedResultSchema.safeParse(parseJson(stored)); + if (!record.success) return undefined; + const { session, caller } = record.data.owner; + return session === owner.session && caller === owner.caller ? { value: record.data.value } : undefined; + } + + async onShutdown(): Promise { + await this.storage.disconnect(); + } +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} diff --git a/plugins/plugin-result-handles/src/result-handles.errors.ts b/plugins/plugin-result-handles/src/result-handles.errors.ts new file mode 100644 index 000000000..9b73c9b4c --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.errors.ts @@ -0,0 +1,51 @@ +import { InternalMcpError, PublicMcpError } from '@frontmcp/sdk'; + +/** A handle that expired, was never issued, or is another session's or caller's: all answer alike. */ +export class ResultHandleNotFoundError extends PublicMcpError { + override readonly name = 'ResultHandleNotFoundError'; + + constructor(handle: string) { + super( + `No result for handle "${handle}": it expired, or it belongs to another session or caller.`, + 'RESULT_HANDLE_NOT_FOUND', + 404, + undefined, + { say: 'Call the tool again for a new handle.' }, + ); + } +} + +/** A `result_query` path that is not a dot path or a JSON pointer, or that names nothing in the result. */ +export class ResultPathNotFoundError extends PublicMcpError { + override readonly name = 'ResultPathNotFoundError'; + + constructor(path: string) { + super(`No value at path "${path}" in this result.`, 'RESULT_PATH_NOT_FOUND', 404, undefined, { + say: 'Use a dot path ($.rows[2].title) or a JSON pointer (/rows/2/title) through the keys the shape lists.', + }); + } +} + +/** A `result_query` slice larger than the fold threshold: sending it whole would undo the fold. */ +export class ResultSliceTooLargeError extends PublicMcpError { + override readonly name = 'ResultSliceTooLargeError'; + + constructor(bytes: number, thresholdBytes: number) { + super( + `This slice is ${bytes} bytes, over the ${thresholdBytes}-byte limit of a result_query answer.`, + 'RESULT_SLICE_TOO_LARGE', + 413, + undefined, + { say: 'Read fewer items, fewer fields or a deeper path.' }, + ); + } +} + +/** Raised when `ResultHandlesPlugin.init()` is given options it cannot honour. */ +export class ResultHandlesConfigurationError extends InternalMcpError { + override readonly name = 'ResultHandlesConfigurationError'; + + constructor(message: string) { + super(message, 'RESULT_HANDLES_CONFIGURATION'); + } +} diff --git a/plugins/plugin-result-handles/src/result-handles.options.ts b/plugins/plugin-result-handles/src/result-handles.options.ts new file mode 100644 index 000000000..c2693912e --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.options.ts @@ -0,0 +1,95 @@ +import { z } from '@frontmcp/lazy-zod'; +import type { FlowRequestInfo } from '@frontmcp/sdk'; +import type { StorageConfig } from '@frontmcp/utils'; + +import { ResultHandlesConfigurationError } from './result-handles.errors'; + +/** The `_meta` key of a folded answer's handle, its size in bytes and its `ttlSeconds`. */ +export const RESULT_HANDLE_META_KEY = 'frontmcp/result-handle'; + +/** The smallest `thresholdBytes`: a folded answer takes about this much itself. */ +export const MIN_THRESHOLD_BYTES = 1024; + +/** What a fold policy knows about an answer over the threshold. */ +export interface FoldContext { + /** The answer's size: UTF-8 bytes of its JSON. */ + bytes: number; + /** The tool's full name, its app's id first (`reports:export_rows`). */ + fullName: string; + /** The request the answer goes back to: its session, client and headers. */ + request: FlowRequestInfo; +} + +/** Whether an answer over the threshold folds; `answer` is its `structuredContent`, or its text. */ +export type FoldPolicy = (toolName: string, answer: unknown, context: FoldContext) => boolean; + +export interface ResultHandlesPluginOptionsInput { + /** Answers whose data is larger than this, as UTF-8 JSON, fold (16384 by default, at least 1024). */ + thresholdBytes?: number; + /** How long a folded answer can be read, in seconds (600 by default). */ + ttlSeconds?: number; + /** The first `items` of each list and `chars` of each string the preview keeps ({ items: 5, chars: 500 }). */ + preview?: { items?: number; chars?: number }; + /** Which answers over the threshold fold (every one by default). */ + foldPolicy?: FoldPolicy; + /** Where folded answers are kept, through `createStorage()` from `@frontmcp/utils` ({ type: 'auto' }). */ + storage?: StorageConfig; + /** The key prefix of folded answers in the storage ('result-handles'). */ + namespace?: string; +} + +export interface ResultHandlesPluginOptions { + thresholdBytes: number; + ttlSeconds: number; + preview: { items: number; chars: number }; + foldPolicy: FoldPolicy; + storage: StorageConfig; + namespace: string; +} + +const foldEverything: FoldPolicy = () => true; + +const wholeNumberAtLeastOne = { message: 'must be a whole number, at least 1' }; + +const resultHandlesOptionsSchema = z + .object({ + thresholdBytes: z + .number() + .int({ message: `must be a whole number of at least ${MIN_THRESHOLD_BYTES}` }) + .min(MIN_THRESHOLD_BYTES, { message: `must be a whole number of at least ${MIN_THRESHOLD_BYTES}` }) + .default(16 * 1024), + ttlSeconds: z + .number() + .int({ message: 'must be a whole number of seconds, at least 1' }) + .min(1, { message: 'must be a whole number of seconds, at least 1' }) + .default(600), + preview: z + .object({ + items: z.number().int(wholeNumberAtLeastOne).min(1, wholeNumberAtLeastOne).default(5), + chars: z.number().int(wholeNumberAtLeastOne).min(1, wholeNumberAtLeastOne).default(500), + }) + .strict() + .default({ items: 5, chars: 500 }), + foldPolicy: z + .custom((value) => typeof value === 'function', { + message: 'must be a function (toolName, answer, context) => boolean', + }) + .default(() => foldEverything), + storage: z + .custom((value) => typeof value === 'object' && value !== null && !Array.isArray(value), { + message: 'must be a storage config, such as { type: "redis", redis: { url } }', + }) + .default({ type: 'auto' }), + namespace: z.string().min(1, { message: 'must not be empty' }).default('result-handles'), + }) + .strict(); + +/** The plugin's options with their defaults, or a {@link ResultHandlesConfigurationError} naming each problem. */ +export function parseResultHandlesOptions(input: ResultHandlesPluginOptionsInput): ResultHandlesPluginOptions { + const result = resultHandlesOptionsSchema.safeParse(input); + if (result.success) return result.data; + const problems = result.error.issues.map((issue) => + issue.path.length > 0 ? `${issue.path.join('.')}: ${issue.message}` : issue.message, + ); + throw new ResultHandlesConfigurationError(`Invalid ResultHandlesPlugin options: ${problems.join('; ')}`); +} diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts new file mode 100644 index 000000000..9d4826c77 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -0,0 +1,144 @@ +import { z } from '@frontmcp/lazy-zod'; +import { + DynamicPlugin, + FrontMcpLogger, + Plugin, + ProviderScope, + ToolHook, + type CallToolResult, + type FlowCtxOf, + type ProviderType, +} from '@frontmcp/sdk'; + +import { jsonBytes, previewOf, shapeOf, type FoldedAnswer } from './folded-answer'; +import { createResultHandleStore } from './result-handle-store.factory'; +import { resultOwnerOf } from './result-handle.store'; +import { + parseResultHandlesOptions, + RESULT_HANDLE_META_KEY, + type ResultHandlesPluginOptions, + type ResultHandlesPluginOptionsInput, +} from './result-handles.options'; +import { ResultHandlesConfigToken, ResultHandleStoreToken } from './result-handles.symbols'; +import ResultQueryTool, { RESULT_QUERY_TOOL_NAME } from './result-query.tool'; + +type FoldedTool = NonNullable['state']['tool']>; + +function parsedJsonText(text: string): unknown { + const start = text.trimStart()[0]; + if (start !== '{' && start !== '[') return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** What an answer of text content only carries: its `structuredContent`, else its one text (as JSON when it is JSON). */ +function answerDataOf(result: CallToolResult): unknown { + const texts = result.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])); + if (texts.length !== result.content.length) return undefined; + if (result.structuredContent !== undefined) return result.structuredContent; + if (texts.length !== 1) return undefined; + return parsedJsonText(texts[0]) ?? texts[0]; +} + +/** The tool's answer for `folded`, or undefined when its Zod schema (no undeclared key) or JSON Schema rejects it. */ +function foldedAnswerOf(tool: FoldedTool, folded: FoldedAnswer): CallToolResult | undefined { + const jsonSchema = tool.getRawOutputSchema(); + const hasJsonSchemaOnly = tool.outputSchema === undefined && typeof jsonSchema === 'object' && jsonSchema !== null; + if (hasJsonSchemaOnly && !jsonSchemaAdmits(jsonSchema, folded)) return undefined; + const parsed = tool.safeParseOutput(folded, { undeclaredKeys: 'fail' }); + if (!parsed.success || parsed.data.structuredContent?.['handle'] !== folded.handle) return undefined; + return parsed.data; +} + +function jsonSchemaAdmits(jsonSchema: object, folded: FoldedAnswer): boolean { + try { + return z.fromJSONSchema(jsonSchema as Parameters[0]).safeParse(folded).success; + } catch { + return false; + } +} + +/** Folds an answer over `thresholdBytes` into a preview, a shape and a handle that `result_query` reads. */ +@Plugin({ + name: 'result-handles', + description: 'Folds large tool answers into a preview, a shape and a handle that result_query reads', + tools: [ResultQueryTool], +}) +export default class ResultHandlesPlugin extends DynamicPlugin< + ResultHandlesPluginOptions, + ResultHandlesPluginOptionsInput +> { + options: ResultHandlesPluginOptions; + private readonly unfoldableTools = new WeakSet(); + + constructor(options: ResultHandlesPluginOptionsInput = {}) { + super(); + this.options = parseResultHandlesOptions(options); + } + + static override dynamicProviders = (input: ResultHandlesPluginOptionsInput): ProviderType[] => { + const options = parseResultHandlesOptions(input); + return [ + { name: 'result-handles:config', provide: ResultHandlesConfigToken, useValue: options }, + { + name: 'result-handles:store', + provide: ResultHandleStoreToken, + scope: ProviderScope.GLOBAL, + inject: () => [] as const, + useFactory: () => createResultHandleStore(options), + }, + ]; + }; + + /** Runs before the answer budget (`fitAnswer`), so the budget sees the folded answer. */ + @ToolHook.Will('fitAnswer') + async foldAnswer(flowCtx: FlowCtxOf<'tools:call-tool'>): Promise { + const { tool, toolContext, output, uiResult } = flowCtx.state; + if (!tool || !toolContext || !output || output.isError || uiResult || !flowCtx.isClientCall()) return; + if (toolContext instanceof ResultQueryTool) return; + const answer = answerDataOf(output); + const bytes = jsonBytes(answer); + if (answer === undefined || bytes <= this.options.thresholdBytes) return; + const owner = resultOwnerOf(toolContext); + if (owner.session === undefined && owner.caller === undefined) return; + const context = { bytes, fullName: tool.fullName, request: flowCtx.request }; + if (!this.options.foldPolicy(flowCtx.toolName, answer, context)) return; + + const store = this.get(ResultHandleStoreToken); + const handle = store.newHandle(); + const preview = previewOf(answer, this.options.preview); + const folded = foldedAnswerOf(tool, { preview, shape: shapeOf(answer), handle }); + if (!folded) { + this.warnUnfoldableOnce(tool, bytes); + return; + } + await store.save(handle, answer, owner); + const { ttlSeconds } = this.options; + const note = + `This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. ` + + `Read the rest with ${RESULT_QUERY_TOOL_NAME} and handle "${handle}" ` + + `(a path, then offset and limit or a page, and fields) within ${ttlSeconds} seconds.`; + flowCtx.respond({ + ...folded, + content: [...folded.content, { type: 'text', text: note }], + _meta: { ...output._meta, [RESULT_HANDLE_META_KEY]: { handle, bytes, ttlSeconds } }, + }); + } + + private warnUnfoldableOnce(tool: FoldedTool, bytes: number): void { + if (this.unfoldableTools.has(tool)) return; + this.unfoldableTools.add(tool); + this.get(FrontMcpLogger) + .child('ResultHandles') + .warn( + `Tool "${tool.fullName}" answered ${bytes} bytes, over the ${this.options.thresholdBytes}-byte fold threshold, ` + + 'but its outputSchema does not admit the folded form { preview, shape, handle }, so its answers are sent ' + + 'whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.', + ); + } +} + +export { ResultHandlesPlugin }; diff --git a/plugins/plugin-result-handles/src/result-handles.symbols.ts b/plugins/plugin-result-handles/src/result-handles.symbols.ts new file mode 100644 index 000000000..1d21a2460 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.symbols.ts @@ -0,0 +1,10 @@ +import type { Reference } from '@frontmcp/sdk'; + +import type { ResultHandleStore } from './result-handle.store'; +import type { ResultHandlesPluginOptions } from './result-handles.options'; + +/** The store of folded answers: `this.get(ResultHandleStoreToken)`. */ +export const ResultHandleStoreToken = Symbol('plugin:result-handles:store') as Reference; + +/** The plugin's options, with their defaults. */ +export const ResultHandlesConfigToken = Symbol('plugin:result-handles:config') as Reference; diff --git a/plugins/plugin-result-handles/src/result-query.tool.ts b/plugins/plugin-result-handles/src/result-query.tool.ts new file mode 100644 index 000000000..f74e1ab56 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-query.tool.ts @@ -0,0 +1,75 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { jsonBytes } from './folded-answer'; +import { resultOwnerOf } from './result-handle.store'; +import { ResultHandleNotFoundError, ResultSliceTooLargeError } from './result-handles.errors'; +import { ResultHandlesConfigToken, ResultHandleStoreToken } from './result-handles.symbols'; +import { DEFAULT_CHAR_LIMIT, DEFAULT_ITEM_LIMIT, queryResult, type ResultSlice } from './result-query'; + +export const RESULT_QUERY_TOOL_NAME = 'result_query'; + +export const resultQueryInputSchema = { + handle: z.string().min(1).max(100).describe('The handle of a folded answer'), + path: z + .string() + .max(1000) + .optional() + .describe( + 'What to read: a dot path ($.rows[2].title, rows.2.title) or a JSON pointer (/rows/2/title); $ by default', + ), + offset: z.number().int().min(0).optional().describe('The first item of a list, or character of a string, to read'), + limit: z + .number() + .int() + .min(1) + .max(100_000) + .optional() + .describe(`How many list items (${DEFAULT_ITEM_LIMIT} by default) or characters (${DEFAULT_CHAR_LIMIT}) to read`), + page: z.number().int().min(1).optional().describe('A page of limit items, from 1, in place of offset'), + fields: z + .array(z.string().min(1)) + .max(100) + .optional() + .describe('Keep only these keys of the object, or of each object of the list'), +}; + +export const resultQueryOutputSchema = z.object({ + path: z.string(), + value: z.unknown(), + total: z.number().int().optional(), + offset: z.number().int().optional(), + nextOffset: z.number().int().optional(), +}); + +type ResultQueryInput = { + handle: string; + path?: string; + offset?: number; + limit?: number; + page?: number; + fields?: string[]; +}; + +/** Reads slices of an answer the plugin folded, for the session and caller it was folded for. */ +@Tool({ + name: RESULT_QUERY_TOOL_NAME, + description: + 'Read part of a large tool answer that was folded into a preview, a shape and a handle. ' + + 'Give the handle and a path from the shape; read a list by offset and limit or by page, ' + + 'a long string by character offset and limit, and keep only some keys with fields.', + inputSchema: resultQueryInputSchema, + outputSchema: resultQueryOutputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, +}) +export default class ResultQueryTool extends ToolContext { + async execute({ handle, ...query }: ResultQueryInput): Promise { + const stored = await this.get(ResultHandleStoreToken).read(handle, resultOwnerOf(this)); + if (!stored) throw new ResultHandleNotFoundError(handle); + const slice = queryResult(stored.value, query); + const { thresholdBytes } = this.get(ResultHandlesConfigToken); + const bytes = jsonBytes(slice.value); + if (bytes > thresholdBytes) throw new ResultSliceTooLargeError(bytes, thresholdBytes); + return slice; + } +} diff --git a/plugins/plugin-result-handles/src/result-query.ts b/plugins/plugin-result-handles/src/result-query.ts new file mode 100644 index 000000000..9a2853b85 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-query.ts @@ -0,0 +1,122 @@ +import { InvalidInputError } from '@frontmcp/sdk'; + +import { textPrefix } from './folded-answer'; +import { ResultPathNotFoundError } from './result-handles.errors'; + +/** How many list items `result_query` reads when no `limit` is given. */ +export const DEFAULT_ITEM_LIMIT = 20; +/** How many characters of a string `result_query` reads when no `limit` is given. */ +export const DEFAULT_CHAR_LIMIT = 2000; + +export type PathSegment = string | number; + +export interface ResultQuery { + path?: string; + offset?: number; + limit?: number; + page?: number; + fields?: string[]; +} + +export interface ResultSlice { + path: string; + value: unknown; + /** The length of the list, or of the string, the slice is from. */ + total?: number; + offset?: number; + /** Where the next slice starts, while the list or the string has more. */ + nextOffset?: number; +} + +/** One step of a dot path: `.key`, `key` first, `[2]` or `["key"]`. */ +const DOT_STEP = /\.?([^.[\]"]+)|\[(\d+)\]|\[("(?:[^"\\]|\\.)*")\]/y; + +function parsePointer(path: string): PathSegment[] { + return path + .slice(1) + .split('/') + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); +} + +function parseDotPath(path: string, body: string): PathSegment[] { + const segments: PathSegment[] = []; + const step = new RegExp(DOT_STEP.source, 'y'); + while (step.lastIndex < body.length) { + const match = step.exec(body); + if (!match) throw new ResultPathNotFoundError(path); + const [, key, index, quoted] = match; + if (key !== undefined) segments.push(key); + else if (index !== undefined) segments.push(Number(index)); + else segments.push(JSON.parse(quoted) as string); + } + return segments; +} + +/** The steps of a `result_query` path: a JSON pointer (`/rows/2/title`) or a dot path (`$.rows[2].title`, `rows.2.title`). */ +export function parseResultPath(path: string): PathSegment[] { + if (path.startsWith('/')) return parsePointer(path); + if (!path.startsWith('$')) return parseDotPath(path, path); + const body = path.slice(1); + if (body !== '' && !body.startsWith('.') && !body.startsWith('[')) throw new ResultPathNotFoundError(path); + return parseDotPath(path, body); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function childOf(node: unknown, segment: PathSegment): { found: boolean; value?: unknown } { + if (Array.isArray(node)) { + const index = typeof segment === 'number' ? segment : /^\d+$/.test(segment) ? Number(segment) : -1; + return index >= 0 && index < node.length ? { found: true, value: node[index] } : { found: false }; + } + const key = String(segment); + return isRecord(node) && Object.hasOwn(node, key) ? { found: true, value: node[key] } : { found: false }; +} + +function valueAt(result: unknown, path: string): unknown { + let node = result; + for (const segment of parseResultPath(path)) { + const child = childOf(node, segment); + if (!child.found) throw new ResultPathNotFoundError(path); + node = child.value; + } + return node; +} + +function project(value: unknown, fields: string[] | undefined): unknown { + if (!fields || !isRecord(value)) return value; + return Object.fromEntries( + fields.filter((field) => Object.hasOwn(value, field)).map((field) => [field, value[field]]), + ); +} + +function rangeOf(query: ResultQuery, defaultLimit: number): { offset: number; limit: number } { + if (query.page !== undefined && query.offset !== undefined) { + throw new InvalidInputError('Give result_query a page or an offset, not both'); + } + const limit = query.limit ?? defaultLimit; + return { offset: query.page !== undefined ? (query.page - 1) * limit : (query.offset ?? 0), limit }; +} + +function sliced(path: string, value: unknown, total: number, offset: number, read: number): ResultSlice { + const next = offset + read; + return { path, value, total, offset, ...(next < total && { nextOffset: next }) }; +} + +/** The value at `path`: a range of a list's items or a string's characters, `fields` kept of each object. */ +export function queryResult(result: unknown, query: ResultQuery): ResultSlice { + const path = query.path ?? '$'; + const value = valueAt(result, path); + if (Array.isArray(value)) { + const { offset, limit } = rangeOf(query, DEFAULT_ITEM_LIMIT); + const items = value.slice(offset, offset + limit).map((item) => project(item, query.fields)); + return sliced(path, items, value.length, offset, items.length); + } + if (typeof value === 'string') { + const { offset, limit } = rangeOf(query, DEFAULT_CHAR_LIMIT); + const text = textPrefix(value.slice(offset), limit); + return sliced(path, text, value.length, offset, text.length); + } + return { path, value: project(value, query.fields) }; +} diff --git a/plugins/plugin-result-handles/tsconfig.json b/plugins/plugin-result-handles/tsconfig.json new file mode 100644 index 000000000..19b9eece4 --- /dev/null +++ b/plugins/plugin-result-handles/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/plugins/plugin-result-handles/tsconfig.lib.json b/plugins/plugin-result-handles/tsconfig.lib.json new file mode 100644 index 000000000..d3a068ad7 --- /dev/null +++ b/plugins/plugin-result-handles/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "paths": { + "@frontmcp/lazy-zod": ["libs/lazy-zod/dist/index.d.ts"], + "@frontmcp/sdk": ["libs/sdk/dist/index.d.ts"], + "@frontmcp/utils": ["libs/utils/dist/index.d.ts"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/__tests__/**", "src/**/__test-utils__/**"] +} diff --git a/plugins/plugin-result-handles/tsconfig.spec.json b/plugins/plugin-result-handles/tsconfig.spec.json new file mode 100644 index 000000000..87a28b70f --- /dev/null +++ b/plugins/plugin-result-handles/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "module": "commonjs", + "types": ["jest", "node"], + "rootDir": "../..", + "declaration": true, + "moduleResolution": "node10" + }, + "include": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.d.ts", "src/**/__tests__/**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a0b90c56..2fa0e12e8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,6 +37,7 @@ "@frontmcp/plugin-dashboard": ["plugins/plugin-dashboard/src/index.ts"], "@frontmcp/plugin-feature-flags": ["plugins/plugin-feature-flags/src/index.ts"], "@frontmcp/plugin-remember": ["plugins/plugin-remember/src/index.ts"], + "@frontmcp/plugin-result-handles": ["plugins/plugin-result-handles/src/index.ts"], "@frontmcp/plugin-skilled-openapi": ["plugins/plugin-skilled-openapi/src/index.ts"], "@frontmcp/plugin-webmcp": ["plugins/plugin-webmcp/src/index.ts"], "@frontmcp/sdk": ["libs/sdk/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index 22f820f5c..137687e21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2624,6 +2624,19 @@ __metadata: languageName: unknown linkType: soft +"@frontmcp/plugin-result-handles@workspace:plugins/plugin-result-handles": + version: 0.0.0-use.local + resolution: "@frontmcp/plugin-result-handles@workspace:plugins/plugin-result-handles" + dependencies: + "@frontmcp/sdk": "npm:1.10.16" + "@frontmcp/utils": "npm:1.10.16" + reflect-metadata: "npm:^0.2.2" + peerDependencies: + "@frontmcp/lazy-zod": 1.10.16 + "@frontmcp/sdk": 1.10.16 + languageName: unknown + linkType: soft + "@frontmcp/plugin-skilled-openapi@npm:1.10.16, @frontmcp/plugin-skilled-openapi@workspace:plugins/plugin-skilled-openapi": version: 0.0.0-use.local resolution: "@frontmcp/plugin-skilled-openapi@workspace:plugins/plugin-skilled-openapi" From 7d1b7e372a7ae648a0beca65bb1047272f510c95 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:08:29 +0200 Subject: [PATCH 2/7] fix(plugin-result-handles): send the answer whole when the store cannot keep it A store that failed to save a folded answer (Redis down, say) made the tool call fail. The answer now goes to the caller whole, as without the plugin, and the plugin logs a warning naming the tool and the reason. The README also says that folded answers are kept unencrypted until their TTL, and how to cap a memory store with maxEntries. --- .../references/result-handles-plugin.md | 1 + plugins/plugin-result-handles/README.md | 5 ++++ .../__tests__/result-handles.plugin.spec.ts | 12 +++++++++ .../src/result-handles.plugin.ts | 26 +++++++++++++------ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md index 7bd6c7f94..f0b9a9d8e 100644 --- a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -170,6 +170,7 @@ It answers `{ path, value, total?, offset?, nextOffset? }`. | ----------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Warning: "does not admit the folded form" | The tool's output schema rejects `{ preview, shape, handle }` | Add `foldedAnswerSchema` to the schema as a union member | | A large answer is not folded | Not text only, an error result, a widget answer, an in-process call, or a stateless anonymous call | Fold only applies to a client's text answer with a session or a signed-in caller | +| Warning: "Could not keep the answer" | The store failed to save it (Redis down, say); the answer was sent whole | Check the `storage` connection | | `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, or another instance with memory storage | Call the tool again; use a shared store across instances | | `RESULT_SLICE_TOO_LARGE` | The slice is over `thresholdBytes` | Read fewer items, fewer `fields`, or a deeper `path` | | `RESULT_PATH_NOT_FOUND` | The path is malformed or names nothing | Follow the keys the `shape` lists | diff --git a/plugins/plugin-result-handles/README.md b/plugins/plugin-result-handles/README.md index 45403ad32..6329ecdc5 100644 --- a/plugins/plugin-result-handles/README.md +++ b/plugins/plugin-result-handles/README.md @@ -78,6 +78,8 @@ sees the folded answer. An answer folds when all of these hold: - `foldPolicy(toolName, answer, context)` returns `true`; - the tool's output schema admits the folded form (below). +When the store can't keep the answer (Redis is down, say), the answer is sent whole and the plugin logs a warning. + ## Tools with an output schema A client checks `structuredContent` against the `outputSchema` it was listed, so a tool with an output schema folds @@ -148,6 +150,9 @@ ResultHandlesPlugin.init({ storage: { type: 'sqlite', sqlite: { path: '/var/lib/ SQLite needs `@frontmcp/storage-sqlite`. +Each folded answer is kept, as the tool returned it and unencrypted, until its TTL runs out. With memory storage, +`storage: { type: 'memory', memory: { maxEntries: 1000 } }` caps how many are held at once, dropping the oldest. + ## Docs - [Result handles plugin reference](https://frontmcp.dev/reference/plugins/result-handles) diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts index 47a91f59f..48d4d2c64 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -17,6 +17,7 @@ import { } from '@frontmcp/sdk'; import { foldedAnswerSchema, type FoldedAnswer } from '../folded-answer'; +import { ResultHandleStore } from '../result-handle.store'; import { RESULT_HANDLE_META_KEY, type FoldContext } from '../result-handles.options'; import ResultHandlesPlugin from '../result-handles.plugin'; @@ -306,6 +307,17 @@ describe('ResultHandlesPlugin', () => { ]); }); + it('sends the answer whole, with a warning, when the store cannot keep it', async () => { + jest.spyOn(ResultHandleStore.prototype, 'save').mockRejectedValueOnce(new Error('connection refused')); + const client = await open(); + const result = await call(client, 'export_rows', { count: 200 }); + + expect(result.structuredContent).toEqual({ rows: rows(200), total: 200 }); + expect(warnings).toEqual([ + 'Could not keep the answer of tool "exports:export_rows" for result_query, so it is sent whole: connection refused', + ]); + }); + it('leaves media, several-text, error and in-process answers alone', async () => { const client = await open(); expect((await call(client, 'chart')).content[0]?.type).toBe('image'); diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts index 9d4826c77..e12e0ecbd 100644 --- a/plugins/plugin-result-handles/src/result-handles.plugin.ts +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -115,7 +115,15 @@ export default class ResultHandlesPlugin extends DynamicPlugin< this.warnUnfoldableOnce(tool, bytes); return; } - await store.save(handle, answer, owner); + try { + await store.save(handle, answer, owner); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.logger().warn( + `Could not keep the answer of tool "${tool.fullName}" for result_query, so it is sent whole: ${reason}`, + ); + return; + } const { ttlSeconds } = this.options; const note = `This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. ` + @@ -128,16 +136,18 @@ export default class ResultHandlesPlugin extends DynamicPlugin< }); } + private logger(): FrontMcpLogger { + return this.get(FrontMcpLogger).child('ResultHandles'); + } + private warnUnfoldableOnce(tool: FoldedTool, bytes: number): void { if (this.unfoldableTools.has(tool)) return; this.unfoldableTools.add(tool); - this.get(FrontMcpLogger) - .child('ResultHandles') - .warn( - `Tool "${tool.fullName}" answered ${bytes} bytes, over the ${this.options.thresholdBytes}-byte fold threshold, ` + - 'but its outputSchema does not admit the folded form { preview, shape, handle }, so its answers are sent ' + - 'whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.', - ); + this.logger().warn( + `Tool "${tool.fullName}" answered ${bytes} bytes, over the ${this.options.thresholdBytes}-byte fold threshold, ` + + 'but its outputSchema does not admit the folded form { preview, shape, handle }, so its answers are sent ' + + 'whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.', + ); } } From cc5b883c92952f9c4a75c4d9a4bccc6419d29028 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:08:39 +0200 Subject: [PATCH 3/7] fix(plugin-result-handles): answer a quoted path key with an invalid escape as RESULT_PATH_NOT_FOUND A result_query path such as $["a\x"] reached JSON.parse, whose SyntaxError was wrapped as a tool execution error, so the model got no error code and no remedy. --- .../src/__tests__/result-query.spec.ts | 2 +- plugins/plugin-result-handles/src/result-query.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts index 381480262..731e90689 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-query.spec.ts @@ -25,7 +25,7 @@ describe('parseResultPath', () => { expect(parseResultPath(path)).toEqual(segments); }); - it.each(['rows..title', 'rows[', 'rows[x]', '$rows', 'rows["a]'])('refuses %j', (path) => { + it.each(['rows..title', 'rows[', 'rows[x]', '$rows', 'rows["a]', '$["a\\x"]'])('refuses %j', (path) => { expect(() => parseResultPath(path)).toThrow(ResultPathNotFoundError); }); }); diff --git a/plugins/plugin-result-handles/src/result-query.ts b/plugins/plugin-result-handles/src/result-query.ts index 9a2853b85..4b7aaf351 100644 --- a/plugins/plugin-result-handles/src/result-query.ts +++ b/plugins/plugin-result-handles/src/result-query.ts @@ -38,6 +38,14 @@ function parsePointer(path: string): PathSegment[] { .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); } +function parseQuotedKey(path: string, quoted: string): string { + try { + return JSON.parse(quoted) as string; + } catch { + throw new ResultPathNotFoundError(path); + } +} + function parseDotPath(path: string, body: string): PathSegment[] { const segments: PathSegment[] = []; const step = new RegExp(DOT_STEP.source, 'y'); @@ -47,7 +55,7 @@ function parseDotPath(path: string, body: string): PathSegment[] { const [, key, index, quoted] = match; if (key !== undefined) segments.push(key); else if (index !== undefined) segments.push(Number(index)); - else segments.push(JSON.parse(quoted) as string); + else segments.push(parseQuotedKey(path, quoted)); } return segments; } From ddb777215fe4b4b78cd71a9346453b557969055a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:32:33 +0200 Subject: [PATCH 4/7] fix(plugin-result-handles): encrypt folded answers under keys from the handle and owner, and read every app's handles - The store keeps each answer encrypted with AES-256-GCM; its encryption key and its storage key are both derived with HKDF-SHA256 from the handle, the session and the subject. The storage holds nothing readable without the handle, and another session's or caller's lookup misses like an unknown or expired handle, with no owner comparison left to time. - RESULT_HANDLE_NOT_FOUND no longer repeats the handle in its message. - With the plugin on several apps, the one listed result_query reads the handles each app's copy folded; before, the second app's handles were never readable with memory storage. - A memory store holds 1000 answers at most (DEFAULT_MEMORY_MAX_ENTRIES) unless storage.memory.maxEntries is set. - A result_query page whose offset would not be a safe integer is INVALID_INPUT instead of INVALID_OUTPUT. --- .../e2e/result-handles.e2e.spec.ts | 11 ++++ .../src/apps/results/index.ts | 22 ++++--- .../apps/results/tools/export-archive.tool.ts | 14 +++++ apps/e2e/demo-e2e-answer-shaping/src/main.ts | 4 +- .../references/official-plugins.md | 3 +- .../references/result-handles-plugin.md | 10 ++-- plugins/plugin-result-handles/README.md | 15 +++-- .../src/__tests__/result-handle.store.spec.ts | 55 ++++++++++++++--- .../__tests__/result-handles.options.spec.ts | 16 +++-- .../__tests__/result-handles.plugin.spec.ts | 45 ++++++++++++++ plugins/plugin-result-handles/src/index.ts | 1 + .../src/result-handle.store.ts | 59 +++++++++++++------ .../src/result-handles.errors.ts | 4 +- .../src/result-handles.options.ts | 8 ++- .../src/result-handles.plugin.ts | 3 +- .../src/result-query.tool.ts | 23 ++++++-- 16 files changed, 234 insertions(+), 59 deletions(-) create mode 100644 apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-archive.tool.ts diff --git a/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts b/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts index d939b29ce..cea7b5f25 100644 --- a/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts +++ b/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts @@ -59,6 +59,16 @@ test.describe('Result handles E2E', () => { expect(note.raw.structuredContent).toMatchObject({ value: 'Order 7 note' }); }); + test("the one listed result_query reads the handles of another app's copy of the plugin", async ({ mcp }) => { + const tools = await mcp.tools.list(); + expect(tools.filter((tool) => tool.name === 'result_query')).toHaveLength(1); + + const folded = await mcp.tools.call('export-archive', { count: 500 }); + const { handle } = folded.raw.structuredContent as unknown as FoldedAnswer; + const slice = await mcp.tools.call('result_query', { handle, path: '/orders/250/id' }); + expect(slice.raw.structuredContent).toEqual({ path: '/orders/250/id', value: 250 }); + }); + test('another session gets "not found", never the data', async ({ mcp, server }) => { const folded = await mcp.tools.call('export-orders', { count: 500 }); const { handle } = folded.raw.structuredContent as unknown as FoldedAnswer; @@ -69,6 +79,7 @@ test.describe('Result handles E2E', () => { expect(result.isError).toBe(true); expect(result.raw._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); expect(JSON.stringify(result.raw)).not.toContain('Order 0 note'); + expect(JSON.stringify(result.raw)).not.toContain(handle); } finally { await other.disconnect(); } diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts index 46592d59d..91662b7d3 100644 --- a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts @@ -1,20 +1,26 @@ import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; import { App } from '@frontmcp/sdk'; +import ExportArchiveTool from './tools/export-archive.tool'; import ExportOrdersTool from './tools/export-orders.tool'; import StrictOrdersTool from './tools/strict-orders.tool'; +const resultHandles = () => + ResultHandlesPlugin.init({ + thresholdBytes: 8 * 1024, + ttlSeconds: 4, + preview: { items: 3, chars: 80 }, + storage: { type: 'memory' }, + }); + @App({ id: 'results', name: 'Results', tools: [ExportOrdersTool, StrictOrdersTool], - plugins: [ - ResultHandlesPlugin.init({ - thresholdBytes: 8 * 1024, - ttlSeconds: 4, - preview: { items: 3, chars: 80 }, - storage: { type: 'memory' }, - }), - ], + plugins: [resultHandles()], }) export class ResultsApp {} + +/** A second app with its own copy of the plugin: the one listed result_query reads its handles too. */ +@App({ id: 'archive', name: 'Archive', tools: [ExportArchiveTool], plugins: [resultHandles()] }) +export class ArchiveApp {} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-archive.tool.ts b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-archive.tool.ts new file mode 100644 index 000000000..32a212392 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/export-archive.tool.ts @@ -0,0 +1,14 @@ +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { orders } from './orders'; + +@Tool({ + name: 'export-archive', + description: 'Archived orders, from an app with its own copy of the result handles plugin', + inputSchema: { count: z.number().int().min(1).max(5000).default(500) }, +}) +export default class ExportArchiveTool extends ToolContext { + async execute({ count }: { count: number }) { + return { orders: orders(count), total: count }; + } +} diff --git a/apps/e2e/demo-e2e-answer-shaping/src/main.ts b/apps/e2e/demo-e2e-answer-shaping/src/main.ts index ac0e69982..6bdf731d2 100644 --- a/apps/e2e/demo-e2e-answer-shaping/src/main.ts +++ b/apps/e2e/demo-e2e-answer-shaping/src/main.ts @@ -1,13 +1,13 @@ import { FrontMcp, LogLevel } from '@frontmcp/sdk'; import { ReportsApp } from './apps/reports'; -import { ResultsApp } from './apps/results'; +import { ArchiveApp, ResultsApp } from './apps/results'; const port = parseInt(process.env['PORT'] ?? '3210', 10); @FrontMcp({ info: { name: 'Demo E2E Answer Shaping', version: '0.1.0' }, - apps: [ReportsApp, ResultsApp], + apps: [ReportsApp, ResultsApp, ArchiveApp], logging: { level: LogLevel.Warn }, http: { port, forwardHeaders: ['x-acme-*'] }, auth: { diff --git a/libs/skills/catalog/frontmcp-development/references/official-plugins.md b/libs/skills/catalog/frontmcp-development/references/official-plugins.md index 63be957bc..8f4228e15 100644 --- a/libs/skills/catalog/frontmcp-development/references/official-plugins.md +++ b/libs/skills/catalog/frontmcp-development/references/official-plugins.md @@ -1177,7 +1177,8 @@ void registerWebMcpTools(resolveDocumentModelContext(), tools, (modelContext) => Folds a tool answer over `thresholdBytes` (16 KB by default) into `{ preview, shape, handle }` and adds the `result_query` tool, which reads slices of the rest by path, range or page, and fields. A handle is a random UUID bound -to the call's session and authenticated caller; another session or caller gets `RESULT_HANDLE_NOT_FOUND`. It runs on +to the call's session and authenticated caller; another session or caller gets `RESULT_HANDLE_NOT_FOUND`, and the +store keeps each answer encrypted under keys derived from its handle and owner. It runs on the `fitAnswer` stage, before `output.budget`. A tool with an output schema folds only when the schema admits the folded form (`z.union([schema, foldedAnswerSchema])`). diff --git a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md index f0b9a9d8e..b58c548d9 100644 --- a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -7,7 +7,8 @@ description: Fold large tool answers into a preview, a shape and a handle, and l `@frontmcp/plugin-result-handles` keeps a tool answer that is too large to send whole on the server. The client gets `{ preview, shape, handle }` in its place, and the model reads the parts it needs with the `result_query` tool. A -handle works only for the session and the caller it was issued to, until its TTL runs out. +handle works only for the session and the caller it was issued to, until its TTL runs out. The store keeps the answer +encrypted, under keys derived from the handle and its owner, so it holds nothing readable without the handle. ## When to Use This Skill @@ -63,8 +64,9 @@ class OrdersApp {} ``` On an app, the plugin folds that app's tools; on `@FrontMcp({ plugins })`, every app's. It adds the `result_query` -tool. `storage` goes to `createStorage()` from `@frontmcp/utils`; `{ type: 'sqlite', sqlite: { path } }` needs -`@frontmcp/storage-sqlite`. +tool; on several apps the server lists one, and it reads the handles of every app's copy. `storage` goes to +`createStorage()` from `@frontmcp/utils`; `{ type: 'sqlite', sqlite: { path } }` needs `@frontmcp/storage-sqlite`. A +memory store holds 1000 answers at most and drops the oldest (`storage.memory.maxEntries`, `0` for no limit). ### Step 2: Let tools with an output schema fold @@ -171,7 +173,7 @@ It answers `{ path, value, total?, offset?, nextOffset? }`. | Warning: "does not admit the folded form" | The tool's output schema rejects `{ preview, shape, handle }` | Add `foldedAnswerSchema` to the schema as a union member | | A large answer is not folded | Not text only, an error result, a widget answer, an in-process call, or a stateless anonymous call | Fold only applies to a client's text answer with a session or a signed-in caller | | Warning: "Could not keep the answer" | The store failed to save it (Redis down, say); the answer was sent whole | Check the `storage` connection | -| `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, or another instance with memory storage | Call the tool again; use a shared store across instances | +| `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, another instance or a full memory store | Call the tool again; use a shared store across instances | | `RESULT_SLICE_TOO_LARGE` | The slice is over `thresholdBytes` | Read fewer items, fewer `fields`, or a deeper `path` | | `RESULT_PATH_NOT_FOUND` | The path is malformed or names nothing | Follow the keys the `shape` lists | diff --git a/plugins/plugin-result-handles/README.md b/plugins/plugin-result-handles/README.md index 6329ecdc5..bd6ff9479 100644 --- a/plugins/plugin-result-handles/README.md +++ b/plugins/plugin-result-handles/README.md @@ -17,7 +17,8 @@ Requires Node.js 24 or later. Keep `@frontmcp/sdk` on the same version as this p ## Usage -Register the plugin on the app whose answers it should fold, or on `@FrontMcp({ plugins })` for every app: +Register the plugin on the app whose answers it should fold, or on `@FrontMcp({ plugins })` for every app. On several +apps, the server lists one `result_query`, and it reads the handles of every app's copy: ```ts import ResultHandlesPlugin from '@frontmcp/plugin-result-handles'; @@ -118,7 +119,7 @@ It answers `{ path, value, total?, offset?, nextOffset? }`: `total` is the lengt | `RESULT_HANDLE_NOT_FOUND` | The handle expired, was never issued, or belongs to another session or caller. | | `RESULT_PATH_NOT_FOUND` | The path isn't a dot path or a JSON pointer, or names nothing in the answer. | | `RESULT_SLICE_TOO_LARGE` | The slice is larger than `thresholdBytes`: read fewer items, fewer fields or a deeper path. | -| `INVALID_INPUT` | Both `page` and `offset` were given. | +| `INVALID_INPUT` | Both `page` and `offset` were given, or an argument is out of range. | ## Who can read a handle @@ -126,6 +127,11 @@ A handle is a random UUID, never derived from the answer. It's bound to the call caller is anonymous, to its authenticated subject. A different session or a different caller gets `RESULT_HANDLE_NOT_FOUND`, the same answer as for a handle that never existed, and never the data. +The store never sees the handle. Each answer is encrypted with AES-256-GCM, and both its encryption key and its +storage key are derived (HKDF-SHA256) from the handle, the session and the subject. The storage holds nothing readable +without the handle, and a lookup by another session or caller finds no record at all, as for an unknown or expired +handle. + ## Options | Option | Default | Description | @@ -150,8 +156,9 @@ ResultHandlesPlugin.init({ storage: { type: 'sqlite', sqlite: { path: '/var/lib/ SQLite needs `@frontmcp/storage-sqlite`. -Each folded answer is kept, as the tool returned it and unencrypted, until its TTL runs out. With memory storage, -`storage: { type: 'memory', memory: { maxEntries: 1000 } }` caps how many are held at once, dropping the oldest. +Each folded answer is kept, encrypted, until its TTL runs out. A memory store holds 1000 answers at most +(`DEFAULT_MEMORY_MAX_ENTRIES`) and drops the oldest past that; set `storage.memory.maxEntries` to change it, or to `0` +for no limit. ## Docs diff --git a/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts index 2d791646f..a2ecbb1e6 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts @@ -1,7 +1,7 @@ import { createMemoryStorage } from '@frontmcp/utils'; import { createResultHandleStore } from '../result-handle-store.factory'; -import { ResultHandleStore, type ResultOwner } from '../result-handle.store'; +import { addFoldingStore, foldingStoresOf, ResultHandleStore, type ResultOwner } from '../result-handle.store'; const alice = { session: 'session-1', caller: 'alice' }; const answer = { rows: [1, 2, 3] }; @@ -65,19 +65,60 @@ describe('ResultHandleStore', () => { expect(await store.read(handle, alice)).toBeUndefined(); }); - it('reads nothing from a key that does not hold a stored answer', async () => { + async function rootStore() { const root = createMemoryStorage(); await root.connect(); - const storage = root.namespace('result-handles'); - const store = new ResultHandleStore(storage, 60); - const handle = '3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60'; + return { root, store: new ResultHandleStore(root.namespace('result-handles'), 60) }; + } + + it('keeps an answer encrypted, under a key that is not its handle', async () => { + const { root, store } = await rootStore(); + const handle = await saved(store, alice); + + const keys = await root.keys('*'); + expect(keys).toHaveLength(1); + expect(keys[0]).not.toContain(handle); + const stored = (await root.get(keys[0])) ?? ''; + expect(stored).not.toContain('rows'); + expect(stored).not.toContain('alice'); + expect(stored).not.toContain('session-1'); + }); + + it("keeps each owner's answer under its own key, so another owner looks up a key that holds nothing", async () => { + const { root, store } = await rootStore(); + const get = jest.spyOn(root.root, 'get'); + const handle = await saved(store, alice); - await storage.set(handle, 'not json'); + await store.read(handle, alice); + await store.read(handle, { session: 'session-2', caller: 'alice' }); + const [ownKey, otherKey] = get.mock.calls.map(([key]) => key); + expect(otherKey).not.toBe(ownKey); + expect(await get.mock.results[1].value).toBeNull(); + }); + + it('reads nothing from a record it cannot decrypt', async () => { + const { root, store } = await rootStore(); + const handle = await saved(store, alice); + const [key] = await root.keys('*'); + const blob = JSON.parse((await root.get(key)) ?? '{}') as Record; + + await root.set(key, JSON.stringify({ ...blob, tag: 'A'.repeat(22) })); expect(await store.read(handle, alice)).toBeUndefined(); - await storage.set(handle, JSON.stringify({ owner: 'alice', value: 1 })); + await root.set(key, 'not json'); expect(await store.read(handle, alice)).toBeUndefined(); }); + it('lists, once each, the stores that folded an answer in a scope', async () => { + const scope = {}; + const store = await memoryStore(); + + expect([...foldingStoresOf(scope)]).toEqual([]); + addFoldingStore(scope, store); + addFoldingStore(scope, store); + expect([...foldingStoresOf(scope)]).toEqual([store]); + expect([...foldingStoresOf({})]).toEqual([]); + }); + it('disconnects its storage at shutdown', async () => { const storage = createMemoryStorage(); const disconnect = jest.spyOn(storage.root, 'disconnect'); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts index 1568681e0..49df5942a 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts @@ -1,5 +1,5 @@ import { ResultHandlesConfigurationError } from '../result-handles.errors'; -import { parseResultHandlesOptions } from '../result-handles.options'; +import { DEFAULT_MEMORY_MAX_ENTRIES, MIN_THRESHOLD_BYTES, parseResultHandlesOptions } from '../result-handles.options'; import ResultHandlesPlugin from '../result-handles.plugin'; describe('parseResultHandlesOptions', () => { @@ -9,7 +9,7 @@ describe('parseResultHandlesOptions', () => { thresholdBytes: 16 * 1024, ttlSeconds: 600, preview: { items: 5, chars: 500 }, - storage: { type: 'auto' }, + storage: { type: 'auto', memory: { maxEntries: 1000 } }, namespace: 'result-handles', }); expect(options.foldPolicy('any_tool', {}, { bytes: 1, fullName: 'app:any_tool', request: {} as never })).toBe(true); @@ -30,13 +30,21 @@ describe('parseResultHandlesOptions', () => { ttlSeconds: 30, preview: { items: 2, chars: 500 }, foldPolicy, - storage: { type: 'memory' }, + storage: { type: 'memory', memory: { maxEntries: 1000 } }, namespace: 'answers', }); }); + it('caps the memory store at 1000 answers unless told otherwise', () => { + expect(DEFAULT_MEMORY_MAX_ENTRIES).toBe(1000); + const unlimited = parseResultHandlesOptions({ storage: { type: 'memory', memory: { maxEntries: 0 } } }); + expect(unlimited.storage).toEqual({ type: 'memory', memory: { maxEntries: 0 } }); + const swept = parseResultHandlesOptions({ storage: { type: 'auto', memory: { sweepIntervalSeconds: 5 } } }); + expect(swept.storage).toEqual({ type: 'auto', memory: { maxEntries: 1000, sweepIntervalSeconds: 5 } }); + }); + it.each([ - [{ thresholdBytes: 100 }, 'thresholdBytes: must be a whole number of at least 1024'], + [{ thresholdBytes: MIN_THRESHOLD_BYTES - 1 }, 'thresholdBytes: must be a whole number of at least 1024'], [{ thresholdBytes: 2048.5 }, 'thresholdBytes: must be a whole number of at least 1024'], [{ ttlSeconds: 0 }, 'ttlSeconds: must be a whole number of seconds, at least 1'], [{ preview: { items: 0 } }, 'preview.items: must be a whole number, at least 1'], diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts index 48d4d2c64..e2c389d4b 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -372,6 +372,20 @@ describe('ResultHandlesPlugin', () => { expect(textAt(result, 0)).toContain('Read fewer items, fewer fields or a deeper path'); }); + it('refuses a page past any list as invalid input', async () => { + const client = await open(); + const handle = await foldedHandle(client); + + const result = await call(client, 'result_query', { + handle, + path: 'rows', + page: Number.MAX_SAFE_INTEGER, + limit: 100_000, + }); + expect(result.isError).toBe(true); + expect(result._meta?.['code']).toBe('INVALID_INPUT'); + }); + it('answers a path with no value with RESULT_PATH_NOT_FOUND', async () => { const client = await open(); const handle = await foldedHandle(client); @@ -390,6 +404,7 @@ describe('ResultHandlesPlugin', () => { expect(result.isError).toBe(true); expect(result._meta?.['code']).toBe('RESULT_HANDLE_NOT_FOUND'); expect(JSON.stringify(result)).not.toContain('Body of row'); + expect(JSON.stringify(result)).not.toContain(handle); }); it('answers another caller in the same session with "not found"', async () => { @@ -416,6 +431,36 @@ describe('ResultHandlesPlugin', () => { }); }); + describe('on two apps', () => { + @Tool({ name: 'ledger_rows', inputSchema: { count: z.number() } }) + class LedgerRowsTool extends ExportRowsTool {} + + @Tool({ name: 'archive_rows', inputSchema: { count: z.number() } }) + class ArchiveRowsTool extends ExportRowsTool {} + + const plugins = () => [ResultHandlesPlugin.init({ thresholdBytes: 4096, storage: { type: 'memory' } })]; + + @App({ id: 'ledger', name: 'Ledger', tools: [LedgerRowsTool], plugins: plugins() }) + class LedgerApp {} + + @App({ id: 'archive', name: 'Archive', tools: [ArchiveRowsTool], plugins: plugins() }) + class ArchiveApp {} + + it('lists one result_query, which reads the answers either app folded', async () => { + const client = await connect({ ...serverConfig, apps: [LedgerApp, ArchiveApp] }); + clients.push(client); + + const tools = (await client.listTools()) as unknown as Array<{ name: string }>; + expect(tools.map((listed) => listed.name).sort()).toEqual(['archive_rows', 'ledger_rows', 'result_query']); + for (const toolName of ['ledger_rows', 'archive_rows']) { + const folded = await call(client, toolName, { count: 200 }); + const { handle } = folded.structuredContent as unknown as FoldedAnswer; + const slice = await call(client, 'result_query', { handle, path: 'rows[150].id' }); + expect(slice.structuredContent).toEqual({ path: 'rows[150].id', value: 150 }); + } + }); + }); + describe('a call nobody could read back', () => { it('is left alone', async () => { const plugin = new ResultHandlesPlugin({ thresholdBytes: 1024 }); diff --git a/plugins/plugin-result-handles/src/index.ts b/plugins/plugin-result-handles/src/index.ts index 9e95888d2..b7062dff2 100644 --- a/plugins/plugin-result-handles/src/index.ts +++ b/plugins/plugin-result-handles/src/index.ts @@ -2,6 +2,7 @@ export { default, default as ResultHandlesPlugin } from './result-handles.plugin export { default as ResultQueryTool, RESULT_QUERY_TOOL_NAME } from './result-query.tool'; export { foldedAnswerSchema, type AnswerShape, type FoldedAnswer } from './folded-answer'; export { + DEFAULT_MEMORY_MAX_ENTRIES, MIN_THRESHOLD_BYTES, RESULT_HANDLE_META_KEY, type FoldContext, diff --git a/plugins/plugin-result-handles/src/result-handle.store.ts b/plugins/plugin-result-handles/src/result-handle.store.ts index 85d0c59b3..66e427c82 100644 --- a/plugins/plugin-result-handles/src/result-handle.store.ts +++ b/plugins/plugin-result-handles/src/result-handle.store.ts @@ -1,6 +1,12 @@ -import { z } from '@frontmcp/lazy-zod'; import type { OnShutdown } from '@frontmcp/sdk'; -import { randomUUID, type NamespacedStorage } from '@frontmcp/utils'; +import { + base64urlEncode, + deserializeAndDecrypt, + encryptAndSerialize, + hkdfSha256, + randomUUID, + type NamespacedStorage, +} from '@frontmcp/utils'; /** Who may read a folded answer: the session and the authenticated caller it was folded for. */ export interface ResultOwner { @@ -12,10 +18,18 @@ export interface ResultOwner { const HANDLE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const storedResultSchema = z.object({ - owner: z.object({ session: z.string().optional(), caller: z.string().optional() }).strict(), - value: z.unknown(), -}); +const encoder = new TextEncoder(); +const KEY_SALT = encoder.encode('frontmcp-result-handles-v1'); + +/** A 32-byte secret of `handle` and `owner`: the storage key (`'id'`) or the AES-256-GCM key (`'key'`) of an answer. */ +function deriveFromHandle(purpose: 'id' | 'key', handle: string, owner: ResultOwner): Uint8Array { + const info = encoder.encode(JSON.stringify([purpose, owner.session ?? null, owner.caller ?? null])); + return hkdfSha256(encoder.encode(handle), KEY_SALT, info, 32); +} + +function storageKeyOf(handle: string, owner: ResultOwner): string { + return base64urlEncode(deriveFromHandle('id', handle, owner)); +} /** The caller of a tool or a hook's call: its verified session and, unless anonymous, its subject. */ export function resultOwnerOf(context: { @@ -27,7 +41,7 @@ export function resultOwnerOf(context: { return { ...(session && { session }), ...(caller && { caller }) }; } -/** Folded answers, each under a random handle, readable by its owner until its TTL runs out. */ +/** Folded answers, encrypted under keys derived from their handle and owner, each readable by that owner until its TTL. */ export class ResultHandleStore implements OnShutdown { constructor( private readonly storage: NamespacedStorage, @@ -41,18 +55,20 @@ export class ResultHandleStore implements OnShutdown { /** Keep `value` under `handle` for `owner` until the TTL runs out. */ async save(handle: string, value: unknown, owner: ResultOwner): Promise { - await this.storage.set(handle, JSON.stringify({ owner, value }), { ttlSeconds: this.ttlSeconds }); + const encrypted = encryptAndSerialize(value, deriveFromHandle('key', handle, owner)); + await this.storage.set(storageKeyOf(handle, owner), encrypted, { ttlSeconds: this.ttlSeconds }); } /** The value under `handle`, or undefined when it expired, never existed, or `owner` is not its owner. */ async read(handle: string, owner: ResultOwner): Promise<{ value: unknown } | undefined> { if (!HANDLE.test(handle)) return undefined; - const stored = await this.storage.get(handle); + const stored = await this.storage.get(storageKeyOf(handle, owner)); if (stored === null) return undefined; - const record = storedResultSchema.safeParse(parseJson(stored)); - if (!record.success) return undefined; - const { session, caller } = record.data.owner; - return session === owner.session && caller === owner.caller ? { value: record.data.value } : undefined; + try { + return { value: deserializeAndDecrypt(stored, deriveFromHandle('key', handle, owner)) }; + } catch { + return undefined; + } } async onShutdown(): Promise { @@ -60,10 +76,15 @@ export class ResultHandleStore implements OnShutdown { } } -function parseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return undefined; - } +const foldingStoresByScope = new WeakMap>(); + +/** Records that `store` folded an answer in `scope`, whose one listed `result_query` may belong to another app's copy. */ +export function addFoldingStore(scope: object, store: ResultHandleStore): void { + const stores = foldingStoresByScope.get(scope) ?? new Set(); + foldingStoresByScope.set(scope, stores.add(store)); +} + +/** The stores of every copy of the plugin that folded an answer in `scope`. */ +export function foldingStoresOf(scope: object): ReadonlySet { + return foldingStoresByScope.get(scope) ?? new Set(); } diff --git a/plugins/plugin-result-handles/src/result-handles.errors.ts b/plugins/plugin-result-handles/src/result-handles.errors.ts index 9b73c9b4c..79d078084 100644 --- a/plugins/plugin-result-handles/src/result-handles.errors.ts +++ b/plugins/plugin-result-handles/src/result-handles.errors.ts @@ -4,9 +4,9 @@ import { InternalMcpError, PublicMcpError } from '@frontmcp/sdk'; export class ResultHandleNotFoundError extends PublicMcpError { override readonly name = 'ResultHandleNotFoundError'; - constructor(handle: string) { + constructor() { super( - `No result for handle "${handle}": it expired, or it belongs to another session or caller.`, + 'No result for this handle: it expired, or it belongs to another session or caller.', 'RESULT_HANDLE_NOT_FOUND', 404, undefined, diff --git a/plugins/plugin-result-handles/src/result-handles.options.ts b/plugins/plugin-result-handles/src/result-handles.options.ts index c2693912e..8e437e2c1 100644 --- a/plugins/plugin-result-handles/src/result-handles.options.ts +++ b/plugins/plugin-result-handles/src/result-handles.options.ts @@ -10,6 +10,9 @@ export const RESULT_HANDLE_META_KEY = 'frontmcp/result-handle'; /** The smallest `thresholdBytes`: a folded answer takes about this much itself. */ export const MIN_THRESHOLD_BYTES = 1024; +/** How many answers a memory store holds at once, dropping the oldest, unless `storage.memory.maxEntries` is set. */ +export const DEFAULT_MEMORY_MAX_ENTRIES = 1000; + /** What a fold policy knows about an answer over the threshold. */ export interface FoldContext { /** The answer's size: UTF-8 bytes of its JSON. */ @@ -32,7 +35,7 @@ export interface ResultHandlesPluginOptionsInput { preview?: { items?: number; chars?: number }; /** Which answers over the threshold fold (every one by default). */ foldPolicy?: FoldPolicy; - /** Where folded answers are kept, through `createStorage()` from `@frontmcp/utils` ({ type: 'auto' }). */ + /** Where folded answers are kept, through `createStorage()` from `@frontmcp/utils` ({ type: 'auto' }, 1000 at most in memory). */ storage?: StorageConfig; /** The key prefix of folded answers in the storage ('result-handles'). */ namespace?: string; @@ -79,7 +82,8 @@ const resultHandlesOptionsSchema = z .custom((value) => typeof value === 'object' && value !== null && !Array.isArray(value), { message: 'must be a storage config, such as { type: "redis", redis: { url } }', }) - .default({ type: 'auto' }), + .default({ type: 'auto' }) + .transform((storage) => ({ ...storage, memory: { maxEntries: DEFAULT_MEMORY_MAX_ENTRIES, ...storage.memory } })), namespace: z.string().min(1, { message: 'must not be empty' }).default('result-handles'), }) .strict(); diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts index e12e0ecbd..05543a2d1 100644 --- a/plugins/plugin-result-handles/src/result-handles.plugin.ts +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -12,7 +12,7 @@ import { import { jsonBytes, previewOf, shapeOf, type FoldedAnswer } from './folded-answer'; import { createResultHandleStore } from './result-handle-store.factory'; -import { resultOwnerOf } from './result-handle.store'; +import { addFoldingStore, resultOwnerOf } from './result-handle.store'; import { parseResultHandlesOptions, RESULT_HANDLE_META_KEY, @@ -124,6 +124,7 @@ export default class ResultHandlesPlugin extends DynamicPlugin< ); return; } + addFoldingStore(toolContext.scope, store); const { ttlSeconds } = this.options; const note = `This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. ` + diff --git a/plugins/plugin-result-handles/src/result-query.tool.ts b/plugins/plugin-result-handles/src/result-query.tool.ts index f74e1ab56..1bca893ea 100644 --- a/plugins/plugin-result-handles/src/result-query.tool.ts +++ b/plugins/plugin-result-handles/src/result-query.tool.ts @@ -2,13 +2,15 @@ import { z } from '@frontmcp/lazy-zod'; import { Tool, ToolContext } from '@frontmcp/sdk'; import { jsonBytes } from './folded-answer'; -import { resultOwnerOf } from './result-handle.store'; +import { foldingStoresOf, resultOwnerOf } from './result-handle.store'; import { ResultHandleNotFoundError, ResultSliceTooLargeError } from './result-handles.errors'; import { ResultHandlesConfigToken, ResultHandleStoreToken } from './result-handles.symbols'; import { DEFAULT_CHAR_LIMIT, DEFAULT_ITEM_LIMIT, queryResult, type ResultSlice } from './result-query'; export const RESULT_QUERY_TOOL_NAME = 'result_query'; +const MAX_LIMIT = 100_000; + export const resultQueryInputSchema = { handle: z.string().min(1).max(100).describe('The handle of a folded answer'), path: z @@ -23,10 +25,16 @@ export const resultQueryInputSchema = { .number() .int() .min(1) - .max(100_000) + .max(MAX_LIMIT) .optional() .describe(`How many list items (${DEFAULT_ITEM_LIMIT} by default) or characters (${DEFAULT_CHAR_LIMIT}) to read`), - page: z.number().int().min(1).optional().describe('A page of limit items, from 1, in place of offset'), + page: z + .number() + .int() + .min(1) + .max(Math.floor(Number.MAX_SAFE_INTEGER / MAX_LIMIT)) + .optional() + .describe('A page of limit items, from 1, in place of offset'), fields: z .array(z.string().min(1)) .max(100) @@ -64,8 +72,13 @@ type ResultQueryInput = { }) export default class ResultQueryTool extends ToolContext { async execute({ handle, ...query }: ResultQueryInput): Promise { - const stored = await this.get(ResultHandleStoreToken).read(handle, resultOwnerOf(this)); - if (!stored) throw new ResultHandleNotFoundError(handle); + const owner = resultOwnerOf(this); + let stored: { value: unknown } | undefined; + for (const store of new Set([this.get(ResultHandleStoreToken), ...foldingStoresOf(this.scope)])) { + stored = await store.read(handle, owner); + if (stored) break; + } + if (!stored) throw new ResultHandleNotFoundError(); const slice = queryResult(stored.value, query); const { thresholdBytes } = this.get(ResultHandlesConfigToken); const bytes = jsonBytes(slice.value); From 6408bc8ff3a2ee154db1240faa6c5bc481149e08 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 17:02:13 +0200 Subject: [PATCH 5/7] fix(plugin-result-handles): leave an answer with several texts whole, and take init providers An answer with structuredContent and several text contents was folded from its structuredContent alone, so the other texts were neither sent nor kept for result_query. It is now left whole, as an answer of several texts was. ResultHandlesPlugin.init({ providers }) failed with Unrecognized key "providers"; the extra providers every plugin init() takes are now set apart before the options are checked. The docs also say that a text that is a JSON object or array reaches the fold policy and result_query parsed. --- .../references/result-handles-plugin.md | 3 ++- plugins/plugin-result-handles/README.md | 7 ++++--- .../__tests__/result-handles.options.spec.ts | 8 ++++++++ .../__tests__/result-handles.plugin.spec.ts | 18 ++++++++++++++++++ .../src/result-handles.options.ts | 3 ++- .../src/result-handles.plugin.ts | 4 ++-- 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md index b58c548d9..a04105648 100644 --- a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -103,7 +103,8 @@ ResultHandlesPlugin.init({ }); ``` -The policy is asked only for answers over the threshold. `answer` is the answer's `structuredContent`, or its text. +The policy is asked only for answers over the threshold. `answer` is the answer's `structuredContent`, or its text: a +text that is a JSON object or array is parsed, so the policy (and `result_query`) gets the value, not the string. ## What the Model Sees diff --git a/plugins/plugin-result-handles/README.md b/plugins/plugin-result-handles/README.md index bd6ff9479..47bfabb18 100644 --- a/plugins/plugin-result-handles/README.md +++ b/plugins/plugin-result-handles/README.md @@ -71,12 +71,13 @@ The plugin runs on the `fitAnswer` stage of `tools/call`, before the answer budg sees the folded answer. An answer folds when all of these hold: - a client called the tool: a tool calling another with `this.callTool()` gets the whole answer; -- the answer is text only: `structuredContent`, or one text content. Images, audio, resources, several texts, error - results and answers that render a widget are left alone; +- the answer is `structuredContent` with at most one text content, or one text content. Images, audio, resources, + several texts (even beside `structuredContent`), error results and answers that render a widget are left alone; - its data, `structuredContent` or the text, is larger than `thresholdBytes` as UTF-8 JSON; - the call has a verified session or an authenticated caller. A stateless anonymous call is left alone, since nobody could read its handle back; -- `foldPolicy(toolName, answer, context)` returns `true`; +- `foldPolicy(toolName, answer, context)` returns `true`. `answer` is the `structuredContent`, or the text; a text that + is a JSON object or array is parsed, so the policy and `result_query` get the value; - the tool's output schema admits the folded form (below). When the store can't keep the answer (Redis is down, say), the answer is sent whole and the plugin logs a warning. diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts index 49df5942a..4c6e7a9d9 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts @@ -58,6 +58,14 @@ describe('parseResultHandlesOptions', () => { expect(() => parseResultHandlesOptions(input as never)).toThrow(message); }); + it('takes extra providers beside its options, as every plugin init() does', () => { + const extra = { name: 'extra', provide: Symbol('EXTRA'), useValue: 1 }; + + const record = ResultHandlesPlugin.init({ thresholdBytes: 4096, providers: [extra] }); + + expect(record.providers).toContain(extra); + }); + it('refuses invalid options when the plugin is created', () => { expect(() => ResultHandlesPlugin.init({ thresholdBytes: 10 })).toThrow(ResultHandlesConfigurationError); expect(() => new ResultHandlesPlugin({ ttlSeconds: -5 })).toThrow(ResultHandlesConfigurationError); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts index e2c389d4b..1e2fb6771 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -93,6 +93,20 @@ class TwoTextsTool extends ToolContext { } } +@Tool({ name: 'rows_and_summary', inputSchema: {} }) +class RowsAndSummaryTool extends ToolContext { + async execute() { + const data = { rows: rows(100), total: 100 }; + return { + content: [ + { type: 'text', text: JSON.stringify(data) }, + { type: 'text', text: 'Exported 100 rows from the archive.' }, + ], + structuredContent: data, + }; + } +} + @Tool({ name: 'chart', inputSchema: {} }) class ChartTool extends ToolContext { async execute() { @@ -143,6 +157,7 @@ const foldPolicy = jest.fn((toolName: string, _answer: unknown, _context: FoldCo LongTextTool, RowsTextTool, TwoTextsTool, + RowsAndSummaryTool, ChartTool, FailingRowsTool, RowCountTool, @@ -322,6 +337,9 @@ describe('ResultHandlesPlugin', () => { const client = await open(); expect((await call(client, 'chart')).content[0]?.type).toBe('image'); expect((await call(client, 'two_texts')).content).toHaveLength(2); + const withSummary = await call(client, 'rows_and_summary'); + expect(textAt(withSummary, 1)).toBe('Exported 100 rows from the archive.'); + expect(withSummary.structuredContent).toEqual({ rows: rows(100), total: 100 }); const failing = await call(client, 'failing_rows'); expect(failing.isError).toBe(true); expect(JSON.parse(textAt(failing, 0))).toHaveLength(100); diff --git a/plugins/plugin-result-handles/src/result-handles.options.ts b/plugins/plugin-result-handles/src/result-handles.options.ts index 8e437e2c1..a3ec104bd 100644 --- a/plugins/plugin-result-handles/src/result-handles.options.ts +++ b/plugins/plugin-result-handles/src/result-handles.options.ts @@ -90,7 +90,8 @@ const resultHandlesOptionsSchema = z /** The plugin's options with their defaults, or a {@link ResultHandlesConfigurationError} naming each problem. */ export function parseResultHandlesOptions(input: ResultHandlesPluginOptionsInput): ResultHandlesPluginOptions { - const result = resultHandlesOptionsSchema.safeParse(input); + const { providers, ...options } = input as ResultHandlesPluginOptionsInput & { providers?: unknown }; + const result = resultHandlesOptionsSchema.safeParse(Array.isArray(providers) ? options : input); if (result.success) return result.data; const problems = result.error.issues.map((issue) => issue.path.length > 0 ? `${issue.path.join('.')}: ${issue.message}` : issue.message, diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts index 05543a2d1..34bedd519 100644 --- a/plugins/plugin-result-handles/src/result-handles.plugin.ts +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -34,10 +34,10 @@ function parsedJsonText(text: string): unknown { } } -/** What an answer of text content only carries: its `structuredContent`, else its one text (as JSON when it is JSON). */ +/** What an answer of at most one text content carries: its `structuredContent`, else its text (as JSON when it is JSON). */ function answerDataOf(result: CallToolResult): unknown { const texts = result.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])); - if (texts.length !== result.content.length) return undefined; + if (texts.length !== result.content.length || texts.length > 1) return undefined; if (result.structuredContent !== undefined) return result.structuredContent; if (texts.length !== 1) return undefined; return parsedJsonText(texts[0]) ?? texts[0]; From 941c23c299b1064e0de7a5a9c80b72a22147d67a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 17:19:36 +0200 Subject: [PATCH 6/7] fix(plugin-result-handles): leave structuredContent with a text that says something else whole An answer with structuredContent and one text that was not a copy of it (a note or a warning) was folded from structuredContent, so the text was lost. It now folds only when the text repeats structuredContent as the SDK writes it: its JSON, or the primitive that { content } wraps. The README example also imports z. --- .../references/result-handles-plugin.md | 16 ++++++++-------- plugins/plugin-result-handles/README.md | 6 ++++-- .../src/__tests__/result-handles.plugin.spec.ts | 14 ++++++++++++++ .../src/result-handles.plugin.ts | 14 ++++++++++++-- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md index a04105648..713dc62dc 100644 --- a/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -169,14 +169,14 @@ It answers `{ path, value, total?, offset?, nextOffset? }`. ## Troubleshooting -| Problem | Cause | Solution | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| Warning: "does not admit the folded form" | The tool's output schema rejects `{ preview, shape, handle }` | Add `foldedAnswerSchema` to the schema as a union member | -| A large answer is not folded | Not text only, an error result, a widget answer, an in-process call, or a stateless anonymous call | Fold only applies to a client's text answer with a session or a signed-in caller | -| Warning: "Could not keep the answer" | The store failed to save it (Redis down, say); the answer was sent whole | Check the `storage` connection | -| `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, another instance or a full memory store | Call the tool again; use a shared store across instances | -| `RESULT_SLICE_TOO_LARGE` | The slice is over `thresholdBytes` | Read fewer items, fewer `fields`, or a deeper `path` | -| `RESULT_PATH_NOT_FOUND` | The path is malformed or names nothing | Follow the keys the `shape` lists | +| Problem | Cause | Solution | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Warning: "does not admit the folded form" | The tool's output schema rejects `{ preview, shape, handle }` | Add `foldedAnswerSchema` to the schema as a union member | +| A large answer is not folded | Not text only, several texts or a text beside `structuredContent` that says something else, an error result, a widget answer, an in-process call, or a stateless anonymous call | Fold only applies to a client's text answer with a session or a signed-in caller | +| Warning: "Could not keep the answer" | The store failed to save it (Redis down, say); the answer was sent whole | Check the `storage` connection | +| `RESULT_HANDLE_NOT_FOUND` | Expired, another session or caller, another instance or a full memory store | Call the tool again; use a shared store across instances | +| `RESULT_SLICE_TOO_LARGE` | The slice is over `thresholdBytes` | Read fewer items, fewer `fields`, or a deeper `path` | +| `RESULT_PATH_NOT_FOUND` | The path is malformed or names nothing | Follow the keys the `shape` lists | ## Reference diff --git a/plugins/plugin-result-handles/README.md b/plugins/plugin-result-handles/README.md index 47bfabb18..29d09cd5a 100644 --- a/plugins/plugin-result-handles/README.md +++ b/plugins/plugin-result-handles/README.md @@ -71,8 +71,9 @@ The plugin runs on the `fitAnswer` stage of `tools/call`, before the answer budg sees the folded answer. An answer folds when all of these hold: - a client called the tool: a tool calling another with `this.callTool()` gets the whole answer; -- the answer is `structuredContent` with at most one text content, or one text content. Images, audio, resources, - several texts (even beside `structuredContent`), error results and answers that render a widget are left alone; +- the answer is one text content, or `structuredContent` with at most one text that only repeats it (as the SDK + writes it). Images, audio, resources, several texts, a text beside `structuredContent` that says something else, + error results and answers that render a widget are left alone; - its data, `structuredContent` or the text, is larger than `thresholdBytes` as UTF-8 JSON; - the call has a verified session or an authenticated caller. A stateless anonymous call is left alone, since nobody could read its handle back; @@ -89,6 +90,7 @@ only when that schema admits `{ preview, shape, handle }`. Add `foldedAnswerSche ```ts import { foldedAnswerSchema } from '@frontmcp/plugin-result-handles'; +import { Tool, ToolContext, z } from '@frontmcp/sdk'; const reportSchema = z.object({ orders: z.array(orderSchema), total: z.number() }); diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts index 1e2fb6771..d747db7bc 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -107,6 +107,16 @@ class RowsAndSummaryTool extends ToolContext { } } +@Tool({ name: 'rows_with_note', inputSchema: {} }) +class RowsWithNoteTool extends ToolContext { + async execute() { + return { + content: [{ type: 'text', text: 'Rows past 100 are in the archive.' }], + structuredContent: { rows: rows(100), total: 100 }, + }; + } +} + @Tool({ name: 'chart', inputSchema: {} }) class ChartTool extends ToolContext { async execute() { @@ -158,6 +168,7 @@ const foldPolicy = jest.fn((toolName: string, _answer: unknown, _context: FoldCo RowsTextTool, TwoTextsTool, RowsAndSummaryTool, + RowsWithNoteTool, ChartTool, FailingRowsTool, RowCountTool, @@ -340,6 +351,9 @@ describe('ResultHandlesPlugin', () => { const withSummary = await call(client, 'rows_and_summary'); expect(textAt(withSummary, 1)).toBe('Exported 100 rows from the archive.'); expect(withSummary.structuredContent).toEqual({ rows: rows(100), total: 100 }); + const withNote = await call(client, 'rows_with_note'); + expect(textAt(withNote, 0)).toBe('Rows past 100 are in the archive.'); + expect(withNote.structuredContent).toEqual({ rows: rows(100), total: 100 }); const failing = await call(client, 'failing_rows'); expect(failing.isError).toBe(true); expect(JSON.parse(textAt(failing, 0))).toHaveLength(100); diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts index 34bedd519..5ad2e4de9 100644 --- a/plugins/plugin-result-handles/src/result-handles.plugin.ts +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -34,11 +34,21 @@ function parsedJsonText(text: string): unknown { } } -/** What an answer of at most one text content carries: its `structuredContent`, else its text (as JSON when it is JSON). */ +/** Whether `text` only repeats `structured` as the SDK writes it: its JSON, or the primitive `{ content }` wraps. */ +function repeatsStructuredContent(text: string, structured: Record): boolean { + if (JSON.stringify(parsedJsonText(text) ?? text) === JSON.stringify(structured)) return true; + const keys = Object.keys(structured); + return keys.length === 1 && keys[0] === 'content' && text === String(structured['content']); +} + +/** The answer's data: `structuredContent` when its one text only repeats it, else its one text (parsed when JSON). */ function answerDataOf(result: CallToolResult): unknown { const texts = result.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])); if (texts.length !== result.content.length || texts.length > 1) return undefined; - if (result.structuredContent !== undefined) return result.structuredContent; + const structured = result.structuredContent; + if (structured !== undefined) { + return texts.length === 0 || repeatsStructuredContent(texts[0], structured) ? structured : undefined; + } if (texts.length !== 1) return undefined; return parsedJsonText(texts[0]) ?? texts[0]; } From 3d917c0b0d02b2608adc96597c8f149219d7a110 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 17:37:12 +0200 Subject: [PATCH 7/7] fix(plugin-result-handles): compare only a primitive { content } with the answer's text String() throws on an object whose toString is not callable, so a structuredContent of { content: } beside another text made the fold hook throw and failed the tool call. Only a primitive is now compared with the text. --- .../src/__tests__/result-handles.plugin.spec.ts | 14 ++++++++++++++ .../src/result-handles.plugin.ts | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts index d747db7bc..d2e0ec727 100644 --- a/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -117,6 +117,16 @@ class RowsWithNoteTool extends ToolContext { } } +@Tool({ name: 'echoed_object', inputSchema: {} }) +class EchoedObjectTool extends ToolContext { + async execute() { + return { + content: [{ type: 'text', text: 'Echoed the request.' }], + structuredContent: { content: { toString: 1, rows: rows(100) } }, + }; + } +} + @Tool({ name: 'chart', inputSchema: {} }) class ChartTool extends ToolContext { async execute() { @@ -169,6 +179,7 @@ const foldPolicy = jest.fn((toolName: string, _answer: unknown, _context: FoldCo TwoTextsTool, RowsAndSummaryTool, RowsWithNoteTool, + EchoedObjectTool, ChartTool, FailingRowsTool, RowCountTool, @@ -354,6 +365,9 @@ describe('ResultHandlesPlugin', () => { const withNote = await call(client, 'rows_with_note'); expect(textAt(withNote, 0)).toBe('Rows past 100 are in the archive.'); expect(withNote.structuredContent).toEqual({ rows: rows(100), total: 100 }); + const echoed = await call(client, 'echoed_object'); + expect(echoed.isError).toBeFalsy(); + expect(textAt(echoed, 0)).toBe('Echoed the request.'); const failing = await call(client, 'failing_rows'); expect(failing.isError).toBe(true); expect(JSON.parse(textAt(failing, 0))).toHaveLength(100); diff --git a/plugins/plugin-result-handles/src/result-handles.plugin.ts b/plugins/plugin-result-handles/src/result-handles.plugin.ts index 5ad2e4de9..f52bd104c 100644 --- a/plugins/plugin-result-handles/src/result-handles.plugin.ts +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -38,7 +38,9 @@ function parsedJsonText(text: string): unknown { function repeatsStructuredContent(text: string, structured: Record): boolean { if (JSON.stringify(parsedJsonText(text) ?? text) === JSON.stringify(structured)) return true; const keys = Object.keys(structured); - return keys.length === 1 && keys[0] === 'content' && text === String(structured['content']); + const content = structured['content']; + const isPrimitive = content === null || typeof content !== 'object'; + return keys.length === 1 && keys[0] === 'content' && isPrimitive && text === String(content); } /** The answer's data: `structuredContent` when its one text only repeats it, else its one text (parsed when JSON). */