@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,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
+
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # mcp_setup.sh — One-line MCP server onboarding.
3
+ # Creates .venv-mcp/ (gitignored) and installs the `mcp` SDK.
4
+ # Idempotent: safe to re-run; reuses an existing .venv-mcp/.
5
+
6
+ set -euo pipefail
7
+
8
+ VENV_DIR=".venv-mcp"
9
+
10
+ log_ok() { echo "✅ $*"; }
11
+ log_warn() { echo "⚠️ $*" >&2; }
12
+ log_err() { echo "❌ $*" >&2; }
13
+
14
+ # --- Locate a Python ≥ 3.11 ---
15
+ find_python() {
16
+ for cand in python3.13 python3.12 python3.11; do
17
+ if command -v "$cand" >/dev/null 2>&1; then
18
+ echo "$cand"
19
+ return 0
20
+ fi
21
+ done
22
+ if command -v python3 >/dev/null 2>&1; then
23
+ local ver
24
+ ver="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
25
+ case "$ver" in
26
+ 3.11|3.12|3.13|3.1[4-9]|3.[2-9][0-9]) echo "python3"; return 0 ;;
27
+ esac
28
+ fi
29
+ return 1
30
+ }
31
+
32
+ PY="$(find_python || true)"
33
+ if [[ -z "${PY:-}" ]]; then
34
+ log_err "Python 3.11+ not found."
35
+ log_err "Install Python 3.11+ (e.g. via pyenv, asdf, brew, or apt) and re-run."
36
+ exit 1
37
+ fi
38
+
39
+ PY_VER="$("$PY" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])')"
40
+
41
+ # --- Create or reuse venv ---
42
+ if [[ -d "$VENV_DIR" ]]; then
43
+ log_ok "$VENV_DIR/ exists — reusing (Python $("$VENV_DIR/bin/python" --version 2>&1 | awk '{print $2}'))"
44
+ else
45
+ "$PY" -m venv "$VENV_DIR"
46
+ log_ok "Created $VENV_DIR/ with $PY ($PY_VER)"
47
+ fi
48
+
49
+ # --- Install / upgrade mcp SDK ---
50
+ "$VENV_DIR/bin/pip" install --quiet --upgrade pip
51
+ "$VENV_DIR/bin/pip" install --quiet --upgrade mcp
52
+
53
+ MCP_VER="$("$VENV_DIR/bin/python" -c 'import mcp, importlib.metadata as m; print(m.version("mcp"))' 2>/dev/null || echo "?")"
54
+ log_ok "Installed mcp SDK ($MCP_VER) in $VENV_DIR/"
55
+
56
+ # --- Smoke: import the server module ---
57
+ if ! "$VENV_DIR/bin/python" -c 'import scripts.mcp_server' 2>/dev/null; then
58
+ log_warn "scripts.mcp_server import failed — check repository checkout."
59
+ exit 1
60
+ fi
61
+ log_ok "scripts.mcp_server import OK"
62
+
63
+ # --- Print client config snippet ---
64
+ ROOT="$(pwd)"
65
+ PY_BIN="$ROOT/$VENV_DIR/bin/python"
66
+
67
+ echo ""
68
+ echo "── MCP server ready ─────────────────────────────────────────"
69
+ echo ""
70
+ echo "Run over stdio:"
71
+ echo " task mcp:run"
72
+ echo ""
73
+ echo "Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):"
74
+ cat <<JSON
75
+ {
76
+ "mcpServers": {
77
+ "agent-config": {
78
+ "command": "$PY_BIN",
79
+ "args": ["-m", "scripts.mcp_server"],
80
+ "env": { "PYTHONPATH": "$ROOT" }
81
+ }
82
+ }
83
+ }
84
+ JSON
85
+ echo ""
86
+ echo "After saving the config: ⌘Q Claude Desktop and restart."
87
+ echo "──────────────────────────────────────────────────────────────"