diff --git a/api/main.py b/api/main.py index cf6f5d5..0699713 100644 --- a/api/main.py +++ b/api/main.py @@ -136,4 +136,4 @@ app.include_router(policy.router) @app.get("/", include_in_schema=False) async def root(): - return {"service": "state-hub", "docs": "/docs"} + return {"service": "dev-hub", "docs": "/docs"} diff --git a/custodian_cli.py b/custodian_cli.py index 70f554b..159ef35 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -126,11 +126,9 @@ def _detect_domain(project_path: Path) -> str | None: def _check_mcp() -> bool: - claude_json = Path.home() / ".claude.json" - if not claude_json.exists(): - return False - config = json.loads(claude_json.read_text()) - return "state-hub" in config.get("mcpServers", {}) + from scripts.mcp_registration import load_claude_json, mcp_server_registered + + return mcp_server_registered(load_claude_json()) # ── Subcommands ──────────────────────────────────────────────────────────────── @@ -193,7 +191,8 @@ def cmd_register(args: argparse.Namespace) -> None: if _check_mcp(): print(" MCP OK") else: - print("WARNING: 'state-hub' not in ~/.claude.json.") + print("WARNING: 'dev-hub' (or legacy 'state-hub') not in ~/.claude.json.") + print(" Run: python scripts/migrate_mcp_config.py # if upgrading legacy config") print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.") # ── Step 5: Write CLAUDE.custodian.md ───────────────────────────────────── diff --git a/mcp_server/constants.py b/mcp_server/constants.py new file mode 100644 index 0000000..ade070f --- /dev/null +++ b/mcp_server/constants.py @@ -0,0 +1,4 @@ +"""Canonical MCP server identifiers for State Hub / dev-hub.""" + +MCP_SERVER_NAME = "dev-hub" +LEGACY_MCP_SERVER_NAME = "state-hub" \ No newline at end of file diff --git a/mcp_server/server.py b/mcp_server/server.py index ecd40b6..9ed5ccf 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -19,10 +19,12 @@ import httpx from fastmcp import FastMCP from hub_core.mcp import HubCoreMCPServer +from mcp_server.constants import MCP_SERVER_NAME + API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") mcp = FastMCP( - name="state-hub", + name=MCP_SERVER_NAME, instructions=( "Custodian State Hub: tracks topics, workstreams, tasks, decisions, and progress events. " "Start every session with get_state_summary() for orientation. " @@ -45,7 +47,7 @@ _HUB_CORE_MCP_EXCLUDE = frozenset({ "ingest_tpsc_tool", }) HubCoreMCPServer( - name="state-hub", + name=MCP_SERVER_NAME, api_base=API_BASE, register_tools=False, ).attach_to(mcp, exclude=_HUB_CORE_MCP_EXCLUDE) diff --git a/scripts/mcp_registration.py b/scripts/mcp_registration.py new file mode 100644 index 0000000..85870d3 --- /dev/null +++ b/scripts/mcp_registration.py @@ -0,0 +1,30 @@ +"""Helpers for ~/.claude.json MCP server registration checks.""" +from __future__ import annotations + +import json +from pathlib import Path + +from mcp_server.constants import LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME + + +def load_claude_json(path: Path | None = None) -> dict | None: + claude_json = path or Path.home() / ".claude.json" + if not claude_json.exists(): + return None + return json.loads(claude_json.read_text()) + + +def mcp_server_registered(config: dict | None) -> bool: + if not config: + return False + servers = config.get("mcpServers", {}) + return MCP_SERVER_NAME in servers or LEGACY_MCP_SERVER_NAME in servers + + +def resolve_mcp_server_name(config: dict) -> str | None: + servers = config.get("mcpServers", {}) + if MCP_SERVER_NAME in servers: + return MCP_SERVER_NAME + if LEGACY_MCP_SERVER_NAME in servers: + return LEGACY_MCP_SERVER_NAME + return None \ No newline at end of file diff --git a/scripts/migrate_mcp_config.py b/scripts/migrate_mcp_config.py new file mode 100644 index 0000000..2fea568 --- /dev/null +++ b/scripts/migrate_mcp_config.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Rename legacy state-hub MCP registration to dev-hub in ~/.claude.json.""" +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from mcp_server.constants import LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME + +CLAUDE_JSON = Path.home() / ".claude.json" + + +def migrate_config(config: dict) -> tuple[dict, bool]: + servers = config.setdefault("mcpServers", {}) + if MCP_SERVER_NAME in servers: + return config, False + if LEGACY_MCP_SERVER_NAME not in servers: + return config, False + servers[MCP_SERVER_NAME] = servers.pop(LEGACY_MCP_SERVER_NAME) + return config, True + + +def main() -> None: + if not CLAUDE_JSON.exists(): + print(f"ERROR: {CLAUDE_JSON} not found.") + raise SystemExit(1) + + config = json.loads(CLAUDE_JSON.read_text()) + migrated_config, changed = migrate_config(config) + if not changed: + if MCP_SERVER_NAME in config.get("mcpServers", {}): + print(f"OK: '{MCP_SERVER_NAME}' already registered.") + else: + print( + f"Nothing to migrate: neither '{LEGACY_MCP_SERVER_NAME}' nor " + f"'{MCP_SERVER_NAME}' found in mcpServers." + ) + return + + stamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup = CLAUDE_JSON.with_name(f".claude.json.bak-{stamp}") + shutil.copy2(CLAUDE_JSON, backup) + CLAUDE_JSON.write_text(json.dumps(migrated_config, indent=2) + "\n") + print(f"Backed up: {backup}") + print(f"Migrated: mcpServers['{LEGACY_MCP_SERVER_NAME}'] → mcpServers['{MCP_SERVER_NAME}']") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/patch_mcp_cwd.py b/scripts/patch_mcp_cwd.py index 8724f89..41e9839 100644 --- a/scripts/patch_mcp_cwd.py +++ b/scripts/patch_mcp_cwd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Patch ~/.claude.json to add the cwd field to the state-hub MCP entry. +Patch ~/.claude.json to add the cwd field to the dev-hub MCP entry. claude mcp add-json silently drops the cwd field. Run this script after any claude mcp add-json call to restore it. @@ -11,22 +11,35 @@ import json import os from pathlib import Path +from mcp_server.constants import LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME +from scripts.mcp_registration import load_claude_json, resolve_mcp_server_name + CLAUDE_JSON = Path.home() / ".claude.json" -STATE_HUB_DIR = Path(__file__).resolve().parent.parent # state-hub/ +STATE_HUB_DIR = Path(__file__).resolve().parent.parent + def main() -> None: - if not CLAUDE_JSON.exists(): + config = load_claude_json(CLAUDE_JSON) + if config is None: print(f"ERROR: {CLAUDE_JSON} not found. Run 'claude mcp add-json' first.") raise SystemExit(1) - config = json.loads(CLAUDE_JSON.read_text()) servers = config.setdefault("mcpServers", {}) - - if "state-hub" not in servers: - print("ERROR: 'state-hub' not found in ~/.claude.json. Run 'claude mcp add-json' first.") + server_name = resolve_mcp_server_name(config) + if server_name is None: + print( + f"ERROR: neither '{MCP_SERVER_NAME}' nor '{LEGACY_MCP_SERVER_NAME}' " + f"found in ~/.claude.json. Run 'claude mcp add-json' first." + ) raise SystemExit(1) - entry = servers["state-hub"] + if server_name == LEGACY_MCP_SERVER_NAME: + print( + f"NOTE: patching legacy '{LEGACY_MCP_SERVER_NAME}' entry. " + "Run scripts/migrate_mcp_config.py to rename to dev-hub." + ) + + entry = servers[server_name] cwd_str = str(STATE_HUB_DIR) if entry.get("cwd") == cwd_str: @@ -35,8 +48,8 @@ def main() -> None: entry["cwd"] = cwd_str CLAUDE_JSON.write_text(json.dumps(config, indent=2) + "\n") - print(f"Patched: ~/.claude.json state-hub.cwd = {cwd_str}") + print(f"Patched: ~/.claude.json {server_name}.cwd = {cwd_str}") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/project_rules/session-protocol.template b/scripts/project_rules/session-protocol.template index 355ad24..cf0110d 100644 --- a/scripts/project_rules/session-protocol.template +++ b/scripts/project_rules/session-protocol.template @@ -1,6 +1,7 @@ ## Session Protocol -State Hub: http://127.0.0.1:8000 +Dev Hub (State Hub API): http://127.0.0.1:8000 +MCP server name in `~/.claude.json`: `dev-hub` **Step 1 — Orient** diff --git a/scripts/register-mcp.sh b/scripts/register-mcp.sh index ae63bf5..f79b9e8 100755 --- a/scripts/register-mcp.sh +++ b/scripts/register-mcp.sh @@ -3,7 +3,7 @@ set -euo pipefail STATE_HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CLAUDE_JSON="${CLAUDE_JSON:-$HOME/.claude.json}" -SERVER_NAME="${STATE_HUB_MCP_NAME:-state-hub}" +SERVER_NAME="${STATE_HUB_MCP_NAME:-dev-hub}" API_BASE="${API_BASE:-}" MCP_URL="${MCP_URL:-}" DRY_RUN=0 diff --git a/scripts/register_project.sh b/scripts/register_project.sh index 0dd1b0d..e6f1dee 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -112,11 +112,12 @@ if not f.exists(): print('MISSING_FILE') else: d = json.loads(f.read_text()) - print('OK' if 'state-hub' in d.get('mcpServers', {}) else 'NOT_REGISTERED') + servers = d.get('mcpServers', {}) + print('OK' if 'dev-hub' in servers or 'state-hub' in servers else 'NOT_REGISTERED') ")" case "$MCP_OK" in MISSING_FILE) echo "WARNING: ~/.claude.json not found. MCP server not registered." ;; - NOT_REGISTERED) echo "WARNING: 'state-hub' not in ~/.claude.json. See global CLAUDE.md §MCP Server Registration." ;; + NOT_REGISTERED) echo "WARNING: 'dev-hub' not in ~/.claude.json. See global CLAUDE.md §MCP Server Registration." ;; *) echo " MCP OK" ;; esac fi diff --git a/tests/test_mcp_registration.py b/tests/test_mcp_registration.py new file mode 100644 index 0000000..6bf3e2a --- /dev/null +++ b/tests/test_mcp_registration.py @@ -0,0 +1,74 @@ +"""Tests for dev-hub MCP registration helpers and config migration.""" +from __future__ import annotations + +import json + +import mcp_server.server as server +from mcp_server.constants import LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME +from scripts.mcp_registration import ( + mcp_server_registered, + resolve_mcp_server_name, +) +from scripts.migrate_mcp_config import migrate_config + + +def test_mcp_server_name_is_dev_hub() -> None: + assert server.mcp.name == MCP_SERVER_NAME + + +def test_mcp_server_registered_accepts_dev_hub() -> None: + config = {"mcpServers": {MCP_SERVER_NAME: {"type": "sse"}}} + assert mcp_server_registered(config) is True + + +def test_mcp_server_registered_accepts_legacy_state_hub() -> None: + config = {"mcpServers": {LEGACY_MCP_SERVER_NAME: {"type": "sse"}}} + assert mcp_server_registered(config) is True + + +def test_resolve_mcp_server_name_prefers_dev_hub() -> None: + config = { + "mcpServers": { + MCP_SERVER_NAME: {"type": "sse"}, + LEGACY_MCP_SERVER_NAME: {"type": "sse"}, + } + } + assert resolve_mcp_server_name(config) == MCP_SERVER_NAME + + +def test_migrate_config_renames_legacy_entry() -> None: + legacy_entry = {"type": "sse", "url": "http://127.0.0.1:8001/sse"} + config = {"mcpServers": {LEGACY_MCP_SERVER_NAME: legacy_entry}} + migrated, changed = migrate_config(config) + assert changed is True + assert LEGACY_MCP_SERVER_NAME not in migrated["mcpServers"] + assert migrated["mcpServers"][MCP_SERVER_NAME] == legacy_entry + + +def test_migrate_config_noop_when_dev_hub_present() -> None: + config = {"mcpServers": {MCP_SERVER_NAME: {"type": "sse"}}} + migrated, changed = migrate_config(config) + assert changed is False + assert migrated is config + + +def test_migrate_config_noop_when_neither_present() -> None: + config = {"mcpServers": {"ops-bridge": {"type": "sse"}}} + migrated, changed = migrate_config(config) + assert changed is False + assert migrated is config + + +def test_migrate_config_preserves_other_servers(tmp_path) -> None: + claude_json = tmp_path / ".claude.json" + config = { + "mcpServers": { + LEGACY_MCP_SERVER_NAME: {"type": "sse"}, + "ops-bridge": {"type": "sse", "url": "http://127.0.0.1:8002/sse"}, + } + } + claude_json.write_text(json.dumps(config)) + migrated, changed = migrate_config(json.loads(claude_json.read_text())) + assert changed is True + assert "ops-bridge" in migrated["mcpServers"] + assert migrated["mcpServers"]["ops-bridge"]["url"] == "http://127.0.0.1:8002/sse" \ No newline at end of file