@event4u/agent-config 1.36.1 → 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.
@@ -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())
@@ -0,0 +1,363 @@
1
+ """MCP Server — Phase 4 tools layer.
2
+
3
+ A0 contract amendment: Phase 4 lifts the read-only line for exactly the
4
+ tools listed in ``ALLOWLIST`` (`lint_skills` + `chat_history_append`).
5
+ Every other tool name is unreachable — `tools/call` against an unknown
6
+ name raises ``ValueError``, not just "unlisted".
7
+
8
+ Path-scoping is mandatory for any tool that writes: the resolved target
9
+ path must stay under ``<consumer_root>`` and within the allowlist of
10
+ filenames (`.agent-chat-history`, `agents/.agent-chat-history`). Escape
11
+ attempts surface as ``ValueError`` before the underlying writer runs.
12
+
13
+ This module deliberately does **not** import the ``subprocess`` module
14
+ or ``os``-level shell-execution helpers directly. It imports project
15
+ modules (``skill_linter``, ``chat_history``) that internally use them;
16
+ the wire surface exposes no shell execution.
17
+
18
+ Tools return ``dict`` from their handlers — the SDK wraps that in a
19
+ ``TextContent`` block with the JSON-serialized payload, so MCP clients
20
+ can render structured output.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Awaitable, Callable
28
+
29
+ # Allowlisted directories (relative to consumer_root) where tool writes
30
+ # are permitted. ``chat_history_append`` resolves its path through this
31
+ # guard before the underlying writer touches the filesystem.
32
+ _ALLOWED_WRITE_REL_PATHS: frozenset[str] = frozenset(
33
+ {
34
+ "agents/.agent-chat-history",
35
+ ".agent-chat-history",
36
+ }
37
+ )
38
+
39
+
40
+ ToolHandler = Callable[[dict[str, Any], Path], Awaitable[dict[str, Any]]]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class BuiltinTool:
45
+ """Static registration record for an allowlisted MCP tool.
46
+
47
+ ``input_schema`` is a JSON-Schema dict the SDK validates against on
48
+ each ``tools/call``. ``handler`` is an async function that receives
49
+ the validated arguments + the resolved ``consumer_root`` Path.
50
+ """
51
+
52
+ name: str
53
+ description: str
54
+ input_schema: dict[str, Any]
55
+ handler: ToolHandler
56
+
57
+
58
+ def _resolve_consumer_root(override: Path | None = None) -> Path:
59
+ """Pick the consumer-project root.
60
+
61
+ Default: the current working directory. Tests pass an explicit
62
+ override; the stdio entrypoint relies on the CWD set by the
63
+ ``./agent-config mcp:run`` wrapper.
64
+ """
65
+ if override is not None:
66
+ return override.resolve()
67
+ return Path.cwd().resolve()
68
+
69
+
70
+ def _validate_in_tree_path(raw: str | None, consumer_root: Path) -> Path:
71
+ """Resolve ``raw`` under ``consumer_root`` and assert it stays in tree.
72
+
73
+ Returns the resolved target path. Raises ``ValueError`` when the
74
+ path escapes the root or is not in the write allowlist. ``None``
75
+ falls back to the default chat-history location.
76
+ """
77
+ root = consumer_root.resolve()
78
+ if raw is None or raw == "":
79
+ target = root / "agents" / ".agent-chat-history"
80
+ else:
81
+ candidate = Path(raw)
82
+ if candidate.is_absolute():
83
+ target = candidate.resolve()
84
+ else:
85
+ target = (root / candidate).resolve()
86
+ try:
87
+ rel = target.relative_to(root)
88
+ except ValueError as exc:
89
+ raise ValueError(
90
+ f"path escapes consumer_root: {target} not under {root}"
91
+ ) from exc
92
+ rel_str = rel.as_posix()
93
+ if rel_str not in _ALLOWED_WRITE_REL_PATHS:
94
+ raise ValueError(
95
+ f"path not in write allowlist: {rel_str!r} "
96
+ f"(allowed: {sorted(_ALLOWED_WRITE_REL_PATHS)})"
97
+ )
98
+ return target
99
+
100
+
101
+ async def _lint_skills_handler(
102
+ arguments: dict[str, Any],
103
+ consumer_root: Path,
104
+ ) -> dict[str, Any]:
105
+ """D2 — read-only wrapper around ``skill_linter.lint_file``.
106
+
107
+ Arguments:
108
+ paths: optional list of repo-relative paths to lint. Empty /
109
+ missing → lint the full ``.agent-src.uncompressed/`` tree
110
+ via ``gather_all_candidate_files``.
111
+
112
+ Never spawns ``git`` (no ``--changed`` mode); never writes; mirrors
113
+ the JSON output format of ``scripts/skill_linter.py --format json``.
114
+ """
115
+ # Import lazily so the loader-layer import-surface test stays clean.
116
+ from scripts.skill_linter import ( # noqa: PLC0415
117
+ format_json,
118
+ gather_all_candidate_files,
119
+ lint_file,
120
+ )
121
+
122
+ root = consumer_root.resolve()
123
+ requested = arguments.get("paths") or []
124
+ if not isinstance(requested, list):
125
+ raise ValueError("'paths' must be a list of strings")
126
+
127
+ paths: list[Path] = []
128
+ if requested:
129
+ for raw in requested:
130
+ if not isinstance(raw, str):
131
+ raise ValueError("'paths' entries must be strings")
132
+ candidate = Path(raw)
133
+ resolved = (
134
+ candidate.resolve()
135
+ if candidate.is_absolute()
136
+ else (root / candidate).resolve()
137
+ )
138
+ try:
139
+ resolved.relative_to(root)
140
+ except ValueError as exc:
141
+ raise ValueError(
142
+ f"path escapes consumer_root: {resolved}"
143
+ ) from exc
144
+ if resolved.exists():
145
+ paths.append(resolved)
146
+ else:
147
+ paths = gather_all_candidate_files(root)
148
+
149
+ results = [lint_file(p, repo_root=root) for p in sorted(set(paths))]
150
+ payload = json.loads(format_json(results))
151
+ return payload
152
+
153
+
154
+ async def _chat_history_append_handler(
155
+ arguments: dict[str, Any],
156
+ consumer_root: Path,
157
+ ) -> dict[str, Any]:
158
+ """D3 — append one entry to the consumer's chat-history JSONL.
159
+
160
+ Arguments:
161
+ text: free-form entry text. Stored under the ``text`` field.
162
+ entry_type: short ``t`` tag (e.g. ``note``, ``decision``).
163
+ path: optional override of the target file. Must resolve to one
164
+ of ``agents/.agent-chat-history`` / ``.agent-chat-history``
165
+ under ``consumer_root``.
166
+ session: optional 16-char session tag. Falls back to the most
167
+ recent body entry's ``s`` (see ``chat_history.append``).
168
+ dry_run: when true, validates the payload + path guard and
169
+ returns the entry that *would* be written without touching
170
+ the filesystem.
171
+ min_schema_version: when set, the call fails fast if the
172
+ chat-history schema version is below this number. Defaults
173
+ to ``None`` (no version check).
174
+
175
+ # TODO(phase-6): wrap the file write in ``fcntl.flock`` for SSE /
176
+ # multi-process safety. stdio is single-process so this is a moot
177
+ # concern in Phase 4.
178
+ """
179
+ from scripts.chat_history import ( # noqa: PLC0415
180
+ SCHEMA_VERSION,
181
+ append,
182
+ init,
183
+ read_header,
184
+ )
185
+
186
+ text = arguments.get("text")
187
+ if not isinstance(text, str) or not text.strip():
188
+ raise ValueError("'text' must be a non-empty string")
189
+ entry_type = arguments.get("entry_type") or "note"
190
+ if not isinstance(entry_type, str) or not entry_type.strip():
191
+ raise ValueError("'entry_type' must be a non-empty string")
192
+ if entry_type == "header":
193
+ raise ValueError("'entry_type' must not be 'header'")
194
+
195
+ session = arguments.get("session")
196
+ if session is not None and not isinstance(session, str):
197
+ raise ValueError("'session' must be a string when provided")
198
+
199
+ dry_run = bool(arguments.get("dry_run", False))
200
+
201
+ raw_path = arguments.get("path")
202
+ target = _validate_in_tree_path(raw_path, consumer_root)
203
+
204
+ min_schema = arguments.get("min_schema_version")
205
+ if min_schema is not None:
206
+ if not isinstance(min_schema, int):
207
+ raise ValueError("'min_schema_version' must be an integer")
208
+ existing_header = read_header(target) if target.exists() else None
209
+ observed = (
210
+ int(existing_header.get("v", 0))
211
+ if isinstance(existing_header, dict)
212
+ else SCHEMA_VERSION
213
+ )
214
+ if observed < min_schema:
215
+ raise ValueError(
216
+ f"chat-history schema {observed} below required "
217
+ f"{min_schema}"
218
+ )
219
+
220
+ entry: dict[str, Any] = {"t": entry_type, "text": text}
221
+
222
+ if dry_run:
223
+ return {
224
+ "dry_run": True,
225
+ "target_path": str(target),
226
+ "entry": entry,
227
+ "session": session,
228
+ }
229
+
230
+ # `append` requires the parent directory and a header line. Lazy-init
231
+ # the JSONL when the consumer hasn't run `agent-config chat:init` yet.
232
+ if not target.exists() or read_header(target) is None:
233
+ target.parent.mkdir(parents=True, exist_ok=True)
234
+ init(path=target)
235
+ append(entry, path=target, session=session)
236
+ return {
237
+ "dry_run": False,
238
+ "target_path": str(target),
239
+ "entry": entry,
240
+ "session": session,
241
+ }
242
+
243
+
244
+ # ---------------------------------------------------------------------
245
+ # Allowlist — hardcoded per AI Council Q1-a verdict (2026-05-10).
246
+ # Adding a tool here is a code-review event; settings cannot enable an
247
+ # unlisted tool. Boot-time stderr log enumerates the registered set.
248
+ # ---------------------------------------------------------------------
249
+
250
+ ALLOWLIST: dict[str, BuiltinTool] = {
251
+ "lint_skills": BuiltinTool(
252
+ name="lint_skills",
253
+ description=(
254
+ "Lint skill / rule / command / guideline / persona markdown "
255
+ "files. Returns the same JSON payload as "
256
+ "`scripts/skill_linter.py --format json`. Read-only — never "
257
+ "writes or spawns git. Pass `paths` to lint a subset, omit "
258
+ "for a full tree scan."
259
+ ),
260
+ input_schema={
261
+ "type": "object",
262
+ "properties": {
263
+ "paths": {
264
+ "type": "array",
265
+ "items": {"type": "string"},
266
+ "description": (
267
+ "Repo-relative paths to lint. Empty / missing → "
268
+ "full tree scan via gather_all_candidate_files."
269
+ ),
270
+ },
271
+ },
272
+ "additionalProperties": False,
273
+ },
274
+ handler=_lint_skills_handler,
275
+ ),
276
+ "chat_history_append": BuiltinTool(
277
+ name="chat_history_append",
278
+ description=(
279
+ "Append one entry to the consumer's chat-history JSONL "
280
+ "(`agents/.agent-chat-history`). Path-scoped — writes "
281
+ "outside the allowlist raise ValueError. Use `dry_run` to "
282
+ "preview the payload without touching the filesystem."
283
+ ),
284
+ input_schema={
285
+ "type": "object",
286
+ "properties": {
287
+ "text": {"type": "string"},
288
+ "entry_type": {
289
+ "type": "string",
290
+ "description": (
291
+ "Short ``t`` tag (e.g. note, decision). "
292
+ "Defaults to ``note``."
293
+ ),
294
+ },
295
+ "path": {
296
+ "type": "string",
297
+ "description": (
298
+ "Optional path override. Must resolve to "
299
+ "`agents/.agent-chat-history` or "
300
+ "`.agent-chat-history` under consumer_root."
301
+ ),
302
+ },
303
+ "session": {"type": "string"},
304
+ "dry_run": {"type": "boolean", "default": False},
305
+ "min_schema_version": {"type": "integer"},
306
+ },
307
+ "required": ["text"],
308
+ "additionalProperties": False,
309
+ },
310
+ handler=_chat_history_append_handler,
311
+ ),
312
+ }
313
+
314
+
315
+ def to_mcp_tool_meta(tool: BuiltinTool) -> dict[str, Any]:
316
+ """Render a ``BuiltinTool`` as kwargs for ``mcp.types.Tool``."""
317
+ return {
318
+ "name": tool.name,
319
+ "description": tool.description,
320
+ "inputSchema": tool.input_schema,
321
+ }
322
+
323
+
324
+ class ToolCache:
325
+ """Hardcoded registry view of ``ALLOWLIST`` with a stable interface.
326
+
327
+ Kept as a class for symmetry with ``PromptCache`` / ``ResourceCache``.
328
+ No mtime check needed — the allowlist lives in source and changes
329
+ require a deploy.
330
+ """
331
+
332
+ def __init__(self, registry: dict[str, BuiltinTool] | None = None) -> None:
333
+ self._registry: dict[str, BuiltinTool] = dict(
334
+ registry if registry is not None else ALLOWLIST
335
+ )
336
+
337
+ def names(self) -> list[str]:
338
+ return sorted(self._registry.keys())
339
+
340
+ def list(self) -> list[BuiltinTool]:
341
+ return [self._registry[name] for name in self.names()]
342
+
343
+ def get(self, name: str) -> BuiltinTool | None:
344
+ return self._registry.get(name)
345
+
346
+ async def dispatch(
347
+ self,
348
+ name: str,
349
+ arguments: dict[str, Any],
350
+ consumer_root: Path | None = None,
351
+ ) -> dict[str, Any]:
352
+ tool = self.get(name)
353
+ if tool is None:
354
+ raise ValueError(f"Unknown tool: {name}")
355
+ root = _resolve_consumer_root(consumer_root)
356
+ return await tool.handler(arguments or {}, root)
357
+
358
+
359
+ def boot_log_line(cache: ToolCache) -> str:
360
+ """Single-line stderr enumeration of the registered tools."""
361
+ names = cache.names()
362
+ return f"mcp-server: registered {len(names)} tools: {names}"
363
+