@passioncode-ai/passioncode 0.2.38 → 0.2.40

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  56. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
  57. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  60. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  61. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
  62. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  63. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  64. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  65. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
  66. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  67. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
  68. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  69. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  70. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
  71. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
  72. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  73. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -36,6 +36,8 @@ from fabric_protocols import (
36
36
  protocols_findings,
37
37
  )
38
38
  from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
39
+ from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
40
+ from fabric_a2a import a2a_surface_problems # noqa: E402 - DEC-0122
39
41
  from fabric_usage import (
40
42
  ROUTES,
41
43
  )
@@ -46,7 +48,7 @@ from fabric_usage import (
46
48
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
47
49
 
48
50
 
49
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.4"
51
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
50
52
 
51
53
 
52
54
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -577,11 +579,20 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
577
579
  update_available: Optional[str] = None, pid: Optional[int] = None,
578
580
  extensions: Optional[Dict[str, Any]] = None,
579
581
  configuration: Optional[Dict[str, Any]] = None,
580
- protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
582
+ protocols: Optional[Sequence[str]] = None,
583
+ actions: Optional[Sequence[Dict[str, Any]]] = None,
584
+ a2a: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
581
585
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
582
586
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
583
587
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
584
- in it must be valid (dashboard_extension builds one)."""
588
+ in it must be valid (dashboard_extension builds one).
589
+ `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
590
+ tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
591
+ A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
592
+ humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list.
593
+ `a2a` (DEC-0122) is `surfaces.a2a`, where the service serves its public Agent Card (fabric_a2a.a2a_surface builds
594
+ one); passed here or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before
595
+ DEC-0122 refuses the member, so a service carries it only once its readers are pinned at or after it."""
585
596
  if status not in STATUSES:
586
597
  raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
587
598
  degraded = list(degraded)
@@ -589,6 +600,15 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
589
600
  status = "degraded"
590
601
  if "events" not in surfaces:
591
602
  raise ServiceError("surfaces.events is required.")
603
+ if a2a is not None:
604
+ if "a2a" in surfaces:
605
+ raise ServiceError("surfaces.a2a: pass it in surfaces or as a2a, not both.")
606
+ surfaces = dict(surfaces, a2a=a2a)
607
+ if "a2a" in surfaces:
608
+ # DEC-0122: a host reads the member by the schema; a service never publishes one it would refuse.
609
+ problems = a2a_surface_problems(surfaces["a2a"])
610
+ if problems:
611
+ raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
592
612
  doc: Dict[str, Any] = {
593
613
  "protocol": PROTOCOL,
594
614
  "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
@@ -600,6 +620,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
600
620
  }
601
621
  if summary:
602
622
  doc["summary"] = list(summary)[:6]
623
+ if actions is not None:
624
+ if extensions and ACTIONS_EXTENSION_KEY in extensions:
625
+ raise ServiceError("Actions declaration: pass the action table or a ready declaration, not both.")
626
+ mcp = surfaces.get("mcp")
627
+ capabilities = mcp.get("capabilities") if isinstance(mcp, dict) else None
628
+ extensions = dict(extensions or {}, **actions_extension(actions, capabilities if isinstance(capabilities, list) else None))
603
629
  if extensions:
604
630
  if DASHBOARD_EXTENSION_KEY in extensions:
605
631
  problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
@@ -610,6 +636,13 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
610
636
  view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
611
637
  if view["reported"]:
612
638
  raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
639
+ if ACTIONS_EXTENSION_KEY in extensions:
640
+ # DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The
641
+ # tools are compared with the capabilities this document's MCP surface lists.
642
+ findings = actions_extension_rules({"extensions": {ACTIONS_EXTENSION_KEY: extensions[ACTIONS_EXTENSION_KEY]},
643
+ "surfaces": surfaces})
644
+ if findings:
645
+ raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
613
646
  doc["extensions"] = dict(extensions)
614
647
  if configuration is not None:
615
648
  problems = configuration_problems(configuration)
@@ -776,6 +776,13 @@ def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any])
776
776
  return problems
777
777
 
778
778
 
779
+ def placement_grant_findings(record: Any, placement: Any) -> List[Dict[str, str]]:
780
+ """FAC-SEM-111 for one placement of ``record``, as findings (``instancePath`` relative to the placement). Answers,
781
+ never raises: a record or placement that is no object reads as an empty one (FAA-125)."""
782
+ record, placement = (v if isinstance(v, dict) else {} for v in (record, placement))
783
+ return [{"code": "FAC-SEM-111", "instancePath": at, "message": m} for at, m in _placement_grant_problems(record, placement)]
784
+
785
+
779
786
  def _placement_grants(value: Dict[str, Any], flag) -> None:
780
787
  for machine, m in _indexed(_get(value, "machines")):
781
788
  for placement, p in _indexed(_get(machine, "packages")):
@@ -20,15 +20,22 @@ from __future__ import annotations
20
20
  import base64
21
21
  import hashlib
22
22
  import json
23
+ import math
23
24
  from pathlib import Path
24
25
  import re
25
26
  import secrets
26
27
  import sys
28
+ import time
27
29
  from typing import Any, Callable, Dict, List, Optional
28
30
 
29
31
  sys.path.insert(0, str(Path(__file__).resolve().parent))
30
32
  import fabric_service as _fs # noqa: E402
31
33
  from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
34
+ from fabric_actions import action_caller, readable_action # noqa: E402 - DEC-0121
35
+ from fabric_mandate import EFFECT_CLASSES, is_agent_id # noqa: E402 - DEC-0125
36
+ from fabric_time import format_rfc3339 # noqa: E402 - the core's (FAA-65)
37
+ from fabric_page_links import ( # noqa: E402 - DEC-0123
38
+ PageLinkError, dashboards_link, input_request_link_findings, page_link_findings, parse_dashboards_link)
32
39
 
33
40
  from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
34
41
  _days_from_civil,
@@ -77,6 +84,8 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
77
84
  intent_idempotency_key,
78
85
  reads_null_usage,
79
86
  request_meta,
87
+ action_gate,
88
+ action_refusal_tool_result,
80
89
  usage_findings,
81
90
  usage_toward,
82
91
  _job_findings,
@@ -214,12 +223,32 @@ def choice_request(message: str, field: str, options: List[Any], title: Optional
214
223
 
215
224
 
216
225
  def url_request(message: str, url: str) -> Dict[str, Any]:
217
- """URL mode: the person opens `url` out of band; for credentials and payments. Put nothing sensitive in the URL."""
218
- if not url.startswith(("https://", "http://127.0.0.1", "http://localhost")):
219
- raise InteropError("A URL-mode request needs an https URL (or this machine's loopback).")
226
+ """URL mode: the person opens `url` out of band; for credentials and payments. Put nothing sensitive in the URL.
227
+ A page of this service goes as its open_link (`page_answer`, DEC-0123 PL-4); a raw loopback address and a
228
+ `fabric-dashboards:` link outside the grammar are refused here, when the request is built (FAC-SEM-143)."""
229
+ if not isinstance(url, str):
230
+ raise InteropError("A URL-mode request needs an https URL or Fabric Dashboards' open_link (DEC-0123 PL-4).")
231
+ findings = page_link_findings(url, "/params/url", "the URL-mode request")
232
+ if findings:
233
+ raise InteropError(findings[0]["message"])
234
+ if not url.startswith("https://") and not parse_dashboards_link(url)["ok"]:
235
+ raise InteropError("A URL-mode request needs an https URL or Fabric Dashboards' open_link (DEC-0123 PL-4).")
220
236
  return {"method": "elicitation/create", "params": {"mode": "url", "message": message, "url": url}}
221
237
 
222
238
 
239
+ def page_answer(service: str, instance: str, path: Optional[str] = None, http_url: Optional[str] = None) -> Dict[str, Any]:
240
+ """A tool answer that hands a person one of this service's pages (DEC-0123 PL-4): `{"open_link"}`, the page's
241
+ Fabric Dashboards link, and `http_url`, its raw address, only as a diagnostic beside it (never judged as the
242
+ action). Raises InteropError for an id, instance or path the open_link grammar refuses."""
243
+ try:
244
+ answer: Dict[str, Any] = {"open_link": dashboards_link(service, instance, path)}
245
+ except PageLinkError as refused:
246
+ raise InteropError("No open_link for %s.%s: %s (DEC-0123 PL-1)." % (service, instance, refused)) from None
247
+ if http_url is not None:
248
+ answer["http_url"] = http_url
249
+ return answer
250
+
251
+
223
252
  # --- C3.2 jobs ---------------------------------------------------------------------
224
253
 
225
254
  _SURROGATE = re.compile("[\ud800-\udfff]")
@@ -361,6 +390,8 @@ class JobStore:
361
390
  for key, request in input_requests.items():
362
391
  if not _KEY.match(key) or request.get("method") != "elicitation/create":
363
392
  raise InteropError("Input request %r must be a keyed elicitation/create request." % key)
393
+ for finding in input_request_link_findings({"inputRequests": input_requests}): # FAC-SEM-143 (D12)
394
+ raise InteropError("%s: %s" % (finding["instancePath"], finding["message"]))
364
395
  return self._transition(job_id, "input_required", inputRequests=input_requests, statusMessage=message)
365
396
 
366
397
  def answer(self, job_id: str, input_responses: Dict[str, Any]) -> Dict[str, Any]:
@@ -430,16 +461,28 @@ class JobStore:
430
461
  # --- a minimal MCP tool server (streamable HTTP, JSON responses) ------------------------
431
462
 
432
463
  class CallContext:
433
- """What a tool handler gets: the child span it runs in, and a way to start a job."""
464
+ """What a tool handler gets: the child span it runs in, and a way to start a job.
465
+
466
+ DEC-0121: `tier` is the caller's (DL-25), `caller` the identity the service authenticated (decision D9), `meta`
467
+ the request's `_meta`. For a tool registered with an action, `record` is action_decision's recordAs: "caller"
468
+ (the caller's own act) or {by, via, caller}, the act to put in the service's audit record, and `decision` the
469
+ operator's decision it took (its `instruction` stays in that record, never in events or a page another party
470
+ reads), else None. `input_records` maps each answered input request to its record when input_actions is on.
471
+ DEC-0125: `mandate` is the operator's mandate the call was taken under (record `via: mandate`), else None."""
434
472
 
435
473
  def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
436
- delegation: Optional[Dict[str, Any]] = None):
474
+ delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
475
+ meta: Optional[Dict[str, Any]] = None, record: Any = None, decision: Optional[Dict[str, Any]] = None,
476
+ input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None):
437
477
  self.server = server
438
478
  self.tool = tool
439
479
  self.arguments = arguments
440
480
  self.traceparent = traceparent
441
481
  # DEC-0039: the accepted delegation (principal, caller, chain, claims) when the server has a gate.
442
482
  self.delegation = delegation
483
+ self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
484
+ self.record, self.decision, self.input_records = record, decision, input_records
485
+ self.mandate = mandate
443
486
 
444
487
  def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
445
488
  if self.server.jobs is None:
@@ -535,6 +578,10 @@ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
535
578
  return out
536
579
 
537
580
 
581
+ _UNREADABLE = object() # what a service's spend or project function answers when it raised
582
+ _UNREADABLE_PROJECT = "\x00unreadable" # never a member of a mandate's projects, so a scoped mandate refuses
583
+
584
+
538
585
  class McpToolServer:
539
586
  """tools/list and tools/call for an MCP 2026-07-28 server, with fabric.job.get/cancel built in.
540
587
 
@@ -547,11 +594,32 @@ class McpToolServer:
547
594
  def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
548
595
  on_job_started: Optional[Callable[[str, CallContext], None]] = None,
549
596
  on_input: Optional[OnInput] = None,
550
- authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None):
597
+ authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None, *,
598
+ identify: Optional[Callable[[Any, str], Optional[str]]] = None,
599
+ approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
600
+ elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
601
+ input_actions: bool = False, clock: Callable[[], float] = time.time,
602
+ on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None):
551
603
  # authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
552
604
  # a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
553
605
  # gated by the job's own capability, and answered only for the principal and caller that started it.
606
+ # DEC-0121: a tool added with an action runs action_gate after authorize and before its handler.
607
+ # identify(headers, tier) names the caller the service authenticated when no delegation did (D9; else
608
+ # local:T0 / local:T1); approve(tool, arguments, headers, digest) answers verify_approval's outcome for an
609
+ # approval the call presented, or None; elicited(tool, arguments, caller) is True only on the service's own
610
+ # record of an accepted elicitation. input_actions=True gates each answered input request as an action (D8).
611
+ # on_fault(tool, fault) hears a service fault the caller only sees as unknown-state (e.g. caller-missing: an
612
+ # action-gated server needs a tier, identify or authorize that names who calls).
613
+ # DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
614
+ # relayed action added with its effect (the chain must end in agent_id, and in the delegation's caller before
615
+ # it when authorize accepted a token); without it a mandate is not read, as before.
616
+ if agent_id is not None and not is_agent_id(agent_id):
617
+ raise InteropError("agent_id is this service's agent id (a lower-case id, optionally .instance; DEC-0125).")
618
+ self.agent_id = agent_id
554
619
  self._authorize = authorize
620
+ self._identify, self._approve, self._elicited = identify, approve, elicited
621
+ self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
622
+ self.actions: Dict[str, Dict[str, Any]] = {}
555
623
  self.info = {"name": name, "version": version}
556
624
  self.jobs = jobs
557
625
  self.tools: List[Dict[str, Any]] = []
@@ -559,14 +627,148 @@ class McpToolServer:
559
627
  self._on_job_started = on_job_started
560
628
  self._on_input = on_input
561
629
 
562
- def add_tool(self, tool: Dict[str, Any], handler: Handler) -> None:
630
+ def add_tool(self, tool: Dict[str, Any], handler: Handler, action: Optional[Dict[str, Any]] = None, *,
631
+ target: Any = None, human_step_url: Optional[str] = None, effect: Optional[str] = None,
632
+ spend: Any = None, project: Any = None) -> None:
633
+ """Serve a tool. `action` (DEC-0121 AP-1) is the declared action this tool performs, {id, title, tool, gate,
634
+ humanStep?}, its `tool` this tool's name: the server runs action_gate before the handler. `target` names the
635
+ object a call acts on: an argument's name, or a function of the arguments; `human_step_url` is the link of a
636
+ `human` action's step (an https URL or a path on the service's origin, AP-4).
637
+
638
+ DEC-0125, on a server with agent_id: `effect` is the action's effect class (governance's vocabulary), required
639
+ for a relayed action, since a mandate covers a call by its effect; `spend` the service's own estimate of what a
640
+ call spends, USD (a number, or a function of the arguments answering a number or None); `project` the project a
641
+ call acts in (a name, or a function of the arguments)."""
563
642
  if tool["name"] in self.handlers:
564
643
  raise InteropError("Tool %s is served twice." % tool["name"])
565
644
  if "outputSchema" in tool:
566
645
  require_object_root(tool["name"], tool["outputSchema"])
646
+ if action is not None:
647
+ if not readable_action(action) or action["tool"] != tool["name"]:
648
+ raise InteropError("Tool %s: its action is not a declared action of this tool (DEC-0121)." % tool["name"])
649
+ if action["id"] in {a["action"]["id"] for a in self.actions.values()}:
650
+ raise InteropError("Action %s is declared twice." % action["id"])
651
+ if action["gate"] == "human" and not (isinstance(human_step_url, str) and re.match(r"https://|/", human_step_url)):
652
+ raise InteropError("Tool %s: a human step needs its link, an https URL or a path (DEC-0121 AP-4)." % tool["name"])
653
+ if not (target is None or isinstance(target, str) or callable(target)):
654
+ raise InteropError("Tool %s: target is an argument's name or a function of the arguments." % tool["name"])
655
+ if self.agent_id is not None and action["gate"] == "relayed" and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
656
+ raise InteropError("Tool %s: a relayed action on a server that reads mandates names its effect class (DEC-0125)." % tool["name"])
657
+ if effect is not None and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
658
+ raise InteropError("Tool %s: effect is one of %s." % (tool["name"], ", ".join(EFFECT_CLASSES)))
659
+ if not (spend is None or callable(spend) or (isinstance(spend, (int, float)) and not isinstance(spend, bool))):
660
+ raise InteropError("Tool %s: spend is a number of USD or a function of the arguments." % tool["name"])
661
+ if not (project is None or isinstance(project, str) or callable(project)):
662
+ raise InteropError("Tool %s: project is a name or a function of the arguments." % tool["name"])
663
+ self.actions[tool["name"]] = {"action": dict(action), "target": target, "url": human_step_url,
664
+ "effect": effect, "spend": spend, "project": project}
567
665
  self.tools.append(tool)
568
666
  self.handlers[tool["name"]] = handler
569
667
 
668
+ def action_table(self) -> List[Dict[str, Any]]:
669
+ """The declared actions in the order they were added: build_well_known(actions=) writes them (DEC-0121 AP-1)."""
670
+ return [dict(entry["action"]) for entry in self.actions.values()]
671
+
672
+ def _caller(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
673
+ """D9: the delegation's caller when a gate accepted a token, else identify(headers, tier), else the local floor."""
674
+ authenticated = (verdict or {}).get("caller")
675
+ if not authenticated and self._identify is not None:
676
+ authenticated = self._identify(headers, tier)
677
+ return action_caller(tier, authenticated)
678
+
679
+ def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
680
+ headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str],
681
+ mandate: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
682
+ """action_gate for one call: the approval the call presented, the digest of {tool, arguments}, the clock, and
683
+ under DEC-0125 what a mandate is read with (`mandate`: self_id, effect, caller_agent, spend_usd, project)."""
684
+ digest, approval = None, None
685
+ if action["gate"] == "relayed":
686
+ import fabric_delegation as _fd # noqa: E402 - only an action-gated server needs it
687
+ try:
688
+ digest = _fd.action_digest({"tool": name, "arguments": arguments})
689
+ except (TypeError, ValueError, RecursionError): # no canonical form (a lone surrogate, a non-finite number,
690
+ # nesting past the recursion limit), as intent_digest reads it: no digest to bind
691
+ return {"ok": False, "invalid": "Invalid params: the arguments have no canonical form."}
692
+ outcome = self._approve(name, arguments, headers, digest) if self._approve is not None else None
693
+ if isinstance(outcome, dict) and outcome.get("ok") is True:
694
+ approval = outcome
695
+ elif isinstance(outcome, dict) and outcome.get("code") not in (None, "approval-required"):
696
+ # DL-18: the presented approval's own refusal; an outcome that is not verify_approval's is the service's.
697
+ readable = outcome.get("code") in _fd.REFUSAL_CODES and isinstance(outcome.get("detail"), str) and outcome["detail"]
698
+ if readable:
699
+ return {"ok": False, "refusal": outcome}
700
+ return {"ok": False, "refusal": {"ok": False, "code": "unknown-state", "detail": "service-fault"},
701
+ "fault": "approval-outcome-unreadable"}
702
+ elicited = action["gate"] == "relayed" and self._elicited is not None and self._elicited(name, arguments, caller) is True
703
+ return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
704
+ action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000), **(mandate or {}))
705
+
706
+ def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], verdict: Optional[Dict[str, Any]]) -> Dict[str, Any]:
707
+ """DEC-0125: what action_gate reads a mandate with, empty on a server without agent_id. The caller's agent id is
708
+ the delegation's caller when authorize accepted a token, never a name the call carries."""
709
+ if self.agent_id is None:
710
+ return {}
711
+ spend, project = (self._read(entry.get(k), arguments) for k in ("spend", "project"))
712
+ caller_agent = (verdict or {}).get("caller")
713
+ # The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
714
+ # estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
715
+ try:
716
+ double = float(spend) if isinstance(spend, (int, float)) and not isinstance(spend, bool) else math.nan
717
+ except OverflowError:
718
+ double = math.inf
719
+ finite = math.isfinite(double) # the service's own number goes on when it is finite (25 stays 25)
720
+ if project is _UNREADABLE or not (project is None or isinstance(project, str)):
721
+ project = _UNREADABLE_PROJECT # a function that raised or named no string: a scoped mandate refuses
722
+ return {"self_id": self.agent_id, "effect": entry.get("effect"),
723
+ "caller_agent": caller_agent if isinstance(caller_agent, str) and caller_agent else None,
724
+ # an estimate that raised or is not finite is none: a mandate with a ceiling then refuses (fail closed)
725
+ "spend_usd": spend if finite else None,
726
+ "project": project if isinstance(project, str) else None}
727
+
728
+ @staticmethod
729
+ def _read(value: Any, arguments: Dict[str, Any]) -> Any:
730
+ """A spend or project the service gave, or what its function answers; a function that raises answers _UNREADABLE."""
731
+ try:
732
+ return value(arguments) if callable(value) else value
733
+ except Exception: # noqa: BLE001 - the service's own code; the call is still answered
734
+ return _UNREADABLE
735
+
736
+ def _gate_inputs(self, arguments: Dict[str, Any], headers: Any, tier: str, meta: Dict[str, Any],
737
+ caller: Optional[str]) -> Dict[str, Any]:
738
+ """D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
739
+ one decision per call; the first refusal refuses the call and nothing is applied."""
740
+ job_id = str(arguments.get("id", ""))
741
+ job = self.jobs.get(job_id)
742
+ asked = job.get("inputRequests", {}) if job["status"] == "input_required" else {}
743
+ records: Dict[str, Any] = {}
744
+ for key, value in arguments["inputResponses"].items():
745
+ if key not in asked or not isinstance(value, dict) or value.get("action") not in ("accept", "decline", "cancel"):
746
+ continue
747
+ action = {"id": key, "title": "Answer %s" % key, "tool": "fabric.job.get", "gate": "relayed"}
748
+ gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
749
+ headers, tier, meta, caller)
750
+ if not gated["ok"]:
751
+ return gated
752
+ records[key] = gated["record"]
753
+ return {"ok": True, "records": records}
754
+
755
+ def _fault(self, tool: str, gated: Dict[str, Any]) -> None:
756
+ """Hand a service fault to on_fault; the caller saw only unknown-state. A hook that raises is the service's."""
757
+ if gated.get("fault") and self._on_fault is not None:
758
+ try:
759
+ self._on_fault(tool, str(gated["fault"]))
760
+ except Exception: # noqa: BLE001 - logging must not change the answer
761
+ pass
762
+
763
+ @staticmethod
764
+ def _target(target: Any, arguments: Dict[str, Any]) -> Optional[str]:
765
+ """The object a call acts on: the named argument, or what the service's function answers; a string or None."""
766
+ try:
767
+ value = target(arguments) if callable(target) else arguments.get(target) if isinstance(target, str) else None
768
+ except Exception: # noqa: BLE001 - a function that cannot read this call names no object
769
+ return None
770
+ return value if isinstance(value, str) else None
771
+
570
772
  def job_started(self, job_id: str, ctx: CallContext) -> None:
571
773
  if self._on_job_started:
572
774
  self._on_job_started(job_id, ctx)
@@ -641,7 +843,21 @@ class McpToolServer:
641
843
  if not verdict.get("ok"):
642
844
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
643
845
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
644
- return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict)}
846
+ caller, inputs = self._caller(headers, tier, verdict), None
847
+ responses = arguments.get("inputResponses")
848
+ if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
849
+ try:
850
+ inputs = self._gate_inputs(arguments, headers, tier, meta, caller)
851
+ except UnknownJob:
852
+ return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
853
+ if not inputs["ok"] and "invalid" in inputs:
854
+ return self._error(rid, -32602, inputs["invalid"])
855
+ if not inputs["ok"]:
856
+ self._fault(name, inputs)
857
+ import fabric_delegation as _fd # noqa: E402 - DL-19
858
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
859
+ context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None}
860
+ return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
645
861
  handler = self.handlers.get(name)
646
862
  if handler is None:
647
863
  return self._error(rid, -32602, "Unknown tool: %s." % name)
@@ -652,14 +868,26 @@ class McpToolServer:
652
868
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
653
869
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
654
870
  delegation = verdict
871
+ caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
872
+ if entry is not None: # DEC-0121: after the authorize gate, before the handler
873
+ gated = self._gate_action(entry["action"], name, arguments, self._target(entry["target"], arguments), entry["url"],
874
+ headers, tier, meta, caller, self._mandate_inputs(entry, arguments, delegation))
875
+ if not gated["ok"] and "invalid" in gated:
876
+ return self._error(rid, -32602, gated["invalid"])
877
+ if not gated["ok"]:
878
+ self._fault(name, gated)
879
+ import fabric_delegation as _fd # noqa: E402 - DL-19
880
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
881
+ context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
882
+ record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"))
655
883
  try:
656
- structured = handler(arguments, CallContext(self, name, arguments, span, delegation))
884
+ structured = handler(arguments, context)
657
885
  except InteropError as exc:
658
886
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
659
887
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
660
888
 
661
889
  def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
662
- delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
890
+ delegation: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
663
891
  job_id = str(arguments.get("id", ""))
664
892
  try:
665
893
  job_span = self.jobs.traceparent(job_id) or span
@@ -674,11 +902,14 @@ class McpToolServer:
674
902
  state = self.jobs.get(job_id)
675
903
  return tool_result({"job": state}, traceparent=job_span)
676
904
  responses = arguments.get("inputResponses")
905
+ context = context or {}
906
+ if responses and context.get("input_records") is not None: # D8: only the keys the action gate took
907
+ responses = {key: responses[key] for key in context["input_records"]}
677
908
  if responses:
678
909
  matched = self.jobs.answer(job_id, responses)
679
910
  if matched and self._on_input:
680
911
  private = self.jobs.private(job_id)
681
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
912
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
682
913
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
683
914
  except UnknownJob:
684
915
  return unknown_job_result(job_id, traceparent=span)
@@ -17,6 +17,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
17
17
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
18
18
 
19
19
  from fabric_base import ServiceError # noqa: E402
20
+ from fabric_actions import action_decision, decision_shape # noqa: E402 - DEC-0121
21
+ from fabric_mandate import mandate_problem # noqa: E402 - DEC-0125
20
22
 
21
23
  PROTOCOL = "fabric-interop/0.1"
22
24
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
@@ -288,14 +290,80 @@ def reads_null_usage(request_meta: Any) -> bool:
288
290
  return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
289
291
 
290
292
 
291
- def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
292
- """A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087)."""
293
+ def request_meta(traceparent: Optional[str] = None, decision: Any = None, mandate: Any = None) -> Dict[str, Any]:
294
+ """A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087), for a
295
+ relayed action the operator's decision (DEC-0121 AP-2; build one with fabric_actions.operator_decision), and the
296
+ operator's mandate for the task the call serves (DEC-0125 MD-3; state it with fabric_mandate.operator_mandate and
297
+ hand it on to the callee with fabric_mandate.hand_on before you send it). Both travel here only, never in a tool's
298
+ arguments; a decision decision.schema.json refuses or a mandate FAC-SEM-144 refuses raises InteropError."""
293
299
  meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
294
300
  if parse_traceparent(traceparent):
295
301
  meta["traceparent"] = traceparent
302
+ if decision is not None:
303
+ if not decision_shape(decision):
304
+ raise InteropError("The decision is not one decision.schema.json reads; it is not sent.")
305
+ meta["decision"] = dict(decision)
306
+ if mandate is not None:
307
+ problem = mandate_problem(mandate)
308
+ if problem:
309
+ raise InteropError("The mandate is not one FAC-SEM-144 reads (%s); it is not sent." % problem)
310
+ meta["mandate"] = json.loads(json.dumps(mandate))
296
311
  return meta
297
312
 
298
313
 
314
+ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
315
+ elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
316
+ human_step_url: Optional[str] = None, now: Any = None, self_id: Optional[str] = None,
317
+ effect: Optional[str] = None, caller_agent: Optional[str] = None, spend_usd: Any = None,
318
+ project: Optional[str] = None) -> Dict[str, Any]:
319
+ """One call of a declared action, before its handler runs (DEC-0121 AP-2, AP-4): fabric_actions.action_decision over
320
+ the call's `_meta.decision` (read from `meta` only, never from arguments, decision D11), the service's authenticated
321
+ `caller` (fabric_actions.action_caller), `target` (the object the call acts on), `elicited` (the service's own
322
+ record of an accepted elicitation), `approval` (verify_approval's accepted outcome), `action_digest`,
323
+ `human_step_url` and `now` (RFC 3339). Answers {ok: True, record, decision}, `decision` being the one taken as the
324
+ operator's (None otherwise), or {ok: False, refusal} in the DL-19 form (fabric_delegation.refusal_tool_result
325
+ sends it); a service's own fault is the refusal unknown-state with `fault` beside it for the service's log.
326
+
327
+ DEC-0125: a service that names its own agent id (`self_id`) reads the call's `_meta.mandate` when it carries no
328
+ decision, given `effect` (the action's effect class, required: without it the call is the service's fault
329
+ effect-missing), `caller_agent` (only an agent id the service authenticated: a delegation's caller, its own
330
+ per-agent credential; never a name the call carries), `spend_usd` (its own estimate) and `project`. A covered call
331
+ answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
332
+ `self_id` the mandate is not read and the call is answered as before it existed."""
333
+ import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
334
+ inputs: Dict[str, Any] = {"action": action, "tier": tier, "caller": caller, "elicited": elicited is True, "now": now}
335
+ if isinstance(meta, dict) and "decision" in meta:
336
+ inputs["decision"] = meta["decision"]
337
+ for name, value in (("target", target), ("approval", approval), ("actionDigest", action_digest), ("humanStepUrl", human_step_url)):
338
+ if value is not None:
339
+ inputs[name] = value
340
+ if self_id is not None and isinstance(meta, dict) and "mandate" in meta:
341
+ inputs.update({"mandate": meta["mandate"], "self": self_id})
342
+ for name, value in (("effect", effect), ("callerAgent", caller_agent), ("spendUsd", spend_usd), ("project", project)):
343
+ if value is not None:
344
+ inputs[name] = value
345
+ answer = action_decision(inputs)
346
+ if answer.get("ok") is True:
347
+ record = answer["recordAs"]
348
+ taken = isinstance(record, dict) and record.get("via") == "relayed-instruction"
349
+ out = {"ok": True, "record": record, "decision": inputs["decision"] if taken else None}
350
+ if isinstance(record, dict) and record.get("via") == "mandate":
351
+ out["mandate"] = inputs["mandate"]
352
+ return out
353
+ out: Dict[str, Any] = {"ok": False, "refusal": _fd.refusal_from_action(answer)}
354
+ if "fault" in answer:
355
+ out["fault"] = answer["fault"]
356
+ return out
357
+
358
+
359
+ def action_refusal_tool_result(answer: Any, traceparent: Optional[str] = None) -> Optional[Dict[str, Any]]:
360
+ """The tools/call result a service's own MCP server answers for an action_decision answer: None when the action
361
+ takes the call, else DL-19's refusal as structuredContent with isError true (DEC-0121)."""
362
+ import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
363
+ refusal = _fd.refusal_from_action(answer)
364
+ return None if refusal is None else _fd.refusal_tool_result(refusal, traceparent=traceparent)
365
+
366
+
299
367
  def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
300
368
  """FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
301
369
  if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False: