100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import { constants as bufferConstants } from "node:buffer";
|
|
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { bufferedJsonParts, jsonParts } from "../domain/jsonSerialization.js";
|
|
|
|
export const MCP_RESULT_STRING_LIMIT = bufferConstants.MAX_STRING_LENGTH - 1024;
|
|
|
|
const RESULT_ENVELOPE_BYTES =
|
|
Buffer.byteLength(
|
|
JSON.stringify({
|
|
content: [{ type: "text", text: "" }],
|
|
structuredContent: null,
|
|
}),
|
|
) - 4;
|
|
|
|
const ERROR_RESULT_ENVELOPE_BYTES = Buffer.byteLength(
|
|
JSON.stringify({ content: [{ type: "text", text: "" }], isError: true }),
|
|
);
|
|
|
|
/**
|
|
* Result metadata marking content that originates from the analysis target.
|
|
* Hosts and hooks can read it to treat the result as untrusted data.
|
|
*/
|
|
export const TARGET_CONTENT_RESULT_META = Object.freeze({
|
|
provenance: Object.freeze({
|
|
untrusted_content: true,
|
|
source: "analysis_target",
|
|
}),
|
|
});
|
|
|
|
/** Whether a delivered tool result already carries the untrusted target-content marker. */
|
|
export const carriesTargetContentMarker = (result: {
|
|
readonly _meta?: unknown;
|
|
}): boolean => {
|
|
const meta = result._meta;
|
|
if (typeof meta !== "object" || meta === null) return false;
|
|
const provenance: unknown = Reflect.get(meta, "provenance");
|
|
return (
|
|
typeof provenance === "object" &&
|
|
provenance !== null &&
|
|
Reflect.get(provenance, "untrusted_content") === true
|
|
);
|
|
};
|
|
|
|
// `,"_meta":{...}` added to the delivered result object.
|
|
const TARGET_CONTENT_META_BYTES =
|
|
Buffer.byteLength(JSON.stringify({ _meta: TARGET_CONTENT_RESULT_META })) - 1;
|
|
|
|
/**
|
|
* MCP representations a result carries on the wire. Successful results repeat
|
|
* the JSON as structured content and escaped text; error results carry text
|
|
* only, plus their `isError` flag.
|
|
*/
|
|
export type ToolResultRepresentations = "structured-and-text" | "error-text";
|
|
|
|
/** Result of encoding the complete repeated MCP representations within a wire budget. */
|
|
export type ToolResultEncoding =
|
|
| { readonly ok: true; readonly text: string; readonly bytes: number }
|
|
| {
|
|
readonly ok: false;
|
|
readonly bytesAtLeast: number;
|
|
readonly codeUnitsAtLeast: number;
|
|
readonly constraint: "receive-buffer" | "string-length";
|
|
};
|
|
|
|
/** Account for the delivered JSON representations before allocating the complete text. */
|
|
export const encodeToolResult = (
|
|
value: JsonValue,
|
|
budgetBytes: number,
|
|
representations: ToolResultRepresentations = "structured-and-text",
|
|
targetContent = false,
|
|
): ToolResultEncoding => {
|
|
const structured = representations === "structured-and-text";
|
|
const parts: string[] = [];
|
|
let bytes =
|
|
(structured ? RESULT_ENVELOPE_BYTES : ERROR_RESULT_ENVELOPE_BYTES) +
|
|
(targetContent ? TARGET_CONTENT_META_BYTES : 0);
|
|
let codeUnits = bytes;
|
|
for (const part of bufferedJsonParts(jsonParts(value))) {
|
|
const escapedPart = JSON.stringify(part);
|
|
bytes +=
|
|
(structured ? Buffer.byteLength(part) : 0) +
|
|
Buffer.byteLength(escapedPart) -
|
|
2;
|
|
codeUnits += (structured ? part.length : 0) + escapedPart.length - 2;
|
|
if (bytes > budgetBytes || codeUnits > MCP_RESULT_STRING_LIMIT)
|
|
return {
|
|
ok: false,
|
|
bytesAtLeast: bytes,
|
|
codeUnitsAtLeast: codeUnits,
|
|
constraint:
|
|
codeUnits > MCP_RESULT_STRING_LIMIT
|
|
? "string-length"
|
|
: "receive-buffer",
|
|
};
|
|
parts.push(part);
|
|
}
|
|
return { ok: true, text: parts.join(""), bytes };
|
|
};
|