Result handles

Checked against FrontMCP 1.11.0 on

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.

OptionTypeDefaultDescription
thresholdBytesnumber16384An answer whose data is larger than this, as UTF-8 JSON, folds. Whole number, at least 1024.
ttlSecondsnumber600Lifetime 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) => booleanalways trueDecides whether an answer over the threshold folds. Context has bytes, fullName and request.
storageStorageConfig{ type: "auto" }Backing store. Memory holds at most 1000 answers unless storage.memory.maxEntries changes it.
namespacestring"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).

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

ArgumentTypeDefaultDescription
handlestringrequiredHandle returned in the folded answer.
pathstring"$"Dot path (rows.2.title, $.rows[2].title) or JSON pointer (/rows/2/title).
offsetnumber0First list item or string character.
limitnumber20 list items or 2000 charactersPositive whole count, at most 100000.
pagenumbernoneOne-based page of limit items or characters, in place of offset. A call with both is refused with INVALID_INPUT.
fieldsstring[]allKeys 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 methodReturnsDescription
newHandle()stringA 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:

Open
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.

Report a docs issue · Report a security concern privately