Skip to content

fix(plugin): move the MCP manifest off the path Claude Code auto-discovers as project scope - #287

Merged
kevintseng merged 7 commits into
mainfrom
fix/mcp-manifest-and-fail-closed
Sep 4, 2026
Merged

fix(plugin): move the MCP manifest off the path Claude Code auto-discovers as project scope#287
kevintseng merged 7 commits into
mainfrom
fix/mcp-manifest-and-fail-closed

Conversation

@kevintseng

Copy link
Copy Markdown
Contributor

Summary

The repo shipped .mcp.json at its root. That one path is two things at once: the plugin loader's default MCP manifest, where ${CLAUDE_PLUGIN_ROOT} resolves, and the path Claude Code auto-discovers as a project config for anyone who merely opens this repository, where it does not. Claude Code says so itself — from the repo root, throwaway config dir, no plugin installed:

$ claude mcp list
memesh: node ${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js - ⏸ Pending approval
[Warning] [memesh] mcpServers.memesh: Missing environment variables: CLAUDE_PLUGIN_ROOT

The server then dies with -32000 Connection closed. git log -p -- .mcp.json dates the placeholder to 310ce585 on 2026-05-13, replacing a working npx -y -p @pcircle/memesh memesh-mcp. Three and a half months.

The manifest moves to .claude-plugin/mcp.json, declared by .claude-plugin/plugin.json. Three candidate shapes were installed into throwaway config dirs and all three registered; this one keeps a file, which mcpEntry(), both packed smokes and doctor already read, and it mirrors the Codex side. ${CLAUDE_PLUGIN_ROOT} stays — it is the documented, correct form inside a plugin manifest; the path was wrong, not the variable. Custom component paths supplement the defaults rather than replacing them (plugin-structure/SKILL.md:98), so declaring the new path is only half the fix: the root file has to be gone, or both still load.

Second defect — the reason the first survived. inspectMcpConfig resolved the entry with an unconditional entry.replaceAll('${CLAUDE_PLUGIN_ROOT}', packageRoot), so the referenced file always existed and the check could never fail. A green light was guarding exactly this. It now substitutes only where something really does substitute (plugin-marketplace, or a real CLAUDE_PLUGIN_ROOT in the environment) and otherwise reports the entry as NOT VERIFIED with the command that would verify it — as a warn, not the existing informational "NOT VERIFIED" idiom, because summarizeOverallStatus filters informational rows out and a row that cannot move a verdict is the same shape as the defect being fixed. Consequence reviewers should expect: every source-checkout memesh doctor now reports PASS_WITH_CONCERNS; CI accepts it (the CLI exits non-zero only on FAIL).

The suite was holding the defect in place. tests/installation.test.ts asserted .mcp.json exists; tests/release-preconditions.test.ts and scripts/check-agent-message-sync.mjs required it in their path lists. All three now require its absence and the new declaration instead.

Type of change

  • Bug fix (fix) — install path
  • Docs (docs)

Docs synced

  • CHANGELOG.md, docs/api/API_REFERENCE.md, docs/platforms/README.md, .github/CODEOWNERS, .gitignore
  • 22 new i18n entries (summary + fix × 11 locales), enforced by tests/dashboard-i18n.test.ts
  • dist/ and dashboard/dist/ regenerated

Verification

  • The install, end to end — local marketplace, throwaway CLAUDE_CONFIG_DIR: claude plugin install memesh@pcircle-memeshclaude mcp listplugin:memesh:memesh: node …/dist/mcp/server.js - ✔ Connected. That is a completed MCP handshake, not just registration.
  • The opposite direction, re-run by the orchestrator: repo root, fresh config dir, CLAUDE_PLUGIN_ROOT unset → No MCP servers configured. and ls .mcp.json → no such file.
  • Break-test A: reintroduce the root .mcp.json → 3 tests red and check-agent-message-sync: FAIL. Removed → 36/36, PASS.
  • Break-test B: restore the unconditional substitution → Tests 2 failed | 150 passed, including "resolves against CLAUDE_PLUGIN_ROOT when the environment actually provides one" — a pass it had not earned. Restored → 152/152.
  • Whole suite exit 0 (230 files / 3278 tests); lint 0, typecheck 0, check-doc-claims 0, check-codemap-parity 0, test:packaged 0, test:packaged:upgrade 0, verify:release 0.
  • Evidence Gates: eg check PASS at base 2d4c0f04mcp-manifest-not-project-scoped CODE_COMPLETE and doctor-mcp-check-can-fail INTEGRATION_VERIFIED (ui_route: the dashboard bundle carries the new strings), 9 receipts including two known_bad_mutation failure paths.

Not proven

  • claude plugin details <name> reports MCP servers (0) for every manifest shape, including the inline one — details under-reports; claude mcp list is authoritative. Worth knowing before anyone verifies this with the wrong command.
  • The ✔ Connected handshake ran against this worktree's dist/ via a local marketplace, not a published npm tarball. The packed-artifact smoke covers the tarball's contents; no end-to-end install from the real marketplace was performed.
  • The 10 non-English i18n strings are adapted from each locale's existing mcp-config.* sentences; no native reviewer checked them.

Found while verifying this, not fixed here

scripts/smoke-packed-artifact.mjs spreads ...process.env in six places without stripping MEMESH_DIR/MEMESH_DB_PATH, so a maintainer who has them set gets a failing smoke: env -u MEMESH_DIR npm run test:packaged → exit 0, MEMESH_DIR=… npm run test:packaged → exit 1 (ENOENT … /mr-XXXX/.memesh/router.token). Same defect class as #271, in the sibling script that PR #280 did not touch. Filed separately.

@kevintseng
kevintseng force-pushed the fix/mcp-manifest-and-fail-closed branch 4 times, most recently from e74d7ce to 8235cc5 Compare September 4, 2026 06:02
…overs as project scope

The repo shipped `.mcp.json` at its root. That single path is two things at
once: the plugin loader's default MCP manifest, where `${CLAUDE_PLUGIN_ROOT}`
resolves, and the path Claude Code auto-discovers as a PROJECT config for
anyone who merely opens this repository, where it does not. Claude Code says so
itself, from the repo root with no plugin installed:

    memesh: node ${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js - Pending approval
    [Warning] [memesh] mcpServers.memesh: Missing environment variables: CLAUDE_PLUGIN_ROOT

The server then dies with -32000 Connection closed. `git log -p -- .mcp.json`
dates the placeholder to 310ce58 on 2026-05-13, replacing a working
`npx -y -p @pcircle/memesh memesh-mcp`: three and a half months.

The manifest moves to `.claude-plugin/mcp.json`, declared by
`.claude-plugin/plugin.json`'s `mcpServers`. Three candidate shapes were
installed into throwaway config dirs and all three registered; this one was
chosen because it mirrors the Codex side and keeps a FILE, which `mcpEntry()`,
both packed smokes and doctor already read. `${CLAUDE_PLUGIN_ROOT}` stays — it
is the documented, correct form inside a plugin manifest; the path was wrong,
not the variable. Custom component paths SUPPLEMENT the defaults rather than
replacing them, so declaring the new path is only half the fix: the root file
has to be gone, or both still load.

Second defect, the reason the first survived. doctor's inspectMcpConfig
resolved the entry with an unconditional
`entry.replaceAll('${CLAUDE_PLUGIN_ROOT}', packageRoot)`, so the referenced
file always existed and the check could never fail — a green light guarding
exactly this. It now substitutes only where something actually substitutes
(plugin-marketplace, or a real CLAUDE_PLUGIN_ROOT in the environment) and
otherwise warns that the entry is NOT VERIFIED, with the command that would
verify it. `warn`, not the existing informational "NOT VERIFIED" idiom,
because summarizeOverallStatus filters informational rows out — a row that
cannot move a verdict is the same shape as the defect being fixed. Every
source-checkout `memesh doctor` therefore now reports PASS_WITH_CONCERNS; CI
accepts that (the CLI exits non-zero only on FAIL).

The suite was holding the defect in place: tests/installation.test.ts asserted
`.mcp.json` EXISTS, and release-preconditions plus check-agent-message-sync
required it in their path lists. All three now require its absence and the new
declaration instead.

[Verified-By: claude mcp list from the repo root with a throwaway CLAUDE_CONFIG_DIR -> BEFORE "Missing environment variables: CLAUDE_PLUGIN_ROOT", AFTER "No MCP servers configured", exit=0; local marketplace install of this tree -> "plugin:memesh:memesh: node .../dist/mcp/server.js - Connected", exit=0; node scripts/run-tests-isolated.mjs tests/plugin-mcp-manifest.test.ts tests/installation.test.ts tests/core/doctor.test.ts -> exit=0, "Tests 188 passed (188)"; whole suite -> exit=0, "Test Files 230 passed | 1 skipped (231), Tests 3278 passed | 11 skipped"; break-test A (reintroduce root .mcp.json) -> exit=1, 3 failed + check-agent-message-sync FAIL; break-test B (restore the unconditional substitution) -> exit=1, "Tests 2 failed | 150 passed (152)"; npm run lint 0; npm run typecheck 0; check-doc-claims 0; check-codemap-parity 0; test:packaged 0; test:packaged:upgrade 0; npm run verify:release 0]
CI red on both Windows legs, green on macOS and Linux:

  FAIL tests/core/doctor.test.ts > doctor > fails when the MCP config is invalid JSON
  AssertionError: expected { id: 'mcp-config', …(7) } to match object { status: 'fail', …(1) }

`inspectMcpConfig`'s fix line names the ABSOLUTE path of the file to edit —
`Fix ${mcpPath} so Claude Code can read the MCP server definition.` — which is
what a user needs, and on Windows `path.join` builds it with backslashes. The
test asserted the literal `'.claude-plugin/mcp.json'`, forward slashes, so it
could only ever pass off Windows.

The assertion now derives its separator the same way the code does. The code is
unchanged: a Windows user should see a Windows path.

Swept the rest of this PR's test files for the same shape — `stringContaining`
on a literal carrying a path separator — and this was the only one.

[Verified-By: node scripts/run-tests-isolated.mjs > /tmp/f287.log 2>&1; echo
exit=$? -> exit=0; "Test Files  230 passed | 1 skipped (231)", "Tests  3283
passed | 11 skipped (3294)", no Errors line]
[Verified-By: npm run verify:release > /tmp/v287.log 2>&1; echo exit=$? ->
exit=0, ending "consumer install has no high-or-worse advisories"]
[Not verified: I cannot run the Windows legs locally. The reason to expect this
fixes them is that the assertion is now built by the same `path.join` call the
code under test uses, so the two cannot disagree about the separator on any
platform. CI on this branch is what settles it.]
…board alarm, clean up a fallback that could never fire

The English fix message for mcp-config.entry-missing escaped the \$ in
\${MCP_PLACEHOLDER}, so it printed that literal text instead of the
resolved path — every one of the 11 translated locales substituted
correctly, only the English source, which is what the terminal actually
shows, was broken.

mcp-config.placeholder-unresolved is not a failure of an npm or
source-checkout install — there is no plugin root to resolve
${CLAUDE_PLUGIN_ROOT} against there, so nothing is broken to act on — but
it was missing from the dashboard's QUIET_WARN_CODES, so every one of
those installs got an alarm banner for a state its own fix text calls
harmless.

inspectMcpConfig's `label` fallback (`relativeManifest ?? '...'`) could
never actually use its fallback value: the only path that makes
relativeManifest null returns before label is ever read. Restructured the
early return so the fallback is gone rather than dead, with no change in
behavior.

Added scripts/qa/verify-dashboard-doctor-banner-i18n.mjs: a real-browser
check (real HTTP server, real Chromium, all 11 locales) that the quiet
code stays quiet and mcp-config.missing actually renders translated, not
as a raw i18n key or a silent English fallback — something the existing
i18n-catalogue tests could not have caught, since they check the
translation table, not what a real bundle renders. Also found and closed
a real test gap: no existing test exercised the "manifest declared but the
file is gone" branch of mcp-config.missing at all.
The prior commit's dist was built before the inspectMcpConfig restructure
landed, so CI's committed-build-output check correctly flagged it as
stale. No source change; dist now matches src/core/doctor.ts.
@kevintseng
kevintseng force-pushed the fix/mcp-manifest-and-fail-closed branch from 8235cc5 to af01fda Compare September 4, 2026 08:42
@kevintseng
kevintseng merged commit 3e2bb3e into main Sep 4, 2026
68 of 79 checks passed
@kevintseng
kevintseng deleted the fix/mcp-manifest-and-fail-closed branch September 5, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant