@passioncode-ai/passioncode 0.1.8 → 0.1.9

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 (24) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +22 -5
  3. package/SECURITY.md +1 -1
  4. package/bin/passioncode.js +5 -5
  5. package/family.json +1 -1
  6. package/package.json +1 -1
  7. package/payload/.claude-plugin/marketplace.json +2 -2
  8. package/payload/manifest.json +8 -8
  9. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
  24. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """Reference kit for the fabric-interop/0.1 extension: how agents are called.
3
+
4
+ Standard library only, Python 3.9+. It sits beside fabric_service.py and uses it for
5
+ atomic, private writes. Every function implements one rule of the extension and names
6
+ it, so a service that calls these functions inherits the rule:
7
+
8
+ - C3.1 a capability is served as the MCP tool of its own name, annotations from its effect;
9
+ - C3.2 long work is a job: a stable id, fabric.job.get / fabric.job.cancel, a result envelope;
10
+ - C3.3 a question for a person is an elicitation (form mode, never a secret; URL mode for those);
11
+ - C3.4 every answer is a child span of the caller's traceparent, and so is every event.
12
+
13
+ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016).
14
+ """
15
+
16
+ # #region interop-kit — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#the-kit
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ import re
23
+ import secrets
24
+ import sys
25
+ from typing import Any, Callable, Dict, List, Optional
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+ import fabric_service as _fs # noqa: E402
29
+
30
+ PROTOCOL = "fabric-interop/0.1"
31
+ EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
32
+ MCP_REVISION = "2026-07-28"
33
+ JOB_STATES = ("working", "input_required", "completed", "failed", "cancelled")
34
+ TERMINAL = ("completed", "failed", "cancelled")
35
+
36
+ _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
37
+ _JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
38
+ _KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
39
+ _SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
40
+ r"(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential", re.IGNORECASE)
41
+
42
+
43
+ class InteropError(_fs.ServiceError):
44
+ """A rule of fabric-interop/0.1 was violated; the message is one readable sentence."""
45
+
46
+
47
+ class UnknownJob(InteropError):
48
+ def __init__(self, job_id: str):
49
+ self.job_id = job_id
50
+ super().__init__("unknown-job: no job %s here." % job_id)
51
+
52
+
53
+ # --- C3.4 trace context --------------------------------------------------------
54
+
55
+ def parse_traceparent(value: Any) -> Optional[Dict[str, str]]:
56
+ """W3C Trace Context Level 1, lowercase hex; version ff and all-zero ids are invalid."""
57
+ match = _TRACEPARENT.match(value) if isinstance(value, str) else None
58
+ if not match:
59
+ return None
60
+ version, trace_id, span_id, flags = match.groups()
61
+ if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
62
+ return None
63
+ return {"version": version, "trace_id": trace_id, "span_id": span_id, "flags": flags}
64
+
65
+
66
+ def _span_id() -> str:
67
+ while True:
68
+ value = secrets.token_hex(8)
69
+ if set(value) != {"0"}:
70
+ return value
71
+
72
+
73
+ def child_traceparent(parent: Optional[str]) -> str:
74
+ """A new span under `parent`; a missing or broken parent starts a new trace (W3C: restart)."""
75
+ parsed = parse_traceparent(parent)
76
+ trace_id = parsed["trace_id"] if parsed else secrets.token_hex(16)
77
+ flags = parsed["flags"] if parsed else "01"
78
+ return "00-%s-%s-%s" % (trace_id, _span_id(), flags)
79
+
80
+
81
+ def trace_ids(traceparent: Optional[str]) -> Dict[str, str]:
82
+ """The `traceId`/`spanId` pair an event about this span carries (C3.4 c), or {}."""
83
+ parsed = parse_traceparent(traceparent)
84
+ return {"trace_id": parsed["trace_id"], "span_id": parsed["span_id"]} if parsed else {}
85
+
86
+
87
+ # --- C3.1 capabilities as tools --------------------------------------------------
88
+
89
+ def expected_annotations(effect: str, idempotency: str) -> Dict[str, bool]:
90
+ """effect none -> readOnlyHint; delete|merge|deploy|change-policy -> destructiveHint; idempotency required -> idempotentHint."""
91
+ hints: Dict[str, bool] = {}
92
+ if effect == "none":
93
+ hints["readOnlyHint"] = True
94
+ if effect in ("delete", "merge", "deploy", "change-policy"):
95
+ hints["destructiveHint"] = True
96
+ if idempotency == "required":
97
+ hints["idempotentHint"] = True
98
+ return hints
99
+
100
+
101
+ def tool_for_capability(capability: Dict[str, Any], input_schema: Dict[str, Any], output_schema: Dict[str, Any],
102
+ title: Optional[str] = None) -> Dict[str, Any]:
103
+ """The MCP tool for one manifest capability: its name, its two schemas as they are, derived annotations.
104
+
105
+ A job-capable tool still serves the capability's outputSchema, though its
106
+ structuredContent is the job handle (contract OQ-0006)."""
107
+ tool: Dict[str, Any] = {
108
+ "name": capability["name"],
109
+ "inputSchema": input_schema,
110
+ "outputSchema": output_schema,
111
+ "annotations": expected_annotations(capability.get("effect", ""), capability.get("idempotency", "")),
112
+ }
113
+ if capability.get("description"):
114
+ tool["description"] = capability["description"]
115
+ if title:
116
+ tool["title"] = title
117
+ return tool
118
+
119
+
120
+ def tool_result(structured: Any, *, is_error: bool = False, traceparent: Optional[str] = None) -> Dict[str, Any]:
121
+ """A tools/call result: structuredContent plus its JSON as text, for clients that read only text."""
122
+ result: Dict[str, Any] = {
123
+ "resultType": "complete",
124
+ "content": [{"type": "text", "text": json.dumps(structured, ensure_ascii=False)}],
125
+ "structuredContent": structured,
126
+ "isError": is_error,
127
+ }
128
+ if traceparent:
129
+ result["_meta"] = {"traceparent": traceparent}
130
+ return result
131
+
132
+
133
+ def unknown_job_result(job_id: str, traceparent: Optional[str] = None) -> Dict[str, Any]:
134
+ """C3.2: fabric.job.get for an unknown id answers isError with unknown-job, never a fresh job."""
135
+ return tool_result({"error": {"code": "unknown-job", "message": "No job %s here." % job_id}},
136
+ is_error=True, traceparent=traceparent)
137
+
138
+
139
+ # --- C3.2 the result envelope ------------------------------------------------------
140
+
141
+ def _usage(usage: Any) -> Dict[str, Any]:
142
+ if not isinstance(usage, dict):
143
+ raise InteropError("usage must be an object.")
144
+ for key in ("inputTokens", "outputTokens", "wallMs"):
145
+ if key not in usage:
146
+ raise InteropError("usage.%s is required." % key)
147
+ for key, value in usage.items():
148
+ if key not in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs"):
149
+ raise InteropError("usage.%s is not a usage field." % key)
150
+ numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
151
+ if not numeric or value < 0 or (key != "costUsd" and not isinstance(value, int)):
152
+ raise InteropError("usage.%s must be a non-negative %s." % (key, "number" if key == "costUsd" else "integer"))
153
+ return dict(usage)
154
+
155
+
156
+ def result_envelope(*, done: List[Dict[str, Any]], proof: List[Dict[str, Any]], scope: Dict[str, Any],
157
+ not_verified: List[Dict[str, Any]], output: Any, usage: Dict[str, Any]) -> Dict[str, Any]:
158
+ """DONE / PROOF / SCOPE / NOT VERIFIED (DEC-0011) plus the output and usage; the four collections are always present."""
159
+ for label, value in (("done", done), ("proof", proof), ("notVerified", not_verified)):
160
+ if not isinstance(value, list):
161
+ raise InteropError("%s must be a list, even when empty." % label)
162
+ if not isinstance(scope, dict):
163
+ raise InteropError("scope must be an object.")
164
+ return {"done": list(done), "proof": list(proof), "scope": dict(scope), "notVerified": list(not_verified),
165
+ "output": output, "usage": _usage(usage)}
166
+
167
+
168
+ # --- C3.3 awaiting a choice ------------------------------------------------------------
169
+
170
+ def form_request(message: str, properties: Dict[str, Dict[str, Any]], required: Optional[List[str]] = None) -> Dict[str, Any]:
171
+ """An elicitation in form mode: a flat object of primitive fields, and never a secret (FAC-SEM-018)."""
172
+ for field, schema in properties.items():
173
+ words = " ".join(str(schema.get(key, "")) for key in ("title", "description", "format"))
174
+ if _SECRET_WORDS.search(field) or _SECRET_WORDS.search(words):
175
+ raise InteropError("Form mode cannot ask for %s: a secret goes through url_request." % field)
176
+ if schema.get("type") not in ("string", "number", "integer", "boolean", "array"):
177
+ raise InteropError("Form field %s must be a primitive (string, number, integer, boolean or an enum array)." % field)
178
+ requested: Dict[str, Any] = {"type": "object", "properties": properties}
179
+ if required:
180
+ requested["required"] = list(required)
181
+ return {"method": "elicitation/create", "params": {"mode": "form", "message": message, "requestedSchema": requested}}
182
+
183
+
184
+ def choice_request(message: str, field: str, options: List[Any], title: Optional[str] = None) -> Dict[str, Any]:
185
+ """A titled single-select: oneOf [{const, title}] — what Fabric turns into an interaction point."""
186
+ schema: Dict[str, Any] = {"type": "string", "oneOf": [{"const": value, "title": label} for value, label in options]}
187
+ if title:
188
+ schema["title"] = title
189
+ return form_request(message, {field: schema}, [field])
190
+
191
+
192
+ def url_request(message: str, url: str) -> Dict[str, Any]:
193
+ """URL mode: the person opens `url` out of band; for credentials and payments. Put nothing sensitive in the URL."""
194
+ if not url.startswith(("https://", "http://127.0.0.1", "http://localhost")):
195
+ raise InteropError("A URL-mode request needs an https URL (or this machine's loopback).")
196
+ return {"method": "elicitation/create", "params": {"mode": "url", "message": message, "url": url}}
197
+
198
+
199
+ # --- C3.2 jobs ---------------------------------------------------------------------
200
+
201
+ class JobStore:
202
+ """Durable jobs, one private JSON file each under `directory`, written atomically.
203
+
204
+ A job id survives a restart of the agent, so fabric.job.get after a restart finds
205
+ the same job. The file holds the public `job` object exactly as fabric.job.get
206
+ returns it, and a `private` part (the capability, its input, the trace) that is
207
+ never returned. The Node kit reads and writes the same format.
208
+ """
209
+
210
+ def __init__(self, directory: Path):
211
+ self.dir = Path(directory)
212
+
213
+ def _path(self, job_id: str) -> Path:
214
+ if not _JOB_ID.match(job_id or ""):
215
+ raise UnknownJob(str(job_id))
216
+ return self.dir / ("%s.json" % job_id)
217
+
218
+ def _load(self, job_id: str) -> Dict[str, Any]:
219
+ try:
220
+ return json.loads(self._path(job_id).read_text(encoding="utf-8"))
221
+ except FileNotFoundError:
222
+ raise UnknownJob(job_id) from None
223
+ except ValueError:
224
+ raise InteropError("Job %s is unreadable on disk." % job_id) from None
225
+
226
+ def _save(self, record: Dict[str, Any]) -> Dict[str, Any]:
227
+ record["job"]["updatedAt"] = _fs.now_iso()
228
+ _fs.ensure_private_dir(self.dir)
229
+ _fs.atomic_write(self._path(record["job"]["id"]), (json.dumps(record, ensure_ascii=False) + "\n").encode(), 0o600)
230
+ return dict(record["job"])
231
+
232
+ def create(self, capability: str, arguments: Any, traceparent: Optional[str] = None,
233
+ poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
234
+ """Start a job; returns the handle a job-capable tool puts in structuredContent."""
235
+ job_id = "job_" + secrets.token_hex(12)
236
+ job: Dict[str, Any] = {"id": job_id, "status": "working"}
237
+ if poll_interval_ms:
238
+ job["pollIntervalMs"] = int(poll_interval_ms)
239
+ self._save({"job": job, "private": {"capability": capability, "input": arguments,
240
+ "traceparent": traceparent if parse_traceparent(traceparent) else None}})
241
+ return {"job": {"id": job_id, "status": "working"}}
242
+
243
+ def get(self, job_id: str) -> Dict[str, Any]:
244
+ return dict(self._load(job_id)["job"])
245
+
246
+ def private(self, job_id: str) -> Dict[str, Any]:
247
+ return dict(self._load(job_id)["private"])
248
+
249
+ def traceparent(self, job_id: str) -> Optional[str]:
250
+ return self._load(job_id)["private"].get("traceparent")
251
+
252
+ def _transition(self, job_id: str, status: str, **fields: Any) -> Dict[str, Any]:
253
+ record = self._load(job_id)
254
+ job = record["job"]
255
+ if job["status"] in TERMINAL:
256
+ raise InteropError("Job %s is already %s; a terminal job does not change." % (job_id, job["status"]))
257
+ for key in ("inputRequests", "result", "error", "statusMessage"):
258
+ job.pop(key, None)
259
+ job["status"] = status
260
+ job.update({key: value for key, value in fields.items() if value is not None})
261
+ return self._save(record)
262
+
263
+ def working(self, job_id: str, message: Optional[str] = None) -> Dict[str, Any]:
264
+ return self._transition(job_id, "working", statusMessage=message)
265
+
266
+ def request_input(self, job_id: str, input_requests: Dict[str, Dict[str, Any]], message: Optional[str] = None) -> Dict[str, Any]:
267
+ """input_required with MCP elicitation requests, keyed; build them with choice_request / form_request / url_request."""
268
+ if not input_requests:
269
+ raise InteropError("input_required needs at least one input request.")
270
+ for key, request in input_requests.items():
271
+ if not _KEY.match(key) or request.get("method") != "elicitation/create":
272
+ raise InteropError("Input request %r must be a keyed elicitation/create request." % key)
273
+ return self._transition(job_id, "input_required", inputRequests=input_requests, statusMessage=message)
274
+
275
+ def answer(self, job_id: str, input_responses: Dict[str, Any]) -> Dict[str, Any]:
276
+ """Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
277
+
278
+ Returns the responses that matched; when any did, the job is working again."""
279
+ job = self.get(job_id)
280
+ if job["status"] != "input_required":
281
+ return {}
282
+ matched = {key: value for key, value in (input_responses or {}).items()
283
+ if key in job.get("inputRequests", {}) and isinstance(value, dict)
284
+ and value.get("action") in ("accept", "decline", "cancel")}
285
+ if matched:
286
+ self.working(job_id)
287
+ return matched
288
+
289
+ def complete(self, job_id: str, envelope: Dict[str, Any]) -> Dict[str, Any]:
290
+ if not isinstance(envelope, dict) or not {"done", "proof", "scope", "notVerified", "output", "usage"} <= set(envelope):
291
+ raise InteropError("A completed job carries the full result envelope; build it with result_envelope.")
292
+ return self._transition(job_id, "completed", result=envelope)
293
+
294
+ def fail(self, job_id: str, code: Any, message: str) -> Dict[str, Any]:
295
+ return self._transition(job_id, "failed", error={"code": code, "message": message})
296
+
297
+ def cancel(self, job_id: str) -> Dict[str, Any]:
298
+ return self._transition(job_id, "cancelled")
299
+
300
+
301
+ # --- a minimal MCP tool server (streamable HTTP, JSON responses) ------------------------
302
+
303
+ class CallContext:
304
+ """What a tool handler gets: the child span it runs in, and a way to start a job."""
305
+
306
+ def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str):
307
+ self.server = server
308
+ self.tool = tool
309
+ self.arguments = arguments
310
+ self.traceparent = traceparent
311
+
312
+ def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
313
+ if self.server.jobs is None:
314
+ raise InteropError("This server has no job store.")
315
+ handle = self.server.jobs.create(self.tool, self.arguments, self.traceparent, poll_interval_ms)
316
+ self.server.job_started(handle["job"]["id"], self)
317
+ return handle
318
+
319
+
320
+ Handler = Callable[[Any, CallContext], Any]
321
+ OnInput = Callable[[str, Dict[str, Any], CallContext], None]
322
+
323
+ JOB_REQUEST_SCHEMA = {"type": "object", "required": ["id"], "additionalProperties": False, "properties": {
324
+ "id": {"type": "string", "minLength": 1, "maxLength": 128},
325
+ "inputResponses": {"type": "object"}}}
326
+
327
+
328
+ class McpToolServer:
329
+ """tools/list and tools/call for an MCP 2026-07-28 server, with fabric.job.get/cancel built in.
330
+
331
+ `handle(message)` takes one JSON-RPC message and returns the response (None for a
332
+ notification). Every result carries `_meta.traceparent`, a child span of the
333
+ caller's. It is deliberately small: a service with a full MCP SDK should use that
334
+ and keep only the helpers above.
335
+ """
336
+
337
+ def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
338
+ on_job_started: Optional[Callable[[str, CallContext], None]] = None,
339
+ on_input: Optional[OnInput] = None):
340
+ self.info = {"name": name, "version": version}
341
+ self.jobs = jobs
342
+ self.tools: List[Dict[str, Any]] = []
343
+ self.handlers: Dict[str, Handler] = {}
344
+ self._on_job_started = on_job_started
345
+ self._on_input = on_input
346
+
347
+ def add_tool(self, tool: Dict[str, Any], handler: Handler) -> None:
348
+ if tool["name"] in self.handlers:
349
+ raise InteropError("Tool %s is served twice." % tool["name"])
350
+ self.tools.append(tool)
351
+ self.handlers[tool["name"]] = handler
352
+
353
+ def job_started(self, job_id: str, ctx: CallContext) -> None:
354
+ if self._on_job_started:
355
+ self._on_job_started(job_id, ctx)
356
+
357
+ def _job_tools(self) -> List[Dict[str, Any]]:
358
+ if self.jobs is None:
359
+ return []
360
+ return [
361
+ {"name": "fabric.job.get", "description": "The state of a job this agent started; answer an input request with inputResponses.",
362
+ "inputSchema": JOB_REQUEST_SCHEMA, "annotations": {"idempotentHint": True}},
363
+ {"name": "fabric.job.cancel", "description": "Ask this agent to stop a job it started.",
364
+ "inputSchema": {"type": "object", "required": ["id"], "additionalProperties": False, "properties": {"id": JOB_REQUEST_SCHEMA["properties"]["id"]}},
365
+ "annotations": {"idempotentHint": True}},
366
+ ]
367
+
368
+ @staticmethod
369
+ def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
370
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
371
+
372
+ def handle(self, message: Any) -> Optional[Dict[str, Any]]:
373
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
374
+ return self._error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request.")
375
+ if "id" not in message:
376
+ return None # a notification gets no response
377
+ rid = message["id"]
378
+ params = message.get("params") or {}
379
+ meta = params.get("_meta") or {}
380
+ span = child_traceparent(meta.get("traceparent"))
381
+ method = message["method"]
382
+ if method == "server/discover":
383
+ return {"jsonrpc": "2.0", "id": rid, "result": {"resultType": "complete", "capabilities": {"tools": {}},
384
+ "serverInfo": self.info, "_meta": {"traceparent": span}}}
385
+ if method == "tools/list":
386
+ return {"jsonrpc": "2.0", "id": rid, "result": {"resultType": "complete", "tools": self.tools + self._job_tools(),
387
+ "_meta": {"traceparent": span}}}
388
+ if method != "tools/call":
389
+ return self._error(rid, -32601, "Method %s is not served here." % method)
390
+ name = params.get("name")
391
+ arguments = params.get("arguments") or {}
392
+ if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
393
+ return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span)}
394
+ handler = self.handlers.get(name)
395
+ if handler is None:
396
+ return self._error(rid, -32602, "Unknown tool: %s." % name)
397
+ try:
398
+ structured = handler(arguments, CallContext(self, name, arguments, span))
399
+ except InteropError as exc:
400
+ return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
401
+ return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
402
+
403
+ def _job_call(self, name: str, arguments: Dict[str, Any], span: str) -> Dict[str, Any]:
404
+ job_id = str(arguments.get("id", ""))
405
+ try:
406
+ job_span = self.jobs.traceparent(job_id) or span
407
+ if name == "fabric.job.cancel":
408
+ state = self.jobs.get(job_id)
409
+ if state["status"] not in TERMINAL:
410
+ state = self.jobs.cancel(job_id)
411
+ return tool_result({"job": state}, traceparent=job_span)
412
+ responses = arguments.get("inputResponses")
413
+ if responses:
414
+ matched = self.jobs.answer(job_id, responses)
415
+ if matched and self._on_input:
416
+ private = self.jobs.private(job_id)
417
+ self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span))
418
+ return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
419
+ except UnknownJob:
420
+ return unknown_job_result(job_id, traceparent=span)
421
+
422
+
423
+ __all__ = [name for name in dir() if not name.startswith("_")]
424
+ # #endregion interop-kit
@@ -46,6 +46,8 @@ _INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
46
46
  _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
47
47
  _ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
48
48
  _CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
49
+ _TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
50
+ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
49
51
 
50
52
 
51
53
  class ServiceError(Exception):
@@ -404,8 +406,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
404
406
  # --- events ------------------------------------------------------------------
405
407
 
406
408
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
407
- link: Optional[str] = None, notify: bool = False) -> Dict[str, Any]:
408
- """One activity event: one sentence a person reads, never a machine id."""
409
+ link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
410
+ span_id: Optional[str] = None) -> Dict[str, Any]:
411
+ """One activity event: one sentence a person reads, never a machine id.
412
+
413
+ An event about traced work carries its trace as a pair, `trace_id` and `span_id`
414
+ (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
409
415
  if level not in LEVELS:
410
416
  raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
411
417
  if not _KIND.match(kind):
@@ -415,6 +421,10 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
415
421
  raise ServiceError("An event needs a sentence.")
416
422
  if link is not None and (not link.startswith("/") or link.startswith("//")):
417
423
  raise ServiceError("link must be a path on this service.")
424
+ if (trace_id is None) != (span_id is None):
425
+ raise ServiceError("An event carries traceId and spanId together, or neither.")
426
+ if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
427
+ raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
418
428
  event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
419
429
  if subject:
420
430
  event["subject"] = subject
@@ -422,6 +432,8 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
422
432
  event["link"] = link
423
433
  if notify:
424
434
  event["notify"] = True
435
+ if trace_id is not None:
436
+ event["traceId"], event["spanId"] = trace_id, span_id
425
437
  return event
426
438
 
427
439
 
@@ -3,6 +3,9 @@
3
3
 
4
4
  It is the worked example the skill points at, the target check_service.py tests
5
5
  itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
6
+ Over MCP (fabric-interop/0.1) it serves two capabilities: `sample.echo` answers at
7
+ once, and `sample.draft` is a job that stops for a titled choice before it completes.
8
+ `register` also writes the service's manifest, which names this descriptor.
6
9
 
7
10
  sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
8
11
  sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
@@ -11,6 +14,7 @@ itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
11
14
  from __future__ import annotations
12
15
 
13
16
  import argparse
17
+ import hashlib
14
18
  import html
15
19
  import json
16
20
  import os
@@ -24,6 +28,7 @@ from urllib.parse import parse_qs, urlparse
24
28
 
25
29
  sys.path.insert(0, str(Path(__file__).resolve().parent))
26
30
  import fabric_service as fs # noqa: E402
31
+ import fabric_interop as fi # noqa: E402
27
32
 
28
33
  VERSION = "0.1.0"
29
34
  PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -32,6 +37,24 @@ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
32
37
  </head><body><h1>{name}</h1><p>Status: {status}</p><ul>{rows}</ul></body></html>"""
33
38
 
34
39
 
40
+ def _schema(name: str, body: Dict[str, Any]) -> Dict[str, Any]:
41
+ return {"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:fabric:schema:%s" % name, **body}
42
+
43
+
44
+ TEXT = {"type": "object", "additionalProperties": False, "properties": {"text": {"type": "string", "maxLength": 2000}}, "required": ["text"]}
45
+ CAPABILITIES = [
46
+ {"name": "sample.echo", "effect": "none", "idempotency": "required", "job": False,
47
+ "description": "Returns the text it was given.",
48
+ "input": _schema("sample.echo-input", TEXT), "output": _schema("sample.echo-output", TEXT)},
49
+ {"name": "sample.draft", "effect": "draft", "idempotency": "none", "job": True,
50
+ "description": "Drafts a short note on a topic; asks which title to use.",
51
+ "input": _schema("sample.draft-input", {"type": "object", "additionalProperties": False,
52
+ "properties": {"topic": {"type": "string", "minLength": 1, "maxLength": 200}}, "required": ["topic"]}),
53
+ "output": _schema("sample.draft-output", {"type": "object", "additionalProperties": False,
54
+ "properties": {"title": {"type": "string"}, "body": {"type": "string"}}, "required": ["title", "body"]})},
55
+ ]
56
+
57
+
35
58
  class Service:
36
59
  def __init__(self, args: argparse.Namespace):
37
60
  self.id = args.id
@@ -50,14 +73,57 @@ class Service:
50
73
  self.token = fs.ensure_token(self.token_file)
51
74
  self.log = fs.JsonlEventLog(self.data / "events.jsonl")
52
75
  self.codes = fs.LoginCodes(self.data / "auth")
76
+ self.jobs = fi.JobStore(self.data / "jobs")
77
+ self.mcp = fi.McpToolServer(self.id, VERSION, jobs=self.jobs, on_job_started=self.draft_started, on_input=self.draft_answered)
78
+ for cap in CAPABILITIES:
79
+ handler = self.echo if cap["name"] == "sample.echo" else self.draft
80
+ self.mcp.add_tool(fi.tool_for_capability(cap, cap["input"], cap["output"]), handler)
53
81
  self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
54
82
 
83
+ # --- fabric-interop/0.1: the two capabilities ------------------------------------
84
+ def echo(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
85
+ if not isinstance(args.get("text"), str):
86
+ raise fi.InteropError("sample.echo needs text.")
87
+ return {"text": args["text"]}
88
+
89
+ def draft(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
90
+ if not isinstance(args.get("topic"), str) or not args["topic"].strip():
91
+ raise fi.InteropError("sample.draft needs a topic.")
92
+ return ctx.start_job(poll_interval_ms=1000)
93
+
94
+ def draft_started(self, job_id: str, ctx: fi.CallContext) -> None:
95
+ topic = str(ctx.arguments.get("topic"))
96
+ titles = [("plain", "About %s" % topic), ("question", "What is %s?" % topic)]
97
+ self.jobs.request_input(job_id, {"title_choice": fi.choice_request("Pick the title for the note.", "title", titles, "Title")},
98
+ "Two titles are ready.")
99
+ self.log.append("job.awaiting_choice", "notice", "A note about %s is waiting for you to pick its title." % topic,
100
+ notify=True, **fi.trace_ids(ctx.traceparent))
101
+
102
+ def draft_answered(self, job_id: str, answers: Dict[str, Any], ctx: fi.CallContext) -> None:
103
+ answer = answers.get("title_choice", {})
104
+ topic = str(ctx.arguments.get("topic"))
105
+ if answer.get("action") != "accept":
106
+ self.jobs.cancel(job_id)
107
+ self.log.append("job.cancelled", "info", "The note about %s was dropped: no title was chosen." % topic, **fi.trace_ids(ctx.traceparent))
108
+ return
109
+ choice = str((answer.get("content") or {}).get("title"))
110
+ title = "About %s" % topic if choice == "plain" else "What is %s?" % topic
111
+ body = "%s. This sample note was written by the sample service." % title
112
+ self.jobs.complete(job_id, fi.result_envelope(
113
+ done=[{"claimId": "NOTE", "statement": "A note titled %s was drafted." % title}], proof=[],
114
+ scope={"project": "urn:fabric:project:sample", "run": "urn:fabric:run:%s" % job_id, "node": "urn:fabric:node:draft",
115
+ "binding": {"id": "urn:fabric:binding:sample.draft", "revision": 1, "contentHash": "sha256:" + "0" * 64}, "writeScopes": []},
116
+ not_verified=[{"claim": "NOTE", "reason": "no checker has read the note"}],
117
+ output={"title": title, "body": body}, usage={"inputTokens": 0, "outputTokens": 0, "wallMs": 1}))
118
+ self.log.append("job.completed", "info", "The note %s is drafted." % title, **fi.trace_ids(ctx.traceparent))
119
+
55
120
  def well_known(self) -> Dict[str, Any]:
56
121
  return fs.build_well_known(
57
122
  service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
58
123
  started_at=self.started_at, status="ready", degraded=self.degraded,
59
124
  summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
60
- surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}},
125
+ surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
126
+ "mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]}},
61
127
  )
62
128
 
63
129
 
@@ -137,6 +203,16 @@ def make_handler(svc: Service):
137
203
  if not self._guard():
138
204
  return
139
205
  url = urlparse(self.path)
206
+ if url.path == "/mcp":
207
+ if not self._token_ok():
208
+ return
209
+ length = min(int(self.headers.get("Content-Length") or 0), 1048576)
210
+ try:
211
+ message = json.loads(self.rfile.read(length) or b"null")
212
+ except ValueError:
213
+ return self._send(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error."}})
214
+ response = svc.mcp.handle(message)
215
+ return self._send(202) if response is None else self._send(200, response)
140
216
  if url.path == "/fabric/v1/login-code":
141
217
  if not self._token_ok():
142
218
  return
@@ -180,8 +256,46 @@ def serve(args: argparse.Namespace) -> int:
180
256
  return 0
181
257
 
182
258
 
259
+ def write_manifest(args: argparse.Namespace, data: Path) -> Path:
260
+ """The provider manifest for this service, naming its descriptor (G-07), with its schemas beside it."""
261
+ schemas = data / "fabric" / "schemas"
262
+ for cap in CAPABILITIES:
263
+ for side in ("input", "output"):
264
+ fs.atomic_write(schemas / ("%s-%s.schema.json" % (cap["name"], side)),
265
+ (json.dumps(cap[side], indent=2) + "\n").encode(), 0o600)
266
+ origin = "http://127.0.0.1:%d" % args.port
267
+ capabilities = []
268
+ for cap in CAPABILITIES:
269
+ capabilities.append({
270
+ "id": "urn:fabric:capability:%s" % cap["name"], "name": cap["name"], "description": cap["description"],
271
+ "inputSchema": cap["input"]["$id"], "outputSchema": cap["output"]["$id"],
272
+ "effect": cap["effect"], "idempotency": cap["idempotency"], "dataClasses": ["public"],
273
+ "profile": {"kind": "mcp", "protocolRevision": fi.MCP_REVISION,
274
+ "connection": {"mode": "streamable-http", "url": origin + "/mcp"},
275
+ "requiredFeatures": ["tool:%s" % cap["name"]],
276
+ "probes": [{"id": "%s-shape" % cap["name"].replace(".", "-"), "inputFixture": "urn:fabric:fixture:%s" % cap["name"],
277
+ "outputSchema": cap["output"]["$id"], "timeoutMs": 5000, "sideEffectCeiling": "none",
278
+ "assertions": ["returns a value valid against its output schema"]}]},
279
+ "extensions": {fi.EXTENSION_KEY: {"job": cap["job"]}},
280
+ })
281
+ digest = hashlib.sha256(json.dumps(capabilities, sort_keys=True).encode()).hexdigest()
282
+ subject = "urn:fabric:provider:%s" % args.id
283
+ manifest = {
284
+ "contractVersion": "0.1.0",
285
+ "provider": {"id": subject, "revision": 1, "contentHash": "sha256:" + digest, "createdAt": fs.now_iso(),
286
+ "createdBy": "urn:fabric:adapter:sample-service", "name": args.name,
287
+ "identity": {"subject": subject, "method": "local-install"}, "supportedContractVersions": ["0.1.0"],
288
+ "extensions": {fs.EXTENSION_KEY: {"descriptor": "%s.%s" % (args.id, args.instance)}}},
289
+ "capabilities": capabilities,
290
+ }
291
+ path = data / "fabric-agent.json"
292
+ fs.atomic_write(path, (json.dumps(manifest, indent=2) + "\n").encode(), 0o600)
293
+ return path
294
+
295
+
183
296
  def register(args: argparse.Namespace) -> int:
184
297
  data = Path(args.data_dir)
298
+ manifest = write_manifest(args, data)
185
299
  descriptor = {
186
300
  "protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name,
187
301
  "summary": "Sample fabric-service/0.1 service.",
@@ -190,6 +304,7 @@ def register(args: argparse.Namespace) -> int:
190
304
  "lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label
191
305
  else {"manager": "none"},
192
306
  "paths": {"data": str(data), "logs": []},
307
+ "fabricManifest": str(manifest),
193
308
  "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
194
309
  }
195
310
  try: