From eef9d203fbcbab701a0cbbdf59882605f3a1b875 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:03:08 -0300 Subject: [PATCH] feat(compression): wire MCP tool-cardinality reduction (F4.3, opt-in) (#4221) Integrated into release/v3.8.29 (F4.3 opt-in MCP tool-cardinality reduction; baseline rebaselined server.ts 1458->1468). --- config/quality/file-size-baseline.json | 3 +- open-sse/mcp-server/server.ts | 12 ++++++- open-sse/mcp-server/toolCardinality.ts | 30 ++++++++++++++++ .../compression-tool-cardinality-env.test.ts | 36 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/unit/compression/compression-tool-cardinality-env.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b371b7df87..146ad20da1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_18_4221_tool_cardinality": "PR #4221 own growth: server.ts 1458->1468 (+10 at the existing registerTool override in createMcpServer = F4.3 opt-in tool-cardinality wiring). Reads readMcpToolProfileFromEnv(process.env) once (MCP_TOOL_DENY/MCP_TOOL_ALLOW; null = no filter, the default), and when a registered tool is denied by the profile calls registered.disable() so it is not announced in tools/list (token savings). The default (null) profile never enters the branch — existing behavior byte-identical. The reusable parser + reduceToolManifest decision live in the (non-frozen) toolCardinality.ts. Cohesive opt-in feature at the registration chokepoint; not extractable without hiding the register boundary.", "_rebaseline_2026_06_18_4217_compression_step_streaming": "PR #4217 own growth: chatCore.ts 5063->5086 (+23 at the existing compression-apply chokepoint = the best-effort onEngineStep callback threaded into applyCompressionAsync). The callback builds a compression.step payload and fires emit(\"compression.step\", …) + forwardDashboardEventToLiveWs(…) once per stacked engine as it completes (F3.3 live per-engine streaming), wrapped in try/catch so it never fails the request. It closes over the same emit/traceId/mode locals as the compression.completed emit right below it (line 1749); the reusable per-engine emission lives in strategySelector.ts (reportEngineStep + StackedCompressionStep) and the studio reducers in compressionFlowModel.ts (both 5063 (+3 = wire ensureEngineBreakdown(result.stats) into the existing compression.completed emit + its import line). Single-engine modes (rtk/lite/standard/aggressive/ultra) leave stats.engineBreakdown empty, which made the dashboard studio render an empty Input->Output pipeline (no engine node); the synthesized 1-entry breakdown lives in the new pure leaf open-sse/services/compression/engineBreakdown.ts (2534 (+3 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `zenmux` + a 2-line comment). zenmux carries a real modelsUrl but was not classified by any live-fetch branch, so its hardcoded 9-entry registry catalog was served (source local_catalog, 'API unavailable — using local catalog') instead of the upstream list — hiding the free models it advertises (z-ai/glm-5.2-free, moonshotai/kimi-k2.7-code-free). Same fix shape as #3976 (llm7/byteplus): the `/models` probe (after stripping /chat/completions) resolves to https://zenmux.ai/api/v1/models. Pure additive Set membership; not extractable.", @@ -76,7 +77,7 @@ "open-sse/handlers/sseParser.ts": 812, "open-sse/handlers/videoGeneration.ts": 1078, "open-sse/mcp-server/schemas/tools.ts": 1437, - "open-sse/mcp-server/server.ts": 1458, + "open-sse/mcp-server/server.ts": 1468, "open-sse/mcp-server/tools/advancedTools.ts": 1118, "open-sse/services/accountFallback.ts": 1727, "open-sse/services/batchProcessor.ts": 828, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index d10454167d..ed2fd5b014 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -85,6 +85,7 @@ import { gamificationTools } from "./tools/gamificationTools.ts"; import { notionTools } from "./tools/notionTools.ts"; import { obsidianTools } from "./tools/obsidianTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; +import { reduceToolManifest, readMcpToolProfileFromEnv } from "./toolCardinality.ts"; import { smartFilterText } from "../services/compression/engines/mcpAccessibility/index.ts"; import { DEFAULT_MCP_ACCESSIBILITY_CONFIG, @@ -797,6 +798,8 @@ export function createMcpServer(): McpServer { }); const mcpDescriptionCompressionEnabled = readMcpDescriptionCompressionEnabled(); const mcpAccessibilityConfig = readMcpAccessibilityConfig(); + // F4.3 tool-cardinality: opt-in tool profile (MCP_TOOL_DENY / MCP_TOOL_ALLOW). null = no filter. + const toolProfile = readMcpToolProfileFromEnv(process.env); const registerTool = server.registerTool.bind(server); server.registerTool = ((name: string, config: Record, handler: unknown) => { const metadata = compressMcpRegistryMetadata(config, { @@ -818,7 +821,14 @@ export function createMcpServer(): McpServer { return result; } : handler; - return registerTool(name, metadata, filteredHandler as never); + const registered = registerTool(name, metadata, filteredHandler as never); + if (toolProfile && reduceToolManifest([{ name, scopes: [] }], toolProfile).length === 0) { + // Denied by the cardinality profile: keep the registration valid but disable it so the tool + // is not announced in tools/list (token savings). The default profile never reaches here. + const disablable = registered as unknown as { disable?: () => void }; + if (typeof disablable?.disable === "function") disablable.disable(); + } + return registered; }) as typeof server.registerTool; const registerPrompt = server.registerPrompt.bind(server); server.registerPrompt = ((name: string, config: Record, handler: unknown) => { diff --git a/open-sse/mcp-server/toolCardinality.ts b/open-sse/mcp-server/toolCardinality.ts index 6d0b8e47e6..cb62fad371 100644 --- a/open-sse/mcp-server/toolCardinality.ts +++ b/open-sse/mcp-server/toolCardinality.ts @@ -222,3 +222,33 @@ export function estimateManifestTokens( return sum + nameTokens + descTokens; }, 0); } + +/** + * Build an opt-in {@link ToolProfile} from environment variables, or `null` when unset (no + * filtering — the default). Used by the MCP server to disable tools it should not announce. + * + * MCP_TOOL_DENY — comma-separated tool names to always drop + * MCP_TOOL_ALLOW — comma-separated tool names to keep exclusively (allow-list mode) + * + * Scope-based (`allowScopes`) and `maxTools` filtering need the full manifest at registration + * and are intentionally not env-exposed here (a tools/list-level hook is a tracked follow-up). + */ +export function readMcpToolProfileFromEnv( + env: Record +): ToolProfile | null { + const parse = (value: string | undefined): string[] => + value + ? value + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + const denyTools = parse(env["MCP_TOOL_DENY"]); + const allowTools = parse(env["MCP_TOOL_ALLOW"]); + if (denyTools.length === 0 && allowTools.length === 0) return null; + return { + name: "env", + ...(denyTools.length > 0 ? { denyTools } : {}), + ...(allowTools.length > 0 ? { allowTools } : {}), + }; +} diff --git a/tests/unit/compression/compression-tool-cardinality-env.test.ts b/tests/unit/compression/compression-tool-cardinality-env.test.ts new file mode 100644 index 0000000000..0a09bb759b --- /dev/null +++ b/tests/unit/compression/compression-tool-cardinality-env.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + readMcpToolProfileFromEnv, + reduceToolManifest, +} from "../../../open-sse/mcp-server/toolCardinality.ts"; + +// F4.3 wiring: the MCP server now consults an opt-in tool profile (MCP_TOOL_DENY / MCP_TOOL_ALLOW) +// and disables denied tools so they are not announced to the model (token savings). Default (no +// env) is a no-op — every tool stays registered. +describe("MCP tool profile from env (cardinality opt-in)", () => { + it("returns null when no deny/allow env is set (no-op)", () => { + assert.equal(readMcpToolProfileFromEnv({}), null); + assert.equal(readMcpToolProfileFromEnv({ MCP_TOOL_DENY: "", MCP_TOOL_ALLOW: " " }), null); + }); + + it("parses MCP_TOOL_DENY / MCP_TOOL_ALLOW into a profile (trimmed, empties dropped)", () => { + const p = readMcpToolProfileFromEnv({ MCP_TOOL_DENY: "a, b ,c", MCP_TOOL_ALLOW: "x ," }); + assert.deepEqual(p?.denyTools, ["a", "b", "c"]); + assert.deepEqual(p?.allowTools, ["x"]); + }); + + it("the deny gate drops a denied tool and keeps others (single-entry manifest decision)", () => { + const profile = readMcpToolProfileFromEnv({ MCP_TOOL_DENY: "noisy_tool" }); + assert.ok(profile); + assert.equal(reduceToolManifest([{ name: "noisy_tool", scopes: [] }], profile).length, 0); + assert.equal(reduceToolManifest([{ name: "useful_tool", scopes: [] }], profile).length, 1); + }); + + it("allow-list mode keeps only the listed tools", () => { + const profile = readMcpToolProfileFromEnv({ MCP_TOOL_ALLOW: "keep_me" }); + assert.ok(profile); + assert.equal(reduceToolManifest([{ name: "keep_me", scopes: [] }], profile).length, 1); + assert.equal(reduceToolManifest([{ name: "other", scopes: [] }], profile).length, 0); + }); +});