@passioncode-ai/fabric-agent-adapter 0.10.0 → 0.11.1

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) 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]] = []
@@ -540,17 +551,39 @@ class McpToolServer:
540
551
  name = params.get("name")
541
552
  arguments = params.get("arguments") or {}
542
553
  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)}
554
+ verdict = None
555
+ if self._authorize is not None:
556
+ job_id = str(arguments.get("id", ""))
557
+ try:
558
+ private = self.jobs.private(job_id)
559
+ except UnknownJob:
560
+ return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(job_id, traceparent=span)}
561
+ verdict = self._authorize(private["capability"], headers)
562
+ owner = private.get("delegation")
563
+ if verdict.get("ok") and owner and (owner.get("principal"), owner.get("caller")) != (verdict.get("principal"), verdict.get("caller")):
564
+ verdict = {"ok": False, "code": "scope-not-granted", "detail": "job-of-another-caller"}
565
+ if not verdict.get("ok"):
566
+ import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
567
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
568
+ return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict)}
544
569
  handler = self.handlers.get(name)
545
570
  if handler is None:
546
571
  return self._error(rid, -32602, "Unknown tool: %s." % name)
572
+ delegation = None
573
+ if self._authorize is not None:
574
+ verdict = self._authorize(name, headers)
575
+ if not verdict.get("ok"):
576
+ import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
577
+ return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
578
+ delegation = verdict
547
579
  try:
548
- structured = handler(arguments, CallContext(self, name, arguments, span))
580
+ structured = handler(arguments, CallContext(self, name, arguments, span, delegation))
549
581
  except InteropError as exc:
550
582
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
551
583
  return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
552
584
 
553
- def _job_call(self, name: str, arguments: Dict[str, Any], span: str) -> Dict[str, Any]:
585
+ def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
586
+ delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
554
587
  job_id = str(arguments.get("id", ""))
555
588
  try:
556
589
  job_span = self.jobs.traceparent(job_id) or span
@@ -566,7 +599,7 @@ class McpToolServer:
566
599
  matched = self.jobs.answer(job_id, responses)
567
600
  if matched and self._on_input:
568
601
  private = self.jobs.private(job_id)
569
- self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span))
602
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
570
603
  return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
571
604
  except UnknownJob:
572
605
  return unknown_job_result(job_id, traceparent=span)
@@ -5,7 +5,7 @@ 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.11.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5"
11
11
  ---