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..cea7b5f25 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts @@ -0,0 +1,109 @@ +/** 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("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; + + 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'); + expect(JSON.stringify(result.raw)).not.toContain(handle); + } 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..91662b7d3 --- /dev/null +++ b/apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts @@ -0,0 +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: [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/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..6bdf731d2 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 { 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], + apps: [ReportsApp, ResultsApp, ArchiveApp], 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..8f4228e15 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,28 @@ 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`, 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])`). + +```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..713dc62dc --- /dev/null +++ b/libs/skills/catalog/frontmcp-development/references/result-handles-plugin.md @@ -0,0 +1,184 @@ +--- +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. 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 + +### 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; 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 + +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: 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 + +```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, 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 + +- [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..29d09cd5a --- /dev/null +++ b/plugins/plugin-result-handles/README.md @@ -0,0 +1,185 @@ +# @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. 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'; +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 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; +- `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. + +## 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'; +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +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, or an argument is out of range. | + +## 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. + +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 | +| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `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`. + +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 + +- [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..a2ecbb1e6 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handle.store.spec.ts @@ -0,0 +1,130 @@ +import { createMemoryStorage } from '@frontmcp/utils'; + +import { createResultHandleStore } from '../result-handle-store.factory'; +import { addFoldingStore, foldingStoresOf, 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(); + }); + + async function rootStore() { + const root = createMemoryStorage(); + await root.connect(); + 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 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 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'); + 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..4c6e7a9d9 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.options.spec.ts @@ -0,0 +1,73 @@ +import { ResultHandlesConfigurationError } from '../result-handles.errors'; +import { DEFAULT_MEMORY_MAX_ENTRIES, MIN_THRESHOLD_BYTES, 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', memory: { maxEntries: 1000 } }, + 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', 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: 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'], + [{ 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('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 new file mode 100644 index 000000000..d2e0ec727 --- /dev/null +++ b/plugins/plugin-result-handles/src/__tests__/result-handles.plugin.spec.ts @@ -0,0 +1,533 @@ +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 { ResultHandleStore } from '../result-handle.store'; +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: '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: '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: '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() { + 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, + RowsAndSummaryTool, + RowsWithNoteTool, + EchoedObjectTool, + 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('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'); + 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 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); + 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('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); + + 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'); + expect(JSON.stringify(result)).not.toContain(handle); + }); + + 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('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 }); + 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..731e90689 --- /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]', '$["a\\x"]'])('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..b7062dff2 --- /dev/null +++ b/plugins/plugin-result-handles/src/index.ts @@ -0,0 +1,21 @@ +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, + 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..66e427c82 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handle.store.ts @@ -0,0 +1,90 @@ +import type { OnShutdown } from '@frontmcp/sdk'; +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 { + /** 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 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: { + 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, 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, + 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 { + 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(storageKeyOf(handle, owner)); + if (stored === null) return undefined; + try { + return { value: deserializeAndDecrypt(stored, deriveFromHandle('key', handle, owner)) }; + } catch { + return undefined; + } + } + + async onShutdown(): Promise { + await this.storage.disconnect(); + } +} + +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 new file mode 100644 index 000000000..79d078084 --- /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() { + super( + 'No result for this 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..a3ec104bd --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.options.ts @@ -0,0 +1,100 @@ +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; + +/** 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. */ + 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' }, 1000 at most in memory). */ + 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' }) + .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(); + +/** The plugin's options with their defaults, or a {@link ResultHandlesConfigurationError} naming each problem. */ +export function parseResultHandlesOptions(input: ResultHandlesPluginOptionsInput): ResultHandlesPluginOptions { + 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, + ); + 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..f52bd104c --- /dev/null +++ b/plugins/plugin-result-handles/src/result-handles.plugin.ts @@ -0,0 +1,167 @@ +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 { addFoldingStore, 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; + } +} + +/** 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); + 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). */ +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; + 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]; +} + +/** 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; + } + 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; + } + 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. ` + + `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 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.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.', + ); + } +} + +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..1bca893ea --- /dev/null +++ b/plugins/plugin-result-handles/src/result-query.tool.ts @@ -0,0 +1,88 @@ +import { z } from '@frontmcp/lazy-zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { jsonBytes } from './folded-answer'; +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 + .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(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) + .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) + .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 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); + 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..4b7aaf351 --- /dev/null +++ b/plugins/plugin-result-handles/src/result-query.ts @@ -0,0 +1,130 @@ +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 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'); + 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(parseQuotedKey(path, quoted)); + } + 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"