diff --git a/.debt-scan.json b/.debt-scan.json index 9ae6791..22a2855 100644 --- a/.debt-scan.json +++ b/.debt-scan.json @@ -58,6 +58,10 @@ "packages/mcp/src/index.d.ts", "packages/mcp/src/index.js", "packages/mcp/src/registry.js", + "packages/mcp/src/router-core.d.ts", + "packages/mcp/src/router-core.js", + "packages/mcp/src/tool-names.d.ts", + "packages/mcp/src/tool-names.js", "packages/registry-client/src/index.js", "packages/runner/src/index.js", "packages/runner/src/secrets.js", diff --git a/.github/workflows/publish-mcp-npm.yml b/.github/workflows/publish-mcp-npm.yml new file mode 100644 index 0000000..2933a78 --- /dev/null +++ b/.github/workflows/publish-mcp-npm.yml @@ -0,0 +1,246 @@ +name: Publish @learnrudi/mcp + +on: + workflow_dispatch: + inputs: + version: + description: Exact @learnrudi/mcp version to publish from main + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-mcp-npm + cancel-in-progress: false + +jobs: + verify: + name: verify + if: github.ref == 'refs/heads/main' + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out accepted source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Enable Corepack + run: corepack enable + + - name: Verify trusted-publishing runtime + run: | + NPM_VERSION="$(npm --version)" + node scripts/validate-publish-runtime.mjs "$NPM_VERSION" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify requested package version is releasable + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + node --input-type=module <<'NODE' + import fs from 'node:fs'; + + const expectedName = '@learnrudi/mcp'; + const expectedVersion = process.env.EXPECTED_VERSION; + const packageJson = JSON.parse(fs.readFileSync('packages/mcp/package.json', 'utf8')); + if (packageJson.name !== expectedName) { + throw new Error(`Expected ${expectedName}, found ${packageJson.name}`); + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) { + throw new Error(`Invalid release version: ${expectedVersion}`); + } + if (packageJson.version !== expectedVersion) { + throw new Error(`Requested version ${expectedVersion} does not match package version ${packageJson.version}`); + } + + const encodedName = encodeURIComponent(expectedName); + const response = await fetch(`https://registry.npmjs.org/${encodedName}`, { + headers: { accept: 'application/json' }, + }); + if (response.status !== 404 && !response.ok) { + throw new Error(`npm registry metadata request failed with HTTP ${response.status}`); + } + const versions = response.status === 404 + ? {} + : (await response.json()).versions ?? {}; + if (Object.hasOwn(versions, expectedVersion)) { + throw new Error(`${expectedName}@${expectedVersion} already exists`); + } + NODE + + - name: Test workspace package + run: pnpm --filter @learnrudi/mcp test + + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=moderate + + - name: Verify packed workspace package + working-directory: packages/mcp + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + npm pack --json --pack-destination "$RUNNER_TEMP" --ignore-scripts > "$RUNNER_TEMP/npm-pack-mcp-verify.json" + node --input-type=module -e ' + import fs from "node:fs"; + const expectedFiles = [ + "package.json", + "src/agents.js", + "src/index.d.ts", + "src/index.js", + "src/registry.js", + "src/router-core.d.ts", + "src/router-core.js", + "src/tool-names.d.ts", + "src/tool-names.js", + ]; + const [packed] = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/npm-pack-mcp-verify.json", "utf8")); + const actualFiles = packed.files.map(({ path }) => path).sort(); + if (packed.name !== "@learnrudi/mcp") { + throw new Error(`Packed unexpected package ${packed.name}`); + } + if (packed.version !== process.env.EXPECTED_VERSION) { + throw new Error(`Packed version ${packed.version} does not match ${process.env.EXPECTED_VERSION}`); + } + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`Unexpected package files: ${actualFiles.join(", ")}`); + } + ' + + publish: + name: publish + needs: verify + if: github.ref == 'refs/heads/main' + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out the same accepted source without credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Verify fresh source identity + run: | + ACTUAL_SHA="$(git rev-parse HEAD)" + if [ "$ACTUAL_SHA" != "$GITHUB_SHA" ]; then + echo "Checked out $ACTUAL_SHA instead of $GITHUB_SHA" >&2 + exit 1 + fi + if [ -n "$(git status --porcelain=v1)" ]; then + echo "Publish checkout is not clean" >&2 + exit 1 + fi + + - name: Verify trusted-publishing runtime without repository code + run: | + NPM_VERSION="$(npm --version)" + NPM_VERSION="$NPM_VERSION" node --input-type=module -e ' + const version = process.env.NPM_VERSION; + const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) throw new Error(`Unsupported npm version: ${version}`); + const minimum = [11, 5, 1]; + const actual = match.slice(1, 4).map(Number); + for (let index = 0; index < minimum.length; index += 1) { + if (actual[index] > minimum[index]) process.exit(0); + if (actual[index] < minimum[index]) throw new Error(`npm ${version} does not support trusted publishing; require >=11.5.1`); + } + ' + + - name: Recheck exact version and registry immutability + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + node --input-type=module <<'NODE' + import fs from 'node:fs'; + + const expectedName = '@learnrudi/mcp'; + const expectedVersion = process.env.EXPECTED_VERSION; + const packageJson = JSON.parse(fs.readFileSync('packages/mcp/package.json', 'utf8')); + if (packageJson.name !== expectedName || packageJson.version !== expectedVersion) { + throw new Error(`Expected ${expectedName}@${expectedVersion}, found ${packageJson.name}@${packageJson.version}`); + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expectedVersion)) { + throw new Error(`Invalid release version: ${expectedVersion}`); + } + + const encodedName = encodeURIComponent(expectedName); + const response = await fetch(`https://registry.npmjs.org/${encodedName}`, { + headers: { accept: 'application/json' }, + }); + if (response.status !== 404 && !response.ok) { + throw new Error(`npm registry metadata request failed with HTTP ${response.status}`); + } + const versions = response.status === 404 + ? {} + : (await response.json()).versions ?? {}; + if (Object.hasOwn(versions, expectedVersion)) { + throw new Error(`${expectedName}@${expectedVersion} already exists`); + } + NODE + + - name: Pack fresh verified workspace package without lifecycle scripts + id: pack + working-directory: packages/mcp + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + npm pack --json --pack-destination "$RUNNER_TEMP" --ignore-scripts > "$RUNNER_TEMP/npm-pack-mcp-publish.json" + PACKAGE_TARBALL="$(node --input-type=module -e ' + import fs from "node:fs"; + const expectedFiles = [ + "package.json", + "src/agents.js", + "src/index.d.ts", + "src/index.js", + "src/registry.js", + "src/router-core.d.ts", + "src/router-core.js", + "src/tool-names.d.ts", + "src/tool-names.js", + ]; + const [packed] = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/npm-pack-mcp-publish.json", "utf8")); + const actualFiles = packed.files.map(({ path }) => path).sort(); + if (packed.name !== "@learnrudi/mcp") { + throw new Error(`Packed unexpected package ${packed.name}`); + } + if (packed.version !== process.env.EXPECTED_VERSION) { + throw new Error(`Packed version ${packed.version} does not match ${process.env.EXPECTED_VERSION}`); + } + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error(`Unexpected package files: ${actualFiles.join(", ")}`); + } + process.stdout.write(packed.filename); + ')" + echo "filename=$PACKAGE_TARBALL" >> "$GITHUB_OUTPUT" + + - name: Publish through npm trusted publishing + env: + PACKAGE_TARBALL: ${{ steps.pack.outputs.filename }} + run: npm publish "$RUNNER_TEMP/$PACKAGE_TARBALL" --access public --ignore-scripts --registry=https://registry.npmjs.org diff --git a/dist/router-mcp.js b/dist/router-mcp.js index ad400e0..aa02fec 100644 --- a/dist/router-mcp.js +++ b/dist/router-mcp.js @@ -7,7 +7,7 @@ import * as path from "path"; import * as readline from "readline"; import * as os from "os"; -// src/router-tool-names.js +// packages/mcp/src/tool-names.js import { createHash } from "node:crypto"; var PORTABLE_TOOL_NAME_MAX_LENGTH = 54; var PORTABLE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,54}$/; @@ -45,6 +45,142 @@ function buildPortableToolNameMap(canonicalNames) { return { canonicalToPortable, portableToCanonical }; } +// packages/mcp/src/router-core.js +function nonEmptyString(value, label) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} +function canonicalToolName(stackId, toolName) { + const stack = nonEmptyString(stackId, "stackId"); + const tool = nonEmptyString(toolName, "toolName"); + if (stack.includes(".")) throw new Error("stackId cannot contain a dot"); + return `${stack}.${tool}`; +} +function parseCanonicalToolName(value) { + const name = nonEmptyString(value, "tool name"); + const dotIndex = name.indexOf("."); + if (dotIndex <= 0 || dotIndex === name.length - 1) { + throw new Error(`Invalid tool name format: ${name} (expected: stack.tool_name)`); + } + return { + stackId: name.slice(0, dotIndex), + toolName: name.slice(dotIndex + 1) + }; +} +function namespaceTools(discovered) { + if (!Array.isArray(discovered)) { + throw new Error("discoverStackTools must return an array"); + } + const names = /* @__PURE__ */ new Set(); + const tools = []; + for (const entry of discovered) { + const stackId = nonEmptyString(entry?.stackId, "stackId"); + if (!Array.isArray(entry?.tools)) { + throw new Error(`tools for ${stackId} must be an array`); + } + for (const tool of entry.tools) { + const name = canonicalToolName(stackId, tool?.name); + if (names.has(name)) throw new Error(`Duplicate tool name: ${name}`); + names.add(name); + tools.push({ + name, + description: `[${stackId}] ${tool.description || tool.name}`, + inputSchema: tool.inputSchema || { type: "object", properties: {} } + }); + } + } + return tools; +} +function createRouterDispatcher(options) { + if (typeof options?.discoverStackTools !== "function") { + throw new Error("discoverStackTools adapter is required"); + } + if (typeof options?.executeStackTool !== "function") { + throw new Error("executeStackTool adapter is required"); + } + const protocolVersion = options.protocolVersion || "2024-11-05"; + const serverInfo = options.serverInfo || { name: "rudi-router", version: "1.0.0" }; + const toolNameStyle = options.toolNameStyle === "portable" ? "portable" : "canonical"; + const callPolicy = options.callPolicy === "adapter-authoritative" ? "adapter-authoritative" : "discovered-only"; + let canonicalNames = /* @__PURE__ */ new Set(); + let portableToCanonical = /* @__PURE__ */ new Map(); + async function listTools() { + const tools = namespaceTools(await options.discoverStackTools()); + canonicalNames = new Set(tools.map((tool) => tool.name)); + if (toolNameStyle !== "portable") return tools; + const mapping = buildPortableToolNameMap(tools.map((tool) => tool.name)); + portableToCanonical = mapping.portableToCanonical; + return tools.map((tool) => ({ + ...tool, + name: mapping.canonicalToPortable.get(tool.name) + })); + } + async function callTool(requestedName, arguments_ = {}) { + let name = requestedName; + let parsed; + if (toolNameStyle === "portable") { + if (portableToCanonical.size === 0) await listTools(); + name = portableToCanonical.get(requestedName); + if (!name) throw new Error(`Unknown portable tool name: ${requestedName}`); + } else if (callPolicy === "discovered-only") { + parsed = parseCanonicalToolName(requestedName); + if (canonicalNames.size === 0) await listTools(); + if (!canonicalNames.has(requestedName)) { + throw new Error(`Unknown canonical tool name: ${requestedName}`); + } + } + parsed ||= parseCanonicalToolName(name); + return options.executeStackTool({ + stackId: parsed.stackId, + toolName: parsed.toolName, + arguments: arguments_ + }); + } + async function handleRequest2(request) { + const response = { jsonrpc: "2.0", id: request.id ?? null }; + try { + switch (request.method) { + case "initialize": + response.result = { + protocolVersion, + capabilities: { tools: {} }, + serverInfo + }; + break; + case "notifications/initialized": + return null; + case "tools/list": + response.result = { tools: await listTools() }; + break; + case "tools/call": + response.result = await callTool( + request.params.name, + request.params.arguments || {} + ); + break; + case "ping": + response.result = {}; + break; + default: + if (request.id === null || request.id === void 0) return null; + response.error = { + code: -32601, + message: `Method not found: ${request.method}` + }; + } + } catch (error) { + response.error = { + code: -32603, + message: error instanceof Error ? error.message : "Internal error" + }; + } + return response; + } + return Object.freeze({ listTools, callTool, handleRequest: handleRequest2 }); +} + // src/router-mcp.js var RUDI_HOME = process.env.RUDI_HOME || path.join(os.homedir(), ".rudi"); var RUDI_JSON_PATH = path.join(RUDI_HOME, "rudi.json"); @@ -66,7 +202,6 @@ var serverPool = /* @__PURE__ */ new Map(); var rudiConfig = null; var toolIndex = null; var cleanupTimer = null; -var portableToolNames = /* @__PURE__ */ new Map(); function log(msg) { process.stderr.write(`[rudi-router] ${msg} `); @@ -358,26 +493,18 @@ async function initializeStack(server, stackId) { debug(`Failed to initialize ${stackId}: ${err.message}`); } } -async function listTools() { - const tools = []; +async function discoverStackTools() { + const stacks = []; const skippedStacks = []; for (const [stackId, stackConfig] of Object.entries(rudiConfig?.stacks || {})) { if (!stackConfig.installed) continue; const indexEntry = toolIndex?.byStack?.[stackId]; if (indexEntry?.tools && indexEntry.tools.length > 0 && !indexEntry.error) { - tools.push(...indexEntry.tools.map((t) => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: "object", properties: {} } - }))); + stacks.push({ stackId, tools: indexEntry.tools }); continue; } if (stackConfig.tools && stackConfig.tools.length > 0) { - tools.push(...stackConfig.tools.map((t) => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: "object", properties: {} } - }))); + stacks.push({ stackId, tools: stackConfig.tools }); continue; } if (!LIVE_TOOL_LIST) { @@ -392,13 +519,10 @@ async function listTools() { id: `list-${stackId}-${Date.now()}`, method: "tools/list" }); - if (response.result?.tools) { - tools.push(...response.result.tools.map((t) => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: "object", properties: {} } - }))); + if (!Array.isArray(response.result?.tools)) { + throw new Error("tools/list result.tools must be an array"); } + stacks.push({ stackId, tools: response.result.tools }); } catch (err) { log(`Failed to list tools from ${stackId}: ${err.message}`); } @@ -406,29 +530,9 @@ async function listTools() { if (skippedStacks.length > 0) { log(`Skipped live tools/list for ${skippedStacks.length} stacks (enable RUDI_ROUTER_LIVE_TOOL_LIST=1 or run "rudi index")`); } - if (TOOL_NAME_STYLE !== "portable") return tools; - const mapping = buildPortableToolNameMap(tools.map((tool) => tool.name)); - portableToolNames = mapping.portableToCanonical; - return tools.map((tool) => ({ - ...tool, - name: mapping.canonicalToPortable.get(tool.name) - })); -} -async function callTool(toolName, arguments_) { - let canonicalToolName = toolName; - if (TOOL_NAME_STYLE === "portable") { - if (portableToolNames.size === 0) await listTools(); - canonicalToolName = portableToolNames.get(toolName); - if (!canonicalToolName) { - throw new Error(`Unknown portable tool name: ${toolName}`); - } - } - const dotIndex = canonicalToolName.indexOf("."); - if (dotIndex === -1) { - throw new Error(`Invalid tool name format: ${canonicalToolName} (expected: stack.tool_name)`); - } - const stackId = canonicalToolName.slice(0, dotIndex); - const actualToolName = canonicalToolName.slice(dotIndex + 1); + return stacks; +} +async function executeStackTool({ stackId, toolName, arguments: arguments_ }) { if (!rudiConfig?.stacks?.[stackId]) { throw new Error(`Stack not found: ${stackId}`); } @@ -439,7 +543,7 @@ async function callTool(toolName, arguments_) { id: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`, method: "tools/call", params: { - name: actualToolName, + name: toolName, arguments: arguments_ } }); @@ -448,59 +552,18 @@ async function callTool(toolName, arguments_) { } return response.result; } -async function handleRequest(request) { - const response = { - jsonrpc: "2.0", - id: request.id ?? null - }; - try { - switch (request.method) { - case "initialize": - response.result = { - protocolVersion: PROTOCOL_VERSION, - capabilities: { - tools: {} - }, - serverInfo: { - name: "rudi-router", - version: "1.0.0" - } - }; - break; - case "notifications/initialized": - return null; - case "tools/list": { - const tools = await listTools(); - response.result = { tools }; - break; - } - case "tools/call": { - const params = request.params; - const result = await callTool(params.name, params.arguments || {}); - response.result = result; - break; - } - case "ping": - response.result = {}; - break; - default: - if (request.id !== null && request.id !== void 0) { - response.error = { - code: -32601, - message: `Method not found: ${request.method}` - }; - } else { - return null; - } - } - } catch (err) { - response.error = { - code: -32603, - message: err.message || "Internal error" - }; - } - return response; -} +var dispatcher = createRouterDispatcher({ + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: "rudi-router", version: "1.0.0" }, + toolNameStyle: TOOL_NAME_STYLE, + // Local stdio historically permits direct calls to installed stack tools + // even when discovery is unavailable. Hosted consumers keep the shared + // core's fail-closed discovered-only default. + callPolicy: "adapter-authoritative", + discoverStackTools, + executeStackTool +}); +var { handleRequest } = dispatcher; async function main() { log("Starting RUDI Router MCP Server"); log(`Pool config: max=${MAX_SERVERS <= 0 ? "unlimited" : MAX_SERVERS}, idleTTL=${IDLE_TTL_MS}ms, cleanup=${CLEANUP_INTERVAL_MS}ms`); diff --git a/docs/swe-compliance/2026-09-01-hosted-master-rudi-mcp-router-v1.md b/docs/swe-compliance/2026-09-01-hosted-master-rudi-mcp-router-v1.md new file mode 100644 index 0000000..f447f27 --- /dev/null +++ b/docs/swe-compliance/2026-09-01-hosted-master-rudi-mcp-router-v1.md @@ -0,0 +1,163 @@ +# Shared router dispatch core and local stdio parity + +Status: Phase 5 verification and independent-review remediation. Cross-repository +authority and deployment gates are recorded in the System compliance ledger. + +## Phase 0: Baseline And Manual Lookup + +- Baseline: clean GitHub-main worktree at `f69e76c`. +- Inspect: `src/router-mcp.js`, `src/router-tool-names.js`, `packages/mcp`, test + runner, package/build metadata, and tracked `dist` output. +- Risk: High because all installed local stacks use this router. +- Invariant: the stdio wire behavior, tool names, cache precedence, lazy stack + execution, error isolation, pool lifecycle, and no-network boundary remain + unchanged. + +## Phase 1: Scope Lock + +- Add one transport/execution-independent dispatcher API to `packages/mcp`; + retain subprocess/config/secrets/pool ownership in the local adapter. +- Modify `src/router-mcp.js` only to compose the shared core with that adapter. +- Add characterization/parity tests and package API declarations. Refresh + tracked build output in its own verified slice. +- Non-goals: local OAuth, HTTP listener, relay, stack behavior changes, secrets + migration, or unrelated router hardening. +- Commits/push/PR are authorized; package publication is a later release gate. +- Add a package-specific, manual, main-only trusted-publishing workflow for + `@learnrudi/mcp`; configuring npm trust and invoking publication remain later + release gates. + +## Phase 2: Red Tests + +- Prove canonical/portable names, cache > inline > live precedence, skipped + stacks, unknown/malformed tool denial, downstream error propagation, + initialize/list/call/ping/notification/method-not-found handling. +- Historical proof gap: the exact pre-extraction characterization command was + not durably recorded before implementation and cannot be reconstructed. + Independent review later reproduced two expected red behaviors against the + pre-remediation design: an undiscovered canonical call reached its adapter, + and one malformed live `tools/list` result hid healthy stacks. Those findings + are preserved as review evidence, not rewritten as an invented original red + command. +- Release-workflow contract red, Node `v20.20.2`: + `node --test src/__tests__/unit/quality-workflow-contract.test.js` -> six + passed and one failed because `.github/workflows/publish-mcp-npm.yml` did not + exist. The failure established the missing package publication path before + implementation. +- Independent release-path review then found two P1 defects: job-wide OIDC + permission exposed token-request capability while dependency and repository + code executed, and the package's directory-form test target failed under + Node 24. A separate boundary test also reproduced that the runtime helper + accepted `11.5.1` prereleases as meeting the stable floor. Contract cases for + split permissions, a code-free publish job, the explicit test glob, and + prerelease rejection failed before remediation. + +## Phase 3: Implementation + +- Core accepts explicit adapters and policy predicates; it never reads local + files, environment, secrets, stdio, HTTP, or process-global tenant state. +- Public errors remain stable; inputs are validated before adapter invocation. +- The package release path uses exact manual version input, immutable action + pins, the npm trusted-publishing runtime floor, registry immutability checks, + package-focused tests, dependency audit, and an exact packed-file allowlist. + It contains no npm token fallback. +- Verification and publication are separate jobs. The verification job has no + OIDC permission and runs install, tests, audit, and a pack allowlist check. + Only its dependent publish job receives `id-token: write`; that job uses a + fresh same-SHA credential-free checkout, installs no dependencies, executes + no repository helper or tests, repacks with lifecycle scripts disabled, and + rechecks registry immutability plus the allowlist before publishing. The MCP + package test script names its portable unit-test glob explicitly. The publish + command pins `https://registry.npmjs.org` at command-line precedence. + +## Phase 4: Green Tests And Refactor + +- Release-workflow contract green, Node `v20.20.2`: the exact red command now + passes 7/7 after adding the package metadata and workflow, then remained + green after split-job and prerelease remediation. The workflow YAML parses + successfully and all 12 embedded run scripts pass `bash -n`. +- Clean temporary Node `v24.20.0` / npm `11.6.2` verification passed the exact + `pnpm --filter @learnrudi/mcp test` gate 29/29, production audit with no known + vulnerabilities, and the exact nine-file package dry-run. The temporary + verification checkout was moved to Trash after proof. +- Exact Node 20.20.2 focused core/parity command passed 11/11. +- Exact Node 20.20.2 stdio characterization passed 2/2 and now proves canonical + and portable calls, cache over competing inline declarations, inline over + live discovery, disabled-live skipping, malformed-live isolation, + downstream JSON-RPC call failure propagation, notifications, ping, and + method-not-found behavior. +- Matching-native-runtime full suite passed 783/783 after extraction and + tracked `dist` regeneration. After merging current `origin/main` at + `a7c5b4d`, the same suite passed 786/786; the three additional tests are from + the upstream CLI 1.10.26 release slice. The later docs-only current-main head + `606c586` was also merged before final publication without changing router + behavior or test inventory. + +## Phase 5: Full Verification + +Reproduction record (working directory +`/Users/hoff/RUDI/worktrees/hosted-router-v1/cli` unless noted): + +- Focused, Node `v20.20.2`: `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH node --test packages/mcp/src/__tests__/unit/router-core.test.js src/__tests__/unit/router-tool-names.test.js src/__tests__/unit/router-mcp-characterization.test.js` -> 11/11. +- Full, Node `v25.2.1`: `pnpm test` -> 783/783 before the current-main merge; + rerunning the same command after merging `a7c5b4d`, and again after the + docs-only `606c586` merge -> 786/786. +- Build, Node `v20.20.2`: `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH pnpm build` -> pass. +- Current-main lock/build reconciliation, Node `v20.20.2`: + `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH pnpm install --frozen-lockfile` + followed by the recorded build command -> pass; the regenerated tracked + bundles matched the merged index byte-for-byte. +- Root package, Node `v20.20.2`: `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH npm pack --dry-run --json` -> six files. +- MCP package, working directory `packages/mcp`, Node `v20.20.2`: + `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH npm pack --dry-run --json` -> nine files. +- Historical pre-remediation production audit: `pnpm audit --prod` reported + five high and three moderate findings. The current release-path audit below + supersedes that historical result. +- Node 20 environment gap: `PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH pnpm test` -> blocked when unchanged `better-sqlite3` ABI 141 was loaded by ABI 115. +- Debt tool: `swe_debt_scan` with repo equal to the workdir, config + `.debt-scan.json`, profile `pr-review`, and the seven edited implementation/ + test JS files -> 276 files in graph, seven reported, zero findings. +- Built-router smoke: create an empty root with + `router_smoke_root=$(mktemp -d /private/tmp/rudi-router-dist-smoke.XXXXXX)`, + then pipe JSON-RPC `initialize` and `ping` lines to + `RUDI_HOME="$router_smoke_root" PATH=/Users/hoff/.nvm/versions/node/v20.20.2/bin:$PATH node dist/router-mcp.js` -> protocol `2024-11-05` and `{}` ping result. +- Whitespace: `git diff --check` -> pass. + +- Exact Node 20.20.2 `pnpm build`: pass; temporary outputs matched tracked + generated artifacts byte-for-byte. +- Current release-path verification: `pnpm install --frozen-lockfile` passed; + `pnpm test` passed 787/787; `pnpm build` passed and left `dist` plus + `src/packages-manifest.json` unchanged; changed-file debt scan reported zero + findings; `pnpm audit --prod --audit-level=moderate` reported no known + vulnerabilities; root pack remained six files. +- Root `npm pack --dry-run`: six intended files. `@learnrudi/mcp@1.1.0` + package dry-run: nine intended source/declaration files; tests excluded. +- The nine-file dry-run exactly matches the workflow allowlist. Both action + references are immutable 40-character pins; the workflow YAML parsed and + all embedded shell scripts passed syntax validation. `actionlint` was not + installed locally, so that optional linter was not run. +- The final permission contract proves the verification job lacks + `id-token: write`, the publish job depends on verification, and the publish + job contains no install, test, audit, or repository runtime-helper command. + Stable npm `11.5.1` is accepted; its `alpha` and `rc` prereleases fail closed. +- Edited-file SWE debt scan: zero findings. `git diff --check`: pass. +- Built `dist/router-mcp.js` completed an isolated Node 20 stdio + initialize/ping smoke with an empty temporary RUDI home. +- Current production dependency audit reports no known vulnerabilities. Root + dependency metadata and lockfile are unchanged by this release-workflow + slice, and `@learnrudi/mcp` has no runtime dependencies. +- Exact Node 20 full-suite proof is locally blocked by the unchanged + `better-sqlite3` native binary compiled for ABI 141 instead of Node 20 ABI + 115. The full suite passes on the matching native runtime; a fresh-install + Node 20 CI run remains the closing environment proof. +- Independent final review: Standards pass, Spec pass, Proof pass, overall + pass; no P0-P3 findings remain after ledger and characterization remediation. + +## Phase 6: Docs, Contracts, And Closure + +- Package publication, merge, and hosted activation remain separately gated. +- `@learnrudi/mcp@1.0.0` exists on npm; `1.1.0` is not published. After merge, + npm must trust the exact `publish-mcp-npm.yml` workflow before an authorized + manual run can publish it. +- Record final commit/PR, CI result, admin-Mac verification, review verdict, + and worktree closeout receipt before closure. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 27588a7..01e8960 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,26 +1,43 @@ { "name": "@learnrudi/mcp", - "version": "1.0.0", - "description": "MCP registry utilities for RUDI", + "version": "1.1.0", + "description": "MCP registry and transport-independent router utilities for RUDI", "type": "module", "main": "src/index.js", "types": "src/index.d.ts", + "files": [ + "src/*.js", + "src/*.d.ts" + ], "exports": { ".": { "types": "./src/index.d.ts", "default": "./src/index.js" }, "./agents": "./src/agents.js", - "./registry": "./src/registry.js" + "./registry": "./src/registry.js", + "./router-core": { + "types": "./src/router-core.d.ts", + "default": "./src/router-core.js" + }, + "./tool-names": { + "types": "./src/tool-names.d.ts", + "default": "./src/tool-names.js" + } }, "scripts": { - "test": "node ../../scripts/run-tests.js src/__tests__/" + "test": "node ../../scripts/run-tests.js src/__tests__/unit/*.test.js" }, "dependencies": {}, "engines": { "node": ">=18.0.0" }, "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/learnrudi/cli.git", + "directory": "packages/mcp" + }, "publishConfig": { "access": "public" } diff --git a/packages/mcp/src/__tests__/unit/router-core.test.js b/packages/mcp/src/__tests__/unit/router-core.test.js new file mode 100644 index 0000000..93d5709 --- /dev/null +++ b/packages/mcp/src/__tests__/unit/router-core.test.js @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRouterDispatcher } from '../../router-core.js'; + +function fixture(options = {}) { + const calls = []; + const dispatcher = createRouterDispatcher({ + protocolVersion: '2024-11-05', + serverInfo: { name: 'test-router', version: '1.0.0' }, + toolNameStyle: options.toolNameStyle, + async discoverStackTools() { + return [ + { + stackId: 'stack-one', + tools: [ + { + name: 'read.value', + description: 'Read a value', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }, + ]; + }, + async executeStackTool(call) { + calls.push(call); + return { content: [{ type: 'text', text: 'ok' }] }; + }, + }); + return { dispatcher, calls }; +} + +test('shared dispatcher namespaces discovery and routes exact calls', async () => { + const { dispatcher, calls } = fixture(); + assert.deepEqual(await dispatcher.listTools(), [ + { + name: 'stack-one.read.value', + description: '[stack-one] Read a value', + inputSchema: { type: 'object', properties: {} }, + }, + ]); + + const result = await dispatcher.callTool('stack-one.read.value', { id: 7 }); + assert.deepEqual(result, { content: [{ type: 'text', text: 'ok' }] }); + assert.deepEqual(calls, [ + { stackId: 'stack-one', toolName: 'read.value', arguments: { id: 7 } }, + ]); +}); + +test('default call policy rejects undiscovered canonical tools before adapter execution', async () => { + const { dispatcher, calls } = fixture(); + await assert.rejects( + () => dispatcher.callTool('stack-one.not-listed', {}), + /Unknown canonical tool name/, + ); + assert.deepEqual(calls, []); +}); + +test('portable calls reject undiscovered names before adapter execution', async () => { + const { dispatcher, calls } = fixture({ toolNameStyle: 'portable' }); + await assert.rejects( + () => dispatcher.callTool('not-listed', {}), + /Unknown portable tool name/, + ); + assert.deepEqual(calls, []); +}); + +test('adapter-authoritative policy explicitly preserves local direct-call compatibility', async () => { + const calls = []; + const dispatcher = createRouterDispatcher({ + callPolicy: 'adapter-authoritative', + async discoverStackTools() { return []; }, + async executeStackTool(call) { + calls.push(call); + return { ok: true }; + }, + }); + assert.deepEqual(await dispatcher.callTool('installed.hidden', { value: 1 }), { ok: true }); + assert.deepEqual(calls, [{ + stackId: 'installed', + toolName: 'hidden', + arguments: { value: 1 }, + }]); +}); + +test('portable names remain reversible and bounded', async () => { + const { dispatcher, calls } = fixture({ toolNameStyle: 'portable' }); + const [tool] = await dispatcher.listTools(); + assert.match(tool.name, /^[a-zA-Z0-9_-]{1,54}$/); + await dispatcher.callTool(tool.name, {}); + assert.equal(calls[0].stackId, 'stack-one'); + assert.equal(calls[0].toolName, 'read.value'); +}); + +test('JSON-RPC handling preserves initialize, notifications, calls, and errors', async () => { + const { dispatcher } = fixture(); + + assert.deepEqual(await dispatcher.handleRequest({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + }), { + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'test-router', version: '1.0.0' }, + }, + }); + assert.equal(await dispatcher.handleRequest({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), null); + assert.equal(await dispatcher.handleRequest({ + jsonrpc: '2.0', + method: 'unknown/notification', + }), null); + + const unknown = await dispatcher.handleRequest({ + jsonrpc: '2.0', + id: 2, + method: 'unknown/request', + }); + assert.equal(unknown.error.code, -32601); + + const invalidCall = await dispatcher.handleRequest({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'invalid', arguments: {} }, + }); + assert.equal(invalidCall.error.code, -32603); + assert.match(invalidCall.error.message, /expected: stack\.tool_name/); +}); + +test('duplicate canonical tool names fail closed', async () => { + const dispatcher = createRouterDispatcher({ + async discoverStackTools() { + return [ + { stackId: 'same', tools: [{ name: 'tool' }] }, + { stackId: 'same', tools: [{ name: 'tool' }] }, + ]; + }, + async executeStackTool() {}, + }); + + await assert.rejects(() => dispatcher.listTools(), /Duplicate tool name/); +}); diff --git a/packages/mcp/src/index.d.ts b/packages/mcp/src/index.d.ts index e16b393..51cbe10 100644 --- a/packages/mcp/src/index.d.ts +++ b/packages/mcp/src/index.d.ts @@ -34,6 +34,9 @@ export interface McpRegistrationSummary { }; } +export * from './router-core.js'; +export * from './tool-names.js'; + // From agents.js export const AGENT_CONFIGS: AgentConfig[]; export function findAgentConfig(agent: AgentConfig): string | null; diff --git a/packages/mcp/src/index.js b/packages/mcp/src/index.js index 5fc1d79..79c22ed 100644 --- a/packages/mcp/src/index.js +++ b/packages/mcp/src/index.js @@ -1,2 +1,4 @@ export * from './agents.js'; export * from './registry.js'; +export * from './router-core.js'; +export * from './tool-names.js'; diff --git a/packages/mcp/src/router-core.d.ts b/packages/mcp/src/router-core.d.ts new file mode 100644 index 0000000..0d5cdb4 --- /dev/null +++ b/packages/mcp/src/router-core.d.ts @@ -0,0 +1,40 @@ +export interface RouterToolDefinition { + name: string; + description?: string; + inputSchema?: Record; +} + +export const ROUTER_CORE_VERSION: '1.1.0'; + +export interface StackToolDiscovery { + stackId: string; + tools: RouterToolDefinition[]; +} + +export interface StackToolCall { + stackId: string; + toolName: string; + arguments: Record; +} + +export interface RouterDispatcherOptions { + protocolVersion?: string; + serverInfo?: { name: string; version: string }; + toolNameStyle?: 'canonical' | 'portable'; + callPolicy?: 'discovered-only' | 'adapter-authoritative'; + discoverStackTools(): Promise; + executeStackTool(call: StackToolCall): Promise; +} + +export interface RouterDispatcher { + listTools(): Promise>>; + callTool(name: string, arguments_?: Record): Promise; + handleRequest(request: Record): Promise | null>; +} + +export function canonicalToolName(stackId: string, toolName: string): string; +export function parseCanonicalToolName(value: string): { + stackId: string; + toolName: string; +}; +export function createRouterDispatcher(options: RouterDispatcherOptions): RouterDispatcher; diff --git a/packages/mcp/src/router-core.js b/packages/mcp/src/router-core.js new file mode 100644 index 0000000..1326af2 --- /dev/null +++ b/packages/mcp/src/router-core.js @@ -0,0 +1,148 @@ +import { buildPortableToolNameMap } from './tool-names.js'; + +export const ROUTER_CORE_VERSION = '1.1.0'; + +function nonEmptyString(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +export function canonicalToolName(stackId, toolName) { + const stack = nonEmptyString(stackId, 'stackId'); + const tool = nonEmptyString(toolName, 'toolName'); + if (stack.includes('.')) throw new Error('stackId cannot contain a dot'); + return `${stack}.${tool}`; +} + +export function parseCanonicalToolName(value) { + const name = nonEmptyString(value, 'tool name'); + const dotIndex = name.indexOf('.'); + if (dotIndex <= 0 || dotIndex === name.length - 1) { + throw new Error(`Invalid tool name format: ${name} (expected: stack.tool_name)`); + } + return { + stackId: name.slice(0, dotIndex), + toolName: name.slice(dotIndex + 1), + }; +} + +function namespaceTools(discovered) { + if (!Array.isArray(discovered)) { + throw new Error('discoverStackTools must return an array'); + } + const names = new Set(); + const tools = []; + for (const entry of discovered) { + const stackId = nonEmptyString(entry?.stackId, 'stackId'); + if (!Array.isArray(entry?.tools)) { + throw new Error(`tools for ${stackId} must be an array`); + } + for (const tool of entry.tools) { + const name = canonicalToolName(stackId, tool?.name); + if (names.has(name)) throw new Error(`Duplicate tool name: ${name}`); + names.add(name); + tools.push({ + name, + description: `[${stackId}] ${tool.description || tool.name}`, + inputSchema: tool.inputSchema || { type: 'object', properties: {} }, + }); + } + } + return tools; +} + +export function createRouterDispatcher(options) { + if (typeof options?.discoverStackTools !== 'function') { + throw new Error('discoverStackTools adapter is required'); + } + if (typeof options?.executeStackTool !== 'function') { + throw new Error('executeStackTool adapter is required'); + } + const protocolVersion = options.protocolVersion || '2024-11-05'; + const serverInfo = options.serverInfo || { name: 'rudi-router', version: '1.0.0' }; + const toolNameStyle = options.toolNameStyle === 'portable' ? 'portable' : 'canonical'; + const callPolicy = options.callPolicy === 'adapter-authoritative' + ? 'adapter-authoritative' + : 'discovered-only'; + let canonicalNames = new Set(); + let portableToCanonical = new Map(); + + async function listTools() { + const tools = namespaceTools(await options.discoverStackTools()); + canonicalNames = new Set(tools.map((tool) => tool.name)); + if (toolNameStyle !== 'portable') return tools; + const mapping = buildPortableToolNameMap(tools.map((tool) => tool.name)); + portableToCanonical = mapping.portableToCanonical; + return tools.map((tool) => ({ + ...tool, + name: mapping.canonicalToPortable.get(tool.name), + })); + } + + async function callTool(requestedName, arguments_ = {}) { + let name = requestedName; + let parsed; + if (toolNameStyle === 'portable') { + if (portableToCanonical.size === 0) await listTools(); + name = portableToCanonical.get(requestedName); + if (!name) throw new Error(`Unknown portable tool name: ${requestedName}`); + } else if (callPolicy === 'discovered-only') { + parsed = parseCanonicalToolName(requestedName); + if (canonicalNames.size === 0) await listTools(); + if (!canonicalNames.has(requestedName)) { + throw new Error(`Unknown canonical tool name: ${requestedName}`); + } + } + parsed ||= parseCanonicalToolName(name); + return options.executeStackTool({ + stackId: parsed.stackId, + toolName: parsed.toolName, + arguments: arguments_, + }); + } + + async function handleRequest(request) { + const response = { jsonrpc: '2.0', id: request.id ?? null }; + try { + switch (request.method) { + case 'initialize': + response.result = { + protocolVersion, + capabilities: { tools: {} }, + serverInfo, + }; + break; + case 'notifications/initialized': + return null; + case 'tools/list': + response.result = { tools: await listTools() }; + break; + case 'tools/call': + response.result = await callTool( + request.params.name, + request.params.arguments || {} + ); + break; + case 'ping': + response.result = {}; + break; + default: + if (request.id === null || request.id === undefined) return null; + response.error = { + code: -32601, + message: `Method not found: ${request.method}`, + }; + } + } catch (error) { + response.error = { + code: -32603, + message: error instanceof Error ? error.message : 'Internal error', + }; + } + return response; + } + + return Object.freeze({ listTools, callTool, handleRequest }); +} diff --git a/packages/mcp/src/tool-names.d.ts b/packages/mcp/src/tool-names.d.ts new file mode 100644 index 0000000..43d6f58 --- /dev/null +++ b/packages/mcp/src/tool-names.d.ts @@ -0,0 +1,6 @@ +export const PORTABLE_TOOL_NAME_MAX_LENGTH: number; +export function isPortableToolName(value: unknown): value is string; +export function buildPortableToolNameMap(canonicalNames: string[]): { + canonicalToPortable: Map; + portableToCanonical: Map; +}; diff --git a/src/router-tool-names.js b/packages/mcp/src/tool-names.js similarity index 83% rename from src/router-tool-names.js rename to packages/mcp/src/tool-names.js index 87448bf..719fdb6 100644 --- a/src/router-tool-names.js +++ b/packages/mcp/src/tool-names.js @@ -20,12 +20,6 @@ function hashedAlias(base, canonicalName) { return `${base.slice(0, PORTABLE_TOOL_NAME_MAX_LENGTH - suffix.length)}${suffix}`; } -/** - * Google agent clients prefix MCP tools with `mcp__` and reject the - * namespace punctuation RUDI historically exposes. Keep the provider-owned - * canonical identity behind a stable, reversible, 55-character alias so the - * complete client-visible name remains within Google's 64-character limit. - */ export function buildPortableToolNameMap(canonicalNames) { const uniqueNames = [...new Set(canonicalNames)]; const groupedByBase = new Map(); @@ -39,7 +33,6 @@ export function buildPortableToolNameMap(canonicalNames) { const canonicalToPortable = new Map(); const portableToCanonical = new Map(); - for (const canonicalName of uniqueNames) { const base = portableBase(canonicalName); const collides = groupedByBase.get(base).length > 1; @@ -49,6 +42,5 @@ export function buildPortableToolNameMap(canonicalNames) { canonicalToPortable.set(canonicalName, alias); portableToCanonical.set(alias, canonicalName); } - return { canonicalToPortable, portableToCanonical }; } diff --git a/scripts/validate-publish-runtime.mjs b/scripts/validate-publish-runtime.mjs index 05fe18e..f5dad77 100644 --- a/scripts/validate-publish-runtime.mjs +++ b/scripts/validate-publish-runtime.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; const MINIMUM_TRUSTED_PUBLISHING_NPM = [11, 5, 1]; export function supportsTrustedPublishingNpm(version) { - const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec(version); + const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version); if (!match) return false; const actual = match.slice(1, 4).map(Number); diff --git a/src/__tests__/unit/quality-workflow-contract.test.js b/src/__tests__/unit/quality-workflow-contract.test.js index a9744f0..57bb12a 100644 --- a/src/__tests__/unit/quality-workflow-contract.test.js +++ b/src/__tests__/unit/quality-workflow-contract.test.js @@ -12,6 +12,17 @@ function read(relativePath) { return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); } +function workflowJob(workflow, name) { + const marker = `\n ${name}:\n`; + const start = workflow.indexOf(marker); + assert.notEqual(start, -1, `workflow job ${name} must exist`); + const contentStart = start + marker.length; + const nextJobOffset = workflow.slice(contentStart).search(/\n [A-Za-z0-9_-]+:\n/); + return nextJobOffset === -1 + ? workflow.slice(start) + : workflow.slice(start, contentStart + nextJobOffset); +} + test('GitHub quality workflow blocks unverified changes', () => { const workflow = read('.github/workflows/quality.yml'); @@ -59,12 +70,59 @@ test('npm release workflow verifies the exact version and publishes through OIDC assert.doesNotMatch(workflow, /NODE_AUTH_TOKEN|NPM_TOKEN/); }); +test('@learnrudi/mcp release workflow verifies the workspace package and publishes through OIDC', () => { + const workflow = read('.github/workflows/publish-mcp-npm.yml'); + const packageJson = JSON.parse(read('packages/mcp/package.json')); + const verifyJob = workflowJob(workflow, 'verify'); + const publishJob = workflowJob(workflow, 'publish'); + const workflowHeader = workflow.slice(0, workflow.indexOf('\njobs:\n')); + + assert.equal(packageJson.name, '@learnrudi/mcp'); + assert.equal(packageJson.version, '1.1.0'); + assert.equal(packageJson.scripts.test, 'node ../../scripts/run-tests.js src/__tests__/unit/*.test.js'); + assert.equal(packageJson.repository.url, 'git+https://github.com/learnrudi/cli.git'); + assert.equal(packageJson.repository.directory, 'packages/mcp'); + assert.match(workflow, /^name: Publish @learnrudi\/mcp$/m); + assert.match(workflow, /^\s{2}workflow_dispatch:$/m); + assert.match(workflowHeader, /^\s{2}contents: read$/m); + assert.doesNotMatch(workflowHeader, /id-token:/); + assert.match(verifyJob, /if: github\.ref == 'refs\/heads\/main'/); + assert.match(verifyJob, /permissions:\n\s{6}contents: read/); + assert.doesNotMatch(verifyJob, /id-token:/); + assert.match(publishJob, /needs: verify/); + assert.match(publishJob, /permissions:\n\s{6}contents: read\n\s{6}id-token: write/); + assert.match(workflow, /actions\/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1/); + assert.match(workflow, /actions\/setup-node@820762786026740c76f36085b0efc47a31fe5020/); + assert.match(workflow, /node-version: ['"]24['"]/); + assert.match(workflow, /registry-url: ['"]https:\/\/registry\.npmjs\.org['"]/); + assert.match(workflow, /package-manager-cache: false/); + assert.match(verifyJob, /node scripts\/validate-publish-runtime\.mjs/); + assert.match(verifyJob, /pnpm install --frozen-lockfile/); + assert.match(verifyJob, /pnpm --filter @learnrudi\/mcp test/); + assert.match(verifyJob, /pnpm audit --prod --audit-level=moderate/); + assert.doesNotMatch(publishJob, /pnpm install|pnpm --filter|pnpm audit|scripts\/validate-publish-runtime/); + assert.match(publishJob, /ref: \$\{\{ github\.sha \}\}/); + assert.match(publishJob, /persist-credentials: false/); + assert.match(workflow, /packages\/mcp\/package\.json/); + assert.match(workflow, /registry\.npmjs\.org\/\$\{encodedName\}/); + assert.match(workflow, /npm pack --json --pack-destination/); + assert.match(workflow, /expectedFiles/); + assert.match(verifyJob, /npm pack --json --pack-destination/); + assert.match(publishJob, /npm pack --json --pack-destination/); + assert.match(workflow, /npm publish "\$RUNNER_TEMP\/\$PACKAGE_TARBALL" --access public --ignore-scripts/); + assert.match(publishJob, /--registry=https:\/\/registry\.npmjs\.org/); + assert.doesNotMatch(workflow, /NODE_AUTH_TOKEN|NPM_TOKEN/); +}); + test('trusted-publishing npm gate enforces the complete minimum version', () => { assert.equal(supportsTrustedPublishingNpm('11.4.99'), false); assert.equal(supportsTrustedPublishingNpm('11.5.0'), false); + assert.equal(supportsTrustedPublishingNpm('11.5.1-alpha.0'), false); + assert.equal(supportsTrustedPublishingNpm('11.5.1-rc.1'), false); assert.equal(supportsTrustedPublishingNpm('11.5.1'), true); assert.equal(supportsTrustedPublishingNpm('11.6.0'), true); assert.equal(supportsTrustedPublishingNpm('12.0.0'), true); + assert.equal(supportsTrustedPublishingNpm('11.5.1+build.1'), true); assert.equal(supportsTrustedPublishingNpm('invalid'), false); }); diff --git a/src/__tests__/unit/router-mcp-characterization.test.js b/src/__tests__/unit/router-mcp-characterization.test.js new file mode 100644 index 0000000..6e03523 --- /dev/null +++ b/src/__tests__/unit/router-mcp-characterization.test.js @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +async function readResponse(child, id, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + let buffered = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for response ${id}`)); + }, timeoutMs); + function cleanup() { + clearTimeout(timer); + child.stdout.off('data', onData); + child.off('exit', onExit); + } + function onExit(code) { + cleanup(); + reject(new Error(`Router exited before response ${id}: ${code}`)); + } + function onData(chunk) { + buffered += chunk.toString('utf8'); + const lines = buffered.split(/\r?\n/); + buffered = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line); + if (message.id !== id) continue; + cleanup(); + resolve(message); + return; + } + } + child.stdout.on('data', onData); + child.on('exit', onExit); + }); +} + +function send(child, request) { + child.stdin.write(`${JSON.stringify(request)}\n`); +} + +test('stdio router preserves cached discovery and exact stack call behavior', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rudi-router-characterization-')); + const fixture = path.join(root, 'fixture-stack.mjs'); + const router = path.resolve(import.meta.dirname, '../../router-mcp.js'); + try { + await fs.mkdir(path.join(root, 'cache'), { recursive: true }); + await fs.writeFile(fixture, [ + "import readline from 'node:readline';", + "const rl = readline.createInterface({ input: process.stdin });", + "rl.on('line', (line) => {", + " const request = JSON.parse(line);", + " if (request.id == null) return;", + " if (request.method === 'tools/call' && request.params?.arguments?.fail === true) {", + " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32042, message: 'fixture downstream rejected' } }) + '\\n');", + " return;", + " }", + " const result = request.method === 'initialize'", + " ? { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'fixture', version: '1' } }", + " : { content: [{ type: 'text', text: JSON.stringify(request.params) }] };", + " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');", + "});", + ].join('\n')); + await fs.writeFile(path.join(root, 'rudi.json'), JSON.stringify({ + stacks: { + fixture: { + installed: true, + path: root, + tools: [{ name: 'inline_tool', description: 'Must lose to cache' }], + launch: { bin: process.execPath, args: [fixture], cwd: root }, + }, + live_disabled: { + installed: true, + path: root, + launch: { bin: '/path/that/must/not/run', args: [], cwd: root }, + }, + }, + })); + await fs.writeFile(path.join(root, 'cache', 'tool-index.json'), JSON.stringify({ + byStack: { + fixture: { + tools: [{ + name: 'echo', + description: 'Echo input', + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + }], + }, + }, + })); + + const child = spawn(process.execPath, [router], { + env: { ...process.env, RUDI_HOME: root }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + send(child, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const listed = await readResponse(child, 1); + assert.deepEqual(listed.result.tools, [{ + name: 'fixture.echo', + description: '[fixture] Echo input', + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + }]); + + send(child, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'fixture.echo', arguments: { value: 'hello' } }, + }); + const called = await readResponse(child, 2); + assert.equal(called.error, undefined); + assert.deepEqual( + JSON.parse(called.result.content[0].text), + { name: 'echo', arguments: { value: 'hello' } } + ); + + send(child, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'fixture.echo', arguments: { fail: true } }, + }); + const downstreamFailure = await readResponse(child, 3); + assert.equal(downstreamFailure.error.code, -32603); + assert.equal(downstreamFailure.error.message, 'Tool error: fixture downstream rejected'); + + send(child, { jsonrpc: '2.0', method: 'notifications/initialized' }); + send(child, { jsonrpc: '2.0', id: 4, method: 'unknown/request' }); + const unknown = await readResponse(child, 4); + assert.equal(unknown.error.code, -32601); + + send(child, { jsonrpc: '2.0', id: 5, method: 'ping' }); + assert.deepEqual((await readResponse(child, 5)).result, {}); + child.stdin.end(); + await new Promise((resolve) => child.once('exit', resolve)); + + const portable = spawn(process.execPath, [router], { + env: { + ...process.env, + RUDI_HOME: root, + RUDI_ROUTER_TOOL_NAMES: 'portable', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + send(portable, { jsonrpc: '2.0', id: 6, method: 'tools/list' }); + const portableList = await readResponse(portable, 6); + assert.equal(portableList.result.tools.length, 1); + assert.match(portableList.result.tools[0].name, /^[a-zA-Z0-9_-]{1,54}$/); + send(portable, { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: portableList.result.tools[0].name, arguments: { value: 'portable' } }, + }); + const portableCall = await readResponse(portable, 7); + assert.deepEqual( + JSON.parse(portableCall.result.content[0].text), + { name: 'echo', arguments: { value: 'portable' } }, + ); + portable.stdin.end(); + await new Promise((resolve) => portable.once('exit', resolve)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('stdio live discovery isolates malformed dependencies and honors inline precedence', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rudi-router-live-characterization-')); + const fixture = path.join(root, 'live-stack.mjs'); + const router = path.resolve(import.meta.dirname, '../../router-mcp.js'); + try { + await fs.writeFile(fixture, [ + "import readline from 'node:readline';", + "const mode = process.argv[2];", + "const rl = readline.createInterface({ input: process.stdin });", + "rl.on('line', (line) => {", + " const request = JSON.parse(line);", + " if (request.id == null) return;", + " let result;", + " if (request.method === 'initialize') {", + " result = { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: mode, version: '1' } };", + " } else if (request.method === 'tools/list') {", + " result = mode === 'malformed' ? { tools: { invalid: true } } : { tools: [{ name: 'live_tool', description: 'Live tool' }] };", + " } else {", + " result = { content: [{ type: 'text', text: mode }] };", + " }", + " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');", + "});", + ].join('\n')); + await fs.writeFile(path.join(root, 'rudi.json'), JSON.stringify({ + stacks: { + healthy: { + installed: true, + path: root, + launch: { bin: process.execPath, args: [fixture, 'healthy'], cwd: root }, + }, + malformed: { + installed: true, + path: root, + launch: { bin: process.execPath, args: [fixture, 'malformed'], cwd: root }, + }, + inline: { + installed: true, + path: root, + tools: [{ name: 'inline_tool', description: 'Inline tool' }], + launch: { bin: '/path/that/must/not/run', args: [], cwd: root }, + }, + }, + })); + const child = spawn(process.execPath, [router], { + env: { ...process.env, RUDI_HOME: root, RUDI_ROUTER_LIVE_TOOL_LIST: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + send(child, { jsonrpc: '2.0', id: 7, method: 'tools/list' }); + const listed = await readResponse(child, 7); + assert.deepEqual(listed.result.tools.map((tool) => tool.name), [ + 'healthy.live_tool', + 'inline.inline_tool', + ]); + child.stdin.end(); + await new Promise((resolve) => child.once('exit', resolve)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/unit/router-tool-names.test.js b/src/__tests__/unit/router-tool-names.test.js index d4fc17b..64ba777 100644 --- a/src/__tests__/unit/router-tool-names.test.js +++ b/src/__tests__/unit/router-tool-names.test.js @@ -4,7 +4,7 @@ import { describe, it } from 'node:test'; import { buildPortableToolNameMap, isPortableToolName, -} from '../../router-tool-names.js'; +} from '@learnrudi/mcp/tool-names'; describe('router portable MCP tool names', () => { it('replaces client-incompatible namespace punctuation without losing dispatch identity', () => { diff --git a/src/router-mcp.js b/src/router-mcp.js index 1ae2b91..f87a5f3 100644 --- a/src/router-mcp.js +++ b/src/router-mcp.js @@ -21,7 +21,7 @@ import * as path from 'path'; import * as readline from 'readline'; import * as os from 'os'; -import { buildPortableToolNameMap } from './router-tool-names.js'; +import { createRouterDispatcher } from '@learnrudi/mcp/router-core'; // ============================================================================= // CONSTANTS @@ -61,7 +61,6 @@ let rudiConfig = null; /** @type {Object | null} */ let toolIndex = null; let cleanupTimer = null; -let portableToolNames = new Map(); // ============================================================================= // TYPES (JSDoc) @@ -521,8 +520,8 @@ async function initializeStack(server, stackId) { * Priority: 1. tool-index.json cache, 2. rudi.json inline tools, 3. live query * @returns {Promise>} */ -async function listTools() { - const tools = []; +async function discoverStackTools() { + const stacks = []; const skippedStacks = []; for (const [stackId, stackConfig] of Object.entries(rudiConfig?.stacks || {})) { @@ -531,21 +530,13 @@ async function listTools() { // 1. Check tool-index.json cache (from `rudi index` command) const indexEntry = toolIndex?.byStack?.[stackId]; if (indexEntry?.tools && indexEntry.tools.length > 0 && !indexEntry.error) { - tools.push(...indexEntry.tools.map(t => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: 'object', properties: {} } - }))); + stacks.push({ stackId, tools: indexEntry.tools }); continue; } // 2. Check inline tools in rudi.json (legacy/fallback) if (stackConfig.tools && stackConfig.tools.length > 0) { - tools.push(...stackConfig.tools.map(t => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: 'object', properties: {} } - }))); + stacks.push({ stackId, tools: stackConfig.tools }); continue; } @@ -564,13 +555,10 @@ async function listTools() { method: 'tools/list' }); - if (response.result?.tools) { - tools.push(...response.result.tools.map(t => ({ - name: `${stackId}.${t.name}`, - description: `[${stackId}] ${t.description || t.name}`, - inputSchema: t.inputSchema || { type: 'object', properties: {} } - }))); + if (!Array.isArray(response.result?.tools)) { + throw new Error('tools/list result.tools must be an array'); } + stacks.push({ stackId, tools: response.result.tools }); } catch (err) { log(`Failed to list tools from ${stackId}: ${err.message}`); // Continue with other stacks @@ -581,14 +569,7 @@ async function listTools() { log(`Skipped live tools/list for ${skippedStacks.length} stacks (enable RUDI_ROUTER_LIVE_TOOL_LIST=1 or run "rudi index")`); } - if (TOOL_NAME_STYLE !== 'portable') return tools; - - const mapping = buildPortableToolNameMap(tools.map(tool => tool.name)); - portableToolNames = mapping.portableToCanonical; - return tools.map(tool => ({ - ...tool, - name: mapping.canonicalToPortable.get(tool.name), - })); + return stacks; } /** @@ -597,25 +578,7 @@ async function listTools() { * @param {Object} arguments_ * @returns {Promise<*>} */ -async function callTool(toolName, arguments_) { - let canonicalToolName = toolName; - if (TOOL_NAME_STYLE === 'portable') { - if (portableToolNames.size === 0) await listTools(); - canonicalToolName = portableToolNames.get(toolName); - if (!canonicalToolName) { - throw new Error(`Unknown portable tool name: ${toolName}`); - } - } - - // Parse namespace: "slack.send_message" → stackId="slack", actualTool="send_message" - const dotIndex = canonicalToolName.indexOf('.'); - if (dotIndex === -1) { - throw new Error(`Invalid tool name format: ${canonicalToolName} (expected: stack.tool_name)`); - } - - const stackId = canonicalToolName.slice(0, dotIndex); - const actualToolName = canonicalToolName.slice(dotIndex + 1); - +async function executeStackTool({ stackId, toolName, arguments: arguments_ }) { if (!rudiConfig?.stacks?.[stackId]) { throw new Error(`Stack not found: ${stackId}`); } @@ -628,7 +591,7 @@ async function callTool(toolName, arguments_) { id: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`, method: 'tools/call', params: { - name: actualToolName, + name: toolName, arguments: arguments_ } }); @@ -649,70 +612,18 @@ async function callTool(toolName, arguments_) { * @param {JsonRpcRequest} request * @returns {Promise} */ -async function handleRequest(request) { - /** @type {JsonRpcResponse} */ - const response = { - jsonrpc: '2.0', - id: request.id ?? null - }; - - try { - switch (request.method) { - case 'initialize': - response.result = { - protocolVersion: PROTOCOL_VERSION, - capabilities: { - tools: {} - }, - serverInfo: { - name: 'rudi-router', - version: '1.0.0' - } - }; - break; - - case 'notifications/initialized': - // Client acknowledges initialization - no response needed for notifications - return null; - - case 'tools/list': { - const tools = await listTools(); - response.result = { tools }; - break; - } - - case 'tools/call': { - const params = request.params; - const result = await callTool(params.name, params.arguments || {}); - response.result = result; - break; - } - - case 'ping': - response.result = {}; - break; - - default: - // Unknown method - if (request.id !== null && request.id !== undefined) { - response.error = { - code: -32601, - message: `Method not found: ${request.method}` - }; - } else { - // It's a notification, don't respond - return null; - } - } - } catch (err) { - response.error = { - code: -32603, - message: err.message || 'Internal error' - }; - } - - return response; -} +const dispatcher = createRouterDispatcher({ + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: 'rudi-router', version: '1.0.0' }, + toolNameStyle: TOOL_NAME_STYLE, + // Local stdio historically permits direct calls to installed stack tools + // even when discovery is unavailable. Hosted consumers keep the shared + // core's fail-closed discovered-only default. + callPolicy: 'adapter-authoritative', + discoverStackTools, + executeStackTool, +}); +const { handleRequest } = dispatcher; /** * Main entry point