@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,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
|
+
|