feat(mcp): add MCP accessibility-tree smart filter engine

Adds compression engine that collapses repeated sibling lines (≥30 items
with same indent + role prefix) into head + summary + tail, preserves
[ref=eXX] anchors required by Playwright/computer-use MCPs, and
hard-truncates oversized text with a navigation hint footer.

Reduces token usage 60-80% on browser snapshots/accessibility trees from
external MCP servers (playwright-mcp, chrome-mcp). Configurable via
settings.compression.mcpAccessibility namespace.

Changes:
- open-sse/services/compression/engines/mcpAccessibility/: new engine
  (constants.ts, collapseRepeated.ts, index.ts with smartFilterText)
- open-sse/services/compression/types.ts: re-exports McpAccessibilityConfig
- src/lib/db/compression.ts: getMcpAccessibilityConfig/setMcpAccessibilityConfig
- src/lib/db/migrations/056_mcp_accessibility_compression.sql: default settings
- open-sse/mcp-server/server.ts: apply filter to all tool result text blocks
- tests/unit/compression/mcpAccessibility.test.ts: 4 unit tests
- tests/unit/mcp/serverSmartFilter.test.ts: 4 integration tests (DB getter/setter)

Ref: 9router/src/lib/mcp/stdioSseBridge.js:14-90 (algorithm origin).
This commit is contained in:
diegosouzapw
2026-05-14 21:24:05 -03:00
parent 4fe2ef8887
commit e7a4ea8c7f
9 changed files with 328 additions and 1 deletions

View File

@@ -77,6 +77,11 @@ import { memoryTools } from "./tools/memoryTools.ts";
import { skillTools } from "./tools/skillTools.ts";
import { compressionTools } from "./tools/compressionTools.ts";
import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts";
import {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
type McpAccessibilityConfig,
} from "../services/compression/engines/mcpAccessibility/constants.ts";
import { getDbInstance } from "../../src/lib/db/core.ts";
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
@@ -109,6 +114,20 @@ function readMcpDescriptionCompressionEnabled(): boolean {
}
}
function readMcpAccessibilityConfig(): McpAccessibilityConfig {
try {
const row = getDbInstance()
.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 };
} catch {
return { ...DEFAULT_MCP_ACCESSIBILITY_CONFIG };
}
}
type TextToolResult = {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
@@ -592,12 +611,29 @@ export function createMcpServer(): McpServer {
version: process.env.npm_package_version || "1.8.1",
});
const mcpDescriptionCompressionEnabled = readMcpDescriptionCompressionEnabled();
const mcpAccessibilityConfig = readMcpAccessibilityConfig();
const registerTool = server.registerTool.bind(server);
server.registerTool = ((name: string, config: Record<string, unknown>, handler: unknown) => {
const metadata = compressMcpRegistryMetadata(config, {
enabled: mcpDescriptionCompressionEnabled,
});
return registerTool(name, metadata, handler as never);
const filteredHandler = mcpAccessibilityConfig.enabled
? async (args: unknown, extra?: unknown) => {
const result = await (handler as (a: unknown, e?: unknown) => Promise<TextToolResult>)(
args,
extra
);
if (Array.isArray(result?.content)) {
for (const block of result.content) {
if (block && block.type === "text" && typeof block.text === "string") {
block.text = smartFilterText(block.text, mcpAccessibilityConfig);
}
}
}
return result;
}
: handler;
return registerTool(name, metadata, filteredHandler as never);
}) as typeof server.registerTool;
const registerPrompt = server.registerPrompt.bind(server);
server.registerPrompt = ((name: string, config: Record<string, unknown>, handler: unknown) => {

View File

@@ -0,0 +1,84 @@
const SIBLING_PATTERN = /^(\s*)-\s*([a-zA-Z]+)\b/;
export function findNthSiblingEnd(
lines: string[],
start: number,
indent: string,
role: string,
n: number
): number {
let count = 0;
for (let k = start; k < lines.length; k++) {
const mm = lines[k].match(SIBLING_PATTERN);
if (mm && mm[1] === indent && mm[2] === role) {
count++;
if (count > n) return k;
}
}
return lines.length;
}
export function findLastNSiblingStart(
lines: string[],
end: number,
indent: string,
role: string,
n: number
): number {
const positions: number[] = [];
for (let k = 0; k < end; k++) {
const mm = lines[k].match(SIBLING_PATTERN);
if (mm && mm[1] === indent && mm[2] === role) positions.push(k);
}
return positions.length >= n ? positions[positions.length - n] : end;
}
export function collapseRepeated(
text: string,
threshold: number,
keepHead: number,
keepTail: number
): string {
const lines = text.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const m = line.match(SIBLING_PATTERN);
if (!m) {
out.push(line);
i++;
continue;
}
const indent = m[1];
const role = m[2];
let j = i;
while (j < lines.length) {
const ln = lines[j];
const mm = ln.match(SIBLING_PATTERN);
if (mm && mm[1] === indent && mm[2] === role) {
j++;
continue;
}
if (ln.startsWith(`${indent} `) || ln.startsWith(`${indent}\t`)) {
j++;
continue;
}
break;
}
const groupLen = j - i;
if (groupLen >= threshold) {
const headEnd = findNthSiblingEnd(lines, i, indent, role, keepHead);
const tailStart = findLastNSiblingStart(lines.slice(0, j), j, indent, role, keepTail);
for (let k = i; k < headEnd; k++) out.push(lines[k]);
out.push(
`${indent}... [${groupLen - keepHead - keepTail} similar "${role}" items omitted by OmniRoute MCP filter]`
);
for (let k = tailStart; k < j; k++) out.push(lines[k]);
} else {
for (let k = i; k < j; k++) out.push(lines[k]);
}
i = j;
}
return out.join("\n");
}

View File

@@ -0,0 +1,26 @@
export const MCP_ACCESSIBILITY_DEFAULTS = {
maxTextChars: 50000,
collapseThreshold: 30,
collapseKeepHead: 10,
collapseKeepTail: 5,
minLengthToProcess: 2000,
preserveRefPattern: /\[ref=e\d+\]/g,
} as const;
export type McpAccessibilityConfig = {
enabled: boolean;
maxTextChars: number;
collapseThreshold: number;
collapseKeepHead: number;
collapseKeepTail: number;
minLengthToProcess: number;
};
export const DEFAULT_MCP_ACCESSIBILITY_CONFIG: McpAccessibilityConfig = {
enabled: true,
maxTextChars: MCP_ACCESSIBILITY_DEFAULTS.maxTextChars,
collapseThreshold: MCP_ACCESSIBILITY_DEFAULTS.collapseThreshold,
collapseKeepHead: MCP_ACCESSIBILITY_DEFAULTS.collapseKeepHead,
collapseKeepTail: MCP_ACCESSIBILITY_DEFAULTS.collapseKeepTail,
minLengthToProcess: MCP_ACCESSIBILITY_DEFAULTS.minLengthToProcess,
};

View File

@@ -0,0 +1,33 @@
import { collapseRepeated } from "./collapseRepeated.ts";
import type { McpAccessibilityConfig } from "./constants.ts";
const NOISE_PATTERNS: RegExp[] = [/^\s*-\s*generic:?\s*$/gm, /^\s*-\s*text:\s*""\s*$/gm];
export function smartFilterText(text: string, config: McpAccessibilityConfig): string {
if (typeof text !== "string" || text.length < config.minLengthToProcess) {
return text;
}
let out = text;
for (const pattern of NOISE_PATTERNS) {
out = out.replace(pattern, "");
}
out = collapseRepeated(
out,
config.collapseThreshold,
config.collapseKeepHead,
config.collapseKeepTail
);
if (out.length > config.maxTextChars) {
const headSize = config.maxTextChars - 300;
const head = out.slice(0, headSize);
const omitted = text.length - head.length;
out =
`${head}\n\n... [truncated ${omitted} chars by OmniRoute MCP filter. ` +
`Page is large; ask user to scroll/navigate to a specific section, or click an element with the refs shown above]`;
}
return out;
}
export type { McpAccessibilityConfig } from "./constants.ts";
export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./constants.ts";

View File

@@ -298,3 +298,6 @@ export const DEFAULT_ULTRA_CONFIG: UltraConfig = {
slmFallbackToAggressive: true,
maxTokensPerMessage: 0,
};
export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts";
export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./engines/mcpAccessibility/constants.ts";

View File

@@ -7,6 +7,7 @@ import {
DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG,
DEFAULT_COMPRESSION_LANGUAGE_CONFIG,
DEFAULT_COMPRESSION_CONFIG,
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
DEFAULT_RTK_CONFIG,
DEFAULT_ULTRA_CONFIG,
type AggressiveConfig,
@@ -16,6 +17,7 @@ import {
type CompressionPipelineStep,
type CompressionConfig,
type CompressionMode,
type McpAccessibilityConfig,
type RtkConfig,
type UltraConfig,
} from "@omniroute/open-sse/services/compression/types.ts";
@@ -489,3 +491,54 @@ export function getDefaultUltraConfig(): UltraConfig {
export function getDefaultRtkConfig(): RtkConfig {
return { ...DEFAULT_RTK_CONFIG };
}
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,
};
}
export async function getMcpAccessibilityConfig(): Promise<McpAccessibilityConfig> {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get(NAMESPACE, "mcpAccessibility") as { value: string } | undefined;
return normalizeMcpAccessibilityConfig(parseJsonSafe(row?.value ?? null));
}
export async function setMcpAccessibilityConfig(
value: Partial<McpAccessibilityConfig>
): Promise<void> {
const next = normalizeMcpAccessibilityConfig({ ...DEFAULT_MCP_ACCESSIBILITY_CONFIG, ...value });
const db = getDbInstance();
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
NAMESPACE,
"mcpAccessibility",
JSON.stringify(next)
);
compressionSettingsCache = null;
invalidateDbCache();
}

View File

@@ -0,0 +1,8 @@
-- Adds MCP accessibility filter settings to the compression key_value namespace.
INSERT INTO key_value (namespace, key, value)
VALUES (
'compression',
'mcpAccessibility',
'{"enabled":true,"maxTextChars":50000,"collapseThreshold":30,"collapseKeepHead":10,"collapseKeepTail":5,"minLengthToProcess":2000}'
)
ON CONFLICT(namespace, key) DO NOTHING;

View File

@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { smartFilterText } from "@omniroute/open-sse/services/compression/engines/mcpAccessibility/index.ts";
import { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "@omniroute/open-sse/services/compression/engines/mcpAccessibility/constants.ts";
test("collapses ≥30 sibling buttons into head + summary + tail", () => {
const lines = [];
for (let i = 0; i < 50; i++) {
lines.push(` - button "Item ${i}" [ref=e${i}]`);
}
const input = lines.join("\n").padEnd(3000, " ");
const out = smartFilterText(input, DEFAULT_MCP_ACCESSIBILITY_CONFIG);
assert.ok(out.includes("Item 0"), "keeps head");
assert.ok(out.includes("Item 49"), "keeps tail");
assert.ok(out.includes('similar "button" items omitted'), "summarizes middle");
assert.ok(out.length < input.length, "compressed");
});
test("preserves [ref=eXX] anchors during truncation", () => {
const huge = ' - button "X" [ref=e123]\n' + "a".repeat(60000);
const out = smartFilterText(huge, DEFAULT_MCP_ACCESSIBILITY_CONFIG);
assert.ok(out.includes("[ref=e123]"), "preserves refs even on truncate");
assert.ok(out.length <= 50500, "respects maxTextChars + footer");
});
test('removes noise lines (- generic:, - text: "")', () => {
const input = [' - button "OK"', " - generic:", ' - text: ""', ' - link "Sign in"']
.join("\n")
.padEnd(3000, " ");
const out = smartFilterText(input, DEFAULT_MCP_ACCESSIBILITY_CONFIG);
assert.ok(!out.includes("- generic:"), "drops generic noise");
assert.ok(!out.match(/- text: ""/), "drops empty text noise");
assert.ok(out.includes("button"), "keeps signal");
});
test("returns unchanged when below minLengthToProcess", () => {
const small = "tiny";
const out = smartFilterText(small, DEFAULT_MCP_ACCESSIBILITY_CONFIG);
assert.equal(out, small);
});

View File

@@ -0,0 +1,44 @@
import { test, before } from "node:test";
import assert from "node:assert/strict";
import { getMcpAccessibilityConfig, setMcpAccessibilityConfig } from "@/lib/db/compression";
before(async () => {
// Reset to defaults before each test suite
await setMcpAccessibilityConfig({
enabled: true,
maxTextChars: 50000,
collapseThreshold: 30,
collapseKeepHead: 10,
collapseKeepTail: 5,
minLengthToProcess: 2000,
});
});
test("config defaults are returned when DB has default values", async () => {
const cfg = await getMcpAccessibilityConfig();
assert.equal(cfg.enabled, true);
assert.equal(cfg.maxTextChars, 50000);
assert.equal(cfg.collapseThreshold, 30);
assert.equal(cfg.collapseKeepHead, 10);
assert.equal(cfg.collapseKeepTail, 5);
assert.equal(cfg.minLengthToProcess, 2000);
});
test("setMcpAccessibilityConfig clamps invalid maxTextChars to default", async () => {
await setMcpAccessibilityConfig({ maxTextChars: -1 });
const cfg = await getMcpAccessibilityConfig();
assert.equal(cfg.maxTextChars, 50000);
});
test("setMcpAccessibilityConfig clamps invalid collapseThreshold to default", async () => {
await setMcpAccessibilityConfig({ collapseThreshold: 0 });
const cfg = await getMcpAccessibilityConfig();
assert.equal(cfg.collapseThreshold, 30);
});
test("setMcpAccessibilityConfig persists valid custom values", async () => {
await setMcpAccessibilityConfig({ maxTextChars: 25000, collapseThreshold: 15 });
const cfg = await getMcpAccessibilityConfig();
assert.equal(cfg.maxTextChars, 25000);
assert.equal(cfg.collapseThreshold, 15);
});