@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +47 -21
- package/docs/DISTRIBUTION_CHECKLIST.md +2 -2
- package/docs/catalog.md +4 -3
- package/docs/contracts/file-ownership-matrix.json +27 -0
- package/docs/contracts/mcp-discovery-phase-notice.md +60 -0
- package/docs/contracts/mcp-tool-stub-envelope.md +78 -0
- package/docs/getting-started.md +1 -1
- package/docs/setup/mcp-client-config.md +94 -13
- package/docs/setup/mcp-cloud-setup.md +32 -1
- package/docs/setup/per-ide/claude-desktop.md +32 -7
- package/package.json +1 -1
- package/scripts/_lib/script_output.py +15 -11
- package/scripts/ai_council/session.py +14 -8
- package/scripts/chat_history.py +29 -53
- package/scripts/command_suggester/settings.py +15 -13
- package/scripts/compile_router.py +13 -9
- package/scripts/compress.py +22 -19
- package/scripts/council_cli.py +9 -3
- package/scripts/mcp_parity_smoke.py +20 -2
- package/scripts/mcp_server/catalog.py +125 -0
- package/scripts/mcp_server/consumer_tool_catalog.json +275 -0
- package/scripts/mcp_server/telemetry.py +128 -0
- package/scripts/mcp_server/tools.py +474 -15
- package/scripts/mcp_telemetry_health.py +214 -0
- package/scripts/mcp_telemetry_query.py +203 -0
- package/scripts/mcp_telemetry_store.py +211 -0
- package/scripts/memory_signal.py +12 -10
- package/scripts/pack_mcp_content.py +18 -4
- package/templates/claude_desktop_config.json.template +4 -3
|
@@ -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
|
|
72
|
-
Worker exposes the **full** skill / rule /
|
|
73
|
-
on demand, on top of the 15 you installed
|
|
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://
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
|
56
|
+
from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found] # noqa: PLC0415
|
|
52
57
|
except ImportError:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
package/scripts/chat_history.py
CHANGED
|
@@ -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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
package/scripts/compress.py
CHANGED
|
@@ -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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
except
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
package/scripts/council_cli.py
CHANGED
|
@@ -53,9 +53,15 @@ class CouncilDisabledError(RuntimeError):
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def load_settings(path: Path = SETTINGS_FILE) -> dict[str, Any]:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
+
}
|