@passioncode-ai/passioncode 0.2.37 → 0.2.39

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 (56) 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 +53 -3
  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/references/provider-entry.md +3 -1
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +4 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -14
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +38 -12
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +9 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +53 -3
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -1
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +99 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -2
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +6 -1
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -3
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +47 -3
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +325 -2
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -1
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +246 -0
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -1
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +22 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +45 -3
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +45 -3
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +302 -0
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -1
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +58 -5
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +154 -8
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +45 -2
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +202 -18
  50. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  51. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +53 -3
  52. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  53. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +41 -9
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -0
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  56. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -50,7 +50,7 @@ REFUSAL_CODES = (
50
50
  "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
51
51
  "grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
52
52
  "principal-unproven", "credential-source-refused", "chain-too-deep", "profile-forbids", "issuer-unavailable",
53
- "unknown-state",
53
+ "unknown-state", "operator-decision-required", "decision-invalid", "human-step-required",
54
54
  )
55
55
  EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
56
56
  DELEGATION_HEADER = "Fabric-Delegation"
@@ -92,7 +92,14 @@ REFUSAL_MESSAGES = {
92
92
  "profile-forbids": "The caller's or the target's machine profile does not allow this call.",
93
93
  "issuer-unavailable": "The estate's published keys cannot be read.",
94
94
  "unknown-state": "The state cannot be established, so the call is refused.",
95
+ # DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
96
+ "operator-decision-required": "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
97
+ "decision-invalid": "The call's decision is not valid for this action, object or time.",
98
+ "human-step-required": "This step is the person's own; hand them the link in error.data.url.",
95
99
  }
100
+ # The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters).
101
+ _ACTION_DETAILS = {"operator-decision-required": "no-decision", "decision-invalid": "invalid-decision",
102
+ "human-step-required": "human-step", "approval-required": "no-approval"}
96
103
 
97
104
  _B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
98
105
  # DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
@@ -801,6 +808,20 @@ def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
801
808
  return {"error": error}
802
809
 
803
810
 
811
+ def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
812
+ """DL-19 (DEC-0121): the refusal a service answers for an action call that fabric_actions.action_decision refused,
813
+ or None when it took the call. operator-decision-required, decision-invalid and approval-required carry their
814
+ detail; human-step-required carries `humanStep` and `url` (AP-4). A `fault` is the service's own and is never
815
+ sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer."""
816
+ if isinstance(answer, dict) and answer.get("ok") is True:
817
+ return None
818
+ code = answer.get("code") if isinstance(answer, dict) and answer.get("ok") is False else None
819
+ if not isinstance(code, str) or code not in _ACTION_DETAILS:
820
+ return _refuse("unknown-state", "service-fault")
821
+ data = answer.get("data")
822
+ return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
823
+
824
+
804
825
  def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
805
826
  """The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
806
827
  data = {"detail": refusal["detail"]}
@@ -36,6 +36,7 @@ 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
39
40
  from fabric_usage import (
40
41
  ROUTES,
41
42
  )
@@ -46,7 +47,7 @@ from fabric_usage import (
46
47
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
47
48
 
48
49
 
49
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.3"
50
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
50
51
 
51
52
 
52
53
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -217,6 +218,16 @@ SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "
217
218
  _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?\Z", re.ASCII)
218
219
 
219
220
 
221
+ # FAA-120: source.repository is the schema's common `uri`: RFC 3986 characters only (unreserved, reserved and %HH),
222
+ # the same in both twins; an authority, when there is one, names a host and a port of digits up to 65535, as Fabric's
223
+ # `new URL` reads it. A user:password@ in it is a credential, never the name of a repository.
224
+ _SOURCE_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:(?:[A-Za-z0-9._~:/?#@!$&'()*+,;=-]|%[0-9A-Fa-f]{2})+\Z")
225
+ # Brackets belong to an IP-literal host only (RFC 3986 3.2.2); the literal is set aside before the character check.
226
+ _IP_LITERAL = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@\[\]]*@)?)\[[0-9A-Fa-f:.vV]+\]")
227
+ _URI_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@]*@)?(?P<host>\[[^\]]*\]|[^/?#:]*)(?::(?P<port>[^/?#]*))?")
228
+ _URI_PASSWORD = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?#@]*:[^/?#@]*@")
229
+
230
+
220
231
  _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])\Z", re.ASCII)
221
232
 
222
233
 
@@ -232,8 +243,16 @@ def _text(value: Any, low: int = 1, high: int = 200) -> bool:
232
243
  return isinstance(value, str) and low <= len(value) <= high
233
244
 
234
245
 
246
+ def _authority_ok(uri: str) -> bool:
247
+ m = _URI_AUTHORITY.match(uri)
248
+ if not m:
249
+ return True
250
+ port = m.group("port")
251
+ return bool(m.group("host")) and (port is None or port == "" or (port.isdigit() and len(port) <= 5 and int(port) <= 65535))
252
+
253
+
235
254
  def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
236
- """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
255
+ """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, and `source` (FAA-120), as the schema shapes them, with
237
256
  FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
238
257
  named by its place, never its value)."""
239
258
  import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
@@ -273,6 +292,21 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
273
292
  if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
274
293
  problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
275
294
  places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
295
+ if "source" in descriptor:
296
+ source = descriptor.get("source")
297
+ if not isinstance(source, dict):
298
+ problems.append("source is {repository?}")
299
+ source = {}
300
+ problems += ["source has no member %s" % k for k in source if k != "repository"]
301
+ if "repository" in source:
302
+ repository = source["repository"]
303
+ if not (isinstance(repository, str) and _SOURCE_URI.match(_IP_LITERAL.sub(r"\1h", repository, count=1))):
304
+ problems.append("source.repository is a URI (RFC 3986), such as https://github.com/owner/name")
305
+ elif not _authority_ok(repository):
306
+ problems.append("source.repository names a host, and a port of digits up to 65535 when it has one")
307
+ elif _URI_PASSWORD.match(repository):
308
+ problems.append("source.repository carries no password: it names the repository, never a credential")
309
+ places.append(("/source/repository", repository))
276
310
  if "peer" in descriptor:
277
311
  peer = descriptor.get("peer")
278
312
  if not isinstance(peer, dict):
@@ -299,7 +333,8 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
299
333
  problems.append("a service offered to others never runs on a subscription (DEC-0061)")
300
334
  for place, value in places:
301
335
  if isinstance(value, str) and fabric_secrets.secret_argument(value):
302
- problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
336
+ problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names %s" % (
337
+ place, "a repository" if place.startswith("/source/") else "a kind, a role or an installer"))
303
338
  return problems
304
339
 
305
340
 
@@ -543,11 +578,16 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
543
578
  update_available: Optional[str] = None, pid: Optional[int] = None,
544
579
  extensions: Optional[Dict[str, Any]] = None,
545
580
  configuration: Optional[Dict[str, Any]] = None,
546
- protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
581
+ protocols: Optional[Sequence[str]] = None,
582
+ actions: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
547
583
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
548
584
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
549
585
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
550
- in it must be valid (dashboard_extension builds one)."""
586
+ in it must be valid (dashboard_extension builds one).
587
+ `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
588
+ tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
589
+ A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
590
+ humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list."""
551
591
  if status not in STATUSES:
552
592
  raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
553
593
  degraded = list(degraded)
@@ -566,6 +606,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
566
606
  }
567
607
  if summary:
568
608
  doc["summary"] = list(summary)[:6]
609
+ if actions is not None:
610
+ if extensions and ACTIONS_EXTENSION_KEY in extensions:
611
+ raise ServiceError("Actions declaration: pass the action table or a ready declaration, not both.")
612
+ mcp = surfaces.get("mcp")
613
+ capabilities = mcp.get("capabilities") if isinstance(mcp, dict) else None
614
+ extensions = dict(extensions or {}, **actions_extension(actions, capabilities if isinstance(capabilities, list) else None))
569
615
  if extensions:
570
616
  if DASHBOARD_EXTENSION_KEY in extensions:
571
617
  problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
@@ -576,6 +622,13 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
576
622
  view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
577
623
  if view["reported"]:
578
624
  raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
625
+ if ACTIONS_EXTENSION_KEY in extensions:
626
+ # DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The
627
+ # tools are compared with the capabilities this document's MCP surface lists.
628
+ findings = actions_extension_rules({"extensions": {ACTIONS_EXTENSION_KEY: extensions[ACTIONS_EXTENSION_KEY]},
629
+ "surfaces": surfaces})
630
+ if findings:
631
+ raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
579
632
  doc["extensions"] = dict(extensions)
580
633
  if configuration is not None:
581
634
  problems = configuration_problems(configuration)
@@ -24,11 +24,14 @@ from pathlib import Path
24
24
  import re
25
25
  import secrets
26
26
  import sys
27
+ import time
27
28
  from typing import Any, Callable, Dict, List, Optional
28
29
 
29
30
  sys.path.insert(0, str(Path(__file__).resolve().parent))
30
31
  import fabric_service as _fs # noqa: E402
31
32
  from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
33
+ from fabric_actions import action_caller, readable_action # noqa: E402 - DEC-0121
34
+ from fabric_time import format_rfc3339 # noqa: E402 - the core's (FAA-65)
32
35
 
33
36
  from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
34
37
  _days_from_civil,
@@ -77,6 +80,8 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
77
80
  intent_idempotency_key,
78
81
  reads_null_usage,
79
82
  request_meta,
83
+ action_gate,
84
+ action_refusal_tool_result,
80
85
  usage_findings,
81
86
  usage_toward,
82
87
  _job_findings,
@@ -430,16 +435,26 @@ class JobStore:
430
435
  # --- a minimal MCP tool server (streamable HTTP, JSON responses) ------------------------
431
436
 
432
437
  class CallContext:
433
- """What a tool handler gets: the child span it runs in, and a way to start a job."""
438
+ """What a tool handler gets: the child span it runs in, and a way to start a job.
439
+
440
+ DEC-0121: `tier` is the caller's (DL-25), `caller` the identity the service authenticated (decision D9), `meta`
441
+ the request's `_meta`. For a tool registered with an action, `record` is action_decision's recordAs: "caller"
442
+ (the caller's own act) or {by, via, caller}, the act to put in the service's audit record, and `decision` the
443
+ operator's decision it took (its `instruction` stays in that record, never in events or a page another party
444
+ reads), else None. `input_records` maps each answered input request to its record when input_actions is on."""
434
445
 
435
446
  def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
436
- delegation: Optional[Dict[str, Any]] = None):
447
+ delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
448
+ meta: Optional[Dict[str, Any]] = None, record: Any = None, decision: Optional[Dict[str, Any]] = None,
449
+ input_records: Optional[Dict[str, Any]] = None):
437
450
  self.server = server
438
451
  self.tool = tool
439
452
  self.arguments = arguments
440
453
  self.traceparent = traceparent
441
454
  # DEC-0039: the accepted delegation (principal, caller, chain, claims) when the server has a gate.
442
455
  self.delegation = delegation
456
+ self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
457
+ self.record, self.decision, self.input_records = record, decision, input_records
443
458
 
444
459
  def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
445
460
  if self.server.jobs is None:
@@ -547,11 +562,26 @@ class McpToolServer:
547
562
  def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
548
563
  on_job_started: Optional[Callable[[str, CallContext], None]] = None,
549
564
  on_input: Optional[OnInput] = None,
550
- authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None):
565
+ authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None, *,
566
+ identify: Optional[Callable[[Any, str], Optional[str]]] = None,
567
+ approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
568
+ elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
569
+ input_actions: bool = False, clock: Callable[[], float] = time.time,
570
+ on_fault: Optional[Callable[[str, str], None]] = None):
551
571
  # authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
552
572
  # a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
553
573
  # gated by the job's own capability, and answered only for the principal and caller that started it.
574
+ # DEC-0121: a tool added with an action runs action_gate after authorize and before its handler.
575
+ # identify(headers, tier) names the caller the service authenticated when no delegation did (D9; else
576
+ # local:T0 / local:T1); approve(tool, arguments, headers, digest) answers verify_approval's outcome for an
577
+ # approval the call presented, or None; elicited(tool, arguments, caller) is True only on the service's own
578
+ # record of an accepted elicitation. input_actions=True gates each answered input request as an action (D8).
579
+ # on_fault(tool, fault) hears a service fault the caller only sees as unknown-state (e.g. caller-missing: an
580
+ # action-gated server needs a tier, identify or authorize that names who calls).
554
581
  self._authorize = authorize
582
+ self._identify, self._approve, self._elicited = identify, approve, elicited
583
+ self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
584
+ self.actions: Dict[str, Dict[str, Any]] = {}
555
585
  self.info = {"name": name, "version": version}
556
586
  self.jobs = jobs
557
587
  self.tools: List[Dict[str, Any]] = []
@@ -559,14 +589,101 @@ class McpToolServer:
559
589
  self._on_job_started = on_job_started
560
590
  self._on_input = on_input
561
591
 
562
- def add_tool(self, tool: Dict[str, Any], handler: Handler) -> None:
592
+ def add_tool(self, tool: Dict[str, Any], handler: Handler, action: Optional[Dict[str, Any]] = None, *,
593
+ target: Any = None, human_step_url: Optional[str] = None) -> None:
594
+ """Serve a tool. `action` (DEC-0121 AP-1) is the declared action this tool performs, {id, title, tool, gate,
595
+ humanStep?}, its `tool` this tool's name: the server runs action_gate before the handler. `target` names the
596
+ object a call acts on: an argument's name, or a function of the arguments; `human_step_url` is the link of a
597
+ `human` action's step (an https URL or a path on the service's origin, AP-4)."""
563
598
  if tool["name"] in self.handlers:
564
599
  raise InteropError("Tool %s is served twice." % tool["name"])
565
600
  if "outputSchema" in tool:
566
601
  require_object_root(tool["name"], tool["outputSchema"])
602
+ if action is not None:
603
+ if not readable_action(action) or action["tool"] != tool["name"]:
604
+ raise InteropError("Tool %s: its action is not a declared action of this tool (DEC-0121)." % tool["name"])
605
+ if action["id"] in {a["action"]["id"] for a in self.actions.values()}:
606
+ raise InteropError("Action %s is declared twice." % action["id"])
607
+ if action["gate"] == "human" and not (isinstance(human_step_url, str) and re.match(r"https://|/", human_step_url)):
608
+ raise InteropError("Tool %s: a human step needs its link, an https URL or a path (DEC-0121 AP-4)." % tool["name"])
609
+ if not (target is None or isinstance(target, str) or callable(target)):
610
+ raise InteropError("Tool %s: target is an argument's name or a function of the arguments." % tool["name"])
611
+ self.actions[tool["name"]] = {"action": dict(action), "target": target, "url": human_step_url}
567
612
  self.tools.append(tool)
568
613
  self.handlers[tool["name"]] = handler
569
614
 
615
+ def action_table(self) -> List[Dict[str, Any]]:
616
+ """The declared actions in the order they were added: build_well_known(actions=) writes them (DEC-0121 AP-1)."""
617
+ return [dict(entry["action"]) for entry in self.actions.values()]
618
+
619
+ def _caller(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
620
+ """D9: the delegation's caller when a gate accepted a token, else identify(headers, tier), else the local floor."""
621
+ authenticated = (verdict or {}).get("caller")
622
+ if not authenticated and self._identify is not None:
623
+ authenticated = self._identify(headers, tier)
624
+ return action_caller(tier, authenticated)
625
+
626
+ def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
627
+ headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str]) -> Dict[str, Any]:
628
+ """action_gate for one call: the approval the call presented, the digest of {tool, arguments}, the clock."""
629
+ digest, approval = None, None
630
+ if action["gate"] == "relayed":
631
+ import fabric_delegation as _fd # noqa: E402 - only an action-gated server needs it
632
+ try:
633
+ digest = _fd.action_digest({"tool": name, "arguments": arguments})
634
+ except (TypeError, ValueError, RecursionError): # no canonical form (a lone surrogate, a non-finite number,
635
+ # nesting past the recursion limit), as intent_digest reads it: no digest to bind
636
+ return {"ok": False, "invalid": "Invalid params: the arguments have no canonical form."}
637
+ outcome = self._approve(name, arguments, headers, digest) if self._approve is not None else None
638
+ if isinstance(outcome, dict) and outcome.get("ok") is True:
639
+ approval = outcome
640
+ elif isinstance(outcome, dict) and outcome.get("code") not in (None, "approval-required"):
641
+ # DL-18: the presented approval's own refusal; an outcome that is not verify_approval's is the service's.
642
+ readable = outcome.get("code") in _fd.REFUSAL_CODES and isinstance(outcome.get("detail"), str) and outcome["detail"]
643
+ if readable:
644
+ return {"ok": False, "refusal": outcome}
645
+ return {"ok": False, "refusal": {"ok": False, "code": "unknown-state", "detail": "service-fault"},
646
+ "fault": "approval-outcome-unreadable"}
647
+ elicited = action["gate"] == "relayed" and self._elicited is not None and self._elicited(name, arguments, caller) is True
648
+ return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
649
+ action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000))
650
+
651
+ def _gate_inputs(self, arguments: Dict[str, Any], headers: Any, tier: str, meta: Dict[str, Any],
652
+ caller: Optional[str]) -> Dict[str, Any]:
653
+ """D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
654
+ one decision per call; the first refusal refuses the call and nothing is applied."""
655
+ job_id = str(arguments.get("id", ""))
656
+ job = self.jobs.get(job_id)
657
+ asked = job.get("inputRequests", {}) if job["status"] == "input_required" else {}
658
+ records: Dict[str, Any] = {}
659
+ for key, value in arguments["inputResponses"].items():
660
+ if key not in asked or not isinstance(value, dict) or value.get("action") not in ("accept", "decline", "cancel"):
661
+ continue
662
+ action = {"id": key, "title": "Answer %s" % key, "tool": "fabric.job.get", "gate": "relayed"}
663
+ gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
664
+ headers, tier, meta, caller)
665
+ if not gated["ok"]:
666
+ return gated
667
+ records[key] = gated["record"]
668
+ return {"ok": True, "records": records}
669
+
670
+ def _fault(self, tool: str, gated: Dict[str, Any]) -> None:
671
+ """Hand a service fault to on_fault; the caller saw only unknown-state. A hook that raises is the service's."""
672
+ if gated.get("fault") and self._on_fault is not None:
673
+ try:
674
+ self._on_fault(tool, str(gated["fault"]))
675
+ except Exception: # noqa: BLE001 - logging must not change the answer
676
+ pass
677
+
678
+ @staticmethod
679
+ def _target(target: Any, arguments: Dict[str, Any]) -> Optional[str]:
680
+ """The object a call acts on: the named argument, or what the service's function answers; a string or None."""
681
+ try:
682
+ value = target(arguments) if callable(target) else arguments.get(target) if isinstance(target, str) else None
683
+ except Exception: # noqa: BLE001 - a function that cannot read this call names no object
684
+ return None
685
+ return value if isinstance(value, str) else None
686
+
570
687
  def job_started(self, job_id: str, ctx: CallContext) -> None:
571
688
  if self._on_job_started:
572
689
  self._on_job_started(job_id, ctx)
@@ -641,7 +758,21 @@ class McpToolServer:
641
758
  if not verdict.get("ok"):
642
759
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
643
760
  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)}
761
+ caller, inputs = self._caller(headers, tier, verdict), None
762
+ responses = arguments.get("inputResponses")
763
+ if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
764
+ try:
765
+ inputs = self._gate_inputs(arguments, headers, tier, meta, caller)
766
+ except UnknownJob:
767
+ return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
768
+ if not inputs["ok"] and "invalid" in inputs:
769
+ return self._error(rid, -32602, inputs["invalid"])
770
+ if not inputs["ok"]:
771
+ self._fault(name, inputs)
772
+ import fabric_delegation as _fd # noqa: E402 - DL-19
773
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
774
+ context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None}
775
+ return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
645
776
  handler = self.handlers.get(name)
646
777
  if handler is None:
647
778
  return self._error(rid, -32602, "Unknown tool: %s." % name)
@@ -652,14 +783,26 @@ class McpToolServer:
652
783
  import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
653
784
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
654
785
  delegation = verdict
786
+ caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
787
+ if entry is not None: # DEC-0121: after the authorize gate, before the handler
788
+ gated = self._gate_action(entry["action"], name, arguments, self._target(entry["target"], arguments), entry["url"],
789
+ headers, tier, meta, caller)
790
+ if not gated["ok"] and "invalid" in gated:
791
+ return self._error(rid, -32602, gated["invalid"])
792
+ if not gated["ok"]:
793
+ self._fault(name, gated)
794
+ import fabric_delegation as _fd # noqa: E402 - DL-19
795
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
796
+ context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
797
+ record=gated["record"], decision=gated["decision"])
655
798
  try:
656
- structured = handler(arguments, CallContext(self, name, arguments, span, delegation))
799
+ structured = handler(arguments, context)
657
800
  except InteropError as exc:
658
801
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
659
802
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
660
803
 
661
804
  def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
662
- delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
805
+ delegation: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
663
806
  job_id = str(arguments.get("id", ""))
664
807
  try:
665
808
  job_span = self.jobs.traceparent(job_id) or span
@@ -674,11 +817,14 @@ class McpToolServer:
674
817
  state = self.jobs.get(job_id)
675
818
  return tool_result({"job": state}, traceparent=job_span)
676
819
  responses = arguments.get("inputResponses")
820
+ context = context or {}
821
+ if responses and context.get("input_records") is not None: # D8: only the keys the action gate took
822
+ responses = {key: responses[key] for key in context["input_records"]}
677
823
  if responses:
678
824
  matched = self.jobs.answer(job_id, responses)
679
825
  if matched and self._on_input:
680
826
  private = self.jobs.private(job_id)
681
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
827
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
682
828
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
683
829
  except UnknownJob:
684
830
  return unknown_job_result(job_id, traceparent=span)
@@ -17,6 +17,7 @@ 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
20
21
 
21
22
  PROTOCOL = "fabric-interop/0.1"
22
23
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
@@ -288,14 +289,56 @@ def reads_null_usage(request_meta: Any) -> bool:
288
289
  return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
289
290
 
290
291
 
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)."""
292
+ def request_meta(traceparent: Optional[str] = None, decision: Any = None) -> Dict[str, Any]:
293
+ """A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087) and, for a
294
+ relayed action, the operator's decision (DEC-0121 AP-2; build one with fabric_actions.operator_decision). A
295
+ decision travels here only, never in a tool's arguments; one decision.schema.json refuses raises InteropError."""
293
296
  meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
294
297
  if parse_traceparent(traceparent):
295
298
  meta["traceparent"] = traceparent
299
+ if decision is not None:
300
+ if not decision_shape(decision):
301
+ raise InteropError("The decision is not one decision.schema.json reads; it is not sent.")
302
+ meta["decision"] = dict(decision)
296
303
  return meta
297
304
 
298
305
 
306
+ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
307
+ elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
308
+ human_step_url: Optional[str] = None, now: Any = None) -> Dict[str, Any]:
309
+ """One call of a declared action, before its handler runs (DEC-0121 AP-2, AP-4): fabric_actions.action_decision over
310
+ the call's `_meta.decision` (read from `meta` only, never from arguments, decision D11), the service's authenticated
311
+ `caller` (fabric_actions.action_caller), `target` (the object the call acts on), `elicited` (the service's own
312
+ record of an accepted elicitation), `approval` (verify_approval's accepted outcome), `action_digest`,
313
+ `human_step_url` and `now` (RFC 3339). Answers {ok: True, record, decision}, `decision` being the one taken as the
314
+ operator's (None otherwise), or {ok: False, refusal} in the DL-19 form (fabric_delegation.refusal_tool_result
315
+ sends it); a service's own fault is the refusal unknown-state with `fault` beside it for the service's log."""
316
+ import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
317
+ inputs: Dict[str, Any] = {"action": action, "tier": tier, "caller": caller, "elicited": elicited is True, "now": now}
318
+ if isinstance(meta, dict) and "decision" in meta:
319
+ inputs["decision"] = meta["decision"]
320
+ for name, value in (("target", target), ("approval", approval), ("actionDigest", action_digest), ("humanStepUrl", human_step_url)):
321
+ if value is not None:
322
+ inputs[name] = value
323
+ answer = action_decision(inputs)
324
+ if answer.get("ok") is True:
325
+ record = answer["recordAs"]
326
+ taken = isinstance(record, dict) and record.get("via") == "relayed-instruction"
327
+ return {"ok": True, "record": record, "decision": inputs["decision"] if taken else None}
328
+ out: Dict[str, Any] = {"ok": False, "refusal": _fd.refusal_from_action(answer)}
329
+ if "fault" in answer:
330
+ out["fault"] = answer["fault"]
331
+ return out
332
+
333
+
334
+ def action_refusal_tool_result(answer: Any, traceparent: Optional[str] = None) -> Optional[Dict[str, Any]]:
335
+ """The tools/call result a service's own MCP server answers for an action_decision answer: None when the action
336
+ takes the call, else DL-19's refusal as structuredContent with isError true (DEC-0121)."""
337
+ import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
338
+ refusal = _fd.refusal_from_action(answer)
339
+ return None if refusal is None else _fd.refusal_tool_result(refusal, traceparent=traceparent)
340
+
341
+
299
342
  def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
300
343
  """FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
301
344
  if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
@@ -15,7 +15,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
15
15
  PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
16
16
  "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "agent-directory": ("1",), "call-record": ("1",), "coding-agents": ("1",), "credential-decision": ("1",), "credential-request": ("1",), "credential-revocation": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
17
17
  "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
18
- "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-credential-envelope": ("1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
18
+ "fabric-actions": ("0.1",), "fabric-activity": ("0.1",), "fabric-agent-call": ("1",), "fabric-connections": ("0.1",), "fabric-credential-envelope": ("1",), "fabric-load": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
19
19
  "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
20
20
  "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
21
21
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
@@ -181,6 +181,15 @@ from fabric_backups import ( # noqa: F401
181
181
  newest_intact_backup,
182
182
  restore_plan,
183
183
  )
184
+ from fabric_actions import ( # noqa: F401
185
+ ACTIONS_EXTENSION_KEY,
186
+ action_decision,
187
+ action_rules,
188
+ actions_extension,
189
+ actions_extension_rules,
190
+ actions_view,
191
+ decision_shape,
192
+ )
184
193
  from fabric_connections import ( # noqa: F401
185
194
  CONNECTIONS_EXTENSION_KEY,
186
195
  connections_extension,