@event4u/agent-config 1.36.1 → 1.38.0

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.
@@ -44,6 +44,10 @@ Commands:
44
44
  mcp:render Render mcp.json → .cursor/mcp.json, .windsurf/mcp.json
45
45
  (pass --claude-desktop to also write user-scope config)
46
46
  mcp:check Dry-run mcp:render; exit non-zero if targets are stale
47
+ mcp:setup Create .venv-mcp/ and install the mcp SDK
48
+ (one-line MCP server onboarding; idempotent)
49
+ mcp:run Run the built-in MCP server over stdio
50
+ (requires `mcp:setup` first; see docs/mcp-server.md)
47
51
  roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
48
52
  roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
49
53
  hooks:install Install the pre-commit roadmap-progress hook
@@ -97,6 +101,8 @@ Examples:
97
101
  ./agent-config mcp:render
98
102
  ./agent-config mcp:render --claude-desktop
99
103
  ./agent-config mcp:check
104
+ ./agent-config mcp:setup
105
+ ./agent-config mcp:run
100
106
  ./agent-config roadmap:progress
101
107
  ./agent-config hooks:install
102
108
  ./agent-config keys:install-anthropic
@@ -198,6 +204,27 @@ cmd_mcp_check() {
198
204
  exec python3 "$script" --check "$@"
199
205
  }
200
206
 
207
+ cmd_mcp_setup() {
208
+ local script
209
+ script="$(resolve_script "scripts/mcp_setup.sh")" || return 1
210
+ exec bash "$script" "$@"
211
+ }
212
+
213
+ # Run the built-in stdio MCP server. The server module ships inside the
214
+ # package (PACKAGE_ROOT/scripts/mcp_server/), but the venv is created by
215
+ # `mcp_setup.sh` at CWD — keeping consumer projects in control of where
216
+ # the SDK install lives. PYTHONPATH points at PACKAGE_ROOT so the
217
+ # `scripts.mcp_server` import resolves regardless of CWD.
218
+ cmd_mcp_run() {
219
+ local venv_py="$CONSUMER_ROOT/.venv-mcp/bin/python"
220
+ if [[ ! -x "$venv_py" ]]; then
221
+ echo "❌ agent-config: .venv-mcp/ not found at $CONSUMER_ROOT/.venv-mcp" >&2
222
+ echo " Run \`./agent-config mcp:setup\` first to create it." >&2
223
+ exit 1
224
+ fi
225
+ exec env PYTHONPATH="$PACKAGE_ROOT" "$venv_py" -m scripts.mcp_server "$@"
226
+ }
227
+
201
228
  cmd_roadmap_progress() {
202
229
  require_python3
203
230
  local script
@@ -484,6 +511,8 @@ main() {
484
511
  case "$cmd" in
485
512
  mcp:render) cmd_mcp_render "$@" ;;
486
513
  mcp:check) cmd_mcp_check "$@" ;;
514
+ mcp:setup) cmd_mcp_setup "$@" ;;
515
+ mcp:run) cmd_mcp_run "$@" ;;
487
516
  roadmap:progress) cmd_roadmap_progress "$@" ;;
488
517
  roadmap:progress-check) cmd_roadmap_progress_check "$@" ;;
489
518
  hooks:install) cmd_hooks_install "$@" ;;
@@ -0,0 +1,146 @@
1
+ """Live-replay parity smoke — local stdio kernel vs deployed Worker URL.
2
+
3
+ Replays a fixed set of JSON-RPC calls against:
4
+
5
+ 1. The local Python loaders (`prompts.py` / `resources.py`) — the
6
+ source-of-truth wire surface.
7
+ 2. An HTTP target (typically `wrangler dev` locally, or the deployed
8
+ Cloudflare Worker URL in CI / post-deploy).
9
+
10
+ Diffs the two on a normalised view (signature + release_key + content
11
+ hashes stripped). Exit 0 = parity, 1 = drift.
12
+
13
+ Usage:
14
+ python scripts/mcp_parity_smoke.py --target http://127.0.0.1:8787
15
+ python scripts/mcp_parity_smoke.py --target https://mcp.example.com
16
+
17
+ Phase 5.1 of `road-to-cloudflare-mcp-hosting.md`. Governed by
18
+ `docs/contracts/mcp-cloud-scope.md` §A0-cloud.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ import urllib.request
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ _SCRIPTS = Path(__file__).resolve().parent
30
+ sys.path.insert(0, str(_SCRIPTS))
31
+
32
+ from mcp_server.prompts import load_all_prompts, to_mcp_prompt_meta # noqa: E402
33
+ from mcp_server.resources import load_all_resources, to_mcp_resource_meta # noqa: E402
34
+
35
+ PAGE_SIZE = 50
36
+
37
+
38
+ def _local_prompts_list() -> dict[str, Any]:
39
+ prompts, _ = load_all_prompts()
40
+ metas = [to_mcp_prompt_meta(p) for p in prompts]
41
+ page = metas[:PAGE_SIZE]
42
+ out: dict[str, Any] = {"prompts": page}
43
+ if len(metas) > PAGE_SIZE:
44
+ out["nextCursor"] = page[-1]["name"]
45
+ return out
46
+
47
+
48
+ def _local_resources_list() -> dict[str, Any]:
49
+ resources, _ = load_all_resources()
50
+ metas = [to_mcp_resource_meta(r) for r in resources]
51
+ page = metas[:PAGE_SIZE]
52
+ out: dict[str, Any] = {"resources": page}
53
+ if len(metas) > PAGE_SIZE:
54
+ out["nextCursor"] = page[-1]["uri"]
55
+ return out
56
+
57
+
58
+ def _rpc(target: str, method: str, params: dict[str, Any] | None = None) -> Any:
59
+ body = json.dumps(
60
+ {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}
61
+ ).encode("utf-8")
62
+ req = urllib.request.Request(
63
+ target,
64
+ data=body,
65
+ headers={"content-type": "application/json"},
66
+ method="POST",
67
+ )
68
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
69
+ resp = json.loads(r.read().decode("utf-8"))
70
+ if "error" in resp:
71
+ raise RuntimeError(f"{method}: {resp['error']}")
72
+ return resp["result"]
73
+
74
+
75
+ def _normalize_prompts(payload: dict[str, Any]) -> list[dict[str, Any]]:
76
+ out = []
77
+ for p in payload.get("prompts", []):
78
+ out.append({
79
+ "name": p["name"],
80
+ "description": p["description"],
81
+ "kind": p.get("_meta", {}).get("kind"),
82
+ })
83
+ return sorted(out, key=lambda x: x["name"])
84
+
85
+
86
+ def _normalize_resources(payload: dict[str, Any]) -> list[dict[str, Any]]:
87
+ out = []
88
+ for r in payload.get("resources", []):
89
+ out.append({
90
+ "uri": r["uri"],
91
+ "name": r["name"],
92
+ "description": r["description"],
93
+ "mimeType": r["mimeType"],
94
+ "kind": r.get("_meta", {}).get("kind"),
95
+ })
96
+ return sorted(out, key=lambda x: x["uri"])
97
+
98
+
99
+ def _diff(label: str, local: list[Any], remote: list[Any]) -> int:
100
+ if local == remote:
101
+ print(f"✅ {label}: {len(local)} entries match")
102
+ return 0
103
+ print(f"❌ {label}: drift ({len(local)} local vs {len(remote)} remote)")
104
+ local_set = {json.dumps(x, sort_keys=True) for x in local}
105
+ remote_set = {json.dumps(x, sort_keys=True) for x in remote}
106
+ only_local = local_set - remote_set
107
+ only_remote = remote_set - local_set
108
+ for s in sorted(only_local)[:5]:
109
+ print(f" local-only: {s}")
110
+ for s in sorted(only_remote)[:5]:
111
+ print(f" remote-only: {s}")
112
+ if len(only_local) > 5 or len(only_remote) > 5:
113
+ print(f" (+{len(only_local) - 5} local, +{len(only_remote) - 5} remote more)")
114
+ return 1
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description=__doc__)
119
+ ap.add_argument("--target", required=True, help="HTTP URL of the Worker.")
120
+ args = ap.parse_args()
121
+
122
+ failed = 0
123
+ local_p = _normalize_prompts(_local_prompts_list())
124
+ remote_p = _normalize_prompts(_rpc(args.target, "prompts/list"))
125
+ failed += _diff("prompts/list", local_p, remote_p)
126
+
127
+ local_r = _normalize_resources(_local_resources_list())
128
+ remote_r = _normalize_resources(_rpc(args.target, "resources/list"))
129
+ failed += _diff("resources/list", local_r, remote_r)
130
+
131
+ try:
132
+ _ = _rpc(args.target, "tools/list")
133
+ print("✅ tools/list: round-trips (stub list — content not parity-checked)")
134
+ except Exception as e:
135
+ print(f"❌ tools/list: {e}")
136
+ failed += 1
137
+
138
+ if failed:
139
+ print(f"\n{failed} surface(s) drifted between local stdio and {args.target}")
140
+ return 1
141
+ print(f"\nparity OK against {args.target}")
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ """MCP server for agent-config — Phase 1 MVP.
2
+
3
+ Exposes a hand-picked subset of `.agent-src/skills/` as MCP `prompts`
4
+ over stdio. Read-only and instructional per the A0 execution-safety
5
+ boundary in `agents/roadmaps/road-to-mcp-server.md`. No `tools`
6
+ primitive, no engine spawn, no shell execution.
7
+
8
+ Stability: experimental. Contract: `docs/contracts/mcp-phase-1-scope.md`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+ SERVER_NAME = "agent-config"
@@ -0,0 +1,12 @@
1
+ """Entrypoint — `python -m scripts.mcp_server`.
2
+
3
+ Required by Claude Desktop / Zed / Continue stdio-server config.
4
+ The wrapper forwards to `server.main()`; keep this file flat so
5
+ crash tracebacks point at server.py, not the bootstrap.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .server import main
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,75 @@
1
+ """Phase-6 F1 — server identity metadata.
2
+
3
+ Three values surfaced at boot via stderr (`run_stdio` boot log):
4
+
5
+ - **server version** — wire-surface SemVer in `__init__.py::__version__`.
6
+ Hand-bumped when the MCP-side surface (prompts/resources/tools shape,
7
+ protocol semantics) changes.
8
+ - **package version** — read from `package.json::version` at boot.
9
+ Build-ID semantics; bumps with every release of the agent-config bundle.
10
+ - **skill-set signature** — SHA-256 hex (first 12 chars) over the joined
11
+ `PromptCache._signature` + `ResourceCache._signature` tuples
12
+ (`(uri, mtime)` pairs, already sorted). Content fingerprint, not a
13
+ version — auto-derived, never hand-edited.
14
+
15
+ Wire-surface caveat: the MCP SDK constructs `serverInfo.Implementation`
16
+ internally with a fixed field set (`name`, `version`, `websiteUrl`,
17
+ `icons`), so the package version and skill-set signature cannot be
18
+ attached to `serverInfo._meta` without subclassing the session.
19
+ Stderr is the canonical surface in Phase 6; a wire-surface lift can
20
+ follow once the SDK supports it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Sequence
28
+
29
+ Signature = Sequence[tuple[str, float]]
30
+
31
+
32
+ def read_package_version(root: Path) -> str:
33
+ """Return `package.json::version`, or `"unknown"` if unreadable."""
34
+ path = root / "package.json"
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8"))
37
+ except (OSError, ValueError):
38
+ return "unknown"
39
+ version = data.get("version")
40
+ if not isinstance(version, str) or not version:
41
+ return "unknown"
42
+ return version
43
+
44
+
45
+ def compute_skill_set_signature(*signatures: Signature) -> str:
46
+ """SHA-256 hex (12 chars) over the concatenated `(uri, mtime)` tuples.
47
+
48
+ Deterministic across processes for identical inputs. Changes when
49
+ any tracked file's path-set or mtime changes. Inputs are taken as-is
50
+ (callers pass already-sorted cache signatures); the hash is taken
51
+ over the joined repr to keep the framing unambiguous.
52
+ """
53
+ hasher = hashlib.sha256()
54
+ for sig in signatures:
55
+ for uri, mtime in sig:
56
+ hasher.update(uri.encode("utf-8"))
57
+ hasher.update(b"\x00")
58
+ hasher.update(f"{mtime:.6f}".encode("ascii"))
59
+ hasher.update(b"\x1e") # record separator
60
+ hasher.update(b"\x1d") # group separator between caches
61
+ return hasher.hexdigest()[:12]
62
+
63
+
64
+ def boot_log_line(
65
+ *,
66
+ server_version: str,
67
+ package_version: str,
68
+ skill_set_signature: str,
69
+ ) -> str:
70
+ """Single stderr line surfacing all three identity values at boot."""
71
+ return (
72
+ f"mcp-server: identity serverVersion={server_version} "
73
+ f"packageVersion={package_version} "
74
+ f"skillSetSignature={skill_set_signature}"
75
+ )
@@ -0,0 +1,305 @@
1
+ """Prompt loader — reads `.agent-src/skills/` and `.agent-src/commands/`.
2
+
3
+ Phase 1 (A4) exposed 5 hand-picked, stack-agnostic skills. Phase 2
4
+ (B1–B3) extends to the full set: every `SKILL.md` under
5
+ `.agent-src/skills/` plus every `*.md` under `.agent-src/commands/`.
6
+
7
+ Frontmatter `name` + `description` map to MCP prompt metadata; the
8
+ body (frontmatter stripped) is the prompt content. Frontmatter
9
+ `source:` is forwarded verbatim into the MCP `_meta` field so clients
10
+ can filter package-vs-project entries on the wire.
11
+
12
+ Project-overrides resolution: `.agent-src/` is the already-merged
13
+ view at sync time; the runtime loader does not re-merge.
14
+
15
+ Frontmatter validation (B3): entries missing `name` or `description`
16
+ are skipped and surfaced in the second tuple element of `scan_*`
17
+ helpers (caller decides whether to log).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Literal
24
+
25
+ # Phase 1 hand-picked skills — kept for the Phase-1 entrypoint
26
+ # (`load_phase_1_prompts`) and as the contract-test fixture set. The
27
+ # roadmap originally listed `verify-before-complete`, which lives as
28
+ # a rule, not a skill; its skill counterpart is
29
+ # `verify-completion-evidence` (same evidence-gate obligation).
30
+ PHASE_1_SKILLS: tuple[str, ...] = (
31
+ "verify-completion-evidence",
32
+ "systematic-debugging",
33
+ "test-driven-development",
34
+ "refine-ticket",
35
+ "conventional-commits-writing",
36
+ )
37
+
38
+ PromptKind = Literal["skill", "command"]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class SkillPrompt:
43
+ """Resolved Markdown prompt ready for MCP exposure.
44
+
45
+ `kind` distinguishes the two Phase-2 source families. The name
46
+ field is the frontmatter `name:` value verbatim (e.g.
47
+ `test-driven-development` or `research:report`); MCP wire names
48
+ are derived in `to_mcp_prompt_meta` with `kind`-aware prefixing.
49
+ """
50
+
51
+ name: str
52
+ description: str
53
+ body: str
54
+ source: str
55
+ kind: PromptKind = "skill"
56
+
57
+
58
+ def _project_root() -> Path:
59
+ """Walk up from this file to the repo root (parent of `scripts/`)."""
60
+ return Path(__file__).resolve().parent.parent.parent
61
+
62
+
63
+ def _strip_frontmatter(text: str) -> tuple[dict[str, str], str]:
64
+ """Split a Markdown file with `---` frontmatter into (meta, body).
65
+
66
+ Tiny YAML-ish parser sufficient for our flat key/value frontmatter.
67
+ Avoids a `pyyaml` dependency for Phase 1; the frontmatter shape is
68
+ enforced by `task lint-skills` upstream.
69
+ """
70
+ if not text.startswith("---\n"):
71
+ return {}, text
72
+ try:
73
+ _, fm, body = text.split("---\n", 2)
74
+ except ValueError:
75
+ return {}, text
76
+ meta: dict[str, str] = {}
77
+ for line in fm.splitlines():
78
+ if not line.strip() or line.startswith("#"):
79
+ continue
80
+ if ":" not in line:
81
+ continue
82
+ key, _, value = line.partition(":")
83
+ meta[key.strip()] = value.strip().strip('"').strip("'")
84
+ return meta, body.lstrip("\n")
85
+
86
+
87
+ def load_skill(name: str, root: Path | None = None) -> SkillPrompt:
88
+ """Load a single skill by name. Raises FileNotFoundError if missing."""
89
+ base = root or _project_root()
90
+ path = base / ".agent-src" / "skills" / name / "SKILL.md"
91
+ if not path.exists():
92
+ raise FileNotFoundError(f"SKILL.md not found: {path}")
93
+ return _load_file(path, kind="skill", fallback_name=name)
94
+
95
+
96
+ def _load_file(
97
+ path: Path,
98
+ *,
99
+ kind: PromptKind,
100
+ fallback_name: str,
101
+ ) -> SkillPrompt:
102
+ text = path.read_text(encoding="utf-8")
103
+ meta, body = _strip_frontmatter(text)
104
+ return SkillPrompt(
105
+ name=meta.get("name", fallback_name),
106
+ description=meta.get("description", "").strip(),
107
+ body=body.rstrip() + "\n",
108
+ source=meta.get("source", "package"),
109
+ kind=kind,
110
+ )
111
+
112
+
113
+ def load_phase_1_prompts(root: Path | None = None) -> list[SkillPrompt]:
114
+ """Load every skill listed in PHASE_1_SKILLS.
115
+
116
+ Kept for backward compatibility with Phase-1 tests and as a
117
+ minimal smoke path. Production entrypoint is `load_all_prompts`.
118
+ """
119
+ prompts: list[SkillPrompt] = []
120
+ errors: list[str] = []
121
+ for name in PHASE_1_SKILLS:
122
+ try:
123
+ prompts.append(load_skill(name, root=root))
124
+ except FileNotFoundError as exc:
125
+ errors.append(str(exc))
126
+ if errors and not prompts:
127
+ raise RuntimeError(
128
+ "No Phase 1 skills loaded. Errors:\n - "
129
+ + "\n - ".join(errors)
130
+ )
131
+ return prompts
132
+
133
+
134
+ def scan_skills(
135
+ root: Path | None = None,
136
+ ) -> tuple[list[SkillPrompt], list[str]]:
137
+ """Enumerate every `.agent-src/skills/*/SKILL.md`.
138
+
139
+ Returns `(prompts, errors)`. Files missing `name` or
140
+ `description` frontmatter are skipped with a one-line reason in
141
+ `errors`. Files that fail to read are surfaced the same way.
142
+ """
143
+ base = root or _project_root()
144
+ skills_root = base / ".agent-src" / "skills"
145
+ prompts: list[SkillPrompt] = []
146
+ errors: list[str] = []
147
+ if not skills_root.is_dir():
148
+ return prompts, errors
149
+ for skill_dir in sorted(skills_root.iterdir()):
150
+ path = skill_dir / "SKILL.md"
151
+ if not path.is_file():
152
+ continue
153
+ try:
154
+ prompt = _load_file(path, kind="skill", fallback_name=skill_dir.name)
155
+ except OSError as exc:
156
+ errors.append(f"{path}: read failed ({exc})")
157
+ continue
158
+ if not prompt.description:
159
+ errors.append(f"{path}: missing frontmatter description")
160
+ continue
161
+ prompts.append(prompt)
162
+ return prompts, errors
163
+
164
+
165
+ def scan_commands(
166
+ root: Path | None = None,
167
+ ) -> tuple[list[SkillPrompt], list[str]]:
168
+ """Enumerate every `.agent-src/commands/**/*.md`.
169
+
170
+ Same return contract as `scan_skills`. Command frontmatter `name:`
171
+ values use `:` as cluster/sub separator (e.g. `research:report`);
172
+ the value is preserved verbatim and translated to MCP wire form
173
+ in `to_mcp_prompt_meta`.
174
+ """
175
+ base = root or _project_root()
176
+ cmd_root = base / ".agent-src" / "commands"
177
+ prompts: list[SkillPrompt] = []
178
+ errors: list[str] = []
179
+ if not cmd_root.is_dir():
180
+ return prompts, errors
181
+ for path in sorted(cmd_root.rglob("*.md")):
182
+ if not path.is_file():
183
+ continue
184
+ rel = path.relative_to(cmd_root).with_suffix("")
185
+ fallback = str(rel).replace("/", ":")
186
+ try:
187
+ prompt = _load_file(path, kind="command", fallback_name=fallback)
188
+ except OSError as exc:
189
+ errors.append(f"{path}: read failed ({exc})")
190
+ continue
191
+ if not prompt.description:
192
+ errors.append(f"{path}: missing frontmatter description")
193
+ continue
194
+ prompts.append(prompt)
195
+ return prompts, errors
196
+
197
+
198
+ def load_all_prompts(
199
+ root: Path | None = None,
200
+ ) -> tuple[list[SkillPrompt], list[str]]:
201
+ """Phase 2 entrypoint — all skills + all commands.
202
+
203
+ Result is sorted by MCP wire name (deterministic across boots)
204
+ and de-duplicated: if the same wire name appears in both lists
205
+ (should not happen in a clean tree) the skill copy wins and the
206
+ duplicate is reported in `errors`.
207
+ """
208
+ skills, skill_errors = scan_skills(root)
209
+ commands, command_errors = scan_commands(root)
210
+ errors = list(skill_errors) + list(command_errors)
211
+ seen: dict[str, SkillPrompt] = {}
212
+ for prompt in skills + commands:
213
+ wire = to_mcp_prompt_meta(prompt)["name"]
214
+ if wire in seen:
215
+ errors.append(
216
+ f"duplicate MCP name {wire!r}: keeping {seen[wire].kind}, "
217
+ f"skipping {prompt.kind}"
218
+ )
219
+ continue
220
+ seen[wire] = prompt
221
+ merged = sorted(seen.values(), key=lambda p: to_mcp_prompt_meta(p)["name"])
222
+ return merged, errors
223
+
224
+
225
+ def to_mcp_prompt_meta(prompt: SkillPrompt) -> dict[str, Any]:
226
+ """Project a SkillPrompt into MCP `Prompt` constructor kwargs.
227
+
228
+ Wire-name shape:
229
+ skill.<frontmatter-name> (skills)
230
+ command.<frontmatter-name with : → .> (commands)
231
+ Colons in command names (e.g. `research:report`) become `.` so
232
+ the wire identifier is a single-segment dotted path that survives
233
+ every MCP client we have tested.
234
+ """
235
+ if prompt.kind == "command":
236
+ wire = f"command.{prompt.name.replace(':', '.')}"
237
+ else:
238
+ wire = f"skill.{prompt.name}"
239
+ return {
240
+ "name": wire,
241
+ "title": prompt.name,
242
+ "description": prompt.description,
243
+ "arguments": [],
244
+ "_meta": {"source": prompt.source, "kind": prompt.kind},
245
+ }
246
+
247
+
248
+ class PromptCache:
249
+ """In-memory cache with mtime-based invalidation (B5 hot-reload).
250
+
251
+ `get()` re-scans `.agent-src/skills/` and `.agent-src/commands/`
252
+ when any tracked SKILL.md / command file has changed mtime since
253
+ the previous scan. New / removed files also trigger a refresh
254
+ (the set of tracked paths is part of the staleness key).
255
+
256
+ The cache is intentionally simple: no inotify, no debounce, no
257
+ background thread. The server calls `get()` once per
258
+ `prompts/list` request, which is the natural rate-limiter.
259
+ """
260
+
261
+ def __init__(self, root: Path | None = None) -> None:
262
+ self._root = root or _project_root()
263
+ self._prompts: list[SkillPrompt] = []
264
+ self._errors: list[str] = []
265
+ self._signature: tuple[tuple[str, float], ...] = ()
266
+ self._index: dict[str, SkillPrompt] = {}
267
+
268
+ def _current_signature(self) -> tuple[tuple[str, float], ...]:
269
+ entries: list[tuple[str, float]] = []
270
+ skills_root = self._root / ".agent-src" / "skills"
271
+ if skills_root.is_dir():
272
+ for skill_dir in sorted(skills_root.iterdir()):
273
+ path = skill_dir / "SKILL.md"
274
+ if path.is_file():
275
+ entries.append((str(path), path.stat().st_mtime))
276
+ cmd_root = self._root / ".agent-src" / "commands"
277
+ if cmd_root.is_dir():
278
+ for path in sorted(cmd_root.rglob("*.md")):
279
+ if path.is_file():
280
+ entries.append((str(path), path.stat().st_mtime))
281
+ return tuple(entries)
282
+
283
+ def _refresh(self) -> None:
284
+ prompts, errors = load_all_prompts(self._root)
285
+ self._prompts = prompts
286
+ self._errors = errors
287
+ self._index = {to_mcp_prompt_meta(p)["name"]: p for p in prompts}
288
+
289
+ def get(self) -> tuple[list[SkillPrompt], list[str]]:
290
+ """Return cached prompts + errors, refreshing on mtime change."""
291
+ signature = self._current_signature()
292
+ if signature != self._signature:
293
+ self._signature = signature
294
+ self._refresh()
295
+ return self._prompts, self._errors
296
+
297
+ @property
298
+ def signature(self) -> tuple[tuple[str, float], ...]:
299
+ """Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
300
+ return self._signature
301
+
302
+ def lookup(self, wire_name: str) -> SkillPrompt | None:
303
+ """Resolve an MCP wire name to its SkillPrompt, refreshing first."""
304
+ self.get()
305
+ return self._index.get(wire_name)
@@ -0,0 +1,4 @@
1
+ # MCP server runtime — pinned to the versions the test suite ran against.
2
+ # Mirror upstream when bumping; keep this lean (no dev/test deps).
3
+ mcp==1.27.1
4
+ PyYAML==6.0.3