@passioncode-ai/fabric-agent-adapter 0.4.2 → 0.5.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.
- package/CHANGELOG.md +70 -0
- package/LICENSE +150 -17
- package/README.md +44 -6
- package/SECURITY.md +2 -3
- package/bin/fabric-agent-adapter.js +1 -1
- package/package.json +5 -5
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +25 -6
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +22 -5
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write and remove fabric-provider/0.1 entries: agents that are not services.
|
|
3
|
+
|
|
4
|
+
An agent reached as a CLI or a stdio MCP server announces itself with one entry in
|
|
5
|
+
the providers directory, beside the services directory. Only its installer writes the
|
|
6
|
+
entry and only its uninstaller removes it; the entry grants no Project access.
|
|
7
|
+
Standard library only; it does not import the service kit, so this skill stands alone.
|
|
8
|
+
|
|
9
|
+
fabric_provider.py write --id ID --name NAME --manifest PATH --installed-by TEXT
|
|
10
|
+
(--url http://127.0.0.1:PORT/mcp | --stdio EXECUTABLE [ARG ...])
|
|
11
|
+
[--env NAME=secret-ref:REF ...] [--summary TEXT] [--repository URL]
|
|
12
|
+
fabric_provider.py remove ID
|
|
13
|
+
fabric_provider.py validate PATH
|
|
14
|
+
|
|
15
|
+
Normative source: fabric-agent-contract docs/specification/provider.md (DEC-0016).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# #region provider-writer — docs: plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md#the-writer
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
PROTOCOL = "fabric-provider/0.1"
|
|
33
|
+
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
34
|
+
_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
35
|
+
_SECRET_REF = re.compile(r"^secret-ref:([A-Za-z0-9][A-Za-z0-9._/:@-]{0,255})$")
|
|
36
|
+
_LOCAL_PATH = re.compile(r"^(~/|/)[^\x00]*$")
|
|
37
|
+
_URL = re.compile(r"^http://127\.0\.0\.1:([0-9]{1,5})/mcp$")
|
|
38
|
+
FIELDS = {"protocol", "id", "name", "summary", "manifest", "run", "source", "installedAt", "installedBy", "extensions"}
|
|
39
|
+
# FAC-SEM-015 reads a credential by its SHAPE: a reference named after a key is fine.
|
|
40
|
+
CREDENTIAL_SHAPES = [re.compile(p) for p in (
|
|
41
|
+
r"^(sk|pk|rk)[-_](live|test|proj|or|ant)?[-_]?[A-Za-z0-9_-]{16,}",
|
|
42
|
+
r"^(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
|
|
43
|
+
r"^github_pat_[A-Za-z0-9_]{20,}",
|
|
44
|
+
r"^xox[abprs]-[A-Za-z0-9-]{10,}",
|
|
45
|
+
r"^(AKIA|ASIA)[A-Z0-9]{16}$",
|
|
46
|
+
r"^lin_api_[A-Za-z0-9]{20,}",
|
|
47
|
+
r"^AIza[0-9A-Za-z_-]{30,}",
|
|
48
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
|
49
|
+
r"^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$",
|
|
50
|
+
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9+=_-]{32,}$",
|
|
51
|
+
)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProviderError(Exception):
|
|
55
|
+
"""An entry was refused; the message is one readable sentence and never quotes a value."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def services_dir() -> Path:
|
|
59
|
+
override = os.environ.get("FABRIC_SERVICES_DIR")
|
|
60
|
+
if override:
|
|
61
|
+
return Path(override).expanduser()
|
|
62
|
+
home = Path.home()
|
|
63
|
+
if sys.platform == "darwin":
|
|
64
|
+
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
65
|
+
return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def providers_dir() -> Path:
|
|
69
|
+
"""FABRIC_PROVIDERS_DIR, else `providers/` in the same root as `services/`."""
|
|
70
|
+
override = os.environ.get("FABRIC_PROVIDERS_DIR")
|
|
71
|
+
if override:
|
|
72
|
+
return Path(override).expanduser()
|
|
73
|
+
return services_dir().parent / "providers"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def looks_like_credential(value: str) -> bool:
|
|
77
|
+
return any(shape.search(value) for shape in CREDENTIAL_SHAPES)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_provider_entry(entry: Any) -> List[str]:
|
|
81
|
+
"""Mirrors provider.schema.json and FAC-SEM-015 (the schema stays normative)."""
|
|
82
|
+
if not isinstance(entry, dict):
|
|
83
|
+
return ["an entry is a JSON object"]
|
|
84
|
+
problems: List[str] = []
|
|
85
|
+
for key in ("protocol", "id", "name", "manifest", "run", "installedAt", "installedBy"):
|
|
86
|
+
if key not in entry:
|
|
87
|
+
problems.append("missing %s" % key)
|
|
88
|
+
for key in sorted(set(entry) - FIELDS):
|
|
89
|
+
problems.append("unknown field %s" % key)
|
|
90
|
+
if problems:
|
|
91
|
+
return problems
|
|
92
|
+
if entry["protocol"] != PROTOCOL:
|
|
93
|
+
problems.append("protocol must be %s" % PROTOCOL)
|
|
94
|
+
if not _ID.match(str(entry["id"])):
|
|
95
|
+
problems.append("id must match %s" % _ID.pattern)
|
|
96
|
+
if not isinstance(entry["name"], str) or not 1 <= len(entry["name"]) <= 80:
|
|
97
|
+
problems.append("name is 1 to 80 characters")
|
|
98
|
+
if "summary" in entry and (not isinstance(entry["summary"], str) or len(entry["summary"]) > 200):
|
|
99
|
+
problems.append("summary is at most 200 characters")
|
|
100
|
+
manifest = str(entry["manifest"])
|
|
101
|
+
if not _LOCAL_PATH.match(manifest) or not re.search(r"(^|/)fabric-agent\.json$", manifest):
|
|
102
|
+
problems.append("manifest is an absolute or ~/ path to fabric-agent.json")
|
|
103
|
+
problems.extend(_run_problems(entry["run"]))
|
|
104
|
+
return problems
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_problems(run: Any) -> List[str]:
|
|
108
|
+
mcp = run.get("mcp") if isinstance(run, dict) and set(run) == {"mcp"} else None
|
|
109
|
+
if not isinstance(mcp, dict) or len(mcp) != 1 or not set(mcp) <= {"stdio", "url"}:
|
|
110
|
+
return ["run is {mcp: {stdio: ...}} or {mcp: {url: ...}}, exactly one"]
|
|
111
|
+
if "url" in mcp:
|
|
112
|
+
match = _URL.match(str(mcp["url"]))
|
|
113
|
+
return [] if match and 1 <= int(match.group(1)) <= 65535 else ["run.mcp.url is http://127.0.0.1:<port>/mcp"]
|
|
114
|
+
stdio = mcp["stdio"]
|
|
115
|
+
problems: List[str] = []
|
|
116
|
+
if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env"} or "command" not in stdio:
|
|
117
|
+
return ["run.mcp.stdio is {command, env?}"]
|
|
118
|
+
command = stdio["command"]
|
|
119
|
+
if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
|
|
120
|
+
problems.append("run.mcp.stdio.command is an argument array, never a shell string")
|
|
121
|
+
env = stdio.get("env", {})
|
|
122
|
+
if not isinstance(env, dict):
|
|
123
|
+
return problems + ["run.mcp.stdio.env is an object"]
|
|
124
|
+
for name, value in env.items():
|
|
125
|
+
if not _ENV_NAME.match(name):
|
|
126
|
+
problems.append("env name %s is not an environment variable name" % name)
|
|
127
|
+
reference = _SECRET_REF.match(value) if isinstance(value, str) else None
|
|
128
|
+
if not reference or looks_like_credential(reference.group(1)):
|
|
129
|
+
problems.append("env %s must be a secret reference (secret-ref:<name>), never a secret value (FAC-SEM-015)" % name)
|
|
130
|
+
return problems
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
os.chmod(path.parent, 0o700)
|
|
136
|
+
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
137
|
+
try:
|
|
138
|
+
with os.fdopen(fd, "wb") as handle:
|
|
139
|
+
handle.write(data)
|
|
140
|
+
handle.flush()
|
|
141
|
+
os.fsync(handle.fileno())
|
|
142
|
+
os.chmod(tmp, 0o600)
|
|
143
|
+
os.replace(tmp, path)
|
|
144
|
+
except BaseException:
|
|
145
|
+
try:
|
|
146
|
+
os.unlink(tmp)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
pass
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _service_ids(directory: Path) -> Dict[str, str]:
|
|
153
|
+
ids: Dict[str, str] = {}
|
|
154
|
+
if directory.is_dir():
|
|
155
|
+
for path in sorted(directory.glob("*.json")):
|
|
156
|
+
try:
|
|
157
|
+
ids.setdefault(str(json.loads(path.read_text(encoding="utf-8")).get("id")), path.name)
|
|
158
|
+
except (OSError, ValueError, AttributeError):
|
|
159
|
+
continue
|
|
160
|
+
return ids
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None, services: Optional[Path] = None) -> Path:
|
|
164
|
+
"""Installer-only. Refuses an invalid entry and an id that is already a service (FAC-SEM-013)."""
|
|
165
|
+
problems = validate_provider_entry(entry)
|
|
166
|
+
if problems:
|
|
167
|
+
raise ProviderError("Provider entry is invalid: %s." % "; ".join(problems))
|
|
168
|
+
clash = _service_ids(services or services_dir()).get(entry["id"])
|
|
169
|
+
if clash:
|
|
170
|
+
raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
|
|
171
|
+
target = (directory or providers_dir()) / ("%s.json" % entry["id"])
|
|
172
|
+
_atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
|
|
173
|
+
return target
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def remove_provider_entry(provider_id: str, directory: Optional[Path] = None) -> bool:
|
|
177
|
+
"""Uninstaller-only. Returns whether there was an entry to remove."""
|
|
178
|
+
if not _ID.match(provider_id):
|
|
179
|
+
raise ProviderError("Provider id %s does not match %s." % (provider_id, _ID.pattern))
|
|
180
|
+
try:
|
|
181
|
+
((directory or providers_dir()) / ("%s.json" % provider_id)).unlink()
|
|
182
|
+
return True
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _now() -> str:
|
|
188
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
192
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
193
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
194
|
+
write = sub.add_parser("write")
|
|
195
|
+
write.add_argument("--id", required=True)
|
|
196
|
+
write.add_argument("--name", required=True)
|
|
197
|
+
write.add_argument("--summary")
|
|
198
|
+
write.add_argument("--manifest", required=True)
|
|
199
|
+
write.add_argument("--installed-by", required=True)
|
|
200
|
+
write.add_argument("--repository")
|
|
201
|
+
write.add_argument("--env", action="append", default=[], help="NAME=secret-ref:REF (a reference, never a value)")
|
|
202
|
+
transport = write.add_mutually_exclusive_group(required=True)
|
|
203
|
+
transport.add_argument("--url")
|
|
204
|
+
transport.add_argument("--stdio", nargs=argparse.REMAINDER, help="the executable and its arguments, last on the line")
|
|
205
|
+
remove = sub.add_parser("remove")
|
|
206
|
+
remove.add_argument("id")
|
|
207
|
+
check = sub.add_parser("validate")
|
|
208
|
+
check.add_argument("path", type=Path)
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
try:
|
|
211
|
+
if args.command == "remove":
|
|
212
|
+
print("removed" if remove_provider_entry(args.id) else "no entry for %s" % args.id)
|
|
213
|
+
return 0
|
|
214
|
+
if args.command == "validate":
|
|
215
|
+
problems = validate_provider_entry(json.loads(args.path.read_text(encoding="utf-8")))
|
|
216
|
+
print("; ".join(problems) if problems else "valid")
|
|
217
|
+
return 1 if problems else 0
|
|
218
|
+
env: Dict[str, str] = {}
|
|
219
|
+
for pair in args.env:
|
|
220
|
+
name, sep, value = pair.partition("=")
|
|
221
|
+
if not sep:
|
|
222
|
+
raise ProviderError("--env takes NAME=secret-ref:REF.")
|
|
223
|
+
env[name] = value
|
|
224
|
+
run: Dict[str, Any] = {"mcp": {"url": args.url}} if args.url else {"mcp": {"stdio": {"command": args.stdio}}}
|
|
225
|
+
if env and args.stdio:
|
|
226
|
+
run["mcp"]["stdio"]["env"] = env
|
|
227
|
+
entry: Dict[str, Any] = {"protocol": PROTOCOL, "id": args.id, "name": args.name, "manifest": args.manifest,
|
|
228
|
+
"run": run, "installedAt": _now(), "installedBy": args.installed_by}
|
|
229
|
+
if args.summary:
|
|
230
|
+
entry["summary"] = args.summary
|
|
231
|
+
if args.repository:
|
|
232
|
+
entry["source"] = {"repository": args.repository}
|
|
233
|
+
print(write_provider_entry(entry))
|
|
234
|
+
return 0
|
|
235
|
+
except (ProviderError, OSError, ValueError) as exc:
|
|
236
|
+
print(str(exc), file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
raise SystemExit(main())
|
|
242
|
+
# #endregion provider-writer
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: building-fabric-services
|
|
3
3
|
description: >-
|
|
4
|
-
Use when building or running an agent as a long-lived local service with a dashboard on
|
|
5
|
-
|
|
4
|
+
Use when building or running an agent as a long-lived local service with a dashboard on
|
|
5
|
+
a macOS machine — «сделай агенту дашборд», «локальный сервис агента», «дашборд должен всегда
|
|
6
6
|
работать и не плодить копии», «где агенту хранить настройки», «подключи агента к Fabric
|
|
7
7
|
Dashboards», "make this agent a local service", "always-on dashboard", "fabric-service
|
|
8
8
|
protocol", "add the well-known endpoint", "migrate a service to fabric-service". Covers the
|
|
@@ -11,14 +11,14 @@ description: >-
|
|
|
11
11
|
descriptor, the well-known document, the events feed and notifications; ships Python and Node
|
|
12
12
|
reference kits and a live conformance probe. NOT for a one-off script or cron job, a hosted
|
|
13
13
|
SaaS, the provider manifest itself (adapting-projects-to-fabric), or building Fabric Dashboards.
|
|
14
|
-
license:
|
|
14
|
+
license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
|
|
15
15
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). No network or package install; the contract checkout is optional.
|
|
16
16
|
metadata:
|
|
17
|
-
author:
|
|
18
|
-
version: "0.
|
|
17
|
+
author: PassionCode.ai
|
|
18
|
+
version: "0.5.0"
|
|
19
19
|
contract-version: "0.1.0"
|
|
20
20
|
extension: "fabric-service/0.1"
|
|
21
|
-
extension-commit: "
|
|
21
|
+
extension-commit: "a22dea359ba04b8fe549abe81a5131552cb90eff"
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
# Building Fabric services
|
|
@@ -73,6 +73,13 @@ Answer before writing code, and record the answers in the project README:
|
|
|
73
73
|
Read [the surfaces and auth reference](references/surfaces-and-auth.md) when wiring
|
|
74
74
|
MCP registration into a client config, the login flow or the CSRF header.
|
|
75
75
|
|
|
76
|
+
When other agents or Fabric call the service over MCP, it follows `fabric-interop/0.1`:
|
|
77
|
+
each manifest capability is the tool of its name, long work is a job with a stable id,
|
|
78
|
+
a question for a person is an elicitation, and every call carries one trace. Read
|
|
79
|
+
[the interop reference](references/interop.md) before writing an MCP tool; the helpers
|
|
80
|
+
are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
81
|
+
[`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs).
|
|
82
|
+
|
|
76
83
|
## Step 2 — the non-negotiables
|
|
77
84
|
|
|
78
85
|
1. **Loopback only.** Bind `127.0.0.1`. Refuse any `Host` other than
|
|
@@ -144,6 +151,13 @@ On every request call `check_request(port, host, origin, sec_fetch_site)` and an
|
|
|
144
151
|
`SIGTERM` drains in-flight work within `ExitTimeOut`, then exits; interrupted work
|
|
145
152
|
resumes on the next start.
|
|
146
153
|
|
|
154
|
+
The MCP surface (`POST /mcp`, service token) is `fabric_interop.McpToolServer.handle`
|
|
155
|
+
in Python; jobs live in `fabric_interop.JobStore(dirs["data"] / "jobs")`, created after
|
|
156
|
+
the lock like every other piece of state. Add `"mcp": {"path": "/mcp", "transport":
|
|
157
|
+
"streamable-http", "capabilities": [...]}` to the well-known surfaces, and point the
|
|
158
|
+
descriptor's `fabricManifest` at a manifest whose service key names this
|
|
159
|
+
`<id>.<instance>` — `sample_service.py register` shows both.
|
|
160
|
+
|
|
147
161
|
## Step 4 — install it
|
|
148
162
|
|
|
149
163
|
The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
@@ -186,7 +200,9 @@ python3 <skill-dir>/scripts/check_service.py <id>[.<instance>]
|
|
|
186
200
|
It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, well-known
|
|
187
201
|
shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token file,
|
|
188
202
|
events auth and shape, single-use login, state outside code, instance lock, plist,
|
|
189
|
-
launchd pid equals answering pid
|
|
203
|
+
launchd pid equals answering pid, and the `interop.*` rules: manifest link, capability
|
|
204
|
+
list, tools equal to the manifest, job tools, unknown job, trace propagation, event
|
|
205
|
+
trace pairs) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass —
|
|
190
206
|
name it in the report.
|
|
191
207
|
|
|
192
208
|
Then prove the lock: start a second copy by hand against the same data directory; it
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Being called: `fabric-interop/0.1`
|
|
2
|
+
|
|
3
|
+
Normative source: the Fabric Agent Contract's `docs/specification/interop.md` (DEC-0016)
|
|
4
|
+
at the commit this plugin pins. This page says how the kit implements it; where the two
|
|
5
|
+
differ, the contract is right.
|
|
6
|
+
|
|
7
|
+
A service that other agents — and Fabric — call does five things. Each has one helper in
|
|
8
|
+
`scripts/fabric_interop.py` (Python) and `scripts/fabric-interop.mjs` (Node).
|
|
9
|
+
|
|
10
|
+
| Rule | What the service does | Helper |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| C3.1 capability = tool | serves each `mcp` capability of its manifest as the MCP tool of the same name, with the manifest's two schemas unchanged and annotations from the effect | `tool_for_capability` / `toolForCapability`, `expected_annotations` |
|
|
13
|
+
| C3.2 long work = job | a capability whose work can outlive one request declares `"job": true` in its interop block, returns `{"job": {"id", "status": "working"}}`, and serves `fabric.job.get` and `fabric.job.cancel` | `JobStore`, `McpToolServer` (Python), `unknown_job_result` |
|
|
14
|
+
| C3.2 result | a completed job carries the result envelope: `done`, `proof`, `scope`, `notVerified`, `output`, `usage` | `result_envelope` / `resultEnvelope` |
|
|
15
|
+
| C3.3 a person decides | the job goes `input_required` with an elicitation: a titled single-select in form mode, and URL mode for anything secret | `choice_request`, `form_request`, `url_request` |
|
|
16
|
+
| C3.4 trace | every call runs as a child span of the caller's `_meta.traceparent`; outgoing calls carry the same trace; events about the work carry `traceId` and `spanId` | `child_traceparent`, `trace_ids`, `make_event(..., trace_id=, span_id=)` |
|
|
17
|
+
|
|
18
|
+
The interop block sits in the manifest under the capability's `extensions`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
"extensions": { "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1": { "job": true } }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That key has one spelling (the contract's `src/extensions.ts`); copy it, never retype it.
|
|
25
|
+
|
|
26
|
+
## The kit
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import fabric_interop as fi
|
|
30
|
+
|
|
31
|
+
jobs = fi.JobStore(data_dir / "jobs") # one private JSON file per job; ids survive restarts
|
|
32
|
+
server = fi.McpToolServer("example-agent", "1.0.0", jobs=jobs,
|
|
33
|
+
on_job_started=start_report, on_input=resume_report)
|
|
34
|
+
server.add_tool(fi.tool_for_capability(capability, input_schema, output_schema), handler)
|
|
35
|
+
response = server.handle(json_rpc_message) # POST /mcp body in, JSON response out (None: 202)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- A handler returns the value for `structuredContent`; for a job it returns
|
|
39
|
+
`ctx.start_job()`, and `on_job_started(job_id, ctx)` does or schedules the work.
|
|
40
|
+
- `jobs.request_input(job_id, {"title_choice": fi.choice_request(...)}, "Two titles are ready.")`
|
|
41
|
+
stops for a person. Fabric answers through `fabric.job.get` with `inputResponses`; the
|
|
42
|
+
server calls `on_input(job_id, answers, ctx)` with the answers that matched.
|
|
43
|
+
- `jobs.complete(job_id, fi.result_envelope(...))`, `jobs.fail(job_id, code, message)`,
|
|
44
|
+
`jobs.cancel(job_id)`. A terminal job never changes again.
|
|
45
|
+
- Every result carries `_meta.traceparent`. For a job it is the span of the call that
|
|
46
|
+
started it, so the result is traced (the contract's FAC-SEM-019 records an untraced
|
|
47
|
+
result as an incomplete span).
|
|
48
|
+
- Log events about the work with `**fi.trace_ids(ctx.traceparent)` so the pair lands on
|
|
49
|
+
the event.
|
|
50
|
+
|
|
51
|
+
`McpToolServer` answers `server/discover`, `tools/list` and `tools/call` with JSON
|
|
52
|
+
responses — enough for Fabric and for the probe. A service already on an MCP SDK keeps
|
|
53
|
+
the SDK and uses only the helpers. The Node kit has the helpers and the same job file
|
|
54
|
+
format, without the dispatcher. `scripts/sample_service.py` is the worked example:
|
|
55
|
+
`sample.echo` answers at once, `sample.draft` is a job that stops for a title choice.
|
|
56
|
+
|
|
57
|
+
Two readings the contract leaves open, and what the kit does meanwhile:
|
|
58
|
+
|
|
59
|
+
- a job tool serves the capability's `outputSchema` although its `structuredContent` is
|
|
60
|
+
the handle (contract OQ-0006);
|
|
61
|
+
- events that belong to no traced work carry no trace pair (contract OQ-0007).
|
|
62
|
+
|
|
63
|
+
## What the probe checks
|
|
64
|
+
|
|
65
|
+
`check_service.py` adds these rules; they read, and never call a capability.
|
|
66
|
+
|
|
67
|
+
| Rule | PASS when | NOT_RUN when |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `interop.manifest-link` | the manifest `fabricManifest` names carries the service key with this `<id>.<instance>` (G-07) | the descriptor names no manifest |
|
|
70
|
+
| `interop.well-known-capabilities` | every name in `surfaces.mcp.capabilities` is a manifest capability | the surface lists none |
|
|
71
|
+
| `interop.tools-match` | every `mcp` capability is served as its tool, schemas equal to the files beside the manifest (matched by `$id`), annotations derived | no manifest, no MCP surface, or a schema is not beside the manifest |
|
|
72
|
+
| `interop.job-tools` | a `job: true` capability comes with `fabric.job.get` and `fabric.job.cancel` | no capability is a job |
|
|
73
|
+
| `interop.unknown-job` | `fabric.job.get` for a made-up id answers `isError` with `unknown-job` | `fabric.job.get` is not served |
|
|
74
|
+
| `interop.trace-propagation` | the answer's `_meta.traceparent` has the probe's trace id and a new span | the answer carries no traceparent |
|
|
75
|
+
| `interop.events-trace` | every event with a trace carries both `traceId` and `spanId`, well formed | no events page was read |
|
|
76
|
+
|
|
77
|
+
`python3 scripts/check_service.py <id>` prints them with the rest; `--json` for a machine.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `a22dea359ba04b8fe549abe81a5131552cb90eff`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
|
|
5
5
|
this page is the working summary. Extension key:
|
|
6
6
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
Every rule gets PASS, FAIL or NOT_RUN with its evidence. Exit 0 when nothing
|
|
9
9
|
FAILs, 1 when something does, 2 on a usage error. The probe only reads, except
|
|
10
10
|
that it redeems one login code it asked for itself (that creates one session).
|
|
11
|
+
|
|
12
|
+
The interop.* rules (fabric-interop/0.1) read the manifest the descriptor names and
|
|
13
|
+
the service's MCP surface: tools/list, and fabric.job.get for an id that does not
|
|
14
|
+
exist. They never call a capability.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from __future__ import annotations
|
|
@@ -20,6 +24,7 @@ import os
|
|
|
20
24
|
from pathlib import Path
|
|
21
25
|
import plistlib
|
|
22
26
|
import re
|
|
27
|
+
import secrets
|
|
23
28
|
import shutil
|
|
24
29
|
import stat
|
|
25
30
|
import subprocess
|
|
@@ -29,6 +34,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
29
34
|
|
|
30
35
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
36
|
import fabric_service as fs # noqa: E402
|
|
37
|
+
import fabric_interop as fi # noqa: E402
|
|
32
38
|
|
|
33
39
|
Result = Dict[str, str]
|
|
34
40
|
|
|
@@ -42,6 +48,8 @@ class Probe:
|
|
|
42
48
|
self.results: List[Result] = []
|
|
43
49
|
self.wk: Optional[Dict[str, Any]] = None
|
|
44
50
|
self.token: Optional[str] = None
|
|
51
|
+
self.events: Optional[List[Dict[str, Any]]] = None
|
|
52
|
+
self.sent_traceparent: Optional[str] = None
|
|
45
53
|
try:
|
|
46
54
|
self.port = fs.port_of(str(descriptor.get("origin", "")))
|
|
47
55
|
except fs.ServiceError:
|
|
@@ -184,6 +192,7 @@ class Probe:
|
|
|
184
192
|
except (ValueError, KeyError, AssertionError):
|
|
185
193
|
self.add("events.page", "FAIL", "not an events page")
|
|
186
194
|
return
|
|
195
|
+
self.events = events
|
|
187
196
|
bad = []
|
|
188
197
|
for e in events:
|
|
189
198
|
if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$", str(e.get("kind", ""))):
|
|
@@ -215,6 +224,170 @@ class Probe:
|
|
|
215
224
|
self.add("login.single-use", "PASS" if ok else "FAIL",
|
|
216
225
|
"first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
|
|
217
226
|
|
|
227
|
+
# interop (fabric-interop/0.1) ----------------------------------------------------
|
|
228
|
+
# #region probe-interop — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#what-the-probe-checks
|
|
229
|
+
def mcp_call(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
230
|
+
"""One JSON-RPC request to the MCP surface, as a child of a fresh probe trace."""
|
|
231
|
+
path = (((self.wk or {}).get("surfaces") or {}).get("mcp") or {}).get("path", "/mcp")
|
|
232
|
+
self.sent_traceparent = fi.child_traceparent(None)
|
|
233
|
+
body = dict(params)
|
|
234
|
+
body["_meta"] = {"io.modelcontextprotocol/protocolVersion": fi.MCP_REVISION,
|
|
235
|
+
"io.modelcontextprotocol/clientCapabilities": {}, "traceparent": self.sent_traceparent}
|
|
236
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
|
237
|
+
"MCP-Protocol-Version": fi.MCP_REVISION}
|
|
238
|
+
headers.update(self.auth_headers())
|
|
239
|
+
message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": body}).encode()
|
|
240
|
+
status, resp_headers, raw = self.request("POST", path, headers, message)
|
|
241
|
+
if status != 200:
|
|
242
|
+
raise OSError("HTTP %d from %s" % (status, path))
|
|
243
|
+
if resp_headers.get("content-type", "").startswith("text/event-stream"):
|
|
244
|
+
data = [line[5:].strip() for line in raw.decode().splitlines() if line.startswith("data:")]
|
|
245
|
+
raw = (data[-1] if data else "null").encode()
|
|
246
|
+
return json.loads(raw)
|
|
247
|
+
|
|
248
|
+
def load_manifest(self) -> Tuple[Optional[Dict[str, Any]], Optional[Path], str]:
|
|
249
|
+
named = self.d.get("fabricManifest")
|
|
250
|
+
if not named:
|
|
251
|
+
return None, None, "the descriptor names no fabricManifest"
|
|
252
|
+
try:
|
|
253
|
+
path = fs.expand(str(named))
|
|
254
|
+
return json.loads(path.read_text(encoding="utf-8")), path, str(path)
|
|
255
|
+
except (fs.ServiceError, OSError, ValueError) as exc:
|
|
256
|
+
return None, None, "manifest %s does not resolve: %s" % (named, exc)
|
|
257
|
+
|
|
258
|
+
def resolve_schema(self, manifest_path: Path, uri: str) -> Optional[Dict[str, Any]]:
|
|
259
|
+
"""A schema named by URI, found by its $id among the JSON files beside the manifest. Nothing is fetched."""
|
|
260
|
+
for candidate in sorted(manifest_path.parent.glob("fabric/schemas/*.json")) + sorted(manifest_path.parent.glob("*.schema.json")):
|
|
261
|
+
try:
|
|
262
|
+
doc = json.loads(candidate.read_text(encoding="utf-8"))
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
continue
|
|
265
|
+
if isinstance(doc, dict) and doc.get("$id") == uri:
|
|
266
|
+
return doc
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def interop_rules(self) -> None:
|
|
270
|
+
manifest, manifest_path, where = self.load_manifest()
|
|
271
|
+
me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
|
|
272
|
+
if manifest is None:
|
|
273
|
+
self.add("interop.manifest-link", "NOT_RUN" if not self.d.get("fabricManifest") else "FAIL", where)
|
|
274
|
+
else:
|
|
275
|
+
block = ((manifest.get("provider") or {}).get("extensions") or {}).get(fs.EXTENSION_KEY) or {}
|
|
276
|
+
named = block.get("descriptor")
|
|
277
|
+
self.add("interop.manifest-link", "PASS" if named == me else "FAIL",
|
|
278
|
+
"%s names %s" % (where, named) if named else "%s carries no %s block naming %s" % (where, fs.EXTENSION_KEY, me))
|
|
279
|
+
capabilities = [c for c in (manifest or {}).get("capabilities", []) if isinstance(c, dict)]
|
|
280
|
+
names = {c.get("name") for c in capabilities}
|
|
281
|
+
mcp_surface = ((self.wk or {}).get("surfaces") or {}).get("mcp")
|
|
282
|
+
listed = (mcp_surface or {}).get("capabilities")
|
|
283
|
+
if listed is None or manifest is None:
|
|
284
|
+
self.add("interop.well-known-capabilities", "NOT_RUN", "no capability list on the MCP surface" if listed is None else where)
|
|
285
|
+
else:
|
|
286
|
+
extra = sorted(set(listed) - names)
|
|
287
|
+
self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
|
|
288
|
+
("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
|
|
289
|
+
if not mcp_surface or not self.token:
|
|
290
|
+
for rule in ("interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
|
|
291
|
+
self.add(rule, "NOT_RUN", "no MCP surface" if not mcp_surface else "no readable token")
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
listing = self.mcp_call("tools/list", {})
|
|
295
|
+
except (OSError, ValueError) as exc:
|
|
296
|
+
self.add("interop.tools-match", "FAIL", "tools/list failed: %s" % exc)
|
|
297
|
+
listing = None
|
|
298
|
+
if listing is not None:
|
|
299
|
+
sent = self.sent_traceparent
|
|
300
|
+
tools = {t.get("name"): t for t in (listing.get("result") or {}).get("tools", []) if isinstance(t, dict)}
|
|
301
|
+
self.tools_match_rule(capabilities, manifest_path, tools)
|
|
302
|
+
self.job_tools_rule(capabilities, tools)
|
|
303
|
+
self.unknown_job_rule(list(tools))
|
|
304
|
+
self.trace_rule(listing, sent)
|
|
305
|
+
self.events_trace_rule(self.events)
|
|
306
|
+
|
|
307
|
+
def tools_match_rule(self, capabilities: List[Dict[str, Any]], manifest_path: Optional[Path], tools: Dict[str, Any]) -> None:
|
|
308
|
+
"""FAC-SEM-017: each mcp capability is served as the tool of its name, with its schemas and derived annotations."""
|
|
309
|
+
served = [c for c in capabilities if (c.get("profile") or {}).get("kind") == "mcp"]
|
|
310
|
+
if manifest_path is None or not served:
|
|
311
|
+
self.add("interop.tools-match", "NOT_RUN", "no manifest with an mcp capability")
|
|
312
|
+
return
|
|
313
|
+
problems, unresolved = [], []
|
|
314
|
+
for cap in served:
|
|
315
|
+
name = cap.get("name")
|
|
316
|
+
tool = tools.get(name)
|
|
317
|
+
if tool is None:
|
|
318
|
+
problems.append("%s is not served as a tool" % name)
|
|
319
|
+
continue
|
|
320
|
+
for side in ("inputSchema", "outputSchema"):
|
|
321
|
+
schema = self.resolve_schema(manifest_path, str(cap.get(side)))
|
|
322
|
+
if schema is None:
|
|
323
|
+
unresolved.append("%s %s" % (name, cap.get(side)))
|
|
324
|
+
elif tool.get(side) != schema:
|
|
325
|
+
problems.append("%s serves an %s that differs from %s" % (name, side, cap.get(side)))
|
|
326
|
+
annotations = tool.get("annotations") or {}
|
|
327
|
+
for hint, value in fi.expected_annotations(cap.get("effect", ""), cap.get("idempotency", "")).items():
|
|
328
|
+
if annotations.get(hint) is not value:
|
|
329
|
+
problems.append("%s lacks %s (effect %s, idempotency %s)" % (name, hint, cap.get("effect"), cap.get("idempotency")))
|
|
330
|
+
if problems:
|
|
331
|
+
self.add("interop.tools-match", "FAIL", "; ".join(problems))
|
|
332
|
+
elif unresolved:
|
|
333
|
+
self.add("interop.tools-match", "NOT_RUN", "schemas not found beside the manifest: " + ", ".join(unresolved))
|
|
334
|
+
else:
|
|
335
|
+
self.add("interop.tools-match", "PASS", "%d capabilities served with their schemas and annotations" % len(served))
|
|
336
|
+
|
|
337
|
+
def job_tools_rule(self, capabilities: List[Dict[str, Any]], tools: Dict[str, Any]) -> None:
|
|
338
|
+
jobs = [c.get("name") for c in capabilities if ((c.get("extensions") or {}).get(fi.EXTENSION_KEY) or {}).get("job") is True]
|
|
339
|
+
if not jobs:
|
|
340
|
+
self.add("interop.job-tools", "NOT_RUN", "no capability declares job: true")
|
|
341
|
+
return
|
|
342
|
+
missing = [n for n in ("fabric.job.get", "fabric.job.cancel") if n not in tools]
|
|
343
|
+
self.add("interop.job-tools", "FAIL" if missing else "PASS",
|
|
344
|
+
("job capabilities %s, but %s not served" % (", ".join(jobs), " and ".join(missing))) if missing else "fabric.job.get and fabric.job.cancel served")
|
|
345
|
+
|
|
346
|
+
def unknown_job_rule(self, tool_names: List[str]) -> None:
|
|
347
|
+
"""C3.2: an unknown id answers isError with unknown-job, never a fresh job."""
|
|
348
|
+
if "fabric.job.get" not in tool_names:
|
|
349
|
+
self.add("interop.unknown-job", "NOT_RUN", "fabric.job.get is not served")
|
|
350
|
+
return
|
|
351
|
+
probe_id = "probe-unknown-" + secrets.token_hex(6)
|
|
352
|
+
try:
|
|
353
|
+
answer = self.mcp_call("tools/call", {"name": "fabric.job.get", "arguments": {"id": probe_id}})
|
|
354
|
+
except (OSError, ValueError) as exc:
|
|
355
|
+
self.add("interop.unknown-job", "FAIL", "fabric.job.get failed: %s" % exc)
|
|
356
|
+
return
|
|
357
|
+
result = answer.get("result") or {}
|
|
358
|
+
ok = result.get("isError") is True and "unknown-job" in json.dumps(result)
|
|
359
|
+
self.add("interop.unknown-job", "PASS" if ok else "FAIL",
|
|
360
|
+
"isError with unknown-job for %s" % probe_id if ok else "an unknown id did not answer isError with unknown-job")
|
|
361
|
+
|
|
362
|
+
def trace_rule(self, answer: Dict[str, Any], sent: Optional[str]) -> None:
|
|
363
|
+
"""C3.4: work runs as a child span of the caller — same trace, new span."""
|
|
364
|
+
answered = fi.parse_traceparent(((answer.get("result") or {}).get("_meta") or {}).get("traceparent"))
|
|
365
|
+
mine = fi.parse_traceparent(sent)
|
|
366
|
+
if answered is None or mine is None:
|
|
367
|
+
self.add("interop.trace-propagation", "NOT_RUN", "the answer carries no _meta.traceparent (C3.4 does not require it)")
|
|
368
|
+
elif answered["trace_id"] == mine["trace_id"] and answered["span_id"] != mine["span_id"]:
|
|
369
|
+
self.add("interop.trace-propagation", "PASS", "answered as a child span of trace %s" % mine["trace_id"])
|
|
370
|
+
else:
|
|
371
|
+
self.add("interop.trace-propagation", "FAIL", "answered with trace %s span %s to a call in trace %s span %s"
|
|
372
|
+
% (answered["trace_id"], answered["span_id"], mine["trace_id"], mine["span_id"]))
|
|
373
|
+
|
|
374
|
+
def events_trace_rule(self, events: Optional[List[Dict[str, Any]]]) -> None:
|
|
375
|
+
"""C3.4 c: an event about traced work carries traceId and spanId together, well formed."""
|
|
376
|
+
if events is None:
|
|
377
|
+
self.add("interop.events-trace", "NOT_RUN", "no events page was read")
|
|
378
|
+
return
|
|
379
|
+
bad, traced = [], 0
|
|
380
|
+
for e in events:
|
|
381
|
+
has = ("traceId" in e, "spanId" in e)
|
|
382
|
+
if has == (False, False):
|
|
383
|
+
continue
|
|
384
|
+
traced += 1
|
|
385
|
+
if has != (True, True) or not fi.parse_traceparent("00-%s-%s-01" % (e.get("traceId"), e.get("spanId"))):
|
|
386
|
+
bad.append(str(e.get("id")))
|
|
387
|
+
self.add("interop.events-trace", "FAIL" if bad else "PASS",
|
|
388
|
+
("events with a broken trace pair: " + ", ".join(bad)) if bad else "%d of %d events traced, every pair whole" % (traced, len(events)))
|
|
389
|
+
# #endregion probe-interop
|
|
390
|
+
|
|
218
391
|
# lifecycle ----------------------------------------------------------------
|
|
219
392
|
def lifecycle_rules(self) -> None:
|
|
220
393
|
life = self.d.get("lifecycle") or {}
|
|
@@ -315,6 +488,7 @@ class Probe:
|
|
|
315
488
|
self.network_rules()
|
|
316
489
|
self.auth_rules()
|
|
317
490
|
self.login_rules()
|
|
491
|
+
self.interop_rules()
|
|
318
492
|
self.lifecycle_rules()
|
|
319
493
|
return self.results
|
|
320
494
|
|