Skip to content

fix(mcp): emit external launcher for AGENT_RELAY_MCP_COMMAND in packaged mode#85

Merged
kjgbot merged 2 commits into
mainfrom
fix/mcp-launcher-external-spawn
Jun 5, 2026
Merged

fix(mcp): emit external launcher for AGENT_RELAY_MCP_COMMAND in packaged mode#85
kjgbot merged 2 commits into
mainfrom
fix/mcp-launcher-external-spawn

Conversation

@miyaontherelay

@miyaontherelay miyaontherelay commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Root Cause

Pear previously resolved AGENT_RELAY_MCP_COMMAND from packaged app internals. In a packaged Electron app that can produce an app.asar/.../node_modules/agent-relay/... path, which external MCP clients cannot traverse. The first PR iteration moved the script into Resources, but still emitted node <script>, which is not self-contained on fresh macOS installs because node is not guaranteed on PATH.

What Changed

  • Added packaged MCP launchers at Resources/agent-relay-mcp/launch.sh and Resources/agent-relay-mcp/launch.cmd.
  • The launchers set ELECTRON_RUN_AS_NODE=1, locate the host Pear executable relative to themselves, and run node_modules/agent-relay/dist/cli/agent-relay-mcp.js.
  • Extracted MCP command resolution into src/main/mcp-command.ts; src/main/broker.ts wraps it with Electron state.
  • Packaged mode now emits only the resource launcher path, with no node prefix and no user-local/global fallback.
  • Packaged mode rejects AGENT_RELAY_MCP_COMMAND overrides containing app.asar.
  • Generated electron-builder.mcp-resources.yml from package-lock.json via scripts/generate-mcp-extraResources.mjs; drift is checked by npm run verify:mcp-resources-drift.
  • Updated scripts/verify-mcp-spawn.mjs to call the real packaged resolver output through scripts/resolve-packaged-mcp-command.mjs, run with sanitized PATH, verify the dependency closure, and complete an MCP initialize handshake against the resolver-emitted command.
  • Added minimal PR/push CI in .github/workflows/ci.yml.

No agent-relay repo change was needed.

Verification

Passed locally:

npx vitest run src/main/broker.test.ts
npm test
npm run verify:mcp-resources-drift
npm run build
npm run dist:mac
npm run verify:mcp-spawn
git diff --check

Local packaging used ad-hoc signing and skipped notarization because notarization options were unavailable.

Manual Repro

  1. Build/package Pear with npm run dist:mac.
  2. Run npm run verify:mcp-spawn.
  3. The smoke test resolves the packaged command via resolveAgentRelayMcpCommand, asserts it is Contents/Resources/agent-relay-mcp/launch.sh, runs it with PATH=/usr/bin:/bin, and verifies MCP initialize returns serverInfo.name === "agent-relay".

Non-Negotiables Checklist

  • Self-contained packaged MCP payload; no dependency on ~/.local/bin/agent-relay, Homebrew, global agent-relay, or node on PATH.
  • Packaged resolver emits Resources/agent-relay-mcp/launch.{sh,cmd} only and never returns an .asar/ MCP path.
  • Dependency-complete payload is copied under Resources/agent-relay-mcp/node_modules and checked by smoke.
  • Dev mode keeps local node_modules / PATH-friendly resolution.
  • Cross-platform launcher paths for macOS/Linux/Windows.
  • Regression tests cover .asar rejection, launcher emission, and missing launcher failure.
  • Generated resource filter has a drift check and CI coverage.

@codeant-ai

codeant-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5a505ae3-818c-486b-a468-127482ae2f03

📥 Commits

Reviewing files that changed from the base of the PR and between 0968b7f and d4c6e81.

📒 Files selected for processing (13)
  • .github/workflows/ci.yml
  • electron-builder.mcp-resources.yml
  • electron-builder.yml
  • package.json
  • resources/agent-relay-mcp/launch.cmd
  • resources/agent-relay-mcp/launch.sh
  • scripts/generate-mcp-extraResources.mjs
  • scripts/lib/dependency-closure.mjs
  • scripts/resolve-packaged-mcp-command.mjs
  • scripts/verify-mcp-spawn.mjs
  • src/main/broker.test.ts
  • src/main/broker.ts
  • src/main/mcp-command.ts
✅ Files skipped from review due to trivial changes (1)
  • electron-builder.mcp-resources.yml

📝 Walkthrough

Walkthrough

Adds deterministic generation and packaging of MCP resources, platform launchers, a cross-platform resolver exported as resolveAgentRelayMcpCommand, end-to-end verification and CI wiring, and unit tests validating packaged-mode behavior.

Changes

Agent Relay MCP External Process Support

Layer / File(s) Summary
Build config & resource generation
electron-builder.mcp-resources.yml, electron-builder.yml, scripts/generate-mcp-extraResources.mjs, scripts/lib/dependency-closure.mjs, package.json, .github/workflows/ci.yml
Generates electron-builder extraResources (filtered MCP node_modules), adds npm scripts to generate/verify resources, and wires CI jobs to run resource drift checks and the packaged MCP smoke test.
Packaged launchers
resources/agent-relay-mcp/launch.cmd, resources/agent-relay-mcp/launch.sh
Adds Windows and POSIX launchers that locate the packaged MCP script and an appropriate Electron/Pear executable, set ELECTRON_RUN_AS_NODE, and exec the MCP entry.
Packaged & dev MCP command resolution
src/main/mcp-command.ts, src/main/broker.ts
New exported resolver API (resolveAgentRelayMcpCommand) that validates configured commands (rejects app.asar), resolves packaged launchers or bundled scripts, and removes the prior npx/version fallback.
Verification & CI harness
scripts/resolve-packaged-mcp-command.mjs, scripts/verify-mcp-spawn.mjs
CLI helper to resolve packaged MCP commands and a verification script that locates the built app, confirms packaged MCP payload and dependency coverage, spawns the MCP process, exchanges JSON-RPC initialize, and asserts expected response; CI runs this on macOS.
Unit tests for resolver
src/main/broker.test.ts
Adds tests that mock process.resourcesPath and environment overrides to ensure app.asar is rejected, external launcher resolution succeeds when present, and resolution fails when missing or non-executable.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hop through resources, tidy and spry,
I bundle node_modules so MCP can fly.
Launchers awake, Electron runs as Node,
The verifier nods, the smoke test glowed.
Packages aligned — a carrot-coded tide.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Jun 5, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces changes to package and run the Agent Relay MCP server as an external resource outside of the app.asar archive, allowing external MCP clients to spawn it. It adds a verification script to smoke-test the spawned MCP process, updates the broker command resolution logic to handle packaged environments, and adds corresponding unit tests. The review feedback highlights several key improvements: wrapping paths in double quotes to handle spaces in file paths, making the dependency verification script robust against nested package-lock dependencies and non-JSON stdout, and considering bundling the MCP server with esbuild to avoid maintaining a fragile list of transitive dependencies in electron-builder.yml.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/main/broker.ts Outdated
if (!nodeCommand) {
throw new Error('Node.js was not found on PATH; unable to start packaged Agent Relay MCP server')
}
return assertNoAsarMcpCommand(`${nodeCommand} ${resolvePackagedAgentRelayMcpScript()}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If either nodeCommand or the resolved script path contains spaces (which is extremely common, e.g., /Applications/Pear by Agent Relay.app/... on macOS or C:\Program Files\... on Windows), the constructed command string will be split incorrectly by the shell or command parser, leading to execution failures (e.g., Cannot find module '/Applications/Pear'). Wrapping both paths in double quotes ensures they are treated as single arguments.

    return assertNoAsarMcpCommand(`"${nodeCommand}" "${resolvePackagedAgentRelayMcpScript()}"`)

Comment thread scripts/verify-mcp-spawn.mjs Outdated
Comment on lines +70 to +71
const lockPackage = packages[`node_modules/${packageName}`]
if (!lockPackage) fail(`package-lock entry missing for ${packageName}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In package-lock.json (v2/v3), nested dependencies (e.g., due to version conflicts or non-hoisted packages) are keyed by their full path (e.g., node_modules/agent-relay/node_modules/some-dep) rather than just node_modules/some-dep. Since visit is called with the bare package name, looking up node_modules/${packageName} directly will fail for nested dependencies, causing the verification script to crash. Adding a fallback search for keys ending with /node_modules/${packageName} makes this lookup robust.

    let lockPackage = packages[`node_modules/${packageName}`]
    if (!lockPackage) {
      const nestedKey = Object.keys(packages).find((key) => key.endsWith(`/node_modules/${packageName}`))
      if (nestedKey) {
        lockPackage = packages[nestedKey]
      }
    }
    if (!lockPackage) fail(`package-lock entry missing for ${packageName}`)

Comment thread scripts/verify-mcp-spawn.mjs Outdated
const text = buffer.toString('utf8')
const lines = text.split(/\r?\n/)
const completeLines = text.endsWith('\n') || text.endsWith('\r\n') ? lines : lines.slice(0, -1)
return completeLines.filter(Boolean).map((line) => JSON.parse(line))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the spawned child process prints any non-JSON output to stdout (such as Node.js warnings, deprecation notices, or startup logs from dependencies), JSON.parse(line) will throw a SyntaxError and crash the verification script. Wrapping the parsing logic in a try...catch block to gracefully ignore non-JSON lines makes the verification process much more resilient.

  const messages = []
  for (const line of completeLines) {
    const trimmed = line.trim()
    if (!trimmed) continue
    try {
      messages.push(JSON.parse(trimmed))
    } catch {
      // Ignore non-JSON lines (e.g. logs or warnings)
    }
  }
  return messages

Comment thread electron-builder.yml Outdated
Comment on lines +25 to +27
- from: node_modules
to: agent-relay-mcp/node_modules
filter:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding the entire transitive dependency tree of agent-relay in electron-builder.yml is highly fragile and difficult to maintain. Any future update to agent-relay or its dependencies (including deep transitive ones) will require manual updates to this list, otherwise the packaged app's MCP server will fail at runtime due to missing modules.

Alternative Recommendation:
Consider bundling the MCP entry point (agent-relay-mcp.js) and its dependencies into a single self-contained file using esbuild (which is already in your devDependencies) during the build step. This would completely eliminate the need to ship and maintain this massive list of node_modules in extraResources.

@miyaontherelay

Copy link
Copy Markdown
Contributor Author

Review of #85 — fix(mcp): external launcher for AGENT_RELAY_MCP_COMMAND in packaged mode

Verdict: REQUEST CHANGES (one blocker; otherwise solid)

I reviewed against the sub-contract acceptance bar in /tmp/ws-mcp-fix-contract.md. Most clauses hold up well; one non-negotiable (#1: self-contained on a fresh machine) is not actually satisfied.

Acceptance bar status

# Clause Status Evidence
1 Self-contained: fresh machine, no node/agent-relay installed, /mcp works BLOCKER See §"Self-containment hole" below
2 Packaged resolver never emits .asar/ path; guarded by runtime assertion assertNoAsarMcpCommand + hasAsarPathSegment at src/main/broker.ts:190-199; both code paths (env override + default) routed through the guard at L241,249
3 Dependency-complete payload outside app.asar electron-builder.yml extraResources ships ~100 deps to Resources/agent-relay-mcp/node_modules. Verified in built .app for the critical four (@modelcontextprotocol/sdk, @relaycast/sdk, @agent-relay/sdk, zod) — all present
4 Dev mode (app.isPackaged === false) not regressed Falls through to existing resolveBundledAgentRelayMcpScript() path at L252-259
5 Cross-platform path handling ⚠️ minor Resolver itself is platform-correct (hasAsarPathSegment accepts [\\/], uses path.join). Smoke test's findBuiltApp only matches .app (macOS-only) — acceptable for a mac-first release but worth a comment
6 Failure-class regression test runs RED without fix Verified empirically: checked out src/main/broker.ts from origin/main on top of the test file, ran npx vitest run src/main/broker.test.ts → **2 failed
7 Smoke test against built .app ✅ (with caveat) Ran node scripts/verify-mcp-spawn.mjs against dist/mac-arm64/Pear by Agent Relay.app — pass. Caveat below

Self-containment hole (BLOCKER)

The packaged resolver emits <node-path> <script-path>, where <node-path> comes from resolveNodeCommandForMcp() (src/main/broker.ts:172-179):

function resolveNodeCommandForMcp(): string | undefined {
  const execBasename = basename(process.execPath).toLowerCase()
  if (execBasename === 'node' || execBasename === 'node.exe') {
    return process.execPath
  }
  return resolveCommandOnPath('node')
}

In packaged Electron, process.execPath is the Electron binary (e.g. /Applications/Pear by Agent Relay.app/Contents/MacOS/Pear by Agent Relay), basename ≠ node. So it falls through to resolveCommandOnPath('node')requires node to be findable on PATH.

macOS does not ship node by default. Simulated a fresh user PATH:

env -i HOME=$HOME PATH=/usr/bin:/bin node --version
# env: node: No such file or directory

On such a machine, the resolver throws "Node.js was not found on PATH; unable to start packaged Agent Relay MCP server" (the new error at L247). /mcp from Claude Code launched by Pear will fail.

This directly contradicts contract non-negotiable #1.

Three viable fixes — pick one:

  1. Use Electron as Node (smallest patch). Set ELECTRON_RUN_AS_NODE=1 and use process.execPath. Since AGENT_RELAY_MCP_COMMAND is a single command string and external spawners typically don't read shell-style VAR=val cmd ... prefixes, this likely needs a small shim:

    • Ship a tiny launcher script in extraResources (e.g. Resources/agent-relay-mcp/launch.sh on POSIX, launch.cmd on Windows) that sets ELECTRON_RUN_AS_NODE=1 and execs the Electron binary with the MCP script as argv. Emit <launcher> as the resolver's command.
    • Pros: no extra ~80MB node binary; reuses Electron's bundled Node runtime; clean fallback chain.
    • Cons: needs platform-specific launchers; requires Pear to know its own exec path at launcher-emit time and embed it (or have the launcher resolve it via $0 lookup).
  2. Ship a node binary in extraResources. ~80MB cost, but one binary, no shim, deterministic.

  3. Bundle agent-relay-mcp as a self-contained executable (esbuild --bundle --platform=node + node --build SEA, or pkg). Replaces the 100-line filter list with a single binary. More work; cleanest long-term answer.

I'd lean toward (1) as the minimal-diff fix and (3) as the proper long-term direction.

Smoke test gap related to this: verify-mcp-spawn.mjs spawns process.execPath (which is node when run via node scripts/verify-mcp-spawn.mjs) — it does not invoke resolveAgentRelayMcpCommand() to verify what the packaged resolver actually emits. It also doesn't sanitize PATH. So it cannot catch this failure mode. To gate the fix properly, the smoke test should either:

  • Sanitize PATH to a node-less environment and verify the resolver's actual output runs (proves self-containment).
  • Or read the resolver's emitted command verbatim and exec it as-is (no process.execPath hardcoding).

Other observations (non-blocking)

Hand-maintained dependency filter (drift hazard)

electron-builder.yml lists ~100 packages explicitly in extraResources.filter. If npm install adds/removes deps for agent-relay, the list drifts silently and the smoke test only catches it post-build (which isn't in CI, see below).

Suggestion (follow-up PR): generate the filter at build time from the package-lock closure walk that verify-mcp-spawn.mjs already implements (installedDependencyClosure(rootDir) at scripts/verify-mcp-spawn.mjs:80). A scripts/generate-mcp-extraResources.mjs could emit a electron-builder.mcp-resources.yml consumed via extends, making drift detectable via git diff in CI. Aligns with the workforce skill: "anywhere a hand-maintained registry mirrors a generated source, make drift a CI failure."

No PR-time CI

.github/workflows/ has only release.yml (manual workflow_dispatch) and pullfrog.yml (also workflow_dispatch). No workflow runs npm test or npm run verify:mcp-spawn on push/PR. The new regression tests and smoke script are excellent gates locally, but they don't gate merges.

Suggestion (follow-up PR): a minimal PR-time CI workflow running npm ci && npm test && npm run build + an artifact-cached smoke step. Out of scope for this PR per the contract; flagging for the next.

Removed npx fallback

Removing the npx -y agent-relay@... mcp fallback is correct per the contract. It also lets us delete resolvePackageVersion and AGENT_RELAY_CLI_VERSION. Clean.

One thing worth a one-line PR-description note: in dev mode, if a contributor's node_modules doesn't include agent-relay (e.g. they rm -rf node_modules mid-task), the resolver returns undefined instead of silently npx-installing. That's the intended louder failure — but it'd be friendly to log a hint pointing to npm install.

Smoke test stdout parser (minor)

parseMessages(buffer) in verify-mcp-spawn.mjs calls JSON.parse(line) on every line. If the MCP server writes any non-JSON to stdout (banner, log spillover), it throws an unhandled error inside the data handler. The child.on('error') won't catch it; the test would crash with a stack trace instead of a clean fail(). Wrap in try/catch and skip unparseable lines.

Test isolation

broker.test.ts mutates process.resourcesPath via Object.defineProperty. The afterEach restore using a captured descriptor (line ~143) is solid — handles both "originally defined" and "originally undefined" cases. Good.

Verification I did

  • Read PR diff (all 5 files).
  • Read full resolveAgentRelayMcpCommand() + helpers at src/main/broker.ts:140-262.
  • Ran npx vitest run src/main/broker.test.ts on PR branch: 11 pass.
  • Reverted src/main/broker.ts to origin/main and re-ran: 2 fail | 9 pass (the two new tests RED). Confirms non-vacuity.
  • Ran node scripts/verify-mcp-spawn.mjs against the built .app at dist/mac-arm64/: ok.
  • Inspected Contents/Resources/agent-relay-mcp/node_modules in the built .app: 130 entries, all four critical deps present.
  • Simulated fresh-PATH spawn: confirmed node not present on stock macOS PATH.
  • Read .github/workflows/{release,pullfrog}.yml: no PR-time test/smoke gates.

What I'd ask for

  1. Required: address the node-on-PATH dependency (one of the three fixes above), and extend the smoke test to verify against a sanitized PATH so this class of bug is caught next time.
  2. Strongly suggested (can be same PR or follow-up): generate extraResources.filter from package-lock, OR commit to keeping the smoke test in CI (or both).
  3. Optional: the stdout parser defensiveness; dev-mode hint on missing agent-relay.

Code quality, scope discipline, and the regression-test discipline are all good. The blocker is real but fixable with a small follow-up commit.

Comment thread src/main/broker.ts Outdated
if (!nodeCommand) {
throw new Error('Node.js was not found on PATH; unable to start packaged Agent Relay MCP server')
}
return assertNoAsarMcpCommand(`${nodeCommand} ${resolvePackagedAgentRelayMcpScript()}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The packaged MCP command is built as a plain space-delimited string without quoting the script path. On macOS installs, process.resourcesPath typically contains spaces (for example inside Pear by Agent Relay.app), so downstream command parsing splits the script path into multiple arguments and the MCP process fails to launch. Quote or otherwise safely escape the script path (or pass command/args separately) when composing the packaged command. [logic error]

Severity Level: Critical 🚨
- ❌ Packaged macOS app cannot launch Agent Relay MCP server.
- ⚠️ Claude Code `/mcp` unusable from packaged Pear builds.
Steps of Reproduction ✅
1. Package the Electron app so `app.isPackaged === true` and `process.resourcesPath`
points inside `Pear by Agent Relay.app/Contents/Resources` (the macOS app name with spaces
is defined in `scripts/verify-mcp-spawn.mjs:7` as `APP_NAME = 'Pear by Agent Relay.app'`).

2. Run the packaged app and start a local broker, which calls `BrokerManager.start()` in
`src/main/broker.ts` (class definition around lines 178–360); in the `startBroker` closure
it computes `agentRelayMcpCommand = resolveAgentRelayMcpCommand()` and passes it into
`AgentRelayClient.spawn` via `opts.env.AGENT_RELAY_MCP_COMMAND` (see the `env: { PATH:
..., ...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {}) }`
block in `start()`).

3. With `app.isPackaged` true and no `AGENT_RELAY_MCP_COMMAND` override,
`resolveAgentRelayMcpCommand()` in `src/main/broker.ts` (lines 238–250) takes the packaged
branch and builds `assertNoAsarMcpCommand(`${nodeCommand}
${resolvePackagedAgentRelayMcpScript()}`)`, where `resolvePackagedAgentRelayMcpScript()`
(lines 206–219) joins `process.resourcesPath` with
`agent-relay-mcp/node_modules/agent-relay/dist/cli/agent-relay-mcp.js`, yielding a command
string like `node /Applications/Pear by Agent
Relay.app/Contents/Resources/agent-relay-mcp/node_modules/agent-relay/dist/cli/agent-relay-mcp.js`
that contains spaces in the script path and no quoting or escaping.

4. The Agent Relay broker runtime (inside `@agent-relay/harness-driver`, which reads
`AGENT_RELAY_MCP_COMMAND` from the environment of the spawned broker) interprets this
value as a plain space-delimited command line; when the MCP server is first launched (e.g.
when a user runs `/mcp` from Claude Code, which ultimately triggers the broker's MCP
startup), the embedded spaces in `/Applications/Pear by Agent Relay.app/...` cause the
script path to be split into multiple arguments, so `node` cannot open the intended script
and the MCP process fails to start (spawn/ENOENT or similar), breaking MCP functionality
for the packaged app.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/main/broker.ts
**Line:** 249:249
**Comment:**
	*Logic Error: The packaged MCP command is built as a plain space-delimited string without quoting the script path. On macOS installs, `process.resourcesPath` typically contains spaces (for example inside `Pear by Agent Relay.app`), so downstream command parsing splits the script path into multiple arguments and the MCP process fails to launch. Quote or otherwise safely escape the script path (or pass command/args separately) when composing the packaged command.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai

codeant-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

@miyaontherelay

Copy link
Copy Markdown
Contributor Author

Re-review of #85 (Round 2 — commit d4c6e81)

Verdict: APPROVE

All four R2 deliverables from /tmp/ws-mcp-fix-contract.md are met; the Round 1 blocker (node-on-PATH self-containment) is definitively closed. CI is green end-to-end. Two minor non-blocking follow-ups noted at the end.

R2 acceptance bar — line-by-line

R2.1 — Electron-as-Node launcher (Round 1 BLOCKER) ✅

  • Launchers shipped: resources/agent-relay-mcp/launch.sh (POSIX) and launch.cmd (Windows). launch.sh mode 0755; preserved through electron-builder packaging into Contents/Resources/agent-relay-mcp/launch.sh (confirmed: -rwxr-xr-x in the built .app).
  • Resolver extracted to src/main/mcp-command.ts as a pure, options-taking function. Packaged-mode branch (src/main/mcp-command.ts:132-134) emits only the launcher path — no node prefix, no script suffix, no resolveNodeCommandForMcp() call. The node-on-PATH dependency is gone.
  • Launcher uses ELECTRON_RUN_AS_NODE=1 + execs the host Electron binary resolved relative to $0 / %~dp0. Self-contained.
  • New canExecute guard at mcp-command.ts:104 makes launcher loss surface loudly (test fails packaged MCP resolution when the external launcher is missing proves it).

End-to-end manual proof I ran:

env -i HOME=$HOME PATH=/usr/bin:/bin \
  "/.../Pear by Agent Relay.app/Contents/Resources/agent-relay-mcp/launch.sh" \
  <<<'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'
# → {"result":{"protocolVersion":"2024-11-05",...,"serverInfo":{"name":"agent-rel...

node was not present in PATH; the launcher worked.

R2.2 — Smoke catches the failure class ✅

  • New harness scripts/resolve-packaged-mcp-command.mjs imports the actual resolver from src/main/mcp-command.ts and runs it with isPackaged: true + a real resourcesPath. Output is the resolver's verbatim emission.
  • scripts/verify-mcp-spawn.mjs:184-216 calls that harness, then asserts the emission matches the launcher path (mismatch → fail) and spawns it.
  • smokeEnv() at L218-238 now sets PATH=/usr/bin:/bin on POSIX (C:\Windows\System32;C:\Windows on Windows). No more inherited PATH.
  • Empirically: I reverted just src/main/{broker,mcp-command}.ts to origin/main while keeping the new tests in broker.test.ts. Result: 3 failed | 9 passed (12) — all three new resolver tests RED. Non-vacuous.
  • CI ran the smoke job on a clean macos-latest runner end-to-end and passed.

R2.3 — Generated extraResources.filter from package-lock.json

  • scripts/lib/dependency-closure.mjs correctly handles npm hoisting via dependencyPackageKey traversal (walks up the lockfile's nested node_modules paths). Shared between smoke + generator.
  • scripts/generate-mcp-extraResources.mjs writes electron-builder.mcp-resources.yml; --check mode (verify:mcp-resources-drift) exits non-zero on drift.
  • electron-builder.yml:4 references it via extends: electron-builder.mcp-resources.yml. Hand-list deleted from the main config.
  • npm run build is gated by npm run generate:mcp-resources — no possible "I forgot to regenerate" footgun for local builds.
  • CI runs verify:mcp-resources-drift in both jobs, so a committed but stale generated file fails the build at PR time.

R2.4 — PR-time CI ✅

  • .github/workflows/ci.yml on pull_request, push: main, workflow_dispatch.
  • checks (ubuntu-latest): npm ci + drift + npm test + vitest broker.test + build. ~2 min.
  • packaged-mcp-smoke (macos-latest): npm ci + drift + npm run dist:mac + npm run verify:mcp-spawn. ~6 min.
  • Both jobs GREEN on this PR — first end-to-end CI run for this repo. The smoke job successfully built a packaged .app on a clean macOS runner and exercised the resolver + launcher path under sanitized PATH. That's the strongest possible proof.

Non-vacuity proofs (each fix vs. removed)

Fix Proof of non-vacuity
Resolver packaged-mode emits launcher Reverted mcp-command.ts + broker.ts to main while keeping broker.test.ts → 3 new tests RED
Launcher works under sanitized PATH Manual env -i PATH=/usr/bin:/bin launch.sh returns valid MCP initialize response. Without launcher (or with old node <script> resolver), the smoke would fail because there's no node in /usr/bin:/bin.
Drift check verify:mcp-resources-drift runs on every CI job; adding any package-lock dep to the agent-relay closure without regenerating would fail the build.
CI workflow itself Already exercised by this PR's own run (in_progress → completed/success).

Local validation I ran

  • npx vitest run src/main/broker.test.ts → 12/12 passed.
  • npm run verify:mcp-resources-drift → "MCP extraResources config is in sync."
  • npm run verify:mcp-spawn → ok, emits .../launch.sh as the resolver's command.
  • Manual sanitized-PATH launcher handshake → MCP initialize succeeds.
  • CI run #27003447848 → both jobs SUCCESS.

Minor follow-ups (non-blocking; do NOT need to land in this PR)

Linux launcher executable detection

launch.sh:24-34 picks the first executable in <install>/* (filtering chrome-sandbox and *.so). On a Linux Electron build, <install>/ also contains executables like chrome_crashpad_handler that come alphabetically before the actual Pear binary. This is dead code for the current mac-only release (electron-builder.yml only targets mac), but if Pear ever ships a Linux build, the launcher would pick the wrong binary. Easy fix when needed: match against the productName slug (pear-by-agent-relay) or use electron-builder's exec name.

notarize: true is hardcoded

electron-builder.yml:33 hardcodes notarize: true. The CI smoke job builds successfully without notarize credentials because electron-builder skips notarize gracefully when API key env vars are missing, but that's an undocumented dependency. Consider making notarize env-conditional (e.g. notarize: ${NOTARIZE_PEAR:-false}) so the intent is explicit, or accepting the soft-skip behavior. Either is fine.


Excellent work. The resolver extraction into a pure function with explicit options, the harness-based smoke that exercises the real resolver under sanitized PATH, and the hoist-aware dependency walker are all real upgrades over Round 1. Ship it.

@kjgbot kjgbot merged commit 979c303 into main Jun 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants