@event4u/agent-config 1.36.0 → 1.37.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.
- package/.agent-src/contexts/authority/scope-mechanics.md +20 -2
- package/.agent-src/rules/scope-control.md +3 -5
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/docs/contracts/mcp-phase-1-scope.md +190 -0
- package/docs/mcp-server.md +156 -0
- package/docs/setup/mcp-server-docker.md +97 -0
- package/package.json +1 -1
- package/scripts/agent-config +29 -0
- package/scripts/mcp_server/__init__.py +13 -0
- package/scripts/mcp_server/__main__.py +12 -0
- package/scripts/mcp_server/metadata.py +75 -0
- package/scripts/mcp_server/prompts.py +305 -0
- package/scripts/mcp_server/requirements.txt +4 -0
- package/scripts/mcp_server/resources.py +201 -0
- package/scripts/mcp_server/server.py +269 -0
- package/scripts/mcp_server/tools.py +363 -0
- package/scripts/mcp_setup.sh +87 -0
- package/scripts/skill_linter.py +7 -0
|
@@ -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,201 @@
|
|
|
1
|
+
"""Resource loader — exposes rules, guidelines, contexts as MCP resources.
|
|
2
|
+
|
|
3
|
+
Phase 3 (C1–C4) extends the read-only MCP surface from prompts (skills
|
|
4
|
+
+ commands) to read-only **resources** for the governance layer:
|
|
5
|
+
|
|
6
|
+
- `rule://<basename>` — `.agent-src/rules/*.md`
|
|
7
|
+
- `guideline://<relpath-no-ext>` — `docs/guidelines/**/*.md`
|
|
8
|
+
- `context://<relpath-no-ext>` — `.agent-src/contexts/**/*.md`
|
|
9
|
+
|
|
10
|
+
All three are served with `mimeType=text/markdown`. The merge-at-sync
|
|
11
|
+
contract is the same as for prompts: `.agent-src/` is already the
|
|
12
|
+
package + project merged view; this loader does not re-merge.
|
|
13
|
+
|
|
14
|
+
Description resolution: frontmatter `description:` wins, else the
|
|
15
|
+
first H1 line (`# Title`) is used as a title-style fallback, else the
|
|
16
|
+
filename-derived stem.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from .prompts import _project_root, _strip_frontmatter
|
|
26
|
+
|
|
27
|
+
ResourceKind = Literal["rule", "guideline", "context"]
|
|
28
|
+
MIME_MARKDOWN = "text/markdown"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Resource:
|
|
33
|
+
"""Resolved Markdown asset ready for MCP exposure."""
|
|
34
|
+
|
|
35
|
+
uri: str
|
|
36
|
+
name: str
|
|
37
|
+
description: str
|
|
38
|
+
body: str
|
|
39
|
+
source: str = "package"
|
|
40
|
+
mime_type: str = MIME_MARKDOWN
|
|
41
|
+
kind: ResourceKind = "rule"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _derive_description(meta: dict[str, str], body: str, fallback: str) -> str:
|
|
48
|
+
desc = meta.get("description", "").strip()
|
|
49
|
+
if desc:
|
|
50
|
+
return desc
|
|
51
|
+
match = _H1_RE.search(body)
|
|
52
|
+
if match:
|
|
53
|
+
return match.group(1).strip()
|
|
54
|
+
return fallback
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load(path: Path, *, uri: str, fallback_name: str, kind: ResourceKind) -> Resource:
|
|
58
|
+
text = path.read_text(encoding="utf-8")
|
|
59
|
+
meta, body = _strip_frontmatter(text)
|
|
60
|
+
name = meta.get("name", fallback_name).strip() or fallback_name
|
|
61
|
+
description = _derive_description(meta, body, fallback_name)
|
|
62
|
+
return Resource(
|
|
63
|
+
uri=uri,
|
|
64
|
+
name=name,
|
|
65
|
+
description=description,
|
|
66
|
+
body=text.rstrip() + "\n",
|
|
67
|
+
source=meta.get("source", "package"),
|
|
68
|
+
kind=kind,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_rules(root: Path | None = None) -> tuple[list[Resource], list[str]]:
|
|
73
|
+
base = root or _project_root()
|
|
74
|
+
rules_root = base / ".agent-src" / "rules"
|
|
75
|
+
out: list[Resource] = []
|
|
76
|
+
errors: list[str] = []
|
|
77
|
+
if not rules_root.is_dir():
|
|
78
|
+
return out, errors
|
|
79
|
+
for path in sorted(rules_root.glob("*.md")):
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
continue
|
|
82
|
+
stem = path.stem
|
|
83
|
+
try:
|
|
84
|
+
out.append(_load(path, uri=f"rule://{stem}", fallback_name=stem, kind="rule"))
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
errors.append(f"{path}: read failed ({exc})")
|
|
87
|
+
return out, errors
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scan_tree(
|
|
91
|
+
root: Path,
|
|
92
|
+
*,
|
|
93
|
+
scheme: str,
|
|
94
|
+
kind: ResourceKind,
|
|
95
|
+
) -> tuple[list[Resource], list[str]]:
|
|
96
|
+
out: list[Resource] = []
|
|
97
|
+
errors: list[str] = []
|
|
98
|
+
if not root.is_dir():
|
|
99
|
+
return out, errors
|
|
100
|
+
for path in sorted(root.rglob("*.md")):
|
|
101
|
+
if not path.is_file():
|
|
102
|
+
continue
|
|
103
|
+
rel = path.relative_to(root).with_suffix("")
|
|
104
|
+
slug = str(rel).replace("\\", "/")
|
|
105
|
+
try:
|
|
106
|
+
out.append(
|
|
107
|
+
_load(path, uri=f"{scheme}://{slug}", fallback_name=slug, kind=kind)
|
|
108
|
+
)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
errors.append(f"{path}: read failed ({exc})")
|
|
111
|
+
return out, errors
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def scan_guidelines(root: Path | None = None) -> tuple[list[Resource], list[str]]:
|
|
115
|
+
base = root or _project_root()
|
|
116
|
+
return _scan_tree(base / "docs" / "guidelines", scheme="guideline", kind="guideline")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan_contexts(root: Path | None = None) -> tuple[list[Resource], list[str]]:
|
|
120
|
+
base = root or _project_root()
|
|
121
|
+
return _scan_tree(base / ".agent-src" / "contexts", scheme="context", kind="context")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_all_resources(
|
|
125
|
+
root: Path | None = None,
|
|
126
|
+
) -> tuple[list[Resource], list[str]]:
|
|
127
|
+
"""Phase 3 entrypoint — every rule, guideline, context."""
|
|
128
|
+
rules, e1 = scan_rules(root)
|
|
129
|
+
guidelines, e2 = scan_guidelines(root)
|
|
130
|
+
contexts, e3 = scan_contexts(root)
|
|
131
|
+
errors = list(e1) + list(e2) + list(e3)
|
|
132
|
+
seen: dict[str, Resource] = {}
|
|
133
|
+
for r in rules + guidelines + contexts:
|
|
134
|
+
if r.uri in seen:
|
|
135
|
+
errors.append(f"duplicate URI {r.uri!r}: keeping first")
|
|
136
|
+
continue
|
|
137
|
+
seen[r.uri] = r
|
|
138
|
+
merged = sorted(seen.values(), key=lambda r: r.uri)
|
|
139
|
+
return merged, errors
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def to_mcp_resource_meta(resource: Resource) -> dict[str, object]:
|
|
143
|
+
"""Project a Resource into MCP `Resource` constructor kwargs."""
|
|
144
|
+
return {
|
|
145
|
+
"uri": resource.uri,
|
|
146
|
+
"name": resource.name,
|
|
147
|
+
"description": resource.description,
|
|
148
|
+
"mimeType": resource.mime_type,
|
|
149
|
+
"_meta": {"source": resource.source, "kind": resource.kind},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ResourceCache:
|
|
154
|
+
"""In-memory cache with mtime-based invalidation (mirrors `PromptCache`).
|
|
155
|
+
|
|
156
|
+
Re-scans rules / guidelines / contexts on each `get()` when the set
|
|
157
|
+
of tracked files or any mtime has changed. No watcher dependency.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
161
|
+
self._root = root or _project_root()
|
|
162
|
+
self._resources: list[Resource] = []
|
|
163
|
+
self._errors: list[str] = []
|
|
164
|
+
self._signature: tuple[tuple[str, float], ...] = ()
|
|
165
|
+
self._index: dict[str, Resource] = {}
|
|
166
|
+
|
|
167
|
+
def _current_signature(self) -> tuple[tuple[str, float], ...]:
|
|
168
|
+
entries: list[tuple[str, float]] = []
|
|
169
|
+
for sub in (
|
|
170
|
+
self._root / ".agent-src" / "rules",
|
|
171
|
+
self._root / "docs" / "guidelines",
|
|
172
|
+
self._root / ".agent-src" / "contexts",
|
|
173
|
+
):
|
|
174
|
+
if not sub.is_dir():
|
|
175
|
+
continue
|
|
176
|
+
for path in sorted(sub.rglob("*.md")):
|
|
177
|
+
if path.is_file():
|
|
178
|
+
entries.append((str(path), path.stat().st_mtime))
|
|
179
|
+
return tuple(entries)
|
|
180
|
+
|
|
181
|
+
def _refresh(self) -> None:
|
|
182
|
+
resources, errors = load_all_resources(self._root)
|
|
183
|
+
self._resources = resources
|
|
184
|
+
self._errors = errors
|
|
185
|
+
self._index = {r.uri: r for r in resources}
|
|
186
|
+
|
|
187
|
+
def get(self) -> tuple[list[Resource], list[str]]:
|
|
188
|
+
signature = self._current_signature()
|
|
189
|
+
if signature != self._signature:
|
|
190
|
+
self._signature = signature
|
|
191
|
+
self._refresh()
|
|
192
|
+
return self._resources, self._errors
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def signature(self) -> tuple[tuple[str, float], ...]:
|
|
196
|
+
"""Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
|
|
197
|
+
return self._signature
|
|
198
|
+
|
|
199
|
+
def lookup(self, uri: str) -> Resource | None:
|
|
200
|
+
self.get()
|
|
201
|
+
return self._index.get(uri)
|