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
80 changes: 73 additions & 7 deletions bin/check-mcp-wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
in-repo server: a duplicate implementation exists. Exit 0.

Entries for servers this repo does not ship (other projects' MCP servers,
external services) are none of our business and never flagged.
external services) are normally none of our business. The catalog
(external-servers.json) is advisory — it is not a policy. If a declared
external server uses a package runner instead of bin/mcp-run.sh the
reporter prints an INFO note (not a warning) to point out the routing gap.
If an undeclared server uses a package runner the reporter prints an INFO
note suggesting catalog addition so the launcher can resolve vault sessions
(e.g. the bitwarden MCP server).

Usage:
bin/check-mcp-wiring.py [--config PATH]
Expand Down Expand Up @@ -48,6 +54,17 @@ def shipped_servers(repo: Path) -> set[str]:
return names


def external_server_names(repo: Path) -> set[str]:
"""Names registered in mcp/external-servers.json."""
ext_map = repo / 'mcp' / 'external-servers.json'
if not ext_map.exists():
return set()
try:
return set(json.loads(ext_map.read_text())) - {k for k in json.loads(ext_map.read_text()) if k.startswith('_')}
except Exception:
return set()


def command_text(entry) -> str:
"""opencode accepts a string or argv list for an MCP server command."""
cmd = entry.get('command') if isinstance(entry, dict) else None
Expand All @@ -56,10 +73,11 @@ def command_text(entry) -> str:
return str(cmd or '')


def check(config: dict, repo: Path) -> tuple[list[str], list[str]]:
"""Returns (errors, warnings)."""
def check(config: dict, repo: Path) -> tuple[list[str], list[str], list[str]]:
"""Returns (errors, warnings, infos)."""
servers = shipped_servers(repo)
errors, warnings = [], []
ext_names = external_server_names(repo)
errors, warnings, infos = [], [], []

for name, entry in (config.get('mcp') or {}).items():
cmd = command_text(entry)
Expand Down Expand Up @@ -88,7 +106,53 @@ def check(config: dict, repo: Path) -> tuple[list[str], list[str]]:
warnings.append(
f"{name}: shipped server '{hit}' wired as '{cmd}' — "
f"not this repo's launcher; verify intentional")
return errors, warnings

# Verify external-servers.json entries are actually routed through
# mcp-run.sh in opencode.json (not via a bare package runner).
# This catches the class of defect where a server is declared in the
# catalog but opencode.json launches it directly, bypassing the session
# resolver (opskit #386 — the bitwarden MCP server).
#
# Note: this is informational, not a warning. The catalog is advisory —
# users may run the MCP server with a different vault solution (or none
# at all) and that's fine. We just point out the routing gap.
run_sh = f'{repo}/bin/mcp-run.sh'
for name in ext_names:
if name in servers:
continue # shipped server, handled above
entry = config.get('mcp', {}).get(name)
if not entry:
continue
cmd = command_text(entry)
if not cmd:
continue
first = cmd.split()[0] if cmd.split() else ''
if os.path.basename(first) in PACKAGE_RUNNERS and run_sh not in cmd:
ext_map_rel = str((repo / 'mcp' / 'external-servers.json').relative_to(repo.parent))
infos.append(
f"{name}: declared in {ext_map_rel} but opencode routes "
f"via package runner ({first}) instead of bin/mcp-run.sh {name} — "
f"the launcher will resolve vault sessions for this server")

# For non-shipped servers using a package runner that are also not in
# external-servers.json, suggest adding the entry so the launcher can
# handle vault sessions.
for name, entry in (config.get('mcp') or {}).items():
cmd = command_text(entry)
if not cmd:
continue
first = cmd.split()[0] if cmd.split() else ''
if os.path.basename(first) not in PACKAGE_RUNNERS:
continue
if name in servers or name in ext_names:
continue # already handled or known
ext_map_rel = str((repo / 'mcp' / 'external-servers.json').relative_to(repo.parent))
warnings.append(
f"{name}: uses package runner ({first}) but is not declared in "
f"{ext_map_rel}; if this server needs vault-session support, "
f"add it to mcp/external-servers.json so bin/mcp-run.sh can launch it")

return errors, warnings, infos


def main() -> int:
Expand Down Expand Up @@ -116,8 +180,10 @@ def main() -> int:
return 1

repo = Path(os.environ.get('OPSKIT_ROOT', SCRIPT_REPO)).resolve()
errors, warnings = check(config, repo)
errors, warnings, infos = check(config, repo)

for i in infos:
print(f"INFO {i}")
for w in warnings:
print(f"WARN {w}")
for e in errors:
Expand All @@ -126,7 +192,7 @@ def main() -> int:
print(f"\n{len(errors)} sibling-checkout execution(s) — "
"this is the drift bin/mcp-run.sh exists to end (#146).")
return 1
if not warnings:
if not warnings and not infos:
print("MCP wiring clean: every shipped server runs from this repo.")
return 0

Expand Down
32 changes: 31 additions & 1 deletion bin/mcp-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ for part in entry.get("command") or []:
PY
}

# Whether an external server needs the vault session resolved (e.g. the
# bitwarden MCP server, which IS the vault client and gets its session
# from the resolver, not from a vault item).
external_needs_vault_session() {
[ -f "$EXTERNAL_MAP" ] || return 0
"$(json_python)" - "$EXTERNAL_MAP" "$1" <<'PY' 2>/dev/null || true
import json, sys
entry = json.load(open(sys.argv[1])).get(sys.argv[2]) or {}
print("1" if entry.get("needs_vault_session") else "0")
PY
}

list_servers() {
{
find "$MCP_DIR" -maxdepth 1 -name '*-mcp-server.py' -printf '%f\n' 2>/dev/null \
Expand Down Expand Up @@ -198,6 +210,12 @@ if [ ! -f "$SERVER_PY" ]; then
fi
fi

# Extract needs_vault_session flag for external servers.
NEEDS_VAULT_SESSION=0
if [ "$SERVER_KIND" = "external" ]; then
NEEDS_VAULT_SESSION=$(external_needs_vault_session "$SERVER")
fi

# ── Launch-path validation (runs in both modes) ───────────────────────────────
# Every failure here is one that would otherwise surface as a *silent* MCP
# startup failure — the tools just never appear in the agent session, which
Expand Down Expand Up @@ -272,6 +290,14 @@ if [ "$NEEDS_SECRETS" = "1" ]; then
load_session_file
fi

# External servers with needs_vault_session force session resolution even if
# their vault-map entry is empty or absent. This is the path for the bitwarden
# MCP server — it's the vault client itself, so its session comes from the
# resolver (bw_session.py), not from a vault item.
if [ "$SERVER_KIND" = "external" ] && [ "${NEEDS_VAULT_SESSION:-0}" = "1" ]; then
NEEDS_SECRETS=1
fi

if [ "$NEEDS_SECRETS" = "0" ]; then
report 1 "vault" "not needed — this server declares no secrets"
elif command -v "$BW" >/dev/null 2>&1; then
Expand Down Expand Up @@ -314,8 +340,12 @@ except Exception:
fi

# Which env vars this server expects, and whether the map covers them.
# External servers with needs_vault_session get the session from the resolver
# (BW_SESSION) but have no vault-map entries — skip the entry check for them.
ENTRIES=""
if [ -f "$VAULT_MAP" ]; then
if [ "${NEEDS_VAULT_SESSION:-0}" = "1" ]; then
report 1 "session" "external server (resolved from $BW_SESSION_SOURCE)"
elif [ -f "$VAULT_MAP" ]; then
ENTRIES=$("$(json_python)" - "$VAULT_MAP" "$SERVER" <<'PY' 2>/dev/null || true
import json, sys
raw = json.load(open(sys.argv[1]))
Expand Down
16 changes: 16 additions & 0 deletions mcp/external-servers.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,25 @@
"command = argv, launched with the vault-resolved secrets in its environment.",
"install = how to obtain it, for the operator that --check tells to install it.",
"",
"needs_vault_session = when true, mcp-run.sh resolves BW_SESSION via the",
" shared resolver (bw_session.py) before launching. This is for",
" external servers that ARE the vault client and get their session",
" from the resolver rather than from a vault item (e.g. bitwarden).",
"",
"Keep this file free of environment names, hostnames and vault identifiers",
"(docs/client-data-policy.md) — those belong in the gitignored layers."
],
"bitwarden": {
"command": ["npx", "-y", "@bitwarden/mcp-server"],
"install": "npm install -g @bitwarden/mcp-server",
"needs_vault_session": true,
"why": [
"The Bitwarden MCP server reads its session from BW_SESSION.",
"This is the vault client itself, so its session comes from the",
"resolver (bw_session.py), not from a vault item. needs_vault_session",
"tells mcp-run.sh to resolve and export the session at launch."
]
},
"mikromcp": {
"command": ["mikromcp", "serve"],
"install": "npm install -g mikromcp",
Expand Down
25 changes: 19 additions & 6 deletions tests/test_check_mcp_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,46 +35,59 @@ def test_shipped_servers_derived_from_repo():
def test_sibling_checkout_is_error():
cfg = _cfg({"erpnext": {
"command": "/home/user/Projects/sibling/scripts/erpnext-mcp-run.sh"}})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, _ = cmw.check(cfg, ROOT)
assert len(errors) == 1 and not warnings
assert "OUTSIDE this repo" in errors[0]


def test_in_repo_launcher_is_clean():
cfg = _cfg({"erpnext": {
"command": f"{ROOT}/bin/mcp-run.sh erpnext"}})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, _ = cmw.check(cfg, ROOT)
assert not errors and not warnings


def test_argv_list_command_form_is_handled():
cfg = _cfg({"technitium": {
"command": ["/somewhere/else/technitium-mcp-run.sh"]}})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, _ = cmw.check(cfg, ROOT)
assert len(errors) == 1


def test_package_runner_is_warning_not_error():
cfg = _cfg({"proxmox": {"command": "uvx proxmox-mcp-plus"}})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, _ = cmw.check(cfg, ROOT)
assert not errors
assert len(warnings) == 1 and "package runner" in warnings[0]


def test_foreign_servers_are_ignored():
cfg = _cfg({
"other-project": {"command": "node /elsewhere/dist/mcp/server.js"},
})
errors, warnings, _ = cmw.check(cfg, ROOT)
assert not errors and not warnings


def test_declared_external_server_produces_info():
"""A server declared in external-servers.json but routed via package
runner (not mcp-run.sh) gets an INFO note — not a warning, because the
catalog is advisory and users may use a different vault solution."""
cfg = _cfg({
"bitwarden": {"command": "npx -y @bitwarden/mcp-server"},
})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, infos = cmw.check(cfg, ROOT)
assert not errors and not warnings
assert len(infos) == 1
assert "bitwarden" in infos[0]
assert "declared in" in infos[0]


def test_match_via_entry_name_not_just_command():
"""A sibling wrapper whose filename hides the server name still flags
when the config entry itself is named after the shipped server."""
cfg = _cfg({"technitium": {"command": "/elsewhere/scripts/dns-run.sh"}})
errors, warnings = cmw.check(cfg, ROOT)
errors, warnings, _ = cmw.check(cfg, ROOT)
assert len(errors) == 1


Expand Down
Loading