fix(compression): bound mcpAccessibility maxTextChars on the live read path

smartFilterText reserves 300 chars for the truncation tail, so a maxTextChars at/below that
makes headSize <= 0 and the whole tool result is replaced by the notice (total data loss). Two
gaps let a bad value reach the engine: the DB normalizer floored maxTextChars only at > 0, and
the live MCP-server read path (readMcpAccessibilityConfig) did a raw {...DEFAULT, ...parsed}
spread with no bounding at all.

Centralize the floors in a shared clampMcpAccessibilityConfig (engine layer) used by both the DB
normalizer and the server read path; values in (0, 600) fall back to the default. Export the
tail-reserve constant (300) and use it in smartFilterText so the engine and the bounds stay in
sync. The F5.3 headSize >= 0 clamp stays as a second layer.

Defense-in-depth today (the config isn't writable via the settings API yet — making it
configurable is a tracked follow-up in the compression-completeness work); this closes the
data-loss path for when it lands.

Part of the compression "100% functional" program (audit follow-up).
This commit is contained in:
diegosouzapw
2026-06-18 17:49:02 -03:00
parent 3135be8e7b
commit 02b006031e
6 changed files with 109 additions and 36 deletions

View File

@@ -88,6 +88,7 @@ import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts";
import {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
clampMcpAccessibilityConfig,
type McpAccessibilityConfig,
} from "../services/compression/engines/mcpAccessibility/constants.ts";
import { getDbInstance } from "../../src/lib/db/core.ts";
@@ -137,9 +138,9 @@ function readMcpAccessibilityConfig(): McpAccessibilityConfig {
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("compression", "mcpAccessibility") as { value?: string } | undefined;
if (!row?.value) return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
const parsed = JSON.parse(row.value);
if (!parsed || typeof parsed !== "object") return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG, ...parsed };
// clampMcpAccessibilityConfig bounds every field (and folds in the non-object guard), so a
// persisted out-of-range maxTextChars can't make smartFilterText truncate the whole text.
return clampMcpAccessibilityConfig(JSON.parse(row.value));
} catch {
return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
}

View File

@@ -24,3 +24,40 @@ export const DEFAULT_MCP_ACCESSIBILITY_CONFIG: McpAccessibilityConfig = {
collapseKeepTail: MCP_ACCESSIBILITY_DEFAULTS.collapseKeepTail,
minLengthToProcess: MCP_ACCESSIBILITY_DEFAULTS.minLengthToProcess,
};
/**
* Chars `smartFilterText` reserves for the truncation tail/notice (`maxTextChars - this` is the
* head kept). A `maxTextChars` at or below this leaves no head, so the whole tool result would be
* replaced by the notice. The engine and the config bounds must agree on this number.
*/
export const MCP_ACCESSIBILITY_TAIL_RESERVE = 300;
/**
* Minimum sane `maxTextChars`: below this the truncated head is too small to be useful (or empty).
* Values in `(0, MIN)` are treated as misconfiguration and fall back to the default.
*/
export const MCP_ACCESSIBILITY_MIN_MAX_TEXT_CHARS = MCP_ACCESSIBILITY_TAIL_RESERVE * 2;
function boundedInt(value: unknown, min: number, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value >= min
? Math.floor(value)
: fallback;
}
/**
* Bound a raw/persisted mcpAccessibility config into a safe, fully-populated config. Centralizes
* the numeric floors so both the DB normalizer and the live MCP-server read path agree (a small
* `maxTextChars` would otherwise make `smartFilterText` truncate the whole text away).
*/
export function clampMcpAccessibilityConfig(raw: unknown): McpAccessibilityConfig {
const record = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const d = DEFAULT_MCP_ACCESSIBILITY_CONFIG;
return {
enabled: record["enabled"] !== false,
maxTextChars: boundedInt(record["maxTextChars"], MCP_ACCESSIBILITY_MIN_MAX_TEXT_CHARS, d.maxTextChars),
collapseThreshold: boundedInt(record["collapseThreshold"], 1, d.collapseThreshold),
collapseKeepHead: boundedInt(record["collapseKeepHead"], 0, d.collapseKeepHead),
collapseKeepTail: boundedInt(record["collapseKeepTail"], 0, d.collapseKeepTail),
minLengthToProcess: boundedInt(record["minLengthToProcess"], 1, d.minLengthToProcess),
};
}

View File

@@ -1,5 +1,5 @@
import { collapseRepeated } from "./collapseRepeated.ts";
import type { McpAccessibilityConfig } from "./constants.ts";
import { MCP_ACCESSIBILITY_TAIL_RESERVE, type McpAccessibilityConfig } from "./constants.ts";
const NOISE_PATTERNS: RegExp[] = [/^\s*-\s*generic:?\s*$/gm, /^\s*-\s*text:\s*""\s*$/gm];
@@ -19,10 +19,11 @@ export function smartFilterText(text: string, config: McpAccessibilityConfig): s
);
if (out.length > config.maxTextChars) {
// Clamp to >=0: a maxTextChars below the 300-char tail reservation would make headSize
// negative, and slice(0, negative) counts from the END — silently keeping a wrong,
// oversized fragment instead of the intended head.
const headSize = Math.max(0, config.maxTextChars - 300);
// Clamp to >=0: a maxTextChars below the tail reservation would make headSize negative, and
// slice(0, negative) counts from the END — silently keeping a wrong, oversized fragment
// instead of the intended head. (clampMcpAccessibilityConfig keeps maxTextChars sane, but
// smartFilterText is also called with raw configs in tests, so the clamp stays here too.)
const headSize = Math.max(0, config.maxTextChars - MCP_ACCESSIBILITY_TAIL_RESERVE);
const head = out.slice(0, headSize);
// Measure omitted against the FILTERED text (out), not the raw input (text), which may
// have shrunk via noise removal / collapse above.
@@ -35,4 +36,8 @@ export function smartFilterText(text: string, config: McpAccessibilityConfig): s
}
export type { McpAccessibilityConfig } from "./constants.ts";
export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./constants.ts";
export {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
clampMcpAccessibilityConfig,
MCP_ACCESSIBILITY_TAIL_RESERVE,
} from "./constants.ts";

View File

@@ -338,4 +338,7 @@ export const DEFAULT_ULTRA_CONFIG: UltraConfig = {
};
export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts";
export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./engines/mcpAccessibility/constants.ts";
export {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
clampMcpAccessibilityConfig,
} from "./engines/mcpAccessibility/constants.ts";

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
DEFAULT_RTK_CONFIG,
DEFAULT_ULTRA_CONFIG,
clampMcpAccessibilityConfig,
type AggressiveConfig,
type CavemanConfig,
type CavemanOutputModeConfig,
@@ -509,32 +510,10 @@ export function getDefaultRtkConfig(): RtkConfig {
}
function normalizeMcpAccessibilityConfig(value: unknown): McpAccessibilityConfig {
const record = toRecord(value);
return {
...DEFAULT_MCP_ACCESSIBILITY_CONFIG,
...record,
enabled: record.enabled !== false,
maxTextChars:
typeof record.maxTextChars === "number" && record.maxTextChars > 0
? Math.floor(record.maxTextChars)
: DEFAULT_MCP_ACCESSIBILITY_CONFIG.maxTextChars,
collapseThreshold:
typeof record.collapseThreshold === "number" && record.collapseThreshold > 0
? Math.floor(record.collapseThreshold)
: DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseThreshold,
collapseKeepHead:
typeof record.collapseKeepHead === "number" && record.collapseKeepHead >= 0
? Math.floor(record.collapseKeepHead)
: DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepHead,
collapseKeepTail:
typeof record.collapseKeepTail === "number" && record.collapseKeepTail >= 0
? Math.floor(record.collapseKeepTail)
: DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepTail,
minLengthToProcess:
typeof record.minLengthToProcess === "number" && record.minLengthToProcess > 0
? Math.floor(record.minLengthToProcess)
: DEFAULT_MCP_ACCESSIBILITY_CONFIG.minLengthToProcess,
};
// clampMcpAccessibilityConfig (engine layer) owns the numeric floors so the DB normalizer and
// the live MCP-server read path agree — in particular it floors maxTextChars to a sane minimum
// (a value below the tail reservation would make smartFilterText truncate the whole text away).
return clampMcpAccessibilityConfig(value);
}
export async function getMcpAccessibilityConfig(): Promise<McpAccessibilityConfig> {

View File

@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
smartFilterText,
clampMcpAccessibilityConfig,
} from "../../../open-sse/services/compression/engines/mcpAccessibility/index.ts";
import { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "../../../open-sse/services/compression/engines/mcpAccessibility/constants.ts";
// smartFilterText reserves 300 chars for the truncation tail/notice, so any maxTextChars below
// that leaves headSize <= 0 and the whole tool result is replaced by the notice (total data
// loss). The DB normalizer floored maxTextChars only at > 0, and the production read path in
// server.ts bypassed bounding entirely. clampMcpAccessibilityConfig is the shared guard.
test("clamps maxTextChars below the tail reserve to the default", () => {
for (const bad of [1, 50, 300, 599]) {
assert.equal(
clampMcpAccessibilityConfig({ maxTextChars: bad }).maxTextChars,
DEFAULT_MCP_ACCESSIBILITY_CONFIG.maxTextChars,
`maxTextChars=${bad} must fall back to default`
);
}
});
test("keeps a sane maxTextChars (>= 600)", () => {
assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 600 }).maxTextChars, 600);
assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 1000 }).maxTextChars, 1000);
assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 1234.9 }).maxTextChars, 1234);
});
test("bounds the other numeric fields and honors enabled", () => {
const c = clampMcpAccessibilityConfig({
collapseThreshold: -5,
minLengthToProcess: 0,
collapseKeepHead: -1,
enabled: false,
});
assert.equal(c.collapseThreshold, DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseThreshold);
assert.equal(c.minLengthToProcess, DEFAULT_MCP_ACCESSIBILITY_CONFIG.minLengthToProcess);
assert.equal(c.collapseKeepHead, DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepHead);
assert.equal(c.enabled, false);
});
test("a clamped config never lets smartFilterText truncate the whole text away", () => {
// The previously-dangerous stored value: maxTextChars=50 → clamps to default, so a
// 1000-char tool result is NOT replaced wholesale by the truncation notice.
const cfg = clampMcpAccessibilityConfig({ maxTextChars: 50, minLengthToProcess: 1 });
const out = smartFilterText("A".repeat(1000), cfg);
assert.ok(out.includes("A".repeat(500)), "content preserved (not nuked by a tiny maxTextChars)");
});