@event4u/agent-config 1.40.0 → 1.41.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.
@@ -1,9 +1,14 @@
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".
1
+ """MCP Server — Phase 4 tools layer + Phase 1 discovery stubs.
2
+
3
+ A0 contract amendment: real handlers run only for the tools listed in
4
+ ``ALLOWLIST`` (`lint_skills` + `chat_history_append`). All other names
5
+ in ``scripts/mcp_server/consumer_tool_catalog.json`` are surfaced via
6
+ ``tools/list`` as discovery stubs; ``tools/call`` against them returns
7
+ the ``not_implemented`` envelope defined in
8
+ ``docs/contracts/mcp-tool-stub-envelope.md`` (a successful result with
9
+ ``code: not_implemented``, an ``install_hint`` and an ``alternative``).
10
+ Names that are neither implemented nor catalog-listed raise
11
+ ``ValueError`` (rendered by the SDK as JSON-RPC error).
7
12
 
8
13
  Path-scoping is mandatory for any tool that writes: the resolved target
9
14
  path must stay under ``<consumer_root>`` and within the allowlist of
@@ -26,6 +31,18 @@ from dataclasses import dataclass
26
31
  from pathlib import Path
27
32
  from typing import Any, Awaitable, Callable
28
33
 
34
+ from .catalog import (
35
+ CatalogEntry,
36
+ install_hint as _catalog_install_hint,
37
+ load_catalog,
38
+ not_implemented_envelope,
39
+ )
40
+ from .telemetry import Outcome, record_call
41
+
42
+ # Stable transport tag for the stub envelope. Mirrored verbatim by
43
+ # `workers/mcp/src/stubs.ts` with ``"worker"``.
44
+ STDIO_TRANSPORT = "stdio"
45
+
29
46
  # Allowlisted directories (relative to consumer_root) where tool writes
30
47
  # are permitted. ``chat_history_append`` resolves its path through this
31
48
  # guard before the underlying writer touches the filesystem.
@@ -242,7 +259,249 @@ async def _chat_history_append_handler(
242
259
 
243
260
 
244
261
  # ---------------------------------------------------------------------
245
- # Allowlist hardcoded per AI Council Q1-a verdict (2026-05-10).
262
+ # Phase 3 L2 read-only handlers added under the
263
+ # `agents/decisions/mcp-coverage-cut-2026-05-12.md` waiver verdict.
264
+ # Each handler wraps an existing project module via lazy import so the
265
+ # module-level import surface stays small.
266
+ # ---------------------------------------------------------------------
267
+
268
+
269
+ async def _chat_history_read_handler(
270
+ arguments: dict[str, Any],
271
+ consumer_root: Path,
272
+ ) -> dict[str, Any]:
273
+ """Phase 3 L2 — read entries from the consumer's chat-history JSONL.
274
+
275
+ Arguments:
276
+ last: optional trailing-N filter (positive integer).
277
+ session: optional 16-char session id.
278
+ entry_type: optional ``t`` field exact-match filter.
279
+ path: optional override; must resolve under
280
+ ``agents/.agent-chat-history`` or ``.agent-chat-history``.
281
+ """
282
+ from scripts.chat_history import read_entries # noqa: PLC0415
283
+
284
+ raw_path = arguments.get("path")
285
+ target = _validate_in_tree_path(raw_path, consumer_root)
286
+
287
+ last = arguments.get("last")
288
+ if last is not None and (not isinstance(last, int) or last < 1):
289
+ raise ValueError("'last' must be a positive integer when provided")
290
+ session = arguments.get("session")
291
+ if session is not None and not isinstance(session, str):
292
+ raise ValueError("'session' must be a string when provided")
293
+ entry_type = arguments.get("entry_type")
294
+ if entry_type is not None and not isinstance(entry_type, str):
295
+ raise ValueError("'entry_type' must be a string when provided")
296
+
297
+ if not target.exists():
298
+ return {
299
+ "path": str(target),
300
+ "entries": [],
301
+ "count": 0,
302
+ }
303
+
304
+ entries = read_entries(last=last, path=target, session=session)
305
+ if entry_type:
306
+ entries = [e for e in entries if e.get("t") == entry_type]
307
+ return {
308
+ "path": str(target),
309
+ "entries": entries,
310
+ "count": len(entries),
311
+ }
312
+
313
+
314
+ async def _memory_lookup_handler(
315
+ arguments: dict[str, Any],
316
+ consumer_root: Path,
317
+ ) -> dict[str, Any]:
318
+ """Phase 3 L2 — hybrid memory retrieval over ``agents/memory/``.
319
+
320
+ Wraps ``scripts/memory_lookup.retrieve_v1`` to keep the v1 envelope
321
+ on the wire. File-only fallback by default; ``with_package=true``
322
+ enables the optional ``@event4u/agent-memory`` provider when
323
+ reachable.
324
+ """
325
+ import os # noqa: PLC0415
326
+
327
+ from scripts.memory_lookup import ( # noqa: PLC0415
328
+ package_operational_provider,
329
+ retrieve_v1,
330
+ )
331
+
332
+ types = arguments.get("types")
333
+ if not isinstance(types, list) or not types or not all(
334
+ isinstance(t, str) for t in types
335
+ ):
336
+ raise ValueError("'types' must be a non-empty list of strings")
337
+ keys = arguments.get("keys") or []
338
+ if not isinstance(keys, list) or not all(isinstance(k, str) for k in keys):
339
+ raise ValueError("'keys' must be a list of strings")
340
+ limit_raw = arguments.get("limit", 5)
341
+ if not isinstance(limit_raw, int) or limit_raw < 1:
342
+ raise ValueError("'limit' must be a positive integer")
343
+
344
+ provider = None
345
+ if arguments.get("with_package"):
346
+ provider = package_operational_provider()
347
+
348
+ prev_cwd = Path.cwd()
349
+ try:
350
+ os.chdir(consumer_root)
351
+ envelope = retrieve_v1(
352
+ types=list(types),
353
+ keys=list(keys),
354
+ limit=limit_raw,
355
+ operational_provider=provider,
356
+ )
357
+ finally:
358
+ os.chdir(prev_cwd)
359
+ return envelope
360
+
361
+
362
+ async def _memory_status_handler(
363
+ _arguments: dict[str, Any],
364
+ consumer_root: Path,
365
+ ) -> dict[str, Any]:
366
+ """Phase 3 L2 — surface ``scripts/memory_status.status()`` as JSON."""
367
+ import os # noqa: PLC0415
368
+
369
+ from dataclasses import asdict # noqa: PLC0415
370
+
371
+ from scripts.memory_status import status # noqa: PLC0415
372
+
373
+ prev_cwd = Path.cwd()
374
+ try:
375
+ os.chdir(consumer_root)
376
+ result = status()
377
+ finally:
378
+ os.chdir(prev_cwd)
379
+ payload = asdict(result)
380
+ payload["features"] = list(result.features)
381
+ return payload
382
+
383
+
384
+ # Module-level prompt / resource caches reused across handler calls so
385
+ # repeated `list_*` / `read_resource_body` calls share mtime tracking.
386
+ _PROMPT_CACHES: dict[str, Any] = {}
387
+ _RESOURCE_CACHES: dict[str, Any] = {}
388
+
389
+
390
+ def _get_prompt_cache(consumer_root: Path) -> Any:
391
+ from .prompts import PromptCache # noqa: PLC0415
392
+
393
+ key = str(consumer_root.resolve())
394
+ cache = _PROMPT_CACHES.get(key)
395
+ if cache is None:
396
+ cache = PromptCache(root=consumer_root)
397
+ _PROMPT_CACHES[key] = cache
398
+ return cache
399
+
400
+
401
+ def _get_resource_cache(consumer_root: Path) -> Any:
402
+ from .resources import ResourceCache # noqa: PLC0415
403
+
404
+ key = str(consumer_root.resolve())
405
+ cache = _RESOURCE_CACHES.get(key)
406
+ if cache is None:
407
+ cache = ResourceCache(root=consumer_root)
408
+ _RESOURCE_CACHES[key] = cache
409
+ return cache
410
+
411
+
412
+ async def _list_skills_handler(
413
+ _arguments: dict[str, Any],
414
+ consumer_root: Path,
415
+ ) -> dict[str, Any]:
416
+ """Phase 3 L2 — enumerate skill prompts (kind=='skill')."""
417
+ from .prompts import to_mcp_prompt_meta # noqa: PLC0415
418
+
419
+ cache = _get_prompt_cache(consumer_root)
420
+ prompts, errors = cache.get()
421
+ items = [
422
+ {
423
+ "name": p.name,
424
+ "description": p.description,
425
+ "source": p.source,
426
+ "wire_name": to_mcp_prompt_meta(p)["name"],
427
+ }
428
+ for p in prompts
429
+ if p.kind == "skill"
430
+ ]
431
+ items.sort(key=lambda r: r["name"])
432
+ return {"count": len(items), "skills": items, "errors": list(errors)}
433
+
434
+
435
+ async def _list_commands_handler(
436
+ _arguments: dict[str, Any],
437
+ consumer_root: Path,
438
+ ) -> dict[str, Any]:
439
+ """Phase 3 L2 — enumerate command prompts (kind=='command')."""
440
+ from .prompts import to_mcp_prompt_meta # noqa: PLC0415
441
+
442
+ cache = _get_prompt_cache(consumer_root)
443
+ prompts, errors = cache.get()
444
+ items = [
445
+ {
446
+ "name": p.name,
447
+ "description": p.description,
448
+ "source": p.source,
449
+ "wire_name": to_mcp_prompt_meta(p)["name"],
450
+ }
451
+ for p in prompts
452
+ if p.kind == "command"
453
+ ]
454
+ items.sort(key=lambda r: r["name"])
455
+ return {"count": len(items), "commands": items, "errors": list(errors)}
456
+
457
+
458
+ async def _list_rules_handler(
459
+ _arguments: dict[str, Any],
460
+ consumer_root: Path,
461
+ ) -> dict[str, Any]:
462
+ """Phase 3 L2 — enumerate rule resources (kind=='rule')."""
463
+ cache = _get_resource_cache(consumer_root)
464
+ resources, errors = cache.get()
465
+ items = [
466
+ {
467
+ "uri": r.uri,
468
+ "name": r.name,
469
+ "description": r.description,
470
+ "source": r.source,
471
+ }
472
+ for r in resources
473
+ if r.kind == "rule"
474
+ ]
475
+ items.sort(key=lambda r: r["uri"])
476
+ return {"count": len(items), "rules": items, "errors": list(errors)}
477
+
478
+
479
+ async def _read_resource_body_handler(
480
+ arguments: dict[str, Any],
481
+ consumer_root: Path,
482
+ ) -> dict[str, Any]:
483
+ """Phase 3 L2 — fetch the rendered body of a resource URI."""
484
+ uri = arguments.get("uri")
485
+ if not isinstance(uri, str) or not uri:
486
+ raise ValueError("'uri' must be a non-empty string")
487
+ cache = _get_resource_cache(consumer_root)
488
+ resource = cache.lookup(uri)
489
+ if resource is None:
490
+ raise ValueError(f"resource not found: {uri}")
491
+ return {
492
+ "uri": resource.uri,
493
+ "name": resource.name,
494
+ "description": resource.description,
495
+ "mime_type": resource.mime_type,
496
+ "kind": resource.kind,
497
+ "source": resource.source,
498
+ "body": resource.body,
499
+ }
500
+
501
+
502
+ # ---------------------------------------------------------------------
503
+ # Allowlist — hardcoded per AI Council Q1-a verdict (2026-05-10),
504
+ # extended Phase 3 L2 (2026-05-12) under the council-waiver verdict.
246
505
  # Adding a tool here is a code-review event; settings cannot enable an
247
506
  # unlisted tool. Boot-time stderr log enumerates the registered set.
248
507
  # ---------------------------------------------------------------------
@@ -309,6 +568,123 @@ ALLOWLIST: dict[str, BuiltinTool] = {
309
568
  },
310
569
  handler=_chat_history_append_handler,
311
570
  ),
571
+ "chat_history_read": BuiltinTool(
572
+ name="chat_history_read",
573
+ description=(
574
+ "Read recent chat-history entries from "
575
+ "`agents/.agent-chat-history`. Filter by session, "
576
+ "trailing-N, or entry-type. Read-only."
577
+ ),
578
+ input_schema={
579
+ "type": "object",
580
+ "properties": {
581
+ "last": {"type": "integer", "minimum": 1},
582
+ "session": {"type": "string"},
583
+ "entry_type": {"type": "string"},
584
+ "path": {"type": "string"},
585
+ },
586
+ "additionalProperties": False,
587
+ },
588
+ handler=_chat_history_read_handler,
589
+ ),
590
+ "memory_lookup": BuiltinTool(
591
+ name="memory_lookup",
592
+ description=(
593
+ "Hybrid memory retrieval over `agents/memory/<type>/*.yml` "
594
+ "and `agents/memory/intake/*.jsonl`. Returns the v1 "
595
+ "retrieval envelope. Read-only."
596
+ ),
597
+ input_schema={
598
+ "type": "object",
599
+ "properties": {
600
+ "types": {
601
+ "type": "array",
602
+ "items": {"type": "string"},
603
+ "minItems": 1,
604
+ },
605
+ "keys": {
606
+ "type": "array",
607
+ "items": {"type": "string"},
608
+ },
609
+ "limit": {"type": "integer", "minimum": 1, "default": 5},
610
+ "with_package": {"type": "boolean", "default": False},
611
+ },
612
+ "required": ["types"],
613
+ "additionalProperties": False,
614
+ },
615
+ handler=_memory_lookup_handler,
616
+ ),
617
+ "memory_status": BuiltinTool(
618
+ name="memory_status",
619
+ description=(
620
+ "Report whether the optional `@event4u/agent-memory` "
621
+ "package is reachable, and surface its routing metadata. "
622
+ "Read-only."
623
+ ),
624
+ input_schema={
625
+ "type": "object",
626
+ "properties": {},
627
+ "additionalProperties": False,
628
+ },
629
+ handler=_memory_status_handler,
630
+ ),
631
+ "list_skills": BuiltinTool(
632
+ name="list_skills",
633
+ description=(
634
+ "Enumerate every skill currently exposed as a prompt, with "
635
+ "name + description + source. Read-only manifest view."
636
+ ),
637
+ input_schema={
638
+ "type": "object",
639
+ "properties": {},
640
+ "additionalProperties": False,
641
+ },
642
+ handler=_list_skills_handler,
643
+ ),
644
+ "list_commands": BuiltinTool(
645
+ name="list_commands",
646
+ description=(
647
+ "Enumerate every slash command currently exposed as a "
648
+ "prompt. Read-only manifest view."
649
+ ),
650
+ input_schema={
651
+ "type": "object",
652
+ "properties": {},
653
+ "additionalProperties": False,
654
+ },
655
+ handler=_list_commands_handler,
656
+ ),
657
+ "list_rules": BuiltinTool(
658
+ name="list_rules",
659
+ description=(
660
+ "Enumerate every rule currently exposed as a resource. "
661
+ "Read-only manifest view."
662
+ ),
663
+ input_schema={
664
+ "type": "object",
665
+ "properties": {},
666
+ "additionalProperties": False,
667
+ },
668
+ handler=_list_rules_handler,
669
+ ),
670
+ "read_resource_body": BuiltinTool(
671
+ name="read_resource_body",
672
+ description=(
673
+ "Fetch the rendered body of any resource URI (rule, "
674
+ "guideline, context) without going through "
675
+ "`resources/read`. Convenience for clients that want to "
676
+ "inline content into a tool call result."
677
+ ),
678
+ input_schema={
679
+ "type": "object",
680
+ "properties": {
681
+ "uri": {"type": "string"},
682
+ },
683
+ "required": ["uri"],
684
+ "additionalProperties": False,
685
+ },
686
+ handler=_read_resource_body_handler,
687
+ ),
312
688
  }
313
689
 
314
690
 
@@ -321,17 +697,72 @@ def to_mcp_tool_meta(tool: BuiltinTool) -> dict[str, Any]:
321
697
  }
322
698
 
323
699
 
700
+ # ---------------------------------------------------------------------
701
+ # Phase 1 discovery stubs — catalog entries with no real handler.
702
+ # Loaded at module import time. The Worker reads the same catalog via
703
+ # `content.json` so `tools/list` returns identical metadata on both
704
+ # transports apart from `implemented_on`.
705
+ # ---------------------------------------------------------------------
706
+
707
+
708
+ def _make_stub_handler(entry: CatalogEntry, install_hint_value: str) -> ToolHandler:
709
+ """Closure that returns the `not_implemented` envelope for a stub."""
710
+
711
+ async def _stub(
712
+ _arguments: dict[str, Any],
713
+ _consumer_root: Path,
714
+ ) -> dict[str, Any]:
715
+ return not_implemented_envelope(
716
+ entry.name,
717
+ transport=STDIO_TRANSPORT,
718
+ install_hint_value=install_hint_value,
719
+ )
720
+
721
+ return _stub
722
+
723
+
724
+ def _build_catalog_registry() -> tuple[dict[str, BuiltinTool], frozenset[str]]:
725
+ """Build the stub registry from the catalog. ALLOWLIST wins on overlap.
726
+
727
+ Returns (registry, stub_names). `registry` contains every catalog
728
+ entry not already in ALLOWLIST, each wired to a closure that emits
729
+ the envelope.
730
+ """
731
+ install_hint_value = _catalog_install_hint()
732
+ entries = load_catalog()
733
+ registry: dict[str, BuiltinTool] = {}
734
+ stub_names: set[str] = set()
735
+ for entry in entries:
736
+ if entry.name in ALLOWLIST:
737
+ continue
738
+ registry[entry.name] = BuiltinTool(
739
+ name=entry.name,
740
+ description=entry.description,
741
+ input_schema=entry.input_schema,
742
+ handler=_make_stub_handler(entry, install_hint_value),
743
+ )
744
+ stub_names.add(entry.name)
745
+ return registry, frozenset(stub_names)
746
+
747
+
748
+ CATALOG_STUBS, STUB_NAMES = _build_catalog_registry()
749
+
750
+ # Full wire-surface registry — implemented + stubs. `tools/list` reads
751
+ # from this; `tools/call` dispatches against it.
752
+ REGISTRY: dict[str, BuiltinTool] = {**ALLOWLIST, **CATALOG_STUBS}
753
+
754
+
324
755
  class ToolCache:
325
- """Hardcoded registry view of ``ALLOWLIST`` with a stable interface.
756
+ """Registry view backing the MCP `tools/*` handlers.
326
757
 
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.
758
+ Default registry is ``REGISTRY`` (implemented + catalog stubs).
759
+ Tests can pass a narrower dict (e.g. ``ALLOWLIST`` alone) to isolate
760
+ the implemented surface.
330
761
  """
331
762
 
332
763
  def __init__(self, registry: dict[str, BuiltinTool] | None = None) -> None:
333
764
  self._registry: dict[str, BuiltinTool] = dict(
334
- registry if registry is not None else ALLOWLIST
765
+ registry if registry is not None else REGISTRY
335
766
  )
336
767
 
337
768
  def names(self) -> list[str]:
@@ -343,21 +774,49 @@ class ToolCache:
343
774
  def get(self, name: str) -> BuiltinTool | None:
344
775
  return self._registry.get(name)
345
776
 
777
+ def is_stub(self, name: str) -> bool:
778
+ """True when `name` is a catalog stub on this cache."""
779
+ return name in STUB_NAMES and name in self._registry
780
+
781
+ def implemented_names(self) -> list[str]:
782
+ """Subset of `names()` whose handlers run real logic."""
783
+ return sorted(n for n in self._registry if n in ALLOWLIST)
784
+
346
785
  async def dispatch(
347
786
  self,
348
787
  name: str,
349
788
  arguments: dict[str, Any],
350
789
  consumer_root: Path | None = None,
351
790
  ) -> dict[str, Any]:
791
+ root = _resolve_consumer_root(consumer_root)
352
792
  tool = self.get(name)
353
793
  if tool is None:
794
+ # Sonnet's latent-demand pattern: log the unknown name before
795
+ # surfacing the JSON-RPC error so Phase 2 can rank the gap.
796
+ self._record(name, "latent_demand", root)
354
797
  raise ValueError(f"Unknown tool: {name}")
355
- root = _resolve_consumer_root(consumer_root)
798
+ outcome: Outcome = "stub" if self.is_stub(name) else "implemented"
799
+ self._record(name, outcome, root)
356
800
  return await tool.handler(arguments or {}, root)
357
801
 
802
+ @staticmethod
803
+ def _record(tool_name: str, outcome: Outcome, consumer_root: Path) -> None:
804
+ """Best-effort JSONL write — failures never break the wire surface."""
805
+ record_call(
806
+ tool_name=tool_name,
807
+ outcome=outcome,
808
+ transport=STDIO_TRANSPORT,
809
+ consumer_root=consumer_root,
810
+ )
811
+
358
812
 
359
813
  def boot_log_line(cache: ToolCache) -> str:
360
814
  """Single-line stderr enumeration of the registered tools."""
361
- names = cache.names()
362
- return f"mcp-server: registered {len(names)} tools: {names}"
815
+ total = len(cache.names())
816
+ implemented = len(cache.implemented_names())
817
+ stubs = total - implemented
818
+ return (
819
+ f"mcp-server: registered {total} tools "
820
+ f"({implemented} implemented, {stubs} stubs): {cache.names()}"
821
+ )
363
822