From 017e85ed4dee3e50d444c5644e139710f3d783ad Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 10 Jun 2026 19:21:41 -0300 Subject: [PATCH] fix(mcp): ship MCP server source closure in npm files + coverage gate (#3578) --- CHANGELOG.md | 1 + package.json | 21 ++-- .../mcp-published-files-closure-3578.test.ts | 102 ++++++++++++++++++ 3 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 tests/unit/mcp-published-files-closure-3578.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1517d4c3d7..8d2d76ca1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) --- diff --git a/package.json b/package.json index f6b583af0e..735ba1543f 100644 --- a/package.json +++ b/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/", diff --git a/tests/unit/mcp-published-files-closure-3578.test.ts b/tests/unit/mcp-published-files-closure-3578.test.ts new file mode 100644 index 0000000000..8c7dd68b5a --- /dev/null +++ b/tests/unit/mcp-published-files-closure-3578.test.ts @@ -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(); + 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") + ); +});