From cc4de5d1b1f5ae2a6a826a77034ecf53740044ad Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:30:32 +0900 Subject: [PATCH] feat(install-mcp): register a local-scope mcp server so the vscode extension connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit following the readme in a fresh project did not work in the claude code vs code extension: `install-mcp` wrote only a project-scope `.mcp.json`, which claude code loads only after a per-user approval — and the extension never surfaces that prompt. so the server sat at "pending approval", the kb_* tools never appeared, and the hooks (which need no approval) kept running, which reads as connected but isn't. reproduced end-to-end on a fresh project with released 1.4.0. install-mcp now also registers vouch at local scope in ~/.claude.json (`projects[].mcpServers`), exactly what `claude mcp add` does — a local-scope server is trusted on sight, no approval gate. declared by a new manifest `user_mcp:` block so the quirk lives in the claude-code adapter, not the core (host-neutral: only claude-code opts in). the merge is idempotent, preserves every other key in ~/.claude.json, and never clobbers a `vouch` server the user added themselves; a malformed config is reported as failed, never crashes the install. `--no-approve` opts out. the cli prints the registration and a reload-your-window nudge (mcp servers load once, at window start). verified end-to-end through the shipped cli: fresh project -> `vouch install-mcp claude-code` -> `claude mcp list` reports `vouch ... ✔ Connected` (was `⏸ Pending approval`), and an mcp handshake exposes all eight kb_* tools. tests sandbox ~/.claude.json via an autouse home fixture so the suite (and the many existing install() calls) never touch the developer's real config. readme + adapter readme now document the one-command flow and the reload; changelog updated. --- CHANGELOG.md | 16 ++++ README.md | 4 +- adapters/claude-code/README.md | 27 ++++-- adapters/claude-code/install.yaml | 21 +++++ src/vouch/cli.py | 28 ++++++- src/vouch/install_adapter.py | 129 ++++++++++++++++++++++++++++- tests/test_install_adapter.py | 133 ++++++++++++++++++++++++++++++ 7 files changed, 350 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9023f737..96385951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `vouch install-mcp claude-code` now registers vouch as a **local-scope** + MCP server in `~/.claude.json` (`projects[].mcpServers`), + the same thing `claude mcp add` does. a committed `.mcp.json` is a + *project*-scope server that Claude Code loads only after a per-user + approval — and the **VS Code extension never surfaces that approval + prompt**, so `.mcp.json` alone left the `kb_*` tools stuck at "pending + approval" in the extension while the hooks quietly ran (reads as + connected, isn't). the local-scope entry is trusted on sight, so a fresh + install now connects after a window reload with no manual step. verified + end-to-end: fresh project → `install-mcp` → `claude mcp list` reports + `✔ Connected` (was `⏸ Pending approval`). declared by a manifest + `user_mcp:` block (host-neutral core; only claude-code opts in); + idempotent and never clobbers a server you added yourself; opt out with + `--no-approve`. + ### Changed - the starter config now ships `review.auto_approve_on_receipt: true` (and `require_human_approval: false`, an advisory key no code path diff --git a/README.md b/README.md index e921b300..21ebc8a4 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,9 @@ cd /path/to/your/project vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claude Code ``` -`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. Restart Claude Code so they load. +`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. It also registers vouch as a local-scope MCP server in `~/.claude.json` (the `⚑` line in the output). **Reload your editor window** (VS Code: *Developer: Reload Window*) so it loads. + +> **Why the extra registration?** A committed `.mcp.json` is a *project*-scope server, and Claude Code only loads one after a per-user approval — which the **VS Code extension never prompts for**, so `.mcp.json` alone leaves the `kb_*` tools invisible in the extension (they sit at "pending approval", while the hooks quietly work — easy to misread as "connected"). The local-scope entry `install-mcp` writes is trusted on sight, so a fresh install just connects. Verify with `claude mcp list` (`vouch … ✔ Connected`). Pass `--no-approve` to skip it and approve `.mcp.json` yourself. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 0eaf76be..4d0ab949 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -14,10 +14,12 @@ pipx install vouch-kb Make sure `vouch` is on the `PATH` Claude Code will see. The one-command path — `vouch install-mcp claude-code` from your project -root — does everything below in one go, and initialises the `.vouch/` KB +root — does everything below in one go, initialises the `.vouch/` KB first when the project doesn't have one yet (`vouch init` also does that -on its own; `--no-init` skips it). The rest of this file is the manual -equivalent. +on its own; `--no-init` skips it), **and registers vouch in +`~/.claude.json` so the VS Code extension loads it without a manual +approval it never prompts for** (see step 2). The rest of this file is +the manual equivalent. ## 2. Drop the MCP server into your project @@ -38,7 +40,19 @@ contains `.vouch/` — created by `vouch init` if you're wiring by hand): } ``` -Claude Code will pick it up the next time you open the project. +A `.mcp.json` server is *project*-scope: the terminal CLI prompts once to +approve it, but **the VS Code extension never surfaces that prompt**, so on +its own it sits at "pending approval" and the `kb_*` tools never appear (the +hooks, which need no approval, keep working — easy to misread as connected). +To load it in the extension, register the same server at *local* scope, which +is trusted on sight: + +```bash +claude mcp add vouch --env VOUCH_AGENT=claude-code -- vouch serve +``` + +(the one-command `install-mcp` above does this for you). Then reload the +editor window. Confirm with `claude mcp list` — `vouch … ✔ Connected`. ## 3. Teach Claude about the gate @@ -60,7 +74,10 @@ In a fresh session, ask Claude: > What knowledge-base tools do you have? -It should enumerate `kb_search`, `kb_propose_claim`, etc. If not, run +It should enumerate `kb_search`, `kb_propose_claim`, etc. If not, check +`claude mcp list` first: `vouch … ⏸ Pending approval` means the local-scope +registration from step 2 hasn't happened (`✔ Connected` once it has), and a +running editor window needs a reload to pick it up. For anything else, run `claude --debug-mcp` to see why the server isn't loading. ## Session Capture & Auto-Proposal diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index 6e4f0f99..ef387315 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -9,11 +9,32 @@ # T4 = `.claude/settings.json`: SessionStart (kb status + capture review banner + # recall digest of approved knowledge), PostToolUse (capture observe), # SessionEnd (capture finalize), plus read-only kb_* auto-allow. +# +# user_mcp = a local-scope MCP registration written to the user's +# `~/.claude.json` under `projects[].mcpServers`. The `.mcp.json` +# from T1 is a *project*-scope server, and Claude Code only loads a +# project-scope server after a per-user approval — which the VS Code +# extension never surfaces a prompt for, so `.mcp.json` alone leaves the +# `kb_*` tools invisible in the extension (they sit at "pending approval"). +# A local-scope entry is trusted on sight (no approval gate), so writing one +# is what makes a fresh install actually connect in the extension. This is +# exactly what `claude mcp add vouch -- vouch serve` does; install-mcp does +# it for you (opt out with `--no-approve`). host: claude-code pretty: Claude Code fence: begin: "" end: "" +user_mcp: + config: .claude.json # relative to the user's home directory + scope: project # keyed under projects[].mcpServers + name: vouch + spec: + type: stdio + command: vouch + args: ["serve"] + env: + VOUCH_AGENT: claude-code tiers: T1: - { src: .mcp.json, dst: .mcp.json } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 69099587..0454f4d0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3924,6 +3924,15 @@ def pr_cache_show(repo: str, state: str, limit: int, as_json: bool, cache_dir: s help="Initialise a .vouch/ KB at the target when none is discoverable, " "so install-mcp is a one-command setup.", ) +@click.option( + "--approve/--no-approve", + "approve", + default=True, + show_default=True, + help="Register vouch as a local-scope MCP server in ~/.claude.json so the " + "Claude Code extension loads it without a manual approval it never prompts " + "for. --no-approve leaves only the project .mcp.json (needs manual approval).", +) def install_mcp( host: str | None, list_hosts: bool, @@ -3931,6 +3940,7 @@ def install_mcp( target_alias: str | None, tier: str, auto_init: bool, + approve: bool, ) -> None: """Install vouch into HOST (claude-code, cursor, …) idempotently. @@ -4007,7 +4017,7 @@ def install_mcp( if kb_root != target: click.echo(f"Using existing KB at {kb_root / '.vouch'}") try: - result = install_mod.install(host, target=target, tier=tier) + result = install_mod.install(host, target=target, tier=tier, approve=approve) except install_mod.AdapterError as e: raise click.ClickException(str(e)) from e @@ -4017,6 +4027,8 @@ def install_mcp( click.echo(f" ~ {f} (appended fenced block)") for f in result.merged: click.echo(f" ~ {f} (merged into existing)") + for f in result.registered: + click.echo(f" ⚑ {f} (registered in ~/.claude.json)") for f in result.skipped: click.echo(f" · {f} (already present)") for f in result.failed: @@ -4024,9 +4036,23 @@ def install_mcp( click.echo( f"Done — {len(result.written)} written, " f"{len(result.appended)} appended, {len(result.merged)} merged, " + f"{len(result.registered)} registered, " f"{len(result.skipped)} skipped, {len(result.failed)} failed " f"under {target}" ) + if result.registered: + # the extension reads MCP servers once, at window start — a running + # window won't see the new registration until it reloads. + click.echo( + "Reload your editor window so it picks up the vouch MCP server " + "(VS Code: Developer: Reload Window)." + ) + elif approve and host == "claude-code" and not result.failed: + # already registered on a prior run — still remind, harmlessly. + click.echo( + "vouch is registered for Claude Code. If the kb_* tools aren't " + "visible, reload your editor window." + ) if result.failed: # a failed install is not a no-op: exit non-zero so scripts (and the # user) notice vouch was NOT wired into these files. diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index 06d45529..184c7304 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -78,6 +78,26 @@ class InstallResult: # from ``skipped`` so "already installed" and "install failed" never look # the same to the caller / CLI. failed: list[str] = field(default_factory=list) + # user-scope MCP servers registered outside the target tree (in the host's + # user config, e.g. ~/.claude.json). Reported separately because the write + # lands outside `target` and, unlike the tier files, is a config the host + # engine reads on launch rather than a file the user edits. + registered: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class _UserMcp: + """A local-scope MCP registration declared by an adapter's manifest. + + Written to ``/`` (e.g. ``~/.claude.json``). ``scope: project`` + keys the server under ``projects[].mcpServers``; ``scope: user`` + puts it in the top-level ``mcpServers``. Idempotent: an existing server of + the same ``name`` is never overwritten. + """ + config: str # path relative to the user's home dir + scope: str # "project" | "user" + name: str # server key + spec: dict[str, Any] # the server definition (command/args/env/…) @dataclass(frozen=True) @@ -96,6 +116,7 @@ class _Manifest: tiers: dict[str, list[_FileEntry]] fence_begin: str = _DEFAULT_FENCE_BEGIN fence_end: str = _DEFAULT_FENCE_END + user_mcp: _UserMcp | None = None # false for hosts whose install target is a staging dir for user-global # config (claude-desktop) rather than project wiring — a KB bootstrapped # at the target would land wherever the user happened to be standing. @@ -205,10 +226,40 @@ def _flag(name: str, raw: Any = raw, tier_name: str = tier_name) -> bool: tiers=parsed, fence_begin=fence_begin, fence_end=fence_end, + user_mcp=_parse_user_mcp(host, data.get("user_mcp")), kb_bootstrap=kb_bootstrap, ) +def _parse_user_mcp(host: str, raw: Any) -> _UserMcp | None: + """Parse an optional ``user_mcp:`` manifest block, or return None.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise AdapterError(f"{host}: install.yaml `user_mcp:` must be a mapping") + config = raw.get("config") + if not isinstance(config, str) or not config.strip(): + raise AdapterError(f"{host}: install.yaml user_mcp needs a non-empty `config`") + # `config` names a file in the user's home dir; a `..`/absolute value would + # let a manifest scribble anywhere on disk. Keep it a plain relative name. + if config != Path(config).name or config in {"", ".", ".."}: + raise AdapterError( + f"{host}: install.yaml user_mcp `config` must be a bare filename, got {config!r}" + ) + scope = raw.get("scope", "project") + if scope not in {"project", "user"}: + raise AdapterError( + f"{host}: install.yaml user_mcp `scope` must be 'project' or 'user', got {scope!r}" + ) + name = raw.get("name") + if not isinstance(name, str) or not name.strip(): + raise AdapterError(f"{host}: install.yaml user_mcp needs a non-empty `name`") + spec = raw.get("spec") + if not isinstance(spec, dict): + raise AdapterError(f"{host}: install.yaml user_mcp `spec` must be a mapping") + return _UserMcp(config=config, scope=scope, name=name, spec=spec) + + def wants_kb_bootstrap(host: str) -> bool: """Whether ``install-mcp`` may bootstrap a KB at this host's target. @@ -232,12 +283,26 @@ def available_adapters() -> list[str]: return sorted(out) -def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: +def install( + adapter: str, + *, + target: Path, + tier: str = "T4", + approve: bool = True, + home: Path | None = None, +) -> InstallResult: """Install ``adapter``'s templates under ``target`` up to ``tier``. The call is idempotent: rerunning against a previously-installed tree produces an :class:`InstallResult` with everything in ``skipped`` and nothing in ``written`` / ``appended``. + + When the adapter manifest declares a ``user_mcp:`` block and ``approve`` is + true, a local-scope MCP server is registered in the host's user config + (``home``/````, default ``~/.claude.json``). That is what makes a + fresh install connect in the Claude Code *extension*, whose engine ignores + an unapproved project ``.mcp.json``. Pass ``approve=False`` to skip it. + ``home`` overrides the user-config directory (tests point it at a tmp dir). """ if tier not in _TIER_ORDER: raise AdapterError( @@ -302,9 +367,71 @@ def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: shutil.copy2(src, dst) result.written.append(entry.dst) + if approve and manifest.user_mcp is not None: + _register_user_mcp(manifest.user_mcp, target=target, home=home, result=result) + return result +def _register_user_mcp( + reg: _UserMcp, *, target: Path, home: Path | None, result: InstallResult +) -> None: + """Merge a local-scope MCP server into the host user config (idempotent). + + Mirrors ``claude mcp add``: writes ``/`` with the server + under ``projects[].mcpServers`` (scope=project) or the top-level + ``mcpServers`` (scope=user). Every other key in the file is preserved and + the write is atomic. Failures are recorded (``result.failed``), never + raised — a config we can't safely rewrite must not abort a working install. + """ + cfg_path = (home or Path.home()) / reg.config + label = f"{reg.config}:{reg.name} (mcp)" + try: + if cfg_path.exists(): + raw = cfg_path.read_text(encoding="utf-8") + data = json.loads(raw) if raw.strip() else {} + if not isinstance(data, dict): + result.failed.append(label) + return + else: + data = {} + + if reg.scope == "project": + projects = data.setdefault("projects", {}) + if not isinstance(projects, dict): + result.failed.append(label) + return + container = projects.setdefault(str(target), {}) + else: + container = data + if not isinstance(container, dict): + result.failed.append(label) + return + servers = container.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + result.failed.append(label) + return + + if reg.name in servers: + # already registered — never clobber the user's own edits, and stay + # out of `result.skipped` (a file-path channel; this is a config + # entry, not a file). The CLI reminds the user to reload regardless. + return + servers[reg.name] = dict(reg.spec) + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + tmp = cfg_path.with_name(cfg_path.name + ".vouch-tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + if cfg_path.exists(): + shutil.copymode(cfg_path, tmp) + tmp.replace(cfg_path) + result.registered.append(label) + except (OSError, ValueError): + # malformed json, unreadable/unwritable file — surface as failed, but + # the tier files are already installed, so don't unwind the whole run. + result.failed.append(label) + + def _install_fenced( src: Path, dst: Path, diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 035a82f7..c8438d84 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -35,6 +35,20 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +@pytest.fixture(autouse=True) +def _sandbox_home(tmp_path_factory, monkeypatch): + """Redirect ``Path.home()`` to a throwaway dir for every test in this module. + + ``install()`` now writes a local-scope MCP entry to ``~/.claude.json`` by + default (``approve=True``). Without this, the many tests (and CLI tests) + that call ``install("claude-code", …)`` with no explicit ``home`` would + scribble a ``projects[]`` entry into the developer's real config. + """ + fake_home = tmp_path_factory.mktemp("home") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + return fake_home + + # --- catalogue ------------------------------------------------------------ @@ -1194,3 +1208,122 @@ def test_install_rejects_dst_escaping_target( with pytest.raises(AdapterError, match=r"escape|outside|traversal"): install("evil", target=target, tier="T1") assert not (tmp_path / "escape.txt").exists() + + +# --- user_mcp: local-scope registration in ~/.claude.json ------------------ +# +# The Claude Code VS Code extension will not load a project-scope `.mcp.json` +# server until it is approved, and it never surfaces the approval prompt — so a +# fresh install leaves the kb_* tools invisible. install-mcp also writes a +# *local-scope* entry (projects[].mcpServers) which is trusted on sight, +# exactly what `claude mcp add` does. These tests pin that behaviour with a +# sandboxed `home` so the real ~/.claude.json is never touched. + +_WORKING_SPEC = { + "type": "stdio", + "command": "vouch", + "args": ["serve"], + "env": {"VOUCH_AGENT": "claude-code"}, +} + + +def _claude_json(home: Path) -> dict: + return json.loads((home / ".claude.json").read_text(encoding="utf-8")) + + +def test_install_registers_local_scope_mcp(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + result = install("claude-code", target=target, tier="T4", home=home) + assert any("mcp" in r for r in result.registered) + entry = _claude_json(home)["projects"][str(target.resolve())]["mcpServers"]["vouch"] + # byte-identical to the entry that is proven to load in the extension + assert entry == _WORKING_SPEC + + +def test_install_mcp_registration_is_idempotent(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + install("claude-code", target=target, tier="T4", home=home) + before = (home / ".claude.json").read_text(encoding="utf-8") + second = install("claude-code", target=target, tier="T4", home=home) + after = (home / ".claude.json").read_text(encoding="utf-8") + assert second.registered == [] # nothing new written + assert before == after # file untouched, no dup + + +def test_no_approve_writes_nothing_to_claude_json(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + result = install("claude-code", target=target, tier="T4", approve=False, home=home) + assert result.registered == [] + assert not (home / ".claude.json").exists() + + +def test_registration_preserves_existing_claude_json(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + (home / ".claude.json").write_text( + json.dumps({"numStartups": 5, "projects": {"/other": {"mcpServers": {"x": {}}}}}), + encoding="utf-8", + ) + install("claude-code", target=target, tier="T1", home=home) + data = _claude_json(home) + assert data["numStartups"] == 5 # untouched top-level + assert "x" in data["projects"]["/other"]["mcpServers"] # untouched other project + assert "vouch" in data["projects"][str(target.resolve())]["mcpServers"] + + +def test_registration_never_clobbers_user_server(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + mine = {"command": "my-own-vouch"} + (home / ".claude.json").write_text( + json.dumps({"projects": {str(target.resolve()): {"mcpServers": {"vouch": mine}}}}), + encoding="utf-8", + ) + result = install("claude-code", target=target, tier="T1", home=home) + assert result.registered == [] + kept = _claude_json(home)["projects"][str(target.resolve())]["mcpServers"]["vouch"] + assert kept == mine + + +def test_malformed_claude_json_is_reported_not_raised(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + (home / ".claude.json").write_text("{ not valid json", encoding="utf-8") + result = install("claude-code", target=target, tier="T1", home=home) + # tier files still install; the bad config is surfaced as failed, not a crash + assert ".mcp.json" in result.written + assert any("mcp" in f for f in result.failed) + + +def test_user_mcp_manifest_rejects_traversal_config(tmp_path: Path, monkeypatch) -> None: + import vouch.install_adapter as ia + + adapters = tmp_path / "adapters" + (adapters / "evilmcp").mkdir(parents=True) + (adapters / "evilmcp" / "payload.txt").write_text("x", encoding="utf-8") + (adapters / "evilmcp" / "install.yaml").write_text( + "host: evilmcp\n" + "pretty: Evil\n" + "tiers:\n" + " T1:\n" + " - { src: payload.txt, dst: payload.txt }\n" + "user_mcp:\n" + " config: ../../escape.json\n" + " name: x\n" + " spec: { command: x }\n", + encoding="utf-8", + ) + monkeypatch.setattr(ia, "ADAPTERS_DIR", adapters) + target = tmp_path / "project" + target.mkdir() + with pytest.raises(AdapterError, match=r"bare filename"): + install("evilmcp", target=target, tier="T1", home=tmp_path / "home")