@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.
@@ -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)
@@ -0,0 +1,269 @@
1
+ """MCP Server — registers `prompts/*` + `resources/*` over stdio.
2
+
3
+ Phase 3 boundary (A0 Hard Contract still holds): read-only. No
4
+ `tools/*`, no filesystem writes. New in Phase 3:
5
+
6
+ - **C1/C2** `resources/list` + `resources/read` for rules,
7
+ guidelines, contexts via `ResourceCache`.
8
+ - **C3** cursor-based pagination on `resources/list` (same shape as
9
+ prompts/list).
10
+ - **C4** hot-reload — `ResourceCache` re-scans on mtime change before
11
+ each `resources/list` response.
12
+
13
+ Carried over from Phase 2:
14
+
15
+ - **B1/B2** full skills + commands coverage via `PromptCache`.
16
+ - **B4** cursor-based pagination on `prompts/list`.
17
+ - **B5** hot-reload — `PromptCache` re-scans on mtime change before
18
+ each `prompts/list` response.
19
+
20
+ `build_server` still accepts a plain `list[SkillPrompt]` so the
21
+ Phase-1 contract tests keep passing without touching their fixtures.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import sys
27
+ from typing import Callable, Iterable, Union
28
+
29
+ import mcp.types as types
30
+ from mcp.server import NotificationOptions, Server
31
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
32
+ from mcp.server.models import InitializationOptions
33
+ from mcp.server.stdio import stdio_server
34
+ from pydantic import AnyUrl
35
+
36
+ from . import SERVER_NAME, __version__
37
+ from .metadata import (
38
+ boot_log_line as identity_boot_log_line,
39
+ compute_skill_set_signature,
40
+ read_package_version,
41
+ )
42
+ from .prompts import (
43
+ PromptCache,
44
+ SkillPrompt,
45
+ _project_root,
46
+ to_mcp_prompt_meta,
47
+ )
48
+ from .resources import (
49
+ Resource,
50
+ ResourceCache,
51
+ to_mcp_resource_meta,
52
+ )
53
+ from .tools import (
54
+ ToolCache,
55
+ boot_log_line as tools_boot_log_line,
56
+ to_mcp_tool_meta,
57
+ )
58
+
59
+ # Page size for cursor-based pagination. Conservative default —
60
+ # Claude Desktop and Zed handle larger pages, but small pages keep
61
+ # wire payloads under typical stdio frame limits.
62
+ DEFAULT_PAGE_SIZE = 100
63
+
64
+ PromptsSource = Union[
65
+ list[SkillPrompt],
66
+ Callable[[], tuple[list[SkillPrompt], list[str]]],
67
+ ]
68
+ ResourcesSource = Union[
69
+ list[Resource],
70
+ Callable[[], tuple[list[Resource], list[str]]],
71
+ ]
72
+
73
+
74
+ def _make_loader(
75
+ source: PromptsSource,
76
+ ) -> Callable[[], tuple[list[SkillPrompt], list[str]]]:
77
+ """Normalise to a callable returning `(prompts, errors)`."""
78
+ if callable(source):
79
+ return source
80
+ static = list(source)
81
+ return lambda: (static, [])
82
+
83
+
84
+ def _make_resource_loader(
85
+ source: ResourcesSource,
86
+ ) -> Callable[[], tuple[list[Resource], list[str]]]:
87
+ """Normalise to a callable returning `(resources, errors)`."""
88
+ if callable(source):
89
+ return source
90
+ static = list(source)
91
+ return lambda: (static, [])
92
+
93
+
94
+ def _decode_cursor(cursor: str | None, total: int) -> int:
95
+ """Cursor is a stringified integer offset. Invalid → start at 0."""
96
+ if cursor is None:
97
+ return 0
98
+ try:
99
+ offset = int(cursor)
100
+ except (TypeError, ValueError):
101
+ return 0
102
+ if offset < 0 or offset > total:
103
+ return 0
104
+ return offset
105
+
106
+
107
+ def build_server(
108
+ source: PromptsSource,
109
+ *,
110
+ page_size: int = DEFAULT_PAGE_SIZE,
111
+ resources: ResourcesSource | None = None,
112
+ tools: ToolCache | None = None,
113
+ ) -> Server:
114
+ """Construct the MCP Server with the new-style paginated handlers.
115
+
116
+ Pure factory — no I/O. Tests pass a static list; the stdio
117
+ entrypoint passes a `PromptCache.get` callable for hot-reload.
118
+ When `resources` is omitted, resources/* handlers are still
119
+ registered but return an empty list — clients can probe the
120
+ capability without seeing a protocol error.
121
+ """
122
+ loader = _make_loader(source)
123
+ resource_loader = _make_resource_loader(resources or [])
124
+ server: Server = Server(
125
+ name=SERVER_NAME,
126
+ version=__version__,
127
+ instructions=(
128
+ "agent-config MCP server (Phase 3, experimental). Exposes "
129
+ "all skills + commands as instructional prompts, plus "
130
+ "rules + guidelines + contexts as read-only resources."
131
+ ),
132
+ )
133
+
134
+ @server.list_prompts()
135
+ async def _list_prompts(
136
+ req: types.ListPromptsRequest,
137
+ ) -> types.ListPromptsResult:
138
+ prompts, _errors = loader()
139
+ cursor = req.params.cursor if req.params else None
140
+ start = _decode_cursor(cursor, len(prompts))
141
+ end = start + page_size
142
+ page = prompts[start:end]
143
+ next_cursor: str | None = str(end) if end < len(prompts) else None
144
+ return types.ListPromptsResult(
145
+ prompts=[types.Prompt(**to_mcp_prompt_meta(p)) for p in page],
146
+ nextCursor=next_cursor,
147
+ )
148
+
149
+ @server.get_prompt()
150
+ async def _get_prompt(
151
+ name: str,
152
+ arguments: dict[str, str] | None = None,
153
+ ) -> types.GetPromptResult:
154
+ prompts, _errors = loader()
155
+ index = {to_mcp_prompt_meta(p)["name"]: p for p in prompts}
156
+ prompt = index.get(name)
157
+ if prompt is None:
158
+ raise ValueError(f"Unknown prompt: {name}")
159
+ return types.GetPromptResult(
160
+ description=prompt.description,
161
+ messages=[
162
+ types.PromptMessage(
163
+ role="user",
164
+ content=types.TextContent(
165
+ type="text",
166
+ text=prompt.body,
167
+ ),
168
+ ),
169
+ ],
170
+ )
171
+
172
+ @server.list_resources()
173
+ async def _list_resources(
174
+ req: types.ListResourcesRequest,
175
+ ) -> types.ListResourcesResult:
176
+ items, _errors = resource_loader()
177
+ cursor = req.params.cursor if req.params else None
178
+ start = _decode_cursor(cursor, len(items))
179
+ end = start + page_size
180
+ page = items[start:end]
181
+ next_cursor: str | None = str(end) if end < len(items) else None
182
+ return types.ListResourcesResult(
183
+ resources=[types.Resource(**to_mcp_resource_meta(r)) for r in page],
184
+ nextCursor=next_cursor,
185
+ )
186
+
187
+ @server.read_resource()
188
+ async def _read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
189
+ items, _errors = resource_loader()
190
+ index = {r.uri: r for r in items}
191
+ resource = index.get(str(uri))
192
+ if resource is None:
193
+ raise ValueError(f"Unknown resource: {uri}")
194
+ return [
195
+ ReadResourceContents(content=resource.body, mime_type=resource.mime_type),
196
+ ]
197
+
198
+ if tools is not None:
199
+ tool_cache = tools
200
+
201
+ @server.list_tools()
202
+ async def _list_tools() -> list[types.Tool]:
203
+ return [types.Tool(**to_mcp_tool_meta(t)) for t in tool_cache.list()]
204
+
205
+ @server.call_tool()
206
+ async def _call_tool(
207
+ name: str,
208
+ arguments: dict[str, object],
209
+ ) -> dict[str, object]:
210
+ return await tool_cache.dispatch(name, arguments or {})
211
+
212
+ return server
213
+
214
+
215
+ async def run_stdio() -> None:
216
+ """Entrypoint — load prompts + resources via caches, run server over stdio."""
217
+ cache = PromptCache()
218
+ prompts, errors = cache.get()
219
+ for line in errors:
220
+ print(f"mcp-server: warn: {line}", file=sys.stderr)
221
+ print(
222
+ f"mcp-server: loaded {len(prompts)} prompts "
223
+ f"({len(errors)} warnings)",
224
+ file=sys.stderr,
225
+ )
226
+ resource_cache = ResourceCache()
227
+ resources_list, resource_errors = resource_cache.get()
228
+ for line in resource_errors:
229
+ print(f"mcp-server: warn: {line}", file=sys.stderr)
230
+ print(
231
+ f"mcp-server: loaded {len(resources_list)} resources "
232
+ f"({len(resource_errors)} warnings)",
233
+ file=sys.stderr,
234
+ )
235
+ tool_cache = ToolCache()
236
+ print(tools_boot_log_line(tool_cache), file=sys.stderr)
237
+ package_version = read_package_version(_project_root())
238
+ skill_set_signature = compute_skill_set_signature(
239
+ cache.signature,
240
+ resource_cache.signature,
241
+ )
242
+ print(
243
+ identity_boot_log_line(
244
+ server_version=__version__,
245
+ package_version=package_version,
246
+ skill_set_signature=skill_set_signature,
247
+ ),
248
+ file=sys.stderr,
249
+ )
250
+ server = build_server(
251
+ cache.get,
252
+ resources=resource_cache.get,
253
+ tools=tool_cache,
254
+ )
255
+ init_options = InitializationOptions(
256
+ server_name=SERVER_NAME,
257
+ server_version=__version__,
258
+ capabilities=server.get_capabilities(
259
+ notification_options=NotificationOptions(),
260
+ experimental_capabilities={},
261
+ ),
262
+ )
263
+ async with stdio_server() as (read_stream, write_stream):
264
+ await server.run(read_stream, write_stream, init_options)
265
+
266
+
267
+ def main() -> None:
268
+ """Sync wrapper for `python -m scripts.mcp_server`."""
269
+ asyncio.run(run_stdio())