Result handles
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.
import { ResultHandlesPlugin, foldedAnswerSchema } from "@frontmcp/plugin-result-handles";
ResultHandlesPlugin.init({ thresholdBytes?, ttlSeconds?, preview?, foldPolicy?, storage?, namespace? });
Reference
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
structuredContentwith 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.
foldPolicyreturnstrue, and the tool's output schema admits the folded form (see 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.
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.
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.
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.
Usage
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(), get the folded answer, and read it with result_query:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Troubleshooting
A large answer comes back whole
Check the conditions in ResultHandlesPlugin.init(options). 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.
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.
RESULT_PATH_NOT_FOUND
Use a path present in the returned shape.
RESULT_SLICE_TOO_LARGE
Request fewer items, fewer fields or a shorter string slice.
RESULT_HANDLES_CONFIGURATION
Check the named option. Thresholds below 1024, nonpositive lifetimes and unknown options are refused.