@event4u/agent-config 1.40.0 → 1.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,9 +68,15 @@ If the menu is empty:
68
68
 
69
69
  ## Step 3 — optional MCP server
70
70
 
71
- Claude Desktop also speaks MCP. Wiring up the hosted `agent-config-mcp`
72
- Worker exposes the **full** skill / rule / command catalog (~480 items)
73
- on demand, on top of the 15 you installed in Step 1.
71
+ Claude Desktop also speaks MCP. Wiring up your own self-hosted
72
+ `agent-config-mcp` Cloudflare Worker exposes the **full** skill / rule /
73
+ command catalog (~480 items) on demand, on top of the 15 you installed
74
+ in Step 1.
75
+
76
+ Deploy the Worker first per [`../mcp-cloud-setup.md`](../mcp-cloud-setup.md) — your
77
+ URL will be `https://agent-config-mcp.<your-account>.workers.dev`
78
+ (or a custom domain you wire up in Step 7). Replace
79
+ `https://your-worker.workers.dev` below with that URL.
74
80
 
75
81
  Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
76
82
  (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows /
@@ -81,7 +87,25 @@ Linux is `~/.config/Claude/claude_desktop_config.json`):
81
87
  "mcpServers": {
82
88
  "agent-config": {
83
89
  "command": "npx",
84
- "args": ["-y", "mcp-remote", "https://agent-config-mcp.event4u.workers.dev"]
90
+ "args": ["-y", "mcp-remote", "https://your-worker.workers.dev"]
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ If you set the `MCP-Token` secret on the Worker (recommended — see
97
+ [`../mcp-cloud-setup.md`](../mcp-cloud-setup.md) § Bearer auth), add the header:
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "agent-config": {
103
+ "command": "npx",
104
+ "args": [
105
+ "-y", "mcp-remote", "https://your-worker.workers.dev",
106
+ "--header", "Authorization: Bearer ${MCP_TOKEN}"
107
+ ],
108
+ "env": { "MCP_TOKEN": "paste-token-here" }
85
109
  }
86
110
  }
87
111
  }
@@ -89,11 +113,12 @@ Linux is `~/.config/Claude/claude_desktop_config.json`):
89
113
 
90
114
  A pre-wired template ships at
91
115
  [`templates/claude_desktop_config.json.template`](../../../templates/claude_desktop_config.json.template) —
92
- copy + uncomment the MCP block.
116
+ copy, swap the placeholder URL for your deploy, and uncomment the MCP
117
+ block.
93
118
 
94
119
  Restart Claude Desktop. The 🔌 icon shows the connector under
95
120
  **Settings → Connectors**. Full transport details (mcp-remote vs.
96
- native HTTP) live in
121
+ native HTTP) and per-client Bearer-auth snippets live in
97
122
  [`../mcp-client-config.md`](../mcp-client-config.md).
98
123
 
99
124
  ## Claude Desktop ↔ Claude Code config sharing
@@ -125,7 +150,7 @@ Desktop config**. Once Step 1 + Step 3 are done in Desktop:
125
150
  inside Cowork sessions without a separate install.
126
151
  - Cowork-specific limit (per Anthropic docs): MCP tools that write to
127
152
  the local filesystem are sandboxed — read-only tools (the entire
128
- hosted `agent-config-mcp` surface) work fine.
153
+ `agent-config-mcp` Worker surface) work fine.
129
154
 
130
155
  If a feature works in Desktop but not in Cowork, check that you're on
131
156
  a paid plan — Cowork is gated, Desktop's free tier has the full
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.40.0",
3
+ "version": "1.41.1",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -43,20 +43,24 @@ def _read_settings_level(settings_path: Path) -> str | None:
43
43
  """Read verbosity.script_output from .agent-settings.yml.
44
44
 
45
45
  Returns None when the file is missing, PyYAML is unavailable, or
46
- the key is absent. Errors fall through to the default level.
46
+ the key is absent. Errors fall through to the default level. Goes
47
+ through the centralized loader (road-to-portable-dev-preferences P3)
48
+ so the tolerance contract — missing file, malformed YAML, no PyYAML
49
+ — degrades uniformly across scripts. ``verbosity.script_output`` is
50
+ not on the user-global whitelist, so a value there is silently
51
+ ignored; the project file is the only source for this knob.
47
52
  """
48
- if not settings_path.is_file():
49
- return None
53
+ # Lazy import — supports both `python3 scripts/foo.py` (sys.path
54
+ # contains scripts/) and `pytest` (scripts._lib is a proper package).
50
55
  try:
51
- import yaml # type: ignore[import-untyped]
56
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found] # noqa: PLC0415
52
57
  except ImportError:
53
- return None
54
- try:
55
- with settings_path.open(encoding="utf-8") as fh:
56
- data = yaml.safe_load(fh) or {}
57
- except (OSError, yaml.YAMLError):
58
- return None
59
- section = data.get("verbosity") if isinstance(data, dict) else None
58
+ from scripts._lib.agent_settings import ( # noqa: PLC0415
59
+ load_agent_settings,
60
+ )
61
+
62
+ data = load_agent_settings(project_path=settings_path)
63
+ section = data.get("verbosity")
60
64
  if not isinstance(section, dict):
61
65
  return None
62
66
  value = section.get("script_output")
@@ -88,15 +88,21 @@ def _load_retention_days(settings_path: Path | None = None) -> int:
88
88
  file, invalid YAML, missing key, non-int value). Pruning never
89
89
  blocks the council on a settings error.
90
90
  """
91
- path = settings_path or SETTINGS_FILE
92
- if not path.exists():
93
- return DEFAULT_RETENTION_DAYS
91
+ # Centralized loader (road-to-portable-dev-preferences P3): tolerance
92
+ # contract handles missing file / malformed YAML / no PyYAML uniformly.
93
+ # ``ai_council.session_retention_days`` is not whitelisted, so the
94
+ # user-global file cannot override the project value.
94
95
  try:
95
- import yaml # type: ignore[import-not-found]
96
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
97
- except Exception: # noqa: BLE001 - never block on settings parse
98
- return DEFAULT_RETENTION_DAYS
99
- ai = data.get("ai_council") if isinstance(data, dict) else None
96
+ from scripts._lib.agent_settings import load_agent_settings
97
+ except ImportError: # pragma: no cover — script-style invocation
98
+ import sys as _sys
99
+ from pathlib import Path as _Path
100
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
101
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
102
+
103
+ path = settings_path or SETTINGS_FILE
104
+ data = load_agent_settings(project_path=path)
105
+ ai = data.get("ai_council")
100
106
  if not isinstance(ai, dict):
101
107
  return DEFAULT_RETENTION_DAYS
102
108
  raw = ai.get("session_retention_days", DEFAULT_RETENTION_DAYS)
@@ -609,27 +609,36 @@ def status(*, path: Path | None = None) -> dict[str, Any]:
609
609
  }
610
610
 
611
611
 
612
+ def _load_chat_history_section(settings_path: Path) -> dict | None:
613
+ """Return the ``chat_history`` mapping from .agent-settings.yml or None.
614
+
615
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
616
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
617
+ No ``chat_history.*`` keys are whitelisted, so user-global cannot
618
+ leak into this section — the project file remains authoritative.
619
+ """
620
+ try:
621
+ from scripts._lib.agent_settings import load_agent_settings
622
+ except ImportError: # pragma: no cover — script-style invocation
623
+ import sys as _sys
624
+ from pathlib import Path as _Path
625
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
626
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
627
+
628
+ data = load_agent_settings(project_path=settings_path)
629
+ section = data.get("chat_history")
630
+ return section if isinstance(section, dict) else None
631
+
632
+
612
633
  def _read_chat_history_enabled(settings_path: Path) -> bool:
613
634
  """Read chat_history.enabled from .agent-settings.yml.
614
635
 
615
636
  Returns False when the file is missing, malformed, lacks the
616
637
  `chat_history` section, or sets enabled to false. Default-deny so
617
638
  `turn-check` is safe to run from projects that have not opted in.
618
- PyYAML is imported lazily — the rest of this module works without it.
619
639
  """
620
- if not settings_path.is_file():
621
- return False
622
- try:
623
- import yaml # type: ignore[import-untyped]
624
- except ImportError:
625
- return True # fail open: settings file present but no parser
626
- try:
627
- with settings_path.open(encoding="utf-8") as fh:
628
- data = yaml.safe_load(fh) or {}
629
- except (OSError, yaml.YAMLError):
630
- return False
631
- section = data.get("chat_history") if isinstance(data, dict) else None
632
- if not isinstance(section, dict):
640
+ section = _load_chat_history_section(settings_path)
641
+ if section is None:
633
642
  return False
634
643
  return bool(section.get("enabled", False))
635
644
 
@@ -739,19 +748,8 @@ VALID_PLATFORMS = tuple(PLATFORM_EVENT_MAP.keys())
739
748
 
740
749
  def _read_chat_history_frequency(settings_path: Path) -> str:
741
750
  """Read chat_history.frequency from .agent-settings.yml. Default per_phase."""
742
- if not settings_path.is_file():
743
- return "per_phase"
744
- try:
745
- import yaml # type: ignore[import-untyped]
746
- except ImportError:
747
- return "per_phase"
748
- try:
749
- with settings_path.open(encoding="utf-8") as fh:
750
- data = yaml.safe_load(fh) or {}
751
- except (OSError, yaml.YAMLError):
752
- return "per_phase"
753
- section = data.get("chat_history") if isinstance(data, dict) else None
754
- if not isinstance(section, dict):
751
+ section = _load_chat_history_section(settings_path)
752
+ if section is None:
755
753
  return "per_phase"
756
754
  val = str(section.get("frequency", "per_phase")).lower()
757
755
  return val if val in VALID_FREQS else "per_phase"
@@ -764,19 +762,8 @@ def _read_chat_history_max_sessions(settings_path: Path) -> int:
764
762
  Used by ``prune_sessions`` to decide how many distinct ``s`` tags
765
763
  survive in the body.
766
764
  """
767
- if not settings_path.is_file():
768
- return DEFAULT_MAX_SESSIONS
769
- try:
770
- import yaml # type: ignore[import-untyped]
771
- except ImportError:
772
- return DEFAULT_MAX_SESSIONS
773
- try:
774
- with settings_path.open(encoding="utf-8") as fh:
775
- data = yaml.safe_load(fh) or {}
776
- except (OSError, yaml.YAMLError):
777
- return DEFAULT_MAX_SESSIONS
778
- section = data.get("chat_history") if isinstance(data, dict) else None
779
- if not isinstance(section, dict):
765
+ section = _load_chat_history_section(settings_path)
766
+ if section is None:
780
767
  return DEFAULT_MAX_SESSIONS
781
768
  try:
782
769
  n = int(section.get("max_sessions", DEFAULT_MAX_SESSIONS))
@@ -794,19 +781,8 @@ def _read_text_limits(settings_path: Path) -> dict[str, int]:
794
781
  values are clamped to 0. Non-int values are silently dropped.
795
782
  """
796
783
  out = dict(DEFAULT_TEXT_LIMITS)
797
- if not settings_path.is_file():
798
- return out
799
- try:
800
- import yaml # type: ignore[import-untyped]
801
- except ImportError:
802
- return out
803
- try:
804
- with settings_path.open(encoding="utf-8") as fh:
805
- data = yaml.safe_load(fh) or {}
806
- except (OSError, yaml.YAMLError):
807
- return out
808
- section = data.get("chat_history") if isinstance(data, dict) else None
809
- if not isinstance(section, dict):
784
+ section = _load_chat_history_section(settings_path)
785
+ if section is None:
810
786
  return out
811
787
  overrides = section.get("text_limits")
812
788
  if not isinstance(overrides, dict):
@@ -42,20 +42,22 @@ def load_settings(settings_path: Path | str | None = None) -> Settings:
42
42
 
43
43
 
44
44
  def _read_section(path: Path) -> dict[str, Any] | None:
45
- """Return the ``commands.suggestion`` mapping or ``None`` on any miss."""
46
- if not path.is_file():
47
- return None
48
- try:
49
- import yaml # type: ignore[import-untyped]
50
- except ImportError:
51
- return None
45
+ """Return the ``commands.suggestion`` mapping or ``None`` on any miss.
46
+
47
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
48
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
49
+ No ``commands.*`` keys are whitelisted, so user-global cannot cascade
50
+ into this section.
51
+ """
52
52
  try:
53
- with path.open(encoding="utf-8") as fh:
54
- data = yaml.safe_load(fh) or {}
55
- except (OSError, yaml.YAMLError):
56
- return None
57
- if not isinstance(data, dict):
58
- return None
53
+ from scripts._lib.agent_settings import load_agent_settings
54
+ except ImportError: # pragma: no cover — script-style invocation
55
+ import sys as _sys
56
+ from pathlib import Path as _Path
57
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
58
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
59
+
60
+ data = load_agent_settings(project_path=path)
59
61
  commands = data.get("commands")
60
62
  if not isinstance(commands, dict):
61
63
  return None
@@ -100,16 +100,20 @@ def _normalize_trigger(item) -> dict | None:
100
100
 
101
101
 
102
102
  def _load_settings() -> dict:
103
- """Read .agent-settings.yml for compile-time toggles. Stdlib-only fallback."""
104
- if not SETTINGS_PATH.exists():
105
- return {}
106
- text = SETTINGS_PATH.read_text(encoding="utf-8")
103
+ """Read .agent-settings.yml for compile-time toggles.
104
+
105
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
106
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
107
+ """
107
108
  try:
108
- import yaml # type: ignore
109
- data = yaml.safe_load(text) or {}
110
- return data if isinstance(data, dict) else {}
111
- except ImportError:
112
- return {}
109
+ from scripts._lib.agent_settings import load_agent_settings
110
+ except ImportError: # pragma: no cover — script-style invocation
111
+ import sys as _sys
112
+ from pathlib import Path as _Path
113
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
114
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
115
+
116
+ return load_agent_settings(project_path=SETTINGS_PATH)
113
117
 
114
118
 
115
119
  def _collect(rules_dir: Path) -> dict:
@@ -46,28 +46,31 @@ def _read_augment_rules_use_symlinks() -> bool:
46
46
  """Read augment.rules_use_symlinks from .agent-settings.yml.
47
47
 
48
48
  Returns True only when the setting is present under the top-level
49
- ``augment:`` block and resolves to a truthy YAML scalar
50
- (true/yes/on/1, case-insensitive). Missing file, missing block, or
51
- any other value → False (preserve copy default).
49
+ ``augment:`` block and resolves to a truthy value. Missing file,
50
+ missing block, or any other value False (preserve copy default).
51
+
52
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
53
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
52
54
  """
53
- if not SETTINGS_FILE.exists():
54
- return False
55
55
  try:
56
- text = SETTINGS_FILE.read_text(encoding="utf-8")
57
- except OSError:
56
+ from scripts._lib.agent_settings import load_agent_settings
57
+ except ImportError: # pragma: no cover — script-style invocation
58
+ import sys as _sys
59
+ from pathlib import Path as _Path
60
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
61
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
62
+
63
+ data = load_agent_settings(project_path=SETTINGS_FILE)
64
+ augment = data.get("augment")
65
+ if not isinstance(augment, dict):
58
66
  return False
59
- in_augment = False
60
- for line in text.splitlines():
61
- stripped = line.lstrip()
62
- if not stripped or stripped.startswith("#"):
63
- continue
64
- if not line.startswith((" ", "\t")):
65
- in_augment = stripped.startswith("augment:")
66
- continue
67
- if in_augment:
68
- m = re.match(r"^\s+rules_use_symlinks\s*:\s*([^\s#]+)", line)
69
- if m:
70
- return m.group(1).strip().lower() in ("true", "yes", "on", "1")
67
+ value = augment.get("rules_use_symlinks")
68
+ if isinstance(value, bool):
69
+ return value
70
+ if isinstance(value, str):
71
+ return value.strip().lower() in ("true", "yes", "on", "1")
72
+ if isinstance(value, int):
73
+ return value == 1
71
74
  return False
72
75
 
73
76
 
@@ -53,9 +53,15 @@ class CouncilDisabledError(RuntimeError):
53
53
 
54
54
 
55
55
  def load_settings(path: Path = SETTINGS_FILE) -> dict[str, Any]:
56
- if not path.exists():
57
- return {}
58
- return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
56
+ """Load merged settings via the centralized loader.
57
+
58
+ road-to-portable-dev-preferences P3 migration: tolerance contract
59
+ (missing file / malformed YAML / no PyYAML) is handled uniformly by
60
+ ``load_agent_settings``. ``ai_council.*`` keys are not whitelisted,
61
+ so the project file remains authoritative for council config.
62
+ """
63
+ from scripts._lib.agent_settings import load_agent_settings
64
+ return load_agent_settings(project_path=path)
59
65
 
60
66
 
61
67
  def build_members(
@@ -29,8 +29,10 @@ from typing import Any
29
29
  _SCRIPTS = Path(__file__).resolve().parent
30
30
  sys.path.insert(0, str(_SCRIPTS))
31
31
 
32
+ from mcp_server.catalog import load_catalog # noqa: E402
32
33
  from mcp_server.prompts import load_all_prompts, to_mcp_prompt_meta # noqa: E402
33
34
  from mcp_server.resources import load_all_resources, to_mcp_resource_meta # noqa: E402
35
+ from mcp_server.tools import ALLOWLIST # noqa: E402
34
36
 
35
37
  PAGE_SIZE = 50
36
38
 
@@ -96,6 +98,21 @@ def _normalize_resources(payload: dict[str, Any]) -> list[dict[str, Any]]:
96
98
  return sorted(out, key=lambda x: x["uri"])
97
99
 
98
100
 
101
+ def _local_tools_list() -> dict[str, Any]:
102
+ """Tools catalog + allowlist as the stdio server publishes them."""
103
+ catalog_names = [c.name for c in load_catalog()]
104
+ allowlist_names = list(ALLOWLIST.keys())
105
+ return {"tools": [{"name": n} for n in sorted(set(catalog_names + allowlist_names))]}
106
+
107
+
108
+ def _normalize_tools(payload: dict[str, Any]) -> list[dict[str, Any]]:
109
+ """Compare on `name` only — descriptions / schemas drift acceptably."""
110
+ return sorted(
111
+ [{"name": t["name"]} for t in payload.get("tools", [])],
112
+ key=lambda x: x["name"],
113
+ )
114
+
115
+
99
116
  def _diff(label: str, local: list[Any], remote: list[Any]) -> int:
100
117
  if local == remote:
101
118
  print(f"✅ {label}: {len(local)} entries match")
@@ -129,8 +146,9 @@ def main() -> int:
129
146
  failed += _diff("resources/list", local_r, remote_r)
130
147
 
131
148
  try:
132
- _ = _rpc(args.target, "tools/list")
133
- print("tools/list: round-trips (stub list — content not parity-checked)")
149
+ local_t = _normalize_tools(_local_tools_list())
150
+ remote_t = _normalize_tools(_rpc(args.target, "tools/list"))
151
+ failed += _diff("tools/list", local_t, remote_t)
134
152
  except Exception as e:
135
153
  print(f"❌ tools/list: {e}")
136
154
  failed += 1
@@ -0,0 +1,125 @@
1
+ """Consumer tool catalog — source of truth for Phase 1 discovery stubs.
2
+
3
+ Loaded once at module import from ``consumer_tool_catalog.json``. Both
4
+ the stdio server (``tools.py``) and the cloud pack
5
+ (``scripts/pack_mcp_content.py``) read from this file so the manifest
6
+ returned by ``tools/list`` on either transport is byte-identical apart
7
+ from per-tool ``implemented_on`` metadata.
8
+
9
+ Side-effect classification (``ro`` / ``fs-write`` / ``shell``) and the
10
+ ``not_implemented`` envelope contract live in
11
+ ``docs/contracts/mcp-tool-stub-envelope.md``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ _CATALOG_FILE = Path(__file__).resolve().parent / "consumer_tool_catalog.json"
21
+
22
+ # Stable error code surfaced in the ``not_implemented`` envelope. The
23
+ # Worker mirrors this string verbatim — keep them in sync via the
24
+ # envelope contract.
25
+ NOT_IMPLEMENTED_CODE = "not_implemented"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CatalogEntry:
30
+ """One row in ``consumer_tool_catalog.json``.
31
+
32
+ ``implemented_on`` lists transports where a real handler is wired
33
+ (``stdio`` / ``worker``); missing transports return the
34
+ ``not_implemented`` envelope.
35
+ """
36
+
37
+ name: str
38
+ description: str
39
+ side_effect: str
40
+ implemented_on: tuple[str, ...]
41
+ input_schema: dict[str, Any]
42
+
43
+
44
+ def _validate(raw: dict[str, Any]) -> None:
45
+ """Refuse to boot on a malformed catalog. Boot-time errors only."""
46
+ if raw.get("schema_version") != 1:
47
+ raise ValueError(
48
+ f"catalog: unsupported schema_version="
49
+ f"{raw.get('schema_version')!r}; expected 1"
50
+ )
51
+ tools = raw.get("tools")
52
+ if not isinstance(tools, list) or not tools:
53
+ raise ValueError("catalog: 'tools' must be a non-empty list")
54
+ seen: set[str] = set()
55
+ for entry in tools:
56
+ if not isinstance(entry, dict):
57
+ raise ValueError("catalog: every tool entry must be an object")
58
+ for field in ("name", "description", "side_effect", "input_schema"):
59
+ if field not in entry:
60
+ raise ValueError(f"catalog: tool missing '{field}'")
61
+ if entry["side_effect"] not in ("ro", "fs-write", "shell"):
62
+ raise ValueError(
63
+ f"catalog: tool {entry['name']!r} has invalid side_effect "
64
+ f"{entry['side_effect']!r} (expected ro / fs-write / shell)"
65
+ )
66
+ name = entry["name"]
67
+ if name in seen:
68
+ raise ValueError(f"catalog: duplicate tool name {name!r}")
69
+ seen.add(name)
70
+
71
+
72
+ def load_catalog(path: Path | None = None) -> list[CatalogEntry]:
73
+ """Parse and validate the catalog. Returns entries in file order."""
74
+ target = path or _CATALOG_FILE
75
+ raw = json.loads(target.read_text(encoding="utf-8"))
76
+ _validate(raw)
77
+ return [
78
+ CatalogEntry(
79
+ name=t["name"],
80
+ description=t["description"],
81
+ side_effect=t["side_effect"],
82
+ implemented_on=tuple(t.get("implemented_on") or ()),
83
+ input_schema=t["input_schema"],
84
+ )
85
+ for t in raw["tools"]
86
+ ]
87
+
88
+
89
+ def load_raw(path: Path | None = None) -> dict[str, Any]:
90
+ """Return the raw parsed JSON. Used by the cloud packer."""
91
+ target = path or _CATALOG_FILE
92
+ raw = json.loads(target.read_text(encoding="utf-8"))
93
+ _validate(raw)
94
+ return raw
95
+
96
+
97
+ def install_hint(raw: dict[str, Any] | None = None) -> str:
98
+ """Stable install-hint surfaced in the envelope."""
99
+ data = raw if raw is not None else load_raw()
100
+ return str(data.get("install_hint_stdio") or "")
101
+
102
+
103
+ def not_implemented_envelope(
104
+ tool_name: str,
105
+ *,
106
+ transport: str,
107
+ install_hint_value: str,
108
+ ) -> dict[str, Any]:
109
+ """Wire-shape error envelope used when a stub is invoked.
110
+
111
+ Mirrored verbatim by the Cloud Worker (`workers/mcp/src/stubs.ts`).
112
+ """
113
+ return {
114
+ "code": NOT_IMPLEMENTED_CODE,
115
+ "tool": tool_name,
116
+ "transport": transport,
117
+ "install_hint": install_hint_value,
118
+ "alternative": "stdio",
119
+ "message": (
120
+ f"Tool '{tool_name}' is in the discovery catalog but not "
121
+ f"implemented on the {transport} transport. See the install "
122
+ "hint to wire it up locally, or check "
123
+ "docs/contracts/mcp-tool-stub-envelope.md."
124
+ ),
125
+ }