Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<abs project>].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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
27 changes: 22 additions & 5 deletions adapters/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions adapters/claude-code/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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[<abs project>].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: "<!-- BEGIN vouch -->"
end: "<!-- END vouch -->"
user_mcp:
config: .claude.json # relative to the user's home directory
scope: project # keyed under projects[<abs project>].mcpServers
name: vouch
spec:
type: stdio
command: vouch
args: ["serve"]
env:
VOUCH_AGENT: claude-code
tiers:
T1:
- { src: .mcp.json, dst: .mcp.json }
Expand Down
28 changes: 27 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3924,13 +3924,23 @@ 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,
path: str,
target_alias: str | None,
tier: str,
auto_init: bool,
approve: bool,
) -> None:
"""Install vouch into HOST (claude-code, cursor, …) idempotently.

Expand Down Expand Up @@ -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

Expand All @@ -4017,16 +4027,32 @@ 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:
click.echo(f" ✗ {f} (could not install — left unchanged)")
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.
Expand Down
129 changes: 128 additions & 1 deletion src/vouch/install_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<home>/<config>`` (e.g. ``~/.claude.json``). ``scope: project``
keys the server under ``projects[<abs target>].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)
Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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``/``<config>``, 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(
Expand Down Expand Up @@ -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 ``<home>/<reg.config>`` with the server
under ``projects[<abs target>].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
Comment on lines +415 to +419

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

distinguish an identical registration from a name conflict.

the installer currently emits the same empty result for both states, causing the cli to claim success when a user-owned server blocks vouch.

  • src/vouch/install_adapter.py#L415-L419: compare the existing specification; preserve exact matches, but report differing entries as conflicts or failures.
  • src/vouch/cli.py#L4050-L4055: show the registered reminder only for an exact existing match, and surface conflicts explicitly.
📍 Affects 2 files
  • src/vouch/install_adapter.py#L415-L419 (this comment)
  • src/vouch/cli.py#L4050-L4055
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/install_adapter.py` around lines 415 - 419, In
src/vouch/install_adapter.py lines 415-419, update the existing-server handling
to compare the registered specification: return the normal empty result only for
an exact match, and report differing entries through the established conflict or
failure mechanism. In src/vouch/cli.py lines 4050-4055, show the registered
reminder only for exact matches and surface conflicts explicitly, using the
installer result to distinguish these states.

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)
Comment on lines +422 to +427

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 'NamedTemporaryFile|mkstemp|copymode|\.vouch-tmp' src tests

Repository: vouchdev/vouch

Length of output: 1437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '390,460p' src/vouch/install_adapter.py
printf '\n---\n'
sed -n '1,80p' src/vouch/migrations/rewriter.py

Repository: vouchdev/vouch

Length of output: 5474


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'json\.dumps\(data|servers\[reg\.name\]|cfg_path|install-mcp|registered' src/vouch/install_adapter.py

Repository: vouchdev/vouch

Length of output: 4211


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '420,455p' src/vouch/install_adapter.py

Repository: vouchdev/vouch

Length of output: 1720


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'tmp\.unlink|unlink\(|replace\(cfg_path\)|except \(OSError, ValueError\)' src/vouch/install_adapter.py

Repository: vouchdev/vouch

Length of output: 224


use an exclusive tempfile here. src/vouch/install_adapter.py:422-427 writes a fixed .vouch-tmp before copying the mode, so a failed run can leave the full config behind and the temp file can exist with broader permissions than the final config. use mkstemp/NamedTemporaryFile(delete=False) in the same directory, set the mode before writing, and clean it up in finally.

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 423-423: use jsonify instead of json.dumps for JSON output
Context: json.dumps(data, indent=2)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/install_adapter.py` around lines 422 - 427, Update the config
replacement flow around cfg_path and tmp to use an exclusive temporary file
created in the same directory via mkstemp or NamedTemporaryFile(delete=False),
rather than the fixed .vouch-tmp name. Set the temporary file mode before
writing config contents, replace cfg_path only after a successful write, and
remove the temporary file in a finally block on all paths.

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,
Expand Down
Loading
Loading