# Result handles

> Fold a large tool answer into a preview and a handle, then read just the parts the caller needs with result_query.

Source: https://frontmcp.dev/reference/plugins/result-handles

[Checked against FrontMCP 1.11.0](https://frontmcp.dev/versions#how-pages-are-checked) on November 4, 2026

A large answer can fill the model's context before it knows which rows matter. `ResultHandlesPlugin` keeps the full answer in storage and sends `{ preview, shape, handle }`. The model reads slices with `result_query`. New in 1.11.0.

```ts
import { ResultHandlesPlugin, foldedAnswerSchema } from "@frontmcp/plugin-result-handles";
ResultHandlesPlugin.init({ thresholdBytes?, ttlSeconds?, preview?, foldPolicy?, storage?, namespace? });
```



---

<a id="reference"></a>

## Reference

<a id="resulthandlesplugininitoptions"></a>

### `ResultHandlesPlugin.init(options)`

Install `@frontmcp/plugin-result-handles` at the same version as the SDK, and register it in `plugins`.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `thresholdBytes` | `number` | `16384` | An answer whose data is larger than this, as UTF-8 JSON, folds. Whole number, at least 1024. |
| `ttlSeconds` | `number` | `600` | Lifetime of a stored answer. Positive whole seconds. |
| `preview` | `{ items?: number; chars?: number }` | `{ items: 5, chars: 500 }` | Start of each list and string kept in the preview. Positive whole limits. |
| `foldPolicy` | `(toolName, answer, context) => boolean` | always `true` | Decides whether an answer over the threshold folds. Context has `bytes`, `fullName` and `request`. |
| `storage` | `StorageConfig` | `{ type: "auto" }` | Backing store. Memory holds at most 1000 answers unless `storage.memory.maxEntries` changes it. |
| `namespace` | `string` | `"result-handles"` | Storage key prefix. |

The fold runs before the answer budget. An answer folds only when all of these hold:

- A client called the tool. A tool that calls another with `this.callTool()` gets the full answer.
- The answer is one text, or `structuredContent` with at most one text that repeats it. Images, audio, resources, several texts, error results and answers that render a widget are sent as they are.
- The call has a verified session or an authenticated caller. A stateless anonymous call, such as an MCP 2026-07-28 request without a token, gets the full answer, since nobody could read its handle back.
- `foldPolicy` returns `true`, and the tool's output schema admits the folded form (see [Caveats](https://frontmcp.dev/reference/plugins/result-handles#caveats)).

A folded answer adds a text naming `result_query` and the handle, and `_meta["frontmcp/result-handle"]` holds `{ handle, bytes, ttlSeconds }`. If the store can't keep the answer, it is sent whole, with a warning in the server log.

<a id="result_query"></a>

### `result_query`

| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `handle` | `string` | required | Handle returned in the folded answer. |
| `path` | `string` | `"$"` | Dot path (`rows.2.title`, `$.rows[2].title`) or JSON pointer (`/rows/2/title`). |
| `offset` | `number` | `0` | First list item or string character. |
| `limit` | `number` | 20 list items or 2000 characters | Positive whole count, at most 100000. |
| `page` | `number` | none | One-based page of `limit` items or characters, in place of `offset`. A call with both is refused with `INVALID_INPUT`. |
| `fields` | `string[]` | all | Keys to keep in an object or each list item. |

Returns `{ path, value, total?, offset?, nextOffset? }`. A handle is bound to the session and the authenticated caller it was folded for. Expired, evicted and foreign handles all fail as not found. A slice larger than `thresholdBytes` is refused with `RESULT_SLICE_TOO_LARGE` rather than folded again.

<a id="store-and-schema-exports"></a>

### Store and schema exports

`createResultHandleStore({ storage, namespace, ttlSeconds })` returns `Promise<ResultHandleStore>`. All three options are required: `storage` is a `StorageConfig`, `namespace` a string prefix and `ttlSeconds` the answer lifetime in seconds. For an existing connection, `new ResultHandleStore(namespacedStorage, ttlSeconds)` uses that storage directly.

| Store method | Returns | Description |
| --- | --- | --- |
| `newHandle()` | `string` | A new random UUID. |
| `save(handle, value, owner)` | `Promise<void>` | Keep the value for `{ session?: string; caller?: string }` until its TTL. |
| `read(handle, owner)` | `Promise<{ value: unknown } \| undefined>` | Read as the same owner; missing, expired and foreign handles return `undefined`. |
| `onShutdown()` | `Promise<void>` | Disconnect the backing storage. Call it when a store you created is no longer needed. |

`ResultHandleStoreToken` and `ResultHandlesConfigToken` identify the plugin's injected store and configuration. `foldedAnswerSchema` validates `{ preview, shape, handle }`; `RESULT_HANDLE_META_KEY` is `"frontmcp/result-handle"`, `RESULT_QUERY_TOOL_NAME` is `"result_query"`, and `ResultQueryTool` is its tool class. `DEFAULT_MEMORY_MAX_ENTRIES` is 1000, and `MIN_THRESHOLD_BYTES` is 1024.

<a id="caveats"></a>

#### Caveats

A tool with an output schema folds only if that schema admits `{ preview, shape, handle }`. Add `foldedAnswerSchema` to a union; otherwise the plugin sends the original answer and warns once per tool. Redis and SQLite plugin stores are refused at browser startup; a memory store works in a page. Builds do not open plugin stores.

<a id="usage"></a>

## Usage

<a id="letting-the-model-read-a-report-in-pages"></a>

### Letting the model read a report in pages

`report` returns 200 tickets, about 7 KB, over the plugin's 4096-byte threshold. Its output schema is a union with `foldedAnswerSchema`, so it can fold. The Playground calls it without a token or a session, so it gets the full answer. The tests connect as signed-in users with [`connect()`](https://frontmcp.dev/reference/sdk/connect), get the folded answer, and read it with `result_query`:

Initial call configuration:

```js
{ tool: "report", arguments: {} }
```

```ts report.app.ts active
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ResultHandlesPlugin, foldedAnswerSchema } from "@frontmcp/plugin-result-handles";

const ticket = z.object({ id: z.string(), title: z.string() });

@Tool({
  name: "report",
  description: "Every open ticket",
  inputSchema: {},
  outputSchema: z.union([z.object({ rows: z.array(ticket) }), foldedAnswerSchema]),
})
class Report extends ToolContext {
  async execute() {
    return { rows: Array.from({ length: 200 }, (_, i) => ({ id: `T-${i}`, title: `Ticket ${i}` })) };
  }
}

@App({
  id: "reports",
  name: "Reports",
  tools: [Report],
  plugins: [ResultHandlesPlugin.init({ thresholdBytes: 4096, storage: { type: "memory" } })],
})
export class ReportsApp {}
```

```ts result-handles.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, connect } from "@frontmcp/sdk";
import { ReportsApp } from "./report.app";

const config = { info: { name: "reports", version: "1.0.0" }, apps: [ReportsApp] };
type Folded = { preview: { rows: unknown[] }; shape: any; handle: string };

test("an anonymous call without a session gets the full answer", async ({ mcp }) => {
  const result = await mcp.tools.call("report", {});
  expect((result.raw.structuredContent as { rows: unknown[] }).rows).toHaveLength(200);
  expect(result.raw._meta?.["frontmcp/result-handle"]).toBeUndefined();
});

test("a signed-in caller gets a preview, a shape and a handle", async () => {
  const client = await connect(config, { session: { user: { sub: "ana" } } });
  try {
    const result = (await client.callTool("report", {})) as any;
    const folded = result.structuredContent as Folded;
    expect(Object.keys(folded)).toEqual(["preview", "shape", "handle"]);
    expect(folded.preview.rows).toEqual([0, 1, 2, 3, 4].map((i) => ({ id: `T-${i}`, title: `Ticket ${i}` })));
    expect(folded.shape.keys.rows).toMatchObject({ type: "array", length: 200 });
    expect(result._meta["frontmcp/result-handle"]).toMatchObject({ handle: folded.handle, ttlSeconds: 600 });
    expect(result.content[1].text).toContain(`Read the rest with result_query and handle "${folded.handle}"`);
  } finally {
    await client.close();
  }
});

test("`result_query` reads a slice by path, limit and fields", async () => {
  const client = await connect(config, { session: { user: { sub: "ana" } } });
  try {
    const { handle } = ((await client.callTool("report", {})) as any).structuredContent as Folded;
    const slice = (await client.callTool("result_query", { handle, path: "rows", limit: 3, fields: ["id"] })) as any;
    expect(slice.structuredContent).toEqual({
      path: "rows",
      value: [{ id: "T-0" }, { id: "T-1" }, { id: "T-2" }],
      total: 200,
      offset: 0,
      nextOffset: 3,
    });
  } finally {
    await client.close();
  }
});

test("another caller gets `RESULT_HANDLE_NOT_FOUND`", async () => {
  // One server for both clients: each connect(config) starts its own, with its own memory store
  const server = await FrontMcpInstance.createDirect(config);
  const ana = await server.connect({ session: { user: { sub: "ana" } } });
  const bo = await server.connect({ session: { user: { sub: "bo" } } });
  try {
    const { handle } = ((await ana.callTool("report", {})) as any).structuredContent as Folded;
    const own = (await ana.callTool("result_query", { handle, path: "rows[0]" })) as any;
    expect(own.structuredContent).toEqual({ path: "rows[0]", value: { id: "T-0", title: "Ticket 0" } });
    const refused = (await bo.callTool("result_query", { handle, path: "rows[0]" })) as any;
    expect(refused.isError).toBe(true);
    expect(refused._meta.code).toBe("RESULT_HANDLE_NOT_FOUND");
    expect(refused.content[0].text).toBe(
      "No result for this handle: it expired, or it belongs to another session or caller.\nTo fix: Call the tool again for a new handle.",
    );
  } finally {
    await ana.close();
    await bo.close();
    await server.dispose();
  }
});

test("`page` together with `offset` is refused", async () => {
  const client = await connect(config, { session: { user: { sub: "ana" } } });
  try {
    const { handle } = ((await client.callTool("report", {})) as any).structuredContent as Folded;
    const refused = (await client.callTool("result_query", { handle, path: "rows", page: 2, offset: 10 })) as any;
    expect(refused.isError).toBe(true);
    expect(refused._meta.code).toBe("INVALID_INPUT");
    expect(refused.content[0].text).toBe("Give result_query a page or an offset, not both");
  } finally {
    await client.close();
  }
});

test("a slice over the threshold is refused with `RESULT_SLICE_TOO_LARGE`", async () => {
  const client = await connect(config, { session: { user: { sub: "ana" } } });
  try {
    const { handle } = ((await client.callTool("report", {})) as any).structuredContent as Folded;
    const refused = (await client.callTool("result_query", { handle, path: "rows", limit: 200 })) as any;
    expect(refused.isError).toBe(true);
    expect(refused._meta.code).toBe("RESULT_SLICE_TOO_LARGE");
    expect(refused.content[0].text).toMatch(/^This slice is \d+ bytes, over the 4096-byte limit of a result_query answer\.\nTo fix: Read fewer items, fewer fields or a deeper path\.$/);
  } finally {
    await client.close();
  }
});
```

<a id="troubleshooting"></a>

## Troubleshooting

<a id="a-large-answer-comes-back-whole"></a>

### A large answer comes back whole

Check the conditions in [`ResultHandlesPlugin.init(options)`](https://frontmcp.dev/reference/plugins/result-handles#resulthandlesplugininitoptions). The usual causes: the call is anonymous and has no session, the tool's output schema doesn't admit `foldedAnswerSchema` (the server log warns once), or the answer is under `thresholdBytes`.

<a id="result_handle_not_found"></a>

### `RESULT_HANDLE_NOT_FOUND`

Call the original tool again. The handle expired, was evicted, belongs to another caller or session, or lives in another process's memory store.

<a id="result_path_not_found"></a>

### `RESULT_PATH_NOT_FOUND`

Use a path present in the returned `shape`.

<a id="result_slice_too_large"></a>

### `RESULT_SLICE_TOO_LARGE`

Request fewer items, fewer fields or a shorter string slice.

<a id="result_handles_configuration"></a>

### `RESULT_HANDLES_CONFIGURATION`

Check the named option. Thresholds below 1024, nonpositive lifetimes and unknown options are refused.
