mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
40
tests/unit/compression/mcpAccessibility.test.ts
Normal file
40
tests/unit/compression/mcpAccessibility.test.ts
Normal 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);
|
||||
});
|
||||
44
tests/unit/mcp/serverSmartFilter.test.ts
Normal file
44
tests/unit/mcp/serverSmartFilter.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user