mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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.
This commit is contained in:
committed by
GitHub
parent
51642dcc99
commit
eb48e4d953
@@ -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<unknown>;
|
||||
}) => {
|
||||
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(
|
||||
|
||||
@@ -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<typeof poolStatusInput>) => 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<typeof poolSessionsInput>) => 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<typeof poolResetInput>) => 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<typeof poolWarmInput>) => 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<typeof poolHealthInput>) => handlePoolHealth(args),
|
||||
},
|
||||
|
||||
@@ -65,6 +65,13 @@ export const MCP_TOOL_SCOPES: Record<string, readonly McpScope[]> = {
|
||||
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 ============
|
||||
|
||||
146
tests/unit/mcp-pool-tools-3368.test.ts
Normal file
146
tests/unit/mcp-pool-tools-3368.test.ts
Normal file
@@ -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<string, readonly string[]>)[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<string, { scopes: string[] }>;
|
||||
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");
|
||||
});
|
||||
@@ -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<string, Record<string, McpToolDef>> = {
|
||||
memoryTools: memoryTools as unknown as Record<string, McpToolDef>,
|
||||
skillTools: skillTools as unknown as Record<string, McpToolDef>,
|
||||
compressionTools: compressionTools as unknown as Record<string, McpToolDef>,
|
||||
poolTools: poolTools as unknown as Record<string, McpToolDef>,
|
||||
};
|
||||
|
||||
for (const [collectionName, collection] of Object.entries(COLLECTIONS)) {
|
||||
|
||||
Reference in New Issue
Block a user