@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.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.
- package/CHANGELOG.md +122 -3
- package/README.md +6 -3
- package/SECURITY.md +4 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
|
@@ -50,6 +50,8 @@ from pathlib import Path as _Path # noqa: E402
|
|
|
50
50
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
51
51
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
52
52
|
from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
|
|
53
|
+
import fabric_secrets # noqa: E402 - beside this file: the WHATWG host reading (FAA-98, FAA-309)
|
|
54
|
+
import fabric_socket_proof # noqa: E402 - beside this file: the one loopback host rule (FAA-187 (15))
|
|
53
55
|
import fabric_peer_owner as _po # noqa: E402 - beside this file: the listener's owner (DEC-0154 HA-2)
|
|
54
56
|
|
|
55
57
|
RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
|
|
@@ -211,28 +213,28 @@ def redact_url(url: str) -> str:
|
|
|
211
213
|
return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
|
|
212
214
|
|
|
213
215
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
#: Loopback as a WHATWG URL parser leaves the host: the kit's one rule, shared with fabric_delegation (FAA-187 (15)).
|
|
217
|
+
_is_loopback = fabric_socket_proof.loopback_host
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _idna_sendable(host: Optional[str]) -> bool:
|
|
221
|
+
"""Whether http.client can write the host: a non-ASCII one goes out in IDNA through Python's own codec, which refuses
|
|
222
|
+
some hosts `new URL` maps (U+180E, a fullwidth dot beside a dot), so such a URL would fail on every call (FAA-187
|
|
223
|
+
(16)); it is refused at the check instead."""
|
|
224
|
+
if not host or host.isascii():
|
|
218
225
|
return True
|
|
219
|
-
if not host or any(c.isspace() for c in host):
|
|
220
|
-
return False
|
|
221
226
|
try:
|
|
222
|
-
|
|
223
|
-
return
|
|
224
|
-
except
|
|
225
|
-
pass
|
|
226
|
-
host = host[:-1] if host.endswith(".") else host
|
|
227
|
-
try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
|
|
228
|
-
return socket.inet_aton(host)[0] == 127
|
|
229
|
-
except (OSError, ValueError):
|
|
227
|
+
host.encode("idna")
|
|
228
|
+
return True
|
|
229
|
+
except UnicodeError:
|
|
230
230
|
return False
|
|
231
231
|
|
|
232
232
|
|
|
233
233
|
def check_url(url: str, *, insecure: bool = False) -> None:
|
|
234
234
|
"""Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
|
|
235
|
-
plain http to a host that is not loopback (unless `insecure`).
|
|
235
|
+
plain http to a host that is not loopback (unless `insecure`). A host `new URL` refuses for its structure (a last
|
|
236
|
+
label that is a number but no IPv4 address, `a127.0.0.1`, `09`; a host UTS46 refuses, a joiner beside ASCII alone) is
|
|
237
|
+
refused as the Node twin refuses it, read by fabric_secrets.url_refused (FAA-187 (14))."""
|
|
236
238
|
try:
|
|
237
239
|
# The ends are trimmed as urllib trims what it sends (and `new URL` too); a URL ending in a line break, which
|
|
238
240
|
# 0.55.0 sent, still passes (0.56.0 product r17).
|
|
@@ -242,7 +244,7 @@ def check_url(url: str, *, insecure: bool = False) -> None:
|
|
|
242
244
|
parts = urllib.parse.urlsplit(sent)
|
|
243
245
|
host = parts.hostname
|
|
244
246
|
parts.port # noqa: B018 - raises on a bad port
|
|
245
|
-
if not _authority_ok(parts.netloc):
|
|
247
|
+
if not _authority_ok(parts.netloc) or fabric_secrets.url_refused(sent) or not _idna_sendable(host):
|
|
246
248
|
raise ValueError("authority")
|
|
247
249
|
except (ValueError, TypeError):
|
|
248
250
|
raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
|
|
@@ -96,20 +96,24 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
|
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
|
|
99
|
-
"""pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT:
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
"""pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: the check Node's process.kill(pid, 0) makes
|
|
100
|
+
(libuv's uv_kill), asking OpenProcess for the same access (terminate, query, synchronize; never the limited query an
|
|
101
|
+
elevated process of the same user grants, FAA-323). Refused with ERROR_ACCESS_DENIED (5), another account's or an
|
|
102
|
+
elevated process: None, as Node answers EPERM there (FAA-304); refused otherwise, no process; opened, running
|
|
103
|
+
while its exit code is STILL_ACTIVE and a zero wait times out. `kernel` and `last_error` stand in for kernel32 and
|
|
102
104
|
ctypes.get_last_error in a test."""
|
|
103
105
|
import ctypes
|
|
104
106
|
if kernel is None:
|
|
105
107
|
kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
|
106
108
|
last_error = ctypes.get_last_error # type: ignore[attr-defined]
|
|
107
|
-
handle = kernel.OpenProcess(
|
|
109
|
+
handle = kernel.OpenProcess(0x0001 | 0x0400 | 0x00100000, False, pid) # TERMINATE | QUERY_INFORMATION | SYNCHRONIZE
|
|
108
110
|
if not handle:
|
|
109
111
|
return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
|
|
110
112
|
try:
|
|
111
113
|
code = ctypes.c_ulong()
|
|
112
|
-
|
|
114
|
+
if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value != 259: # STILL_ACTIVE
|
|
115
|
+
return False
|
|
116
|
+
return kernel.WaitForSingleObject(handle, 0) != 0 # WAIT_OBJECT_0: ended, with exit code 259
|
|
113
117
|
finally:
|
|
114
118
|
kernel.CloseHandle(handle)
|
|
115
119
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -17,7 +17,6 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
-
import base64
|
|
21
20
|
import hashlib
|
|
22
21
|
import json
|
|
23
22
|
import math
|
|
@@ -98,6 +97,14 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
|
98
97
|
pause_message,
|
|
99
98
|
intent_digest,
|
|
100
99
|
repeated_key_decision,
|
|
100
|
+
NAMED_METHODS,
|
|
101
|
+
UNREADABLE_AGENT,
|
|
102
|
+
identified_agent,
|
|
103
|
+
HEADER_MISMATCH,
|
|
104
|
+
encode_header_value,
|
|
105
|
+
decode_header_value,
|
|
106
|
+
mcp_header_problem,
|
|
107
|
+
mcp_request_headers,
|
|
101
108
|
)
|
|
102
109
|
|
|
103
110
|
# --- C3.1 capabilities as tools --------------------------------------------------
|
|
@@ -399,6 +406,8 @@ class JobStore:
|
|
|
399
406
|
A step is kept as the gate reads it (kept_step), so a job's start and its answer agree."""
|
|
400
407
|
if not isinstance(input_requests, dict) or not input_requests: # a JSON object of requests, first (readings 38, 39)
|
|
401
408
|
raise InteropError("input_required needs a JSON object of at least one input request.")
|
|
409
|
+
if steps is not None and not isinstance(steps, dict): # a JSON object or none, in both kits (FAA-196 (26))
|
|
410
|
+
raise InteropError("steps maps an input request's key to its approval step: a JSON object, or none.")
|
|
402
411
|
kept: Dict[str, Dict[str, Any]] = {}
|
|
403
412
|
for key, step in (steps or {}).items(): # the value checked is the value kept (seam reading 34)
|
|
404
413
|
kept[key] = kept_step(step) if key in input_requests and isinstance(step, dict) else {}
|
|
@@ -493,7 +502,8 @@ class CallContext:
|
|
|
493
502
|
def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
|
|
494
503
|
delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
|
|
495
504
|
meta: Optional[Dict[str, Any]] = None, record: Any = None, decision: Optional[Dict[str, Any]] = None,
|
|
496
|
-
input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None
|
|
505
|
+
input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None,
|
|
506
|
+
caller_agent: Optional[str] = None):
|
|
497
507
|
self.server = server
|
|
498
508
|
self.tool = tool
|
|
499
509
|
self.arguments = arguments
|
|
@@ -503,12 +513,19 @@ class CallContext:
|
|
|
503
513
|
self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
|
|
504
514
|
self.record, self.decision, self.input_records = record, decision, input_records
|
|
505
515
|
self.mandate = mandate
|
|
516
|
+
# FAA-135: the caller's agent id the service authenticated (the delegation's caller, else identify_agent's).
|
|
517
|
+
delegated = (delegation or {}).get("caller")
|
|
518
|
+
self.caller_agent = caller_agent if caller_agent is not None else (delegated if isinstance(delegated, str) and delegated else None)
|
|
506
519
|
|
|
507
520
|
def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
|
|
508
521
|
if self.server.jobs is None:
|
|
509
522
|
raise InteropError("This server has no job store.")
|
|
510
523
|
handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms, self.delegation)
|
|
511
|
-
|
|
524
|
+
try:
|
|
525
|
+
self.server.job_started(handle["job"]["id"], self)
|
|
526
|
+
except InteropError as exc: # a refused start: the job it created ends with it, never left working (FAA-196 (28))
|
|
527
|
+
self.server.jobs.fail(handle["job"]["id"], "tool-error", str(exc)[:_MAX_ERROR_MESSAGE] or "The job's start was refused.")
|
|
528
|
+
raise
|
|
512
529
|
return handle
|
|
513
530
|
|
|
514
531
|
def approval_step(self, job_id: str, key: str, request: Dict[str, Any], *, effect: str, spend_usd: Any = None,
|
|
@@ -527,7 +544,7 @@ class CallContext:
|
|
|
527
544
|
stored = self.server.jobs.private(job_id).get("capability")
|
|
528
545
|
capability = stored if isinstance(stored, str) else self.tool
|
|
529
546
|
if self.server.agent_id is not None:
|
|
530
|
-
caller =
|
|
547
|
+
caller = self.caller_agent
|
|
531
548
|
carried = self.meta.get("mandate")
|
|
532
549
|
if carried is None and "mandate" in self.meta:
|
|
533
550
|
carried = {} # a null mandate is present and unreadable, as the answer reads it; never "none" (FAA-178)
|
|
@@ -550,84 +567,6 @@ JOB_REQUEST_SCHEMA = {"type": "object", "required": ["id"], "additionalPropertie
|
|
|
550
567
|
"inputResponses": {"type": "object"}}}
|
|
551
568
|
|
|
552
569
|
|
|
553
|
-
NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
|
|
554
|
-
HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
_SENTINEL = ("=?base64?", "?=")
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
def encode_header_value(value: str) -> str:
|
|
561
|
-
"""A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
|
|
562
|
-
it is when it is printable ASCII without leading or trailing whitespace, otherwise — and for a
|
|
563
|
-
plain value that itself looks like the sentinel — `=?base64?<base64 of its UTF-8>?=`."""
|
|
564
|
-
plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
|
|
565
|
-
and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
|
|
566
|
-
if plain:
|
|
567
|
-
return value
|
|
568
|
-
return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def decode_header_value(value: str) -> Optional[str]:
|
|
572
|
-
"""The body value a header carries, decoding the Base64 sentinel; None for a header value with
|
|
573
|
-
invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
|
|
574
|
-
if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
|
|
575
|
-
try:
|
|
576
|
-
return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
|
|
577
|
-
except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
|
|
578
|
-
return None
|
|
579
|
-
if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
|
|
580
|
-
return None
|
|
581
|
-
return value
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
585
|
-
"""MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
586
|
-
notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
587
|
-
resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
|
|
588
|
-
carry them. Earlier revisions may omit them, but what they send must still agree.
|
|
589
|
-
`headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
|
|
590
|
-
if not isinstance(message, dict) or headers is None:
|
|
591
|
-
return None
|
|
592
|
-
def header(name: str) -> Optional[str]:
|
|
593
|
-
value = headers.get(name) if hasattr(headers, "get") else None
|
|
594
|
-
if value is None and isinstance(headers, dict):
|
|
595
|
-
value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
|
|
596
|
-
if isinstance(value, (list, tuple)):
|
|
597
|
-
value = value[0] if value else None
|
|
598
|
-
return None if value is None else str(value)
|
|
599
|
-
declared = header("MCP-Protocol-Version") == MCP_REVISION
|
|
600
|
-
method = message.get("method")
|
|
601
|
-
sent = header("Mcp-Method")
|
|
602
|
-
if sent is None and declared:
|
|
603
|
-
return "the body names method %s but the required Mcp-Method header is absent" % method
|
|
604
|
-
if sent is not None and sent != method:
|
|
605
|
-
return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
|
|
606
|
-
field = NAMED_METHODS.get(str(method))
|
|
607
|
-
if field:
|
|
608
|
-
params = message.get("params")
|
|
609
|
-
value = params.get(field) if isinstance(params, dict) else None
|
|
610
|
-
named = header("Mcp-Name")
|
|
611
|
-
if named is None and declared:
|
|
612
|
-
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
613
|
-
if named is not None:
|
|
614
|
-
decoded = decode_header_value(named)
|
|
615
|
-
if decoded is None:
|
|
616
|
-
return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
|
|
617
|
-
if decoded != str(value):
|
|
618
|
-
return "Mcp-Name disagrees with the body's params.%s" % field
|
|
619
|
-
return None
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
623
|
-
"""The standard headers a 2026-07-28 client sends with one request."""
|
|
624
|
-
out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
|
|
625
|
-
field = NAMED_METHODS.get(method)
|
|
626
|
-
if field and params.get(field) is not None:
|
|
627
|
-
out["Mcp-Name"] = encode_header_value(str(params[field]))
|
|
628
|
-
return out
|
|
629
|
-
|
|
630
|
-
|
|
631
570
|
_UNREADABLE = object() # what a service's spend or project function answers when it raised
|
|
632
571
|
_UNREADABLE_PROJECT = UNREADABLE_PROJECT # never a member of a mandate's projects, so a scoped mandate refuses
|
|
633
572
|
|
|
@@ -649,7 +588,8 @@ class McpToolServer:
|
|
|
649
588
|
approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
|
|
650
589
|
elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
|
|
651
590
|
input_actions: bool = False, clock: Callable[[], float] = time.time,
|
|
652
|
-
on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None
|
|
591
|
+
on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None,
|
|
592
|
+
identify_agent: Optional[Callable[[Any, str], Optional[str]]] = None):
|
|
653
593
|
# authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
|
|
654
594
|
# a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
|
|
655
595
|
# gated by the job's own capability, and answered only for the principal and caller that started it.
|
|
@@ -663,12 +603,14 @@ class McpToolServer:
|
|
|
663
603
|
# (MJ-1), which the caller reads as operator-decision-required naming it in data.detail.
|
|
664
604
|
# DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
|
|
665
605
|
# relayed action added with its effect (the chain must end in agent_id, and in the delegation's caller before
|
|
666
|
-
# it when authorize accepted a token); without it a mandate is not read, as before.
|
|
606
|
+
# it when authorize accepted a token); without it a mandate is not read, as before. FAA-135:
|
|
607
|
+
# identify_agent(headers, tier) names the agent id of a per-agent credential the service authenticated, read
|
|
608
|
+
# when no delegation named a caller: the chain must hold it before agent_id (identified_agent reads its answer).
|
|
667
609
|
if agent_id is not None and not is_agent_id(agent_id):
|
|
668
610
|
raise InteropError("agent_id is this service's agent id (a lower-case id, optionally .instance; DEC-0125).")
|
|
669
611
|
self.agent_id = agent_id
|
|
670
612
|
self._authorize = authorize
|
|
671
|
-
self._identify, self._approve, self._elicited = identify, approve, elicited
|
|
613
|
+
self._identify, self._approve, self._elicited, self._identify_agent = identify, approve, elicited, identify_agent
|
|
672
614
|
self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
|
|
673
615
|
self.actions: Dict[str, Dict[str, Any]] = {}
|
|
674
616
|
self.info = {"name": name, "version": version}
|
|
@@ -739,6 +681,20 @@ class McpToolServer:
|
|
|
739
681
|
authenticated = self._identify(headers, tier)
|
|
740
682
|
return action_caller(tier, authenticated)
|
|
741
683
|
|
|
684
|
+
def _caller_agent(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
685
|
+
"""FAA-135: the caller's agent id a mandate's chain is bound with: the delegation's caller when authorize accepted
|
|
686
|
+
a token, else what identify_agent names (identified_agent; a hook that raises names UNREADABLE_AGENT), else
|
|
687
|
+
None. Read only on a server with agent_id; never a name the call carries."""
|
|
688
|
+
delegated = (verdict or {}).get("caller")
|
|
689
|
+
if self.agent_id is None or (isinstance(delegated, str) and delegated):
|
|
690
|
+
return delegated if self.agent_id is not None else None
|
|
691
|
+
if self._identify_agent is None:
|
|
692
|
+
return None
|
|
693
|
+
try:
|
|
694
|
+
return identified_agent(self._identify_agent(headers, tier))
|
|
695
|
+
except Exception: # noqa: BLE001 - the service's own hook: an id no chain holds, so a mandate is refused
|
|
696
|
+
return UNREADABLE_AGENT
|
|
697
|
+
|
|
742
698
|
def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
|
|
743
699
|
headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str],
|
|
744
700
|
mandate: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -766,13 +722,12 @@ class McpToolServer:
|
|
|
766
722
|
return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
|
|
767
723
|
action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000), **(mandate or {}))
|
|
768
724
|
|
|
769
|
-
def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any],
|
|
725
|
+
def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], caller_agent: Optional[str]) -> Dict[str, Any]:
|
|
770
726
|
"""DEC-0125: what action_gate reads a mandate with, empty on a server without agent_id. The caller's agent id is
|
|
771
|
-
the delegation's caller
|
|
727
|
+
_caller_agent's (the delegation's caller, else identify_agent's, FAA-135), never a name the call carries."""
|
|
772
728
|
if self.agent_id is None:
|
|
773
729
|
return {}
|
|
774
730
|
spend, project = (self._read(entry.get(k), arguments) for k in ("spend", "project"))
|
|
775
|
-
caller_agent = (verdict or {}).get("caller")
|
|
776
731
|
# The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
|
|
777
732
|
# estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
|
|
778
733
|
number = _real_number(spend) # by what it stores; a bool, a mock or not a number is none (seam reading 18)
|
|
@@ -799,7 +754,7 @@ class McpToolServer:
|
|
|
799
754
|
return _UNREADABLE
|
|
800
755
|
|
|
801
756
|
def _gate_inputs(self, arguments: Dict[str, Any], headers: Any, tier: str, meta: Dict[str, Any],
|
|
802
|
-
caller: Optional[str],
|
|
757
|
+
caller: Optional[str], agent: Optional[str] = None) -> Dict[str, Any]:
|
|
803
758
|
"""D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
|
|
804
759
|
one decision per call; the first refusal refuses the call and nothing is applied. DEC-0125: on a server with
|
|
805
760
|
agent_id, a step approval_step kept is read under the call's `_meta.mandate` too (C3.3)."""
|
|
@@ -818,7 +773,6 @@ class McpToolServer:
|
|
|
818
773
|
# A kept step's mandate is read as approval_step read it, under the job's own capability, which must
|
|
819
774
|
# be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
|
|
820
775
|
capability = private.get("capability")
|
|
821
|
-
agent = (verdict or {}).get("caller")
|
|
822
776
|
mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
|
|
823
777
|
"project": step.get("project"), "caller_agent": agent if isinstance(agent, str) and agent else None,
|
|
824
778
|
"mandate_capability": capability if isinstance(capability, str) else None}
|
|
@@ -921,13 +875,13 @@ class McpToolServer:
|
|
|
921
875
|
if not verdict.get("ok"):
|
|
922
876
|
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
923
877
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
924
|
-
caller, inputs = self._caller(headers, tier, verdict), None
|
|
878
|
+
caller, agent, inputs = self._caller(headers, tier, verdict), self._caller_agent(headers, tier, verdict), None
|
|
925
879
|
responses = arguments.get("inputResponses")
|
|
926
880
|
if name == "fabric.job.get" and responses is not None and not isinstance(responses, dict): # seam r37: never a crash
|
|
927
881
|
return self._error(rid, -32602, "fabric.job.get takes inputResponses as a JSON object (interop C3.3).")
|
|
928
882
|
if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
|
|
929
883
|
try:
|
|
930
|
-
inputs = self._gate_inputs(arguments, headers, tier, meta, caller,
|
|
884
|
+
inputs = self._gate_inputs(arguments, headers, tier, meta, caller, agent)
|
|
931
885
|
except UnknownJob:
|
|
932
886
|
return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
|
|
933
887
|
if not inputs["ok"] and "invalid" in inputs:
|
|
@@ -937,7 +891,8 @@ class McpToolServer:
|
|
|
937
891
|
self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
|
|
938
892
|
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
939
893
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
|
|
940
|
-
context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None
|
|
894
|
+
context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None,
|
|
895
|
+
"caller_agent": agent}
|
|
941
896
|
return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
|
|
942
897
|
handler = self.handlers.get(name)
|
|
943
898
|
if handler is None:
|
|
@@ -950,9 +905,10 @@ class McpToolServer:
|
|
|
950
905
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
951
906
|
delegation = verdict
|
|
952
907
|
caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
|
|
908
|
+
agent = self._caller_agent(headers, tier, delegation)
|
|
953
909
|
if entry is not None: # DEC-0121: after the authorize gate, before the handler
|
|
954
910
|
gated = self._gate_action(entry["action"], name, arguments, self._target(entry["target"], arguments), entry["url"],
|
|
955
|
-
headers, tier, meta, caller, self._mandate_inputs(entry, arguments,
|
|
911
|
+
headers, tier, meta, caller, self._mandate_inputs(entry, arguments, agent))
|
|
956
912
|
if not gated["ok"] and "invalid" in gated:
|
|
957
913
|
return self._error(rid, -32602, gated["invalid"])
|
|
958
914
|
if not gated["ok"]:
|
|
@@ -960,7 +916,7 @@ class McpToolServer:
|
|
|
960
916
|
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
961
917
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
|
|
962
918
|
context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
|
|
963
|
-
record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"))
|
|
919
|
+
record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"), caller_agent=agent)
|
|
964
920
|
try:
|
|
965
921
|
structured = handler(arguments, context)
|
|
966
922
|
except InteropError as exc:
|
|
@@ -990,7 +946,10 @@ class McpToolServer:
|
|
|
990
946
|
matched = self.jobs.answer(job_id, responses)
|
|
991
947
|
if matched and self._on_input:
|
|
992
948
|
private = self.jobs.private(job_id)
|
|
993
|
-
|
|
949
|
+
try:
|
|
950
|
+
self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
|
|
951
|
+
except InteropError as exc: # answered as the handler path answers one (FAA-196 (27))
|
|
952
|
+
return tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=job_span)
|
|
994
953
|
return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
|
|
995
954
|
except UnknownJob:
|
|
996
955
|
return unknown_job_result(job_id, traceparent=span)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py
CHANGED
|
@@ -5,6 +5,7 @@ re-exports every name here. Part of the service kit (FAA-92, split from fabric_i
|
|
|
5
5
|
only, Python 3.9+."""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import base64
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import math
|
|
@@ -20,7 +21,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
20
21
|
from fabric_base import ServiceError # noqa: E402
|
|
21
22
|
from fabric_actions import action_decision, decision_shape, job_capability # noqa: E402 - DEC-0121
|
|
22
23
|
from fabric_mandate import ( # noqa: E402 - DEC-0125, DEC-0126
|
|
23
|
-
EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandate_covers, mandate_problem, without_source)
|
|
24
|
+
EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, is_agent_id, mandate_covers, mandate_problem, without_source)
|
|
24
25
|
|
|
25
26
|
PROTOCOL = "fabric-interop/0.1"
|
|
26
27
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
@@ -377,6 +378,22 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
377
378
|
UNREADABLE_PROJECT = "\x00unreadable"
|
|
378
379
|
|
|
379
380
|
|
|
381
|
+
#: The caller's agent id when a service's identify_agent hook answered something no chain can hold (an id the registry
|
|
382
|
+
#: refuses, not a string, or the hook raised): passed as callerAgent it matches no agent, so every mandate reads
|
|
383
|
+
#: mandate-not-for-this-call (fail closed, FAA-135). Never an agent id (`is_agent_id` refuses it).
|
|
384
|
+
UNREADABLE_AGENT = "\x00unreadable-agent"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def identified_agent(value: Any) -> Optional[str]:
|
|
388
|
+
"""FAA-135: the caller's agent id as a service's own hook named it from a per-agent credential (contract delegation.md,
|
|
389
|
+
`surfaces.mcp.auth: own`), read as the mandate chain binds it (`callerAgent`): None (no per-agent credential) is
|
|
390
|
+
none, and the chain's end alone binds the mandate, as before; an agent id the registry accepts is that id; anything
|
|
391
|
+
else is UNREADABLE_AGENT, which no chain holds. Never a name the call itself carries."""
|
|
392
|
+
if value is None:
|
|
393
|
+
return None
|
|
394
|
+
return value if is_agent_id(value) else UNREADABLE_AGENT
|
|
395
|
+
|
|
396
|
+
|
|
380
397
|
def mandate_spend(value: Any) -> Any:
|
|
381
398
|
"""A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
|
|
382
399
|
not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
|
|
@@ -559,3 +576,83 @@ def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None
|
|
|
559
576
|
return "same"
|
|
560
577
|
return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
|
|
561
578
|
# #endregion job-limits-kit
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# --- MCP 2026-07-28 header mirroring (moved from fabric_interop in 0.83.0 to make room under the line ceiling) ---
|
|
582
|
+
|
|
583
|
+
NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
|
|
584
|
+
HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
_SENTINEL = ("=?base64?", "?=")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def encode_header_value(value: str) -> str:
|
|
591
|
+
"""A body value as an HTTP header value, by MCP 2026-07-28 Streamable HTTP "Value Encoding": as
|
|
592
|
+
it is when it is printable ASCII without leading or trailing whitespace, otherwise (and for a
|
|
593
|
+
plain value that itself looks like the sentinel) `=?base64?<base64 of its UTF-8>?=`."""
|
|
594
|
+
plain = (all(0x20 <= ord(c) <= 0x7E for c in value) and value == value.strip(" ")
|
|
595
|
+
and not (value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1])))
|
|
596
|
+
if plain:
|
|
597
|
+
return value
|
|
598
|
+
return _SENTINEL[0] + base64.b64encode(value.encode("utf-8")).decode("ascii") + _SENTINEL[1]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def decode_header_value(value: str) -> Optional[str]:
|
|
602
|
+
"""The body value a header carries, decoding the Base64 sentinel; None for a header value with
|
|
603
|
+
invalid characters or a sentinel that is not valid Base64 of UTF-8 (a server rejects both)."""
|
|
604
|
+
if value.startswith(_SENTINEL[0]) and value.endswith(_SENTINEL[1]) and len(value) >= len(_SENTINEL[0]) + len(_SENTINEL[1]):
|
|
605
|
+
try:
|
|
606
|
+
return base64.b64decode(value[len(_SENTINEL[0]):-len(_SENTINEL[1])], validate=True).decode("utf-8")
|
|
607
|
+
except ValueError: # binascii.Error and UnicodeDecodeError are both ValueErrors
|
|
608
|
+
return None
|
|
609
|
+
if any(not (0x20 <= ord(c) <= 0x7E or c == "\t") for c in value):
|
|
610
|
+
return None
|
|
611
|
+
return value
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
615
|
+
"""MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
616
|
+
notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
617
|
+
resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
|
|
618
|
+
carry them. Earlier revisions may omit them, but what they send must still agree.
|
|
619
|
+
`headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
|
|
620
|
+
if not isinstance(message, dict) or headers is None:
|
|
621
|
+
return None
|
|
622
|
+
def header(name: str) -> Optional[str]:
|
|
623
|
+
value = headers.get(name) if hasattr(headers, "get") else None
|
|
624
|
+
if value is None and isinstance(headers, dict):
|
|
625
|
+
value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
|
|
626
|
+
if isinstance(value, (list, tuple)):
|
|
627
|
+
value = value[0] if value else None
|
|
628
|
+
return None if value is None else str(value)
|
|
629
|
+
declared = header("MCP-Protocol-Version") == MCP_REVISION
|
|
630
|
+
method = message.get("method")
|
|
631
|
+
sent = header("Mcp-Method")
|
|
632
|
+
if sent is None and declared:
|
|
633
|
+
return "the body names method %s but the required Mcp-Method header is absent" % method
|
|
634
|
+
if sent is not None and sent != method:
|
|
635
|
+
return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
|
|
636
|
+
field = NAMED_METHODS.get(str(method))
|
|
637
|
+
if field:
|
|
638
|
+
params = message.get("params")
|
|
639
|
+
value = params.get(field) if isinstance(params, dict) else None
|
|
640
|
+
named = header("Mcp-Name")
|
|
641
|
+
if named is None and declared:
|
|
642
|
+
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
643
|
+
if named is not None:
|
|
644
|
+
decoded = decode_header_value(named)
|
|
645
|
+
if decoded is None:
|
|
646
|
+
return "Mcp-Name is malformed: invalid characters or a broken =?base64?…?= value"
|
|
647
|
+
if decoded != str(value):
|
|
648
|
+
return "Mcp-Name disagrees with the body's params.%s" % field
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
653
|
+
"""The standard headers a 2026-07-28 client sends with one request."""
|
|
654
|
+
out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
|
|
655
|
+
field = NAMED_METHODS.get(method)
|
|
656
|
+
if field and params.get(field) is not None:
|
|
657
|
+
out["Mcp-Name"] = encode_header_value(str(params[field]))
|
|
658
|
+
return out
|
|
@@ -206,15 +206,24 @@ class InstanceLock:
|
|
|
206
206
|
return self
|
|
207
207
|
|
|
208
208
|
def release(self) -> None:
|
|
209
|
+
"""Let the lock go. Never raises, so a release in a `finally` never replaces the caller's own outcome (FAA-196
|
|
210
|
+
(11)): the descriptor number and the name are forgotten before they are closed, so a retry never closes a
|
|
211
|
+
number reused since; a close that fails (a deferred write error) still drops the name, as acquire does."""
|
|
209
212
|
if self._watch is not None:
|
|
210
213
|
self._watch.set()
|
|
211
214
|
self._watch = None
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
fd, name, self._fd, self._name = self._fd, self._name, None, None
|
|
216
|
+
try:
|
|
217
|
+
if fd is not None:
|
|
218
|
+
os.close(fd)
|
|
219
|
+
except OSError: # the descriptor is gone either way; the kernel frees the lock with it
|
|
220
|
+
pass
|
|
221
|
+
finally:
|
|
222
|
+
if name is not None:
|
|
223
|
+
try:
|
|
224
|
+
_drop_lock_name(name)
|
|
225
|
+
except OSError: # nothing to add to the caller's own outcome
|
|
226
|
+
pass
|
|
218
227
|
|
|
219
228
|
|
|
220
229
|
def lost_lock_message(lock_path: Path) -> str:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py
CHANGED
|
@@ -72,6 +72,45 @@ def vendor(model: str) -> str:
|
|
|
72
72
|
return str(model).split("/", 1)[0] if "/" in str(model) else ""
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
#: A base URL's path as both twins send it unchanged: URL path characters and %XX escapes only (FAA-187 (18)).
|
|
76
|
+
_BASE_PATH = re.compile(r"(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*", re.ASCII)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dialed_host(base: str) -> str:
|
|
80
|
+
"""The base with a host outside ASCII written as urllib dials it, in IDNA (lower case), so attribution reads the
|
|
81
|
+
host the call reaches, as the Node twin's `new URL` does (FAA-187 (19)): `openrouter.ai` behind a zero-width space
|
|
82
|
+
or a soft hyphen is openrouter.ai. check_url has already refused a host the codec cannot write."""
|
|
83
|
+
scheme, sep, rest = base.partition("://")
|
|
84
|
+
authority, slash, path = rest.partition("/")
|
|
85
|
+
userinfo, at, hostport = authority.rpartition("@")
|
|
86
|
+
if not sep or hostport.isascii() or hostport.startswith("["):
|
|
87
|
+
return base
|
|
88
|
+
host, colon, port = hostport.partition(":")
|
|
89
|
+
return "%s://%s%s%s%s%s%s" % (scheme, userinfo, at, host.encode("idna").decode("ascii").lower(), colon, port, slash + path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def base_url_problem(base: str) -> Optional[str]:
|
|
93
|
+
"""Why a base URL is not sent the same by both twins, read from the URL as sent (its ends trimmed, as `new URL` and
|
|
94
|
+
urllib trim them), or None: a backslash anywhere (`new URL` reads it as a slash), and after a `//` authority a
|
|
95
|
+
path holding anything but URL path characters and %XX escapes (Python refused a space or a character outside
|
|
96
|
+
ASCII, Node sent it percent-encoded; Node removes a tab or a line break) or a dot segment, escaped or not (Node
|
|
97
|
+
collapses it). The Node twin's `baseUrlProblem` is the same rule (FAA-187 (18), kit-openrouter-cases.json)."""
|
|
98
|
+
text = base.strip("".join(map(chr, range(0x21))))
|
|
99
|
+
if "\\" in text:
|
|
100
|
+
return "the base URL holds a backslash"
|
|
101
|
+
if any(c in text for c in "\t\n\r"): # Node's URL parser removes them anywhere and sends the rest
|
|
102
|
+
return "the base URL holds a tab or a line break"
|
|
103
|
+
rest = text.partition(":")[2]
|
|
104
|
+
if not rest.startswith("//"):
|
|
105
|
+
return None # no authority as written: check_url reads it
|
|
106
|
+
path = "/" + rest[2:].partition("/")[2] if "/" in rest[2:] else ""
|
|
107
|
+
if not _BASE_PATH.fullmatch(path):
|
|
108
|
+
return "the base URL's path holds a character outside URL path characters and %XX escapes"
|
|
109
|
+
if any(re.sub("%2[eE]", ".", seg) in (".", "..") for seg in path.split("/")[1:]):
|
|
110
|
+
return "the base URL's path holds a dot segment"
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
75
114
|
_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?", re.ASCII)
|
|
76
115
|
|
|
77
116
|
|
|
@@ -170,6 +209,9 @@ class Client:
|
|
|
170
209
|
raise fabric_http.HttpError("the URL does not parse", url="<unparseable url>", kind="refused")
|
|
171
210
|
if "?" in base_url or "#" in base_url:
|
|
172
211
|
raise OpenRouterError("the base URL holds no query or fragment", kind="config")
|
|
212
|
+
problem = base_url_problem(base_url)
|
|
213
|
+
if problem:
|
|
214
|
+
raise OpenRouterError(problem + " (both kits would not send it alike)", kind="config")
|
|
173
215
|
fabric_http.check_url(base_url)
|
|
174
216
|
_check_attribution(project_id, None)
|
|
175
217
|
self.ceiling_usd = float(ceiling_usd)
|
|
@@ -179,7 +221,7 @@ class Client:
|
|
|
179
221
|
self.window = window
|
|
180
222
|
# The base URL as check_url read it and urllib would send it: its ends trimmed, so `.../v1\n` is not sent
|
|
181
223
|
# as `.../v1\n/chat/completions` (0.56.0 seam r18; the Node twin trims the ends `new URL` does, product r20).
|
|
182
|
-
self.base_url = base_url.strip(fabric_http._URL_ENDS).rstrip("/")
|
|
224
|
+
self.base_url = _dialed_host(base_url.strip(fabric_http._URL_ENDS).rstrip("/"))
|
|
183
225
|
self.attribution = attribution
|
|
184
226
|
self.project_id = project_id
|
|
185
227
|
self.unmeasured_call_usd = unmeasured_call_usd
|