@passioncode-ai/fabric-agent-adapter 0.10.0 → 0.12.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.
@@ -279,14 +279,17 @@ class JobStore:
279
279
  return dict(record["job"])
280
280
 
281
281
  def create(self, capability: str, arguments: Any, traceparent: Optional[str] = None,
282
- poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
282
+ poll_interval_ms: Optional[int] = None, delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
283
283
  """Start a job; returns the handle a job-capable tool puts in structuredContent."""
284
284
  job_id = "job_" + secrets.token_hex(12)
285
285
  job: Dict[str, Any] = {"id": job_id, "status": "working"}
286
286
  if poll_interval_ms:
287
287
  job["pollIntervalMs"] = int(poll_interval_ms)
288
- self._save({"job": job, "private": {"capability": capability, "input": arguments,
289
- "traceparent": traceparent if parse_traceparent(traceparent) else None}})
288
+ private: Dict[str, Any] = {"capability": capability, "input": arguments,
289
+ "traceparent": traceparent if parse_traceparent(traceparent) else None}
290
+ if delegation: # DEC-0039: who started it; only they read, cancel or answer it
291
+ private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
292
+ self._save({"job": job, "private": private})
290
293
  return {"job": {"id": job_id, "status": "working"}}
291
294
 
292
295
  def get(self, job_id: str) -> Dict[str, Any]:
@@ -361,16 +364,19 @@ class JobStore:
361
364
  class CallContext:
362
365
  """What a tool handler gets: the child span it runs in, and a way to start a job."""
363
366
 
364
- def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str):
367
+ def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
368
+ delegation: Optional[Dict[str, Any]] = None):
365
369
  self.server = server
366
370
  self.tool = tool
367
371
  self.arguments = arguments
368
372
  self.traceparent = traceparent
373
+ # DEC-0039: the accepted delegation (principal, caller, chain, claims) when the server has a gate.
374
+ self.delegation = delegation
369
375
 
370
376
  def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
371
377
  if self.server.jobs is None:
372
378
  raise InteropError("This server has no job store.")
373
- handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms)
379
+ handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms, self.delegation)
374
380
  self.server.job_started(handle["job"]["id"], self)
375
381
  return handle
376
382
 
@@ -471,7 +477,12 @@ class McpToolServer:
471
477
 
472
478
  def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
473
479
  on_job_started: Optional[Callable[[str, CallContext], None]] = None,
474
- on_input: Optional[OnInput] = None):
480
+ on_input: Optional[OnInput] = None,
481
+ authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None):
482
+ # authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
483
+ # a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
484
+ # gated by the job's own capability, and answered only for the principal and caller that started it.
485
+ self._authorize = authorize
475
486
  self.info = {"name": name, "version": version}
476
487
  self.jobs = jobs
477
488
  self.tools: List[Dict[str, Any]] = []
@@ -506,10 +517,12 @@ class McpToolServer:
506
517
  def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
507
518
  return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
508
519
 
509
- def handle(self, message: Any, headers: Any = None) -> Optional[Dict[str, Any]]:
520
+ def handle(self, message: Any, headers: Any = None, tier: str = "T2") -> Optional[Dict[str, Any]]:
510
521
  """One JSON-RPC message. Pass the HTTP request headers so the 2026-07-28 standard headers
511
522
  are checked (`mcp_header_problem`); a mismatch answers HEADER_MISMATCH even for a
512
- notification, so the transport can return HTTP 400."""
523
+ notification, so the transport can return HTTP 400. `tier` is the caller's origin for the
524
+ authorize gate (DL-25: T0 local and authenticated by the service's floor, T1 an enrolled
525
+ machine of the owner, T2 anything else); a gate never requires a token from T0 or T1."""
513
526
  if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
514
527
  return self._error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request.")
515
528
  problem = mcp_header_problem(message, headers)
@@ -540,17 +553,39 @@ class McpToolServer:
540
553
  name = params.get("name")
541
554
  arguments = params.get("arguments") or {}
542
555
  if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
543
- return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span)}
556
+ verdict = None
557
+ if self._authorize is not None:
558
+ job_id = str(arguments.get("id", ""))
559
+ try:
560
+ private = self.jobs.private(job_id)
561
+ except UnknownJob:
562
+ return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(job_id, traceparent=span)}
563
+ verdict = self._authorize(private["capability"], headers, tier=tier)
564
+ owner = private.get("delegation")
565
+ if verdict.get("ok") and owner and (owner.get("principal"), owner.get("caller")) != (verdict.get("principal"), verdict.get("caller")):
566
+ verdict = {"ok": False, "code": "scope-not-granted", "detail": "job-of-another-caller"}
567
+ if not verdict.get("ok"):
568
+ import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
569
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
570
+ return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict)}
544
571
  handler = self.handlers.get(name)
545
572
  if handler is None:
546
573
  return self._error(rid, -32602, "Unknown tool: %s." % name)
574
+ delegation = None
575
+ if self._authorize is not None:
576
+ verdict = self._authorize(name, headers, tier=tier)
577
+ if not verdict.get("ok"):
578
+ import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
579
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
580
+ delegation = verdict
547
581
  try:
548
- structured = handler(arguments, CallContext(self, name, arguments, span))
582
+ structured = handler(arguments, CallContext(self, name, arguments, span, delegation))
549
583
  except InteropError as exc:
550
584
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
551
585
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
552
586
 
553
- def _job_call(self, name: str, arguments: Dict[str, Any], span: str) -> Dict[str, Any]:
587
+ def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
588
+ delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
554
589
  job_id = str(arguments.get("id", ""))
555
590
  try:
556
591
  job_span = self.jobs.traceparent(job_id) or span
@@ -566,7 +601,7 @@ class McpToolServer:
566
601
  matched = self.jobs.answer(job_id, responses)
567
602
  if matched and self._on_input:
568
603
  private = self.jobs.private(job_id)
569
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span))
604
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
570
605
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
571
606
  except UnknownJob:
572
607
  return unknown_job_result(job_id, traceparent=span)
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.10.0"
8
+ version: "0.12.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
10
+ contract-commit: "2eb91c00e852d9c31d804310d38fad91589fcbac"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -119,7 +119,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
119
119
  --schema-base <immutable-base-uri>
120
120
  ```
121
121
 
122
- Pin exactly contract `0.1.0` at commit `8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5` and
122
+ Pin exactly contract `0.1.0` at commit `2eb91c00e852d9c31d804310d38fad91589fcbac` and
123
123
  read the pinned guide before implementing protocol details. If this skill is installed
124
124
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
125
125
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check