Skip to content

Dev - #43

Merged
Edwinbhart merged 5 commits into
mainfrom
dev
Aug 20, 2026
Merged

Dev#43
Edwinbhart merged 5 commits into
mainfrom
dev

Conversation

@Edwinbhart

@Edwinbhart Edwinbhart commented Aug 20, 2026 •

Copy link
Copy Markdown
Member

Drop mcp-remote bridge; fix plugin marketplace registration

🔒 Security · 🐛 Bug Fix · ♻️ Refactor · 🧪 Tests · 📝 Docs

Removes the mcp-remote stdio bridge that otx wrote into claude_desktop_config.json — it exposed the raw API key in an npx process's argv, readable via ps. The Claude Desktop chat surface takes account-level connectors instead, so otx now prints a prefilled claude.ai link. This PR also fixes a silent plugin install failure: claude plugin install reads known_marketplaces.json, not the extraKnownMarketplaces declaration in settings.json, so the marketplace must be explicitly registered first via claude plugin marketplace add.

Both fixes are active migrations: install removes any legacy bridge it finds on disk, and ensurePluginInstalled now runs marketplace add before plugin install.

Complexity

🟢 Low · 17 files changed, 381 insertions(+), 218 deletions(-)

All changes are contained within the onboarding/key-attachment layer. The writable? flag on KeyClient is a small interface evolution with one new call-site guard. The two substantive fixes (bridge removal, marketplace registration) are independent of each other and straightforward to verify in isolation.

Tests

🧪 Adds a new ensurePluginInstalled unit test suite (4 cases) covering marketplace registration ordering, idempotency, and failure propagation — an area that previously had no direct unit tests. Integration tests are fully updated to assert the new behaviour.

Note

⚠️ The mcp-remote bridge put otk_… in process argv — readable by any local process via ps or /proc/<pid>/cmdline. This is the primary motivation for the change and should be treated as a security fix, not just a UX improvement.

Review focus

Pay particular attention to the following areas:

  • write? optionality at call sites — attachClientKey guards against a missing write with a thrown error, but the contract relies on callers filtering via writableKeyClients() first; verify no call site bypasses this.
  • Unconditional bridge removal — install removes a legacy mcp-remote entry from claude_desktop_config.json without prompting, regardless of which tool the current run targets; confirm this is acceptable UX for users who didn't ask to touch the Claude app.
  • --client claude-desktop behaviour change — the flag now exits 0 and prints a link instead of writing a file; any automation that relied on the old behaviour will silently get different output.

Additional details

How the pieces fit together

The two fixes are independent but ship together because they both affect the same onboarding flow.

Bridge removal. claude-desktop stays in KEY_CLIENTS with writable: false so findKeyClient("claude-desktop") still resolves — needed for the cleanup path in install and for the graceful redirect in connect --client claude-desktop. detectKeyClients() and writableKeyClients() filter it out of writable contexts. The write() method is gone; attachClientKey throws if called on a writable: false client (programming-error guard, not user-facing). The new customConnectorUrl() in constants.ts generates the prefilled link and strips the trailing slash to match the server's OAuth resource identifier.

Marketplace registration. The root cause was a two-record system: settings.json's extraKnownMarketplaces is a declaration that Claude Code materialises into known_marketplaces.json only when a session starts. claude plugin install reads the record, not the declaration. ensurePluginInstalled now checks marketplaceRegistered() (reads known_marketplaces.json directly) and runs marketplace add first if needed. The check is idempotent — already-registered marketplaces are skipped. The harness stub is updated to enforce the same two-step, so integration tests would have caught this bug going forward.

Error reporting improvement. Previously, a failed plugin install printed only the command to run — which was the same failing command. Now pluginInstallError captures the last line of stderr/stdout and surfaces it inline, alongside the corrected two-step recovery command (marketplace add && plugin install).

Edwinbhart and others added 5 commits August 20, 2026 17:13
…y lands

`otx install` wrote `extraKnownMarketplaces` into settings.json and then shelled
out to `claude plugin install`. But that declaration is not what the install
reads: Claude Code turns it into a record in plugins/known_marketplaces.json when
a session starts, and `claude plugin install` resolves the plugin through the
record. On a machine where no session had yet registered it, the install failed
with `Plugin "opentrace" not found in marketplace "opentrace"` — and the fallback
we printed, `Run claude plugin install opentrace@opentrace`, failed exactly the
same way. Onboarding reported `✓ added` and left nothing installed.

So register the marketplace first when it is not already recorded, and stop
rather than installing blind if that fails. Also surface the reason: the note used
to give a command and no cause, which is what made the dead end hard to read.

The harness stub was complicit — it accepted any `plugin install` and exited 1 for
everything else, so it green-lit an install the real binary rejects. It now writes
the marketplace record on `marketplace add` and refuses an install from a
marketplace that was never registered, which is the contract the real CLI has.
Verified by removing the fix: two unit tests and two integration tests fail
without it.

Endpoint line in the summary now prints the no-slash form. buildMcpUrl keeps the
trailing slash for the keychain and header-auth entries, but the plugin
authenticates over OAuth against the form the server advertises, and that is the
endpoint a user might paste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onnector

The bridge wrote this into claude_desktop_config.json:

  npx -y mcp-remote <url> --header "Authorization: Bearer otk_…"

which put the raw CLI key in a process argument, readable by any local process
through `ps` or /proc/<pid>/cmdline. It also needed Node on PATH, ran a proxy
process per session, depended on a third-party package to hold our credential,
and — because a plugin's server is scoped `plugin:opentrace:opentrace` — made
local Code-tab sessions list OpenTrace twice, which the code called unavoidable.

It was aimed at the wrong file. The chat surface takes connectors from the
claude.ai account, not from disk, and a custom connector accepts a remote MCP URL
directly on the same runtime, transport and auth a directory listing uses. So otx
now prints the prefilled add-custom-connector link and writes nothing: OAuth in
the browser, no key on disk, no npx, no duplicate, and it reaches claude.ai and
mobile rather than desktop alone.

Answering the question this started from: for Claude Code — terminal and desktop
Code tab alike — the MCP route was already redundant, because the plugin bundles
its own server and supersedes the bare entry. The bridge was the only local MCP
write aimed at Claude, so removing it leaves the Code tab covered by the plugin
and the chat surface covered by a link. The per-editor MCP route still matters for
Cursor, VS Code, Windsurf, Zed and JetBrains, which have no plugin equivalent.

Migration: entries written by older versions are live stdio servers still holding
a key, so `install` removes one when it finds it and says why, and `remove()`
stays for `disconnect`. KeyClient gains `writable?: false` for exactly this shape
— a client otx cleans up after but no longer configures — and
`connect --client claude-desktop` prints the link instead of failing on a flag
that used to work.

BREAKING CHANGE: `otx connect --client claude-desktop` and
`otx login --client claude-desktop` no longer write claude_desktop_config.json.
They print the connector link. `disconnect --client claude-desktop` is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `git add -A` in the previous commit swept in `.claude/settings.json` and
`.claude/worktrees/plugin-hook-prewarm` — the latter as a gitlink, since it is its
own checkout. Neither belongs here: the worktree is local scratch space and the
settings file was untracked by choice. Ignore the worktrees directory and
settings.local.json so this cannot recur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Express and the happy path do the plugin, and the plugin already covers the
desktop Code tab through ~/.claude — so onboarding has nothing left to say about
the Claude app. It was printing an add-custom-connector link whenever the app was
detected, which advertised a product surface the user had not asked about and that
the directory listing will serve anyway once approved.

So `install` no longer mentions chat at all: no line in the Express plan, no link
in the summary. The connector is reached only by asking for it —
`otx connect --client claude-desktop` still prints the link.

Kept unconditional: removing an `mcp-remote` bridge left by an older version. That
is a key sitting in a process argument, not a feature, so it goes whether or not
the user cares about chat. Its note now points at the flag rather than at a link
that is no longer printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(install): register the marketplace, so the plugin install actually lands
@codehub-agent

codehub-agent Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

What this PR does

What this PR does

Removes the mcp-remote stdio-bridge integration for the Claude desktop app's chat surface and replaces it with a prefilled account-level "add custom connector" link, while adding a missing marketplace-registration step to plugin install.

  • Removed: claude-desktop key client can no longer write() (added writable?: boolean / optional write() to KeyClient); it becomes remove-only for cleaning up entries left by older versions. connectWithKey now short-circuits for non-writable clients, printing a customConnectorUrl() link instead of writing claude_desktop_config.json. attachClientKey throws if called on a client with no write.
  • Added: customConnectorUrl(mcpUrl, opts) in util/constants.ts builds a claude.ai/customize/connectors (or /admin-settings/connectors) deep link — no key touches disk for this surface anymore.
  • Behavior change: install's Express summary no longer announces "Desktop: chat surface" (dropped isClaudeAppInstalled check); onboarding now only covers the Code tab via the plugin, sharing ~/.claude. --client claude-desktop docs dropped from otx connect/otx login help text.
  • New install step: ensurePluginInstalled now runs claude plugin marketplace add <repo> and verifies registration via new marketplaceRegistered()/MARKETPLACE_REPO/MARKETPLACE_NAME before claude plugin install, fixing a prior silent-failure mode ("not found in marketplace") that previously reported success incorrectly.
  • Test/harness updates: sandbox claude stub now models plugin marketplace add and rejects plugin install if the marketplace isn't registered; connect/install integration tests updated to assert the connector link is printed and no config/key is written to disk.

Risk: High fan-in on buildMcpUrl (10), toBaseUrl (9), pluginId (8), and createSandbox/cleanup (7 each) means these shared helpers are exercised across install/connect flows and the test harness — regressions in marketplace registration or the new write?/writable optionality on KeyClient could silently break other key-client implementations (e.g., cursor) that assume write() always exists.

Flagged because it touches a contract (buildMcpUrl) — callers elsewhere may be affected.

⚡ Blast radius

17 files · 109 symbols touched · 75 call sites elsewhere in the codebase

symbol kind fan-in file
buildMcpUrl Function 10 src/util/constants.ts
toBaseUrl Function 9 src/util/constants.ts
pluginId Function 8 src/util/constants.ts
cleanup Method 7 test/harness.ts
createSandbox Function 7 test/harness.ts
run Method 6 test/harness.ts

⚠️ Contracts touched (interfaces or fan-in ≥ 10): buildMcpUrl, RunResult, Sandbox, ConnectKeyOptions, InstallCommandOptions — callers elsewhere may be affected.

Explore this PR's blast radius → · graph: workspace 365ee7cde142 · by CodeHub

💰 Agent economics

50 agent sessions reported in the last 30 days, but none matched this PR's commits. A session matches on the author email it reports and a commit landing within ~2h of it — so check the agent's email matches the commit author, and that the work was committed in the same sitting. Sessions →

Full economics → · by CodeHub

@Edwinbhart
Edwinbhart merged commit 1e89b35 into main Aug 20, 2026
11 checks passed

@opentrace opentrace 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.

🔧 Changes Requested

Good change to ship. Removing the mcp-remote bridge was the right call — key-in-argv is a real exposure, and the account-level connector link is a cleaner answer for that surface.

The one thing to fix before merging is the orphaned JSDoc in src/util/claude-plugins.ts — see the inline suggestion. It's a two-line fix.

Everything else holds up: the writable: false pattern is a clean way to keep remove() alive without re-exposing write(), the marketplace-registration-before-install fix addresses the actual root cause of the silent failure, and the tests cover the right things (no file written, no key in output, bridge cleanup, marketplace sequencing). The unconditional stale-bridge removal on any install run is intentional and correct — no reason to scope it to Claude Code targets only.

Comment on lines 224 to +225
/** Is the plugin in the records `/plugin` reads? */
/** True when the marketplace is in Claude Code's own record, not merely declared. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The old JSDoc for isPluginInstalled was left above marketplaceRegistered when the new function was inserted. Two consecutive JSDoc comments means the first one (/** Is the plugin in the records… */) is orphaned — it documents nothing and will confuse readers.

Suggested change
/** Is the plugin in the records `/plugin` reads? */
/** True when the marketplace is in Claude Code's own record, not merely declared. */
/** True when the marketplace is in Claude Code's own record, not merely declared. */

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant