1- """Contract test for .mcp.json — the plugin↔Claude-Code interface.
1+ """Contract test for the plugin MCP server config — the plugin↔Claude-Code interface.
22
3- Verifies the `.mcp.json` shape against the documented Claude Code plugin
4- contract. Source: https://code.claude.com/docs/en/plugins-reference,
3+ Verifies the inline `mcpServers` object in `.claude-plugin/plugin.json`
4+ against the documented Claude Code plugin contract.
5+ Source: https://code.claude.com/docs/en/plugins-reference,
56section "Environment variables":
67
78 > ${CLAUDE_PLUGIN_ROOT}: ... Both are substituted inline anywhere they
1617 "env": { "DB_PATH": "${CLAUDE_PLUGIN_ROOT}/data" }
1718 }
1819
19- Discord 2026-05-09: prior `.mcp.json` used a Python `-c` one-liner that
20+ History of this contract:
21+
22+ Discord 2026-05-09: prior config used a Python `-c` one-liner that
2023read ~/.claude/plugins/installed_plugins.json and execvp'd into the
2124launcher. Failure modes were silent because `python3 -c` swallowed stack
2225traces. The fix routes through the documented substitution mechanism.
2326
24- This test guards against regression to the inline `-c` script (the
25- substitutability violation: the inline script imposed a STRONGER
26- precondition than the contract — it required a specific marketplace
27- key in installed_plugins.json, rejecting --plugin-dir / --plugin-url /
28- manual-install scenarios that the documented contract supports).
27+ 2026-06-12: the config moved from a repo-root `.mcp.json` (referenced by
28+ plugin.json as "./.mcp.json") to an inline object in plugin.json. Reason:
29+ Claude Code ALSO interprets a repo-root `.mcp.json` as PROJECT-scoped MCP
30+ config when the plugin source repo itself is opened as a working
31+ directory. In project scope `${CLAUDE_PLUGIN_ROOT}` is never substituted
32+ (it is plugin-scope only), so the spawn ran
33+ `python3 '<repo>/${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py'` → ENOENT →
34+ "MCP error -32000: Connection closed" on every session in this repo,
35+ shadowing the healthy plugin-scoped server. Inline plugin.json
36+ `mcpServers` is documented and is invisible to project-scope discovery.
37+
38+ This test guards against regression to either failure mode: the inline
39+ `-c` script (substitutability violation: it imposed a STRONGER
40+ precondition than the contract — a specific marketplace key in
41+ installed_plugins.json) and the reintroduction of a repo-root
42+ `.mcp.json`.
2943"""
3044
3145from __future__ import annotations
3650import pytest
3751
3852REPO_ROOT = Path (__file__ ).resolve ().parents [2 ]
39- MCP_JSON = REPO_ROOT / ".mcp .json"
53+ PLUGIN_JSON = REPO_ROOT / ".claude-plugin" / "plugin .json"
4054
4155
4256@pytest .fixture (scope = "module" )
4357def mcp_config () -> dict :
44- return json .loads (MCP_JSON .read_text ())
58+ manifest = json .loads (PLUGIN_JSON .read_text ())
59+ return manifest ["mcpServers" ]
60+
61+
62+ def test_mcp_servers_inline_object (mcp_config : dict ) -> None :
63+ """`mcpServers` must be an inline object, not a path string.
4564
65+ A path string ("./.mcp.json") requires a repo-root `.mcp.json`,
66+ which Claude Code double-interprets as project-scoped config when
67+ the plugin source repo is the working directory (see module
68+ docstring, 2026-06-12).
69+ """
70+ assert isinstance (mcp_config , dict ), (
71+ f"mcpServers must be an inline object, got: { type (mcp_config ).__name__ } "
72+ )
73+ assert "cortex" in mcp_config
4674
47- def test_mcp_json_exists (mcp_config : dict ) -> None :
48- assert "mcpServers" in mcp_config
49- assert "cortex" in mcp_config ["mcpServers" ]
75+
76+ def test_no_repo_root_mcp_json () -> None :
77+ """A repo-root `.mcp.json` must not exist.
78+
79+ Claude Code picks it up as PROJECT-scoped MCP config in this repo,
80+ where ${CLAUDE_PLUGIN_ROOT} is never substituted — the spawn fails
81+ with -32000 and shadows the healthy plugin-scoped server.
82+ """
83+ assert not (REPO_ROOT / ".mcp.json" ).exists (), (
84+ "Repo-root .mcp.json reintroduced — it double-registers as "
85+ "project-scoped config with unsubstituted ${CLAUDE_PLUGIN_ROOT}. "
86+ "Keep the MCP server config inline in .claude-plugin/plugin.json."
87+ )
5088
5189
5290def test_no_inline_python_c_wrapper (mcp_config : dict ) -> None :
@@ -56,9 +94,9 @@ def test_no_inline_python_c_wrapper(mcp_config: dict) -> None:
5694 installed_plugins.json. Manual installs and --plugin-dir runs broke
5795 silently because python3 -c discarded the traceback.
5896 """
59- args = mcp_config ["mcpServers" ][ " cortex" ]["args" ]
97+ args = mcp_config ["cortex" ]["args" ]
6098 assert "-c" not in args , (
61- "Detected `-c` inline script in .mcp.json args. This swallows "
99+ "Detected `-c` inline script in mcpServers args. This swallows "
62100 "launcher errors and breaks --plugin-dir / manual-install. "
63101 "Use ${CLAUDE_PLUGIN_ROOT}/scripts/launcher.py instead."
64102 )
@@ -71,7 +109,7 @@ def test_args_use_claude_plugin_root_substitution(mcp_config: dict) -> None:
71109 the form ${CLAUDE_PLUGIN_ROOT} are substituted inline before the
72110 process is spawned.
73111 """
74- args = mcp_config ["mcpServers" ][ " cortex" ]["args" ]
112+ args = mcp_config ["cortex" ]["args" ]
75113 joined = " " .join (args )
76114 assert "${CLAUDE_PLUGIN_ROOT}" in joined , (
77115 f"args[] must reference ${{CLAUDE_PLUGIN_ROOT}} for plugin path "
@@ -81,7 +119,7 @@ def test_args_use_claude_plugin_root_substitution(mcp_config: dict) -> None:
81119
82120def test_launcher_path_referenced (mcp_config : dict ) -> None :
83121 """The args must point at scripts/launcher.py with the mcp_server target."""
84- args = mcp_config ["mcpServers" ][ " cortex" ]["args" ]
122+ args = mcp_config ["cortex" ]["args" ]
85123 assert any ("scripts/launcher.py" in a for a in args ), (
86124 f"Expected scripts/launcher.py in args, got: { args } "
87125 )
@@ -106,5 +144,5 @@ def test_command_is_python3(mcp_config: dict) -> None:
106144 """`command` must be a python interpreter; the contract requires the
107145 spawned process to be able to execute the launcher.py script.
108146 """
109- cmd = mcp_config ["mcpServers" ][ " cortex" ]["command" ]
147+ cmd = mcp_config ["cortex" ]["command" ]
110148 assert cmd in ("python3" , "python" ), f"Expected python3 (or python), got: { cmd !r} "
0 commit comments