@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.
Files changed (21) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +150 -17
  3. package/README.md +44 -6
  4. package/SECURITY.md +2 -3
  5. package/bin/fabric-agent-adapter.js +1 -1
  6. package/package.json +5 -5
  7. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +4 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +25 -6
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +22 -5
@@ -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 the
5
- operator's Mac — «сделай агенту дашборд», «локальный сервис агента», «дашборд должен всегда
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: MIT
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: passioncode-ai
18
- version: "0.4.2"
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: "a5a27092ba0dcc5facfbeae8b359146dfb403e9a"
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) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass —
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 `a5a27092ba0dcc5facfbeae8b359146dfb403e9a`
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`.
@@ -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