@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/building-fabric-services/scripts/fabric-interop.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Reference kit for the fabric-interop/0.1 extension — Node.js 20+, no dependencies.
|
|
2
|
+
// The Node twin of fabric_interop.py: the same rules and the same job file format, so a
|
|
3
|
+
// job written by one kit is read by the other. The minimal MCP dispatcher is Python-only;
|
|
4
|
+
// a Node service uses the official MCP SDK for the wire and these helpers for the rules.
|
|
5
|
+
// Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016).
|
|
6
|
+
// #region interop-kit-node — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#the-kit
|
|
7
|
+
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
|
|
12
|
+
|
|
13
|
+
export const PROTOCOL = 'fabric-interop/0.1';
|
|
14
|
+
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
|
|
15
|
+
export const MCP_REVISION = '2026-07-28';
|
|
16
|
+
export const TERMINAL = ['completed', 'failed', 'cancelled'];
|
|
17
|
+
|
|
18
|
+
const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
19
|
+
const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
20
|
+
const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
21
|
+
const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
|
|
22
|
+
const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
|
|
23
|
+
|
|
24
|
+
export class InteropError extends ServiceError {}
|
|
25
|
+
export class UnknownJob extends InteropError {
|
|
26
|
+
constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- C3.4 trace context ---------------------------------------------------------------
|
|
30
|
+
export function parseTraceparent(value) {
|
|
31
|
+
const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
const [, version, traceId, spanId, flags] = m;
|
|
34
|
+
if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
|
|
35
|
+
return { version, traceId, spanId, flags };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
|
|
39
|
+
|
|
40
|
+
export function childTraceparent(parent) {
|
|
41
|
+
const p = parseTraceparent(parent);
|
|
42
|
+
return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const traceIds = (traceparent) => {
|
|
46
|
+
const p = parseTraceparent(traceparent);
|
|
47
|
+
return p ? { traceId: p.traceId, spanId: p.spanId } : {};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// --- C3.1 capabilities as tools ----------------------------------------------------------
|
|
51
|
+
export function expectedAnnotations(effect, idempotency) {
|
|
52
|
+
const hints = {};
|
|
53
|
+
if (effect === 'none') hints.readOnlyHint = true;
|
|
54
|
+
if (['delete', 'merge', 'deploy', 'change-policy'].includes(effect)) hints.destructiveHint = true;
|
|
55
|
+
if (idempotency === 'required') hints.idempotentHint = true;
|
|
56
|
+
return hints;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A job-capable tool still serves the capability's outputSchema (contract OQ-0006).
|
|
60
|
+
export function toolForCapability(capability, inputSchema, outputSchema, title) {
|
|
61
|
+
const tool = { name: capability.name, inputSchema, outputSchema, annotations: expectedAnnotations(capability.effect, capability.idempotency) };
|
|
62
|
+
if (capability.description) tool.description = capability.description;
|
|
63
|
+
if (title) tool.title = title;
|
|
64
|
+
return tool;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function toolResult(structured, { isError = false, traceparent } = {}) {
|
|
68
|
+
const result = { resultType: 'complete', content: [{ type: 'text', text: JSON.stringify(structured) }], structuredContent: structured, isError };
|
|
69
|
+
if (traceparent) result._meta = { traceparent };
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const unknownJobResult = (jobId, traceparent) =>
|
|
74
|
+
toolResult({ error: { code: 'unknown-job', message: `No job ${jobId} here.` } }, { isError: true, traceparent });
|
|
75
|
+
|
|
76
|
+
// --- C3.2 the result envelope --------------------------------------------------------------
|
|
77
|
+
function checkUsage(usage) {
|
|
78
|
+
if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
|
|
79
|
+
for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
|
|
80
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
81
|
+
if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
|
|
82
|
+
const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
|
|
83
|
+
if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
|
|
84
|
+
}
|
|
85
|
+
return { ...usage };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resultEnvelope({ done, proof, scope, notVerified, output, usage }) {
|
|
89
|
+
for (const [label, value] of [['done', done], ['proof', proof], ['notVerified', notVerified]]) {
|
|
90
|
+
if (!Array.isArray(value)) throw new InteropError(`${label} must be a list, even when empty.`);
|
|
91
|
+
}
|
|
92
|
+
if (!scope || typeof scope !== 'object') throw new InteropError('scope must be an object.');
|
|
93
|
+
return { done: [...done], proof: [...proof], scope: { ...scope }, notVerified: [...notVerified], output, usage: checkUsage(usage) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- C3.3 awaiting a choice ------------------------------------------------------------------
|
|
97
|
+
export function formRequest(message, properties, required) {
|
|
98
|
+
for (const [field, schema] of Object.entries(properties)) {
|
|
99
|
+
const words = ['title', 'description', 'format'].map((k) => schema[k] ?? '').join(' ');
|
|
100
|
+
if (SECRET_WORDS.test(field) || SECRET_WORDS.test(words)) throw new InteropError(`Form mode cannot ask for ${field}: a secret goes through urlRequest.`);
|
|
101
|
+
if (!['string', 'number', 'integer', 'boolean', 'array'].includes(schema.type)) throw new InteropError(`Form field ${field} must be a primitive.`);
|
|
102
|
+
}
|
|
103
|
+
const requestedSchema = { type: 'object', properties };
|
|
104
|
+
if (required?.length) requestedSchema.required = [...required];
|
|
105
|
+
return { method: 'elicitation/create', params: { mode: 'form', message, requestedSchema } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function choiceRequest(message, field, options, title) {
|
|
109
|
+
const schema = { type: 'string', oneOf: options.map(([value, label]) => ({ const: value, title: label })) };
|
|
110
|
+
if (title) schema.title = title;
|
|
111
|
+
return formRequest(message, { [field]: schema }, [field]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function urlRequest(message, url) {
|
|
115
|
+
if (!/^(https:\/\/|http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(url)) throw new InteropError('A URL-mode request needs an https URL (or this machine\'s loopback).');
|
|
116
|
+
return { method: 'elicitation/create', params: { mode: 'url', message, url } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- C3.2 jobs: the same file format as fabric_interop.JobStore ------------------------------
|
|
120
|
+
export class JobStore {
|
|
121
|
+
constructor(dir) { this.dir = dir; }
|
|
122
|
+
|
|
123
|
+
file(jobId) {
|
|
124
|
+
if (!JOB_ID.test(jobId ?? '')) throw new UnknownJob(String(jobId));
|
|
125
|
+
return path.join(this.dir, `${jobId}.json`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
load(jobId) {
|
|
129
|
+
let text;
|
|
130
|
+
try { text = fs.readFileSync(this.file(jobId), 'utf8'); } catch (e) { if (e.code === 'ENOENT') throw new UnknownJob(jobId); throw e; }
|
|
131
|
+
try { return JSON.parse(text); } catch { throw new InteropError(`Job ${jobId} is unreadable on disk.`); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
save(record) {
|
|
135
|
+
record.job.updatedAt = nowIso();
|
|
136
|
+
ensurePrivateDir(this.dir);
|
|
137
|
+
atomicWrite(this.file(record.job.id), `${JSON.stringify(record)}\n`, 0o600);
|
|
138
|
+
return { ...record.job };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
create(capability, input, { traceparent, pollIntervalMs } = {}) {
|
|
142
|
+
const id = `job_${crypto.randomBytes(12).toString('hex')}`;
|
|
143
|
+
const job = { id, status: 'working' };
|
|
144
|
+
if (pollIntervalMs) job.pollIntervalMs = Math.trunc(pollIntervalMs);
|
|
145
|
+
this.save({ job, private: { capability, input, traceparent: parseTraceparent(traceparent) ? traceparent : null } });
|
|
146
|
+
return { job: { id, status: 'working' } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get(jobId) { return { ...this.load(jobId).job }; }
|
|
150
|
+
traceparent(jobId) { return this.load(jobId).private.traceparent ?? null; }
|
|
151
|
+
|
|
152
|
+
transition(jobId, status, fields = {}) {
|
|
153
|
+
const record = this.load(jobId);
|
|
154
|
+
if (TERMINAL.includes(record.job.status)) throw new InteropError(`Job ${jobId} is already ${record.job.status}; a terminal job does not change.`);
|
|
155
|
+
for (const key of ['inputRequests', 'result', 'error', 'statusMessage']) delete record.job[key];
|
|
156
|
+
record.job.status = status;
|
|
157
|
+
for (const [key, value] of Object.entries(fields)) if (value !== undefined && value !== null) record.job[key] = value;
|
|
158
|
+
return this.save(record);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
working(jobId, statusMessage) { return this.transition(jobId, 'working', { statusMessage }); }
|
|
162
|
+
|
|
163
|
+
requestInput(jobId, inputRequests, statusMessage) {
|
|
164
|
+
const entries = Object.entries(inputRequests ?? {});
|
|
165
|
+
if (!entries.length) throw new InteropError('input_required needs at least one input request.');
|
|
166
|
+
for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
167
|
+
return this.transition(jobId, 'input_required', { inputRequests, statusMessage });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
answer(jobId, inputResponses) {
|
|
171
|
+
const job = this.get(jobId);
|
|
172
|
+
if (job.status !== 'input_required') return {};
|
|
173
|
+
const matched = Object.fromEntries(Object.entries(inputResponses ?? {}).filter(([key, value]) =>
|
|
174
|
+
key in (job.inputRequests ?? {}) && ['accept', 'decline', 'cancel'].includes(value?.action)));
|
|
175
|
+
if (Object.keys(matched).length) this.working(jobId);
|
|
176
|
+
return matched;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
complete(jobId, envelope) {
|
|
180
|
+
const needed = ['done', 'proof', 'scope', 'notVerified', 'output', 'usage'];
|
|
181
|
+
if (!envelope || !needed.every((k) => k in envelope)) throw new InteropError('A completed job carries the full result envelope; build it with resultEnvelope.');
|
|
182
|
+
return this.transition(jobId, 'completed', { result: envelope });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fail(jobId, code, message) { return this.transition(jobId, 'failed', { error: { code, message } }); }
|
|
186
|
+
cancel(jobId) { return this.transition(jobId, 'cancelled'); }
|
|
187
|
+
}
|
|
188
|
+
// #endregion interop-kit-node
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -22,6 +22,8 @@ const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
22
22
|
const KIND = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$/;
|
|
23
23
|
const ORIGIN = /^http:\/\/127\.0\.0\.1:([0-9]{3,5})$/;
|
|
24
24
|
const CODE = /^[A-Za-z0-9_-]{16,256}$/;
|
|
25
|
+
const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
|
|
26
|
+
const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
|
|
25
27
|
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
26
28
|
|
|
27
29
|
export class ServiceError extends Error {}
|
|
@@ -281,16 +283,21 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
281
283
|
return doc;
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
|
|
286
|
+
// An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
|
|
287
|
+
// traceIds(traceparent) in fabric-interop.mjs gives both.
|
|
288
|
+
export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId } = {}) {
|
|
285
289
|
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
286
290
|
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
287
291
|
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
288
292
|
if (!sentence) throw new ServiceError('An event needs a sentence.');
|
|
289
293
|
if (link !== undefined && (!link.startsWith('/') || link.startsWith('//'))) throw new ServiceError('link must be a path on this service.');
|
|
294
|
+
if ((traceId === undefined) !== (spanId === undefined)) throw new ServiceError('An event carries traceId and spanId together, or neither.');
|
|
295
|
+
if (traceId !== undefined && !(TRACE_ID.test(traceId) && SPAN_ID.test(spanId))) throw new ServiceError('traceId is 32 and spanId 16 lowercase hex characters, not all zeros.');
|
|
290
296
|
const event = { id: String(id), at, kind, level, text: sentence.slice(0, 500) };
|
|
291
297
|
if (subject) event.subject = subject;
|
|
292
298
|
if (link) event.link = link;
|
|
293
299
|
if (notify) event.notify = true;
|
|
300
|
+
if (traceId !== undefined) Object.assign(event, { traceId, spanId });
|
|
294
301
|
return event;
|
|
295
302
|
}
|
|
296
303
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
ADDED
|
@@ -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
|