@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +47 -21
- package/docs/catalog.md +4 -3
- package/docs/contracts/file-ownership-matrix.json +27 -0
- package/docs/contracts/mcp-discovery-phase-notice.md +56 -0
- package/docs/contracts/mcp-tool-stub-envelope.md +78 -0
- package/docs/getting-started.md +1 -1
- package/docs/setup/mcp-client-config.md +94 -13
- package/docs/setup/mcp-cloud-setup.md +32 -1
- package/docs/setup/per-ide/claude-desktop.md +32 -7
- package/package.json +1 -1
- package/scripts/_lib/script_output.py +15 -11
- package/scripts/ai_council/session.py +14 -8
- package/scripts/chat_history.py +29 -53
- package/scripts/command_suggester/settings.py +15 -13
- package/scripts/compile_router.py +13 -9
- package/scripts/compress.py +22 -19
- package/scripts/council_cli.py +9 -3
- package/scripts/mcp_parity_smoke.py +20 -2
- package/scripts/mcp_server/catalog.py +125 -0
- package/scripts/mcp_server/consumer_tool_catalog.json +275 -0
- package/scripts/mcp_server/telemetry.py +128 -0
- package/scripts/mcp_server/tools.py +474 -15
- package/scripts/mcp_telemetry_health.py +214 -0
- package/scripts/mcp_telemetry_query.py +203 -0
- package/scripts/mcp_telemetry_store.py +211 -0
- package/scripts/memory_signal.py +12 -10
- package/scripts/pack_mcp_content.py +18 -4
- package/templates/claude_desktop_config.json.template +4 -3
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
"""MCP Server — Phase 4 tools layer.
|
|
2
|
-
|
|
3
|
-
A0 contract amendment:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
#
|
|
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
|
-
"""
|
|
756
|
+
"""Registry view backing the MCP `tools/*` handlers.
|
|
326
757
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|