Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
109 changes: 109 additions & 0 deletions apps/e2e/demo-e2e-answer-shaping/e2e/result-handles.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, { type: string; length?: number }> };
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();
});
});
16 changes: 14 additions & 2 deletions apps/e2e/demo-e2e-answer-shaping/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
]
}
}
}
26 changes: 26 additions & 0 deletions apps/e2e/demo-e2e-answer-shaping/src/apps/results/index.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Original file line number Diff line number Diff line change
@@ -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 };
}
}
13 changes: 13 additions & 0 deletions apps/e2e/demo-e2e-answer-shaping/src/apps/results/tools/orders.ts
Original file line number Diff line number Diff line change
@@ -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, '.'),
}));
}
Original file line number Diff line number Diff line change
@@ -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 };
}
}
3 changes: 2 additions & 1 deletion apps/e2e/demo-e2e-answer-shaping/src/main.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion libs/sdk/src/tool/flows/call-tool.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1733,7 +1733,7 @@ export default class CallToolFlow extends FlowBase<typeof name> {
}

/** 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());
}
Expand Down
4 changes: 3 additions & 1 deletion libs/skills/catalog/frontmcp-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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

Expand Down Expand Up @@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading