mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(mcp): ship MCP server source closure in npm files + coverage gate (#3578)
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- **fix(agent-bridge):** add the missing `POST /api/tools/agent-bridge/upstream-ca/test` route — the UpstreamCaField "Test" button POSTed to it but it didn't exist (404). The new validate-only route checks the CA file exists and is a parseable PEM certificate (returns the subject/expiry) **without** persisting the path or activating it; it inherits the `/api/tools/agent-bridge/` LOCAL_ONLY classification. ([#3488](https://github.com/diegosouzapw/OmniRoute/issues/3488))
|
||||
- **fix(gamification):** the dashboard Profile page no longer hits three 404s — added the missing `GET /api/gamification/{level,badges,badges/earned}` routes (management-scoped). The page is operator-wide (no `apiKeyId`), so `level`/`badges/earned` aggregate across all keys (with an optional `?apiKeyId` for a single key), and `badges` seeds the built-in catalog first (idempotent) so the grid is populated even on installs that never seeded it (see #3472). ([#3484](https://github.com/diegosouzapw/OmniRoute/issues/3484))
|
||||
- **security(oauth):** migrate the five public OAuth client_ids (Claude, Codex, Qwen, Kimi, GitHub Copilot — 9 server-side call-sites in `providerRegistry.ts` + `oauth.ts`) from string literals to `resolvePublicCred()` (Hard Rule #11), matching the existing Gemini/Antigravity pattern. The values decode byte-for-byte to the same public client_ids (env overrides still win), so OAuth flows are unchanged; the `check-public-creds` allowlist is now empty. The browser-bundled `codexDeviceFlow.ts` copy stays a literal by necessity (it cannot import `open-sse`). ([#3493](https://github.com/diegosouzapw/OmniRoute/issues/3493))
|
||||
- **fix(mcp):** `omniroute --mcp` no longer crashes on npm installs with `ERR_MODULE_NOT_FOUND` (e.g. `src/lib/combos/steps.ts`) — the MCP server runs from raw TypeScript and imports across `src/` + `open-sse/`, but the published `files` allowlist only shipped a handful of cherry-picked paths, so the transitive closure (~400 files) was absent from the tarball. `files` now ships the backend source the MCP server needs (`open-sse/` + `src/{domain,lib,mitm,server,shared,sse,types}/`, excluding the `src/app` UI), and a new regression test computes the MCP import closure and fails if any reachable source file is not covered by `files`. ([#3578](https://github.com/diegosouzapw/OmniRoute/issues/3578))
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
package.json
21
package.json
@@ -10,20 +10,15 @@
|
||||
"files": [
|
||||
"bin/",
|
||||
"dist/",
|
||||
"src/lib/cli-helper/",
|
||||
"@omniroute/",
|
||||
"open-sse/mcp-server/index.ts",
|
||||
"open-sse/mcp-server/server.ts",
|
||||
"open-sse/mcp-server/httpTransport.ts",
|
||||
"open-sse/mcp-server/audit.ts",
|
||||
"open-sse/mcp-server/runtimeHeartbeat.ts",
|
||||
"open-sse/mcp-server/scopeEnforcement.ts",
|
||||
"open-sse/mcp-server/schemas/",
|
||||
"open-sse/mcp-server/tools/",
|
||||
"open-sse/mcp-server/README.md",
|
||||
"open-sse/utils/setupPolyfill.ts",
|
||||
"src/shared/contracts/",
|
||||
"src/shared/utils/nodeRuntimeSupport.ts",
|
||||
"open-sse/",
|
||||
"src/domain/",
|
||||
"src/lib/",
|
||||
"src/mitm/",
|
||||
"src/server/",
|
||||
"src/shared/",
|
||||
"src/sse/",
|
||||
"src/types/",
|
||||
".env.example",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"bin/cli/runtime/",
|
||||
|
||||
102
tests/unit/mcp-published-files-closure-3578.test.ts
Normal file
102
tests/unit/mcp-published-files-closure-3578.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// #3578 — `omniroute --mcp` crashed on npm installs with ERR_MODULE_NOT_FOUND for
|
||||
// src/lib/combos/steps.ts: the MCP server runs from raw TypeScript source and imports
|
||||
// across src/ + open-sse/, but the published `files` allowlist only shipped a few
|
||||
// cherry-picked paths. This gate computes the MCP server's transitive import closure
|
||||
// and asserts every reachable src/ + open-sse/ file is covered by a package.json
|
||||
// `files` entry, so a missing dir can never silently ship a broken --mcp again.
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function resolveImport(fromFile: string, spec: string): string | null {
|
||||
let base: string;
|
||||
if (spec.startsWith("@/")) base = path.join("src", spec.slice(2));
|
||||
else if (spec.startsWith("@omniroute/open-sse/"))
|
||||
base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length));
|
||||
else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index");
|
||||
else if (spec.startsWith("./") || spec.startsWith("../"))
|
||||
base = path.join(path.dirname(fromFile), spec);
|
||||
else return null; // bare package — not our source
|
||||
base = base.replace(/\.(ts|tsx|js|mjs)$/, "");
|
||||
const cands = [
|
||||
base + ".ts",
|
||||
base + ".tsx",
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.tsx"),
|
||||
base + ".js",
|
||||
base + ".mjs",
|
||||
];
|
||||
for (const c of cands) if (fs.existsSync(path.join(ROOT, c))) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeMcpClosure(): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const f of fs.readdirSync(path.join(ROOT, "open-sse/mcp-server"))) {
|
||||
if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f);
|
||||
}
|
||||
for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) {
|
||||
const abs = path.join(ROOT, d);
|
||||
if (fs.existsSync(abs))
|
||||
for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stack = [...roots];
|
||||
const importRe =
|
||||
/(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
while (stack.length) {
|
||||
const f = stack.pop() as string;
|
||||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
let src: string;
|
||||
try {
|
||||
src = fs.readFileSync(path.join(ROOT, f), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = importRe.exec(src))) {
|
||||
const spec = m[1] || m[2];
|
||||
if (!spec) continue;
|
||||
const r = resolveImport(f, spec);
|
||||
if (r && !seen.has(r)) stack.push(r);
|
||||
}
|
||||
}
|
||||
return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/"));
|
||||
}
|
||||
|
||||
function isCoveredByFiles(file: string, filesEntries: string[]): boolean {
|
||||
for (const entry of filesEntries) {
|
||||
if (entry.endsWith("/")) {
|
||||
if (file === entry.slice(0, -1) || file.startsWith(entry)) return true;
|
||||
} else if (file === entry || file.startsWith(entry + "/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test("#3578 every MCP-server source file is covered by package.json files", () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
||||
const filesEntries: string[] = pkg.files || [];
|
||||
const closure = computeMcpClosure();
|
||||
|
||||
// Sanity: the closure must actually include the file the bug report hit.
|
||||
assert.ok(
|
||||
closure.includes("src/lib/combos/steps.ts"),
|
||||
"closure should include the file from the bug report (#3578)"
|
||||
);
|
||||
|
||||
const uncovered = closure.filter((f) => !isCoveredByFiles(f, filesEntries));
|
||||
assert.deepEqual(
|
||||
uncovered,
|
||||
[],
|
||||
`These MCP-reachable source files are not in package.json "files" and would 404 a published --mcp:\n` +
|
||||
uncovered.map((f) => " - " + f).join("\n")
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user