From eb48e4d9536e7043bc1ba4359855ae83f22ef3a2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:09 -0300 Subject: [PATCH] feat(mcp): register web-session pool observability tools (#3368) (#4399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool tools (omniroute_pool_status/sessions/reset/warm/health) shipped in open-sse/mcp-server/tools/poolTools.ts but were never imported or registered in server.ts, so they were defined-but-dead — the PR4 observability step of the #3368 web-session roadmap was one wiring step from live. Wire them through the standard registration loop (import + tool-count tally + RESERVED_MCP_NAMES + Object.values(poolTools).forEach), add per-tool scopes (read:health for status/sessions/health, write:resilience for reset/warm) to both the inline tool defs and the canonical MCP_TOOL_SCOPES map. The forEach is typed structurally (no new no-explicit-any) since the shape is pinned by tests. Tests: extend mcp-tool-collections-shape with poolTools; add a dedicated guard pinning the server.ts wiring (import/registration/reserved-name), the scope contract (inline == MCP_TOOL_SCOPES, all in MCP_SCOPE_LIST), and live handler behavior against the in-memory PoolRegistry. --- open-sse/mcp-server/server.ts | 41 +++++ open-sse/mcp-server/tools/poolTools.ts | 5 + src/shared/constants/mcpScopes.ts | 7 + tests/unit/mcp-pool-tools-3368.test.ts | 146 ++++++++++++++++++ tests/unit/mcp-tool-collections-shape.test.ts | 2 + 5 files changed, 201 insertions(+) create mode 100644 tests/unit/mcp-pool-tools-3368.test.ts diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index ed2fd5b014..a809a88d1d 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -81,6 +81,7 @@ import { skillRegistry } from "../../src/lib/skills/registry.ts"; import { skillExecutor } from "../../src/lib/skills/executor.ts"; import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; +import { poolTools } from "./tools/poolTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; import { notionTools } from "./tools/notionTools.ts"; import { obsidianTools } from "./tools/obsidianTools.ts"; @@ -114,6 +115,7 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(memoryTools).length + Object.keys(skillTools).length + Object.keys(agentSkillTools).length + + Object.keys(poolTools).length + gamificationTools.length + pluginTools.length + notionTools.length + @@ -855,6 +857,7 @@ export function createMcpServer(): McpServer { ...Object.keys(memoryTools), ...Object.keys(skillTools), ...Object.keys(compressionTools), + ...Object.keys(poolTools), ...pluginTools.map((t) => t.name), ...gamificationTools.map((t) => t.name), ...obsidianTools.map((t) => t.name), @@ -1290,6 +1293,44 @@ export function createMcpServer(): McpServer { ); }); + // ── Web-Session Pool Tools (#3368 observability) ─ + // Typed structurally (not `any`) — the shape is pinned by + // tests/unit/mcp-tool-collections-shape.test.ts, so the loop can stay strict. + Object.values(poolTools).forEach( + (toolDef: { + name: string; + description: string; + scopes: readonly string[]; + inputSchema: { parse: (input: unknown) => unknown }; + handler: (parsedArgs: unknown) => Promise; + }) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement( + toolDef.name, + async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, + toolDef.scopes + ) + ); + } + ); + // ── Gamification Tools ──────────────────────── gamificationTools.forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/poolTools.ts b/open-sse/mcp-server/tools/poolTools.ts index 13b5184a11..4437e68f88 100644 --- a/open-sse/mcp-server/tools/poolTools.ts +++ b/open-sse/mcp-server/tools/poolTools.ts @@ -147,6 +147,7 @@ export const poolTools = { name: "omniroute_pool_status", description: "Returns session pool status for a specific provider or all providers. Includes session counts by state (active/cooldown/dead), request totals, success rate, and throughput.", + scopes: ["read:health"], inputSchema: poolStatusInput, handler: (args: z.infer) => handlePoolStatus(args), }, @@ -154,6 +155,7 @@ export const poolTools = { name: "omniroute_pool_sessions", description: "Lists all sessions in a provider's pool with per-session details: fingerprint, status, request counts, inflight, cooldown remaining, and age.", + scopes: ["read:health"], inputSchema: poolSessionsInput, handler: (args: z.infer) => handlePoolSessions(args), }, @@ -161,6 +163,7 @@ export const poolTools = { name: "omniroute_pool_reset", description: "Shuts down and removes all sessions for a provider's pool. A new pool will be created automatically on the next request.", + scopes: ["write:resilience"], inputSchema: poolResetInput, handler: (args: z.infer) => handlePoolReset(args), }, @@ -168,6 +171,7 @@ export const poolTools = { name: "omniroute_pool_warm", description: "Warms a session pool to the specified session count (1–50). Sessions beyond the current count are created with fresh browser fingerprints.", + scopes: ["write:resilience"], inputSchema: poolWarmInput, handler: (args: z.infer) => handlePoolWarm(args), }, @@ -175,6 +179,7 @@ export const poolTools = { name: "omniroute_pool_health", description: "Returns aggregated web-session pool health: pool stats + circuit breaker state + per-session details + health status (healthy/degraded/down) + issues list.", + scopes: ["read:health"], inputSchema: poolHealthInput, handler: (args: z.infer) => handlePoolHealth(args), }, diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index a07d09c26d..970b1f40e2 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -65,6 +65,13 @@ export const MCP_TOOL_SCOPES: Record = { omniroute_oneproxy_fetch: ["read:proxies"], omniroute_oneproxy_rotate: ["read:proxies"], omniroute_oneproxy_stats: ["read:proxies"], + + // Web-session pool observability (read) + lifecycle (write) + omniroute_pool_status: ["read:health"], + omniroute_pool_sessions: ["read:health"], + omniroute_pool_health: ["read:health"], + omniroute_pool_reset: ["write:resilience"], + omniroute_pool_warm: ["write:resilience"], } as const; // ============ Scope Groups ============ diff --git a/tests/unit/mcp-pool-tools-3368.test.ts b/tests/unit/mcp-pool-tools-3368.test.ts new file mode 100644 index 0000000000..0041eb0b80 --- /dev/null +++ b/tests/unit/mcp-pool-tools-3368.test.ts @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +// Regression guard for #3368: the web-session pool MCP tools +// (open-sse/mcp-server/tools/poolTools.ts) existed in the repo but were never +// imported or registered in open-sse/mcp-server/server.ts, so omniroute_pool_* +// was defined-but-dead. This pins the wiring (import + registration loop + +// reserved-name entry), the scope contract, and the live handler behavior so +// the observability tools cannot silently fall out of the live MCP server again. + +const { poolTools } = await import("../../open-sse/mcp-server/tools/poolTools.ts"); +const { handlePoolStatus, handlePoolSessions, handlePoolReset, handlePoolWarm } = await import( + "../../open-sse/mcp-server/tools/poolTools.ts" +); +const { MCP_TOOL_SCOPES, MCP_SCOPE_LIST } = await import( + "../../src/shared/constants/mcpScopes.ts" +); + +const POOL_TOOL_NAMES = [ + "omniroute_pool_status", + "omniroute_pool_sessions", + "omniroute_pool_reset", + "omniroute_pool_warm", + "omniroute_pool_health", +]; + +const serverSource = readFileSync( + new URL("../../open-sse/mcp-server/server.ts", import.meta.url), + "utf8" +); + +// ── Wiring guard (the actual #3368 fix) ─────────────────────────────────── +// These assertions fail on the pre-fix tree, where server.ts had no reference +// to poolTools at all. + +test("server.ts imports the poolTools collection", () => { + assert.match( + serverSource, + /import\s*\{\s*poolTools\s*\}\s*from\s*"\.\/tools\/poolTools\.ts"/, + "server.ts must import poolTools so the tools reach the live MCP server" + ); +}); + +test("server.ts registers poolTools via the standard registration loop", () => { + assert.match( + serverSource, + /Object\.values\(poolTools\)\.forEach/, + "server.ts must iterate poolTools through server.registerTool like the other collections" + ); +}); + +test("server.ts reserves the poolTools names", () => { + assert.match( + serverSource, + /\.\.\.Object\.keys\(poolTools\)/, + "poolTools names must be in RESERVED_MCP_NAMES to avoid collisions" + ); +}); + +// ── Collection completeness ─────────────────────────────────────────────── + +test("poolTools exposes exactly the five expected pool tools", () => { + assert.deepEqual(Object.keys(poolTools).sort(), [...POOL_TOOL_NAMES].sort()); +}); + +// ── Scope contract ──────────────────────────────────────────────────────── + +test("every poolTools inline scope is a known MCP scope", () => { + const known = new Set(MCP_SCOPE_LIST as readonly string[]); + for (const toolDef of Object.values(poolTools) as Array<{ name: string; scopes: string[] }>) { + assert.ok( + Array.isArray(toolDef.scopes) && toolDef.scopes.length > 0, + `${toolDef.name}: scopes must be a non-empty array` + ); + for (const scope of toolDef.scopes) { + assert.ok(known.has(scope), `${toolDef.name}: "${scope}" is not in MCP_SCOPE_LIST`); + } + } +}); + +test("inline poolTools scopes match the canonical MCP_TOOL_SCOPES map", () => { + for (const toolDef of Object.values(poolTools) as Array<{ name: string; scopes: string[] }>) { + const canonical = (MCP_TOOL_SCOPES as Record)[toolDef.name]; + assert.ok(canonical, `${toolDef.name}: missing from MCP_TOOL_SCOPES`); + assert.deepEqual( + [...toolDef.scopes].sort(), + [...canonical].sort(), + `${toolDef.name}: inline scopes must equal MCP_TOOL_SCOPES entry` + ); + } +}); + +test("read tools require read:health; lifecycle tools require write:resilience", () => { + const tools = poolTools as Record; + assert.deepEqual(tools.omniroute_pool_status.scopes, ["read:health"]); + assert.deepEqual(tools.omniroute_pool_sessions.scopes, ["read:health"]); + assert.deepEqual(tools.omniroute_pool_health.scopes, ["read:health"]); + assert.deepEqual(tools.omniroute_pool_reset.scopes, ["write:resilience"]); + assert.deepEqual(tools.omniroute_pool_warm.scopes, ["write:resilience"]); +}); + +// ── Live handler behavior (against the in-memory PoolRegistry) ───────────── +// No pools are created here, so the registry stays empty: status returns the +// all-pools aggregate shape and per-provider tools return a clear error. + +test("handlePoolStatus with no provider returns the aggregate shape", async () => { + const result = (await handlePoolStatus({})) as { + totalPools: number; + providers: string[]; + pools: unknown[]; + }; + assert.equal(typeof result.totalPools, "number"); + assert.ok(Array.isArray(result.providers), "providers must be an array"); + assert.ok(Array.isArray(result.pools), "pools must be an array"); +}); + +test("handlePoolStatus errors clearly for an unknown provider", async () => { + const result = (await handlePoolStatus({ provider: "no-such-provider-3368" })) as { + error?: string; + }; + assert.match(String(result.error), /No pool found for provider 'no-such-provider-3368'/); +}); + +test("handlePoolSessions errors clearly for an unknown provider", async () => { + const result = (await handlePoolSessions({ provider: "no-such-provider-3368" })) as { + error?: string; + }; + assert.match(String(result.error), /No pool found/); +}); + +test("handlePoolWarm errors clearly for an unknown provider", async () => { + const result = (await handlePoolWarm({ provider: "no-such-provider-3368", count: 6 })) as { + error?: string; + }; + assert.match(String(result.error), /No pool found/); +}); + +test("handlePoolReset reports reset:false for an unknown provider", async () => { + const result = (await handlePoolReset({ provider: "no-such-provider-3368" })) as { + reset: boolean; + provider: string; + }; + assert.equal(result.reset, false); + assert.equal(result.provider, "no-such-provider-3368"); +}); diff --git a/tests/unit/mcp-tool-collections-shape.test.ts b/tests/unit/mcp-tool-collections-shape.test.ts index 7d34384110..9029d0e01a 100644 --- a/tests/unit/mcp-tool-collections-shape.test.ts +++ b/tests/unit/mcp-tool-collections-shape.test.ts @@ -22,6 +22,7 @@ import assert from "node:assert/strict"; const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts"); const { skillTools } = await import("../../open-sse/mcp-server/tools/skillTools.ts"); const { compressionTools } = await import("../../open-sse/mcp-server/tools/compressionTools.ts"); +const { poolTools } = await import("../../open-sse/mcp-server/tools/poolTools.ts"); type McpToolDef = { name: string; @@ -35,6 +36,7 @@ const COLLECTIONS: Record> = { memoryTools: memoryTools as unknown as Record, skillTools: skillTools as unknown as Record, compressionTools: compressionTools as unknown as Record, + poolTools: poolTools as unknown as Record, }; for (const [collectionName, collection] of Object.entries(COLLECTIONS)) {