figdown 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/dist/figdown.js +613 -43
- package/dist/figdown.mjs +613 -43
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/guide/expressing.md +2 -2
- package/guide/layout.md +21 -13
- package/integrations/mcp-server/README.md +174 -0
- package/integrations/mcp-server/server.js +593 -0
- package/package.json +8 -3
- package/skill/figdown/SKILL.md +14 -4
- package/skill/figdown/figdown.html +611 -41
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// figdown-mcp — a Model Context Protocol server over stdio.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. FigDown's thesis is that an agent should read a figure's
|
|
6
|
+
// MEANING rather than a picture. MCP is how an agent reaches a tool, so this
|
|
7
|
+
// is the channel that carries the thesis to agents that have no skill
|
|
8
|
+
// installed, no repository checkout and no shell.
|
|
9
|
+
//
|
|
10
|
+
// ── THE ENGINE IS NOT COPIED HERE ──────────────────────────────────────────
|
|
11
|
+
// This repository has shipped SEVEN four-copy-drift incidents. There are four
|
|
12
|
+
// engine copies already (`editor/figdown.html`, hand-edited, plus the three
|
|
13
|
+
// generated from it: `dist/figdown.js`, `dist/figdown.mjs`,
|
|
14
|
+
// `skill/figdown/figdown.html`). This file adds NO fifth copy: it `require`s
|
|
15
|
+
// `dist/figdown.js`, exactly as `integrations/kroki-service/server.js` and
|
|
16
|
+
// `integrations/markdown-it-figdown/index.js` already do.
|
|
17
|
+
//
|
|
18
|
+
// `dist/figdown.js` is the right consumption point rather than merely an
|
|
19
|
+
// available one:
|
|
20
|
+
// * it is the package's own `main` — what `require('figdown')` returns;
|
|
21
|
+
// * it is the ONLY artifact with a module API (parse/render/artifact);
|
|
22
|
+
// * `gate:dist` (tools/dist-check.js) already holds it to the reference
|
|
23
|
+
// engine BEHAVIOURALLY — regenerating must be a byte-level no-op, both
|
|
24
|
+
// builds must produce identical SVG, and every published `.fd` must parse
|
|
25
|
+
// through it with the SAME ERROR SET as `editor/figdown.html`. That is
|
|
26
|
+
// agreement on behaviour, not on a version string, and this server
|
|
27
|
+
// inherits it for free. Consuming the editor HTML by string-slicing it
|
|
28
|
+
// (as `tools/build-svg.js` must, being the thing that bootstraps the
|
|
29
|
+
// others) would instead be a new, ungated coupling to a source layout.
|
|
30
|
+
//
|
|
31
|
+
// ── NO NETWORK, NO SERVICE, NO KEY ─────────────────────────────────────────
|
|
32
|
+
// Zero dependencies, including no MCP SDK: the stdio transport is
|
|
33
|
+
// newline-delimited JSON-RPC 2.0, which is ~80 lines, and taking a dependency
|
|
34
|
+
// to save them would be the first dependency in the whole project. Nothing
|
|
35
|
+
// here opens a socket, resolves a name or reads a credential. The only I/O is
|
|
36
|
+
// stdin/stdout and, on explicit request, reading a `.fd`/`.svg` and writing
|
|
37
|
+
// its sidecar `.svg`.
|
|
38
|
+
//
|
|
39
|
+
// node integrations/mcp-server/server.js # speaks MCP on stdin/stdout
|
|
40
|
+
// node integrations/mcp-server/test.js # the gate (npm run gate:mcp)
|
|
41
|
+
|
|
42
|
+
const fs = require('node:fs');
|
|
43
|
+
const path = require('node:path');
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Engine + docs lookup. Same shape as kroki-service: an env override, a
|
|
47
|
+
// co-located copy (a bundled/container layout), then the repository layout —
|
|
48
|
+
// which is also the npm tarball layout, since `dist/` and `skill/figdown/`
|
|
49
|
+
// ship at these paths.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
const LIB = [
|
|
52
|
+
process.env.FIGDOWN_LIB,
|
|
53
|
+
path.join(__dirname, 'figdown.js'),
|
|
54
|
+
path.join(__dirname, '..', '..', 'dist', 'figdown.js'),
|
|
55
|
+
].filter(Boolean).find(p => fs.existsSync(p));
|
|
56
|
+
if (!LIB) {
|
|
57
|
+
console.error('figdown-mcp: dist/figdown.js not found — run: node tools/make-lib.js');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const figdown = require(LIB);
|
|
61
|
+
|
|
62
|
+
const SKILL_DIR = [
|
|
63
|
+
process.env.FIGDOWN_SKILL,
|
|
64
|
+
path.join(__dirname, 'skill'),
|
|
65
|
+
path.join(__dirname, '..', '..', 'skill', 'figdown'),
|
|
66
|
+
].filter(Boolean).find(p => fs.existsSync(p));
|
|
67
|
+
|
|
68
|
+
const SERVER_INFO = { name: 'figdown', title: 'FigDown', version: figdown.version };
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The genre router is DERIVED, never restated.
|
|
72
|
+
//
|
|
73
|
+
// SKILL.md's `<!-- skill-coverage: router -->` table is the one home of
|
|
74
|
+
// "genre on line 1 -> which reference file". Hardcoding a copy of it here
|
|
75
|
+
// would be the same defect as a fifth engine copy, one level down: a renamed
|
|
76
|
+
// reference file would leave this server confidently serving a 404. So the
|
|
77
|
+
// table is parsed at call time, by the same marker and the same cell shape
|
|
78
|
+
// `tools/skill-coverage.js` uses to gate it.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
function ticks(cell) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const re = /`([^`]+)`/g;
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = re.exec(cell))) out.push(m[1].trim());
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readRouter() {
|
|
89
|
+
if (!SKILL_DIR) return null;
|
|
90
|
+
const skill = path.join(SKILL_DIR, 'SKILL.md');
|
|
91
|
+
if (!fs.existsSync(skill)) return null;
|
|
92
|
+
const lines = fs.readFileSync(skill, 'utf8').split(/\r?\n/);
|
|
93
|
+
const start = lines.findIndex(l => /<!--\s*skill-coverage:\s*router\s*-->/.test(l));
|
|
94
|
+
if (start < 0) return null;
|
|
95
|
+
const rows = new Map();
|
|
96
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
97
|
+
const l = lines[i];
|
|
98
|
+
if (!/^\s*\|/.test(l)) { if (rows.size) break; else continue; }
|
|
99
|
+
const cells = l.split('|').slice(1, -1).map(c => c.trim());
|
|
100
|
+
if (cells.length < 2) continue;
|
|
101
|
+
if (cells.every(c => /^:?-+:?$/.test(c))) continue;
|
|
102
|
+
const genres = ticks(cells[0]).filter(t => /^[a-z]+$/.test(t));
|
|
103
|
+
if (!genres.length) continue;
|
|
104
|
+
const md = c => ticks(c || '').filter(t => /\.md$/.test(t));
|
|
105
|
+
for (const g of genres) rows.set(g, { frozen: md(cells[1]), exp: md(cells[2]) });
|
|
106
|
+
}
|
|
107
|
+
return rows.size ? rows : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Task-shaped reference files: they answer a JOB rather than a genre, so they
|
|
111
|
+
// are not in the router table and SKILL.md names them in the prose beneath it.
|
|
112
|
+
const TASKS = {
|
|
113
|
+
reading: 'reference/reading.md',
|
|
114
|
+
transcribe: 'reference/transcribe.md',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Result helpers.
|
|
119
|
+
//
|
|
120
|
+
// FAILURE MODEL, stated once because it is the whole point of the diagnostics:
|
|
121
|
+
// a `.fd` that does not parse is a NORMAL OUTCOME of a build, not an
|
|
122
|
+
// exception. The engine's `Line N: message` text is the product — it names the
|
|
123
|
+
// line, the reason and usually the replacement spelling — so it is returned
|
|
124
|
+
// verbatim in the content, never flattened into an `Error`, never turned into
|
|
125
|
+
// a JSON-RPC error, and never truncated.
|
|
126
|
+
//
|
|
127
|
+
// The three channels are kept distinct on purpose:
|
|
128
|
+
// * JSON-RPC `error` — protocol faults only (bad JSON, unknown method).
|
|
129
|
+
// * `isError: true` — the tool could not run at all (no such file, both
|
|
130
|
+
// `source` and `path` given). The caller's request was
|
|
131
|
+
// malformed; there is nothing to read.
|
|
132
|
+
// * a normal result — including PARSE FAILED. The tool did exactly its
|
|
133
|
+
// job: it reported why the document does not parse.
|
|
134
|
+
// `ok: false` in the first line says so unambiguously,
|
|
135
|
+
// and no client hides it as an error string.
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
function text(...blocks) {
|
|
138
|
+
return { content: blocks.filter(b => b !== null && b !== undefined).map(t => ({ type: 'text', text: String(t) })) };
|
|
139
|
+
}
|
|
140
|
+
function toolError(msg) {
|
|
141
|
+
return { content: [{ type: 'text', text: 'figdown-mcp: ' + msg }], isError: true };
|
|
142
|
+
}
|
|
143
|
+
function parseFailed(label, errors) {
|
|
144
|
+
return text(
|
|
145
|
+
'PARSE FAILED — ' + errors.length + ' diagnostic(s)' + (label ? ' in ' + label : '') + '.\n' +
|
|
146
|
+
'This is a result, not a crash: each line names the 1-based line number, the reason,\n' +
|
|
147
|
+
'and (for a retired spelling) what to write instead. Fix and call again.',
|
|
148
|
+
errors.join('\n')
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `source` or `path`, never both, never neither. Returns {src, label, file} or
|
|
153
|
+
// throws a message string.
|
|
154
|
+
function takeSource(args, exts) {
|
|
155
|
+
const hasSrc = typeof args.source === 'string';
|
|
156
|
+
const hasPath = typeof args.path === 'string';
|
|
157
|
+
if (hasSrc && hasPath) throw 'give either `source` or `path`, not both';
|
|
158
|
+
if (!hasSrc && !hasPath) throw 'one of `source` or `path` is required';
|
|
159
|
+
if (hasSrc) return { src: args.source, label: '<source>', file: null };
|
|
160
|
+
const file = path.resolve(args.path);
|
|
161
|
+
if (!fs.existsSync(file)) throw 'no such file: ' + args.path;
|
|
162
|
+
if (fs.statSync(file).isDirectory()) throw args.path + ' is a directory (figdown_check accepts directories; this tool does not)';
|
|
163
|
+
if (exts && !exts.some(e => file.endsWith(e))) throw args.path + ' is not a ' + exts.join(' or ') + ' file';
|
|
164
|
+
return { src: fs.readFileSync(file, 'utf8'), label: args.path, file };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// TOOL 1 — figdown_build
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
function toolBuild(args) {
|
|
171
|
+
let s;
|
|
172
|
+
try { s = takeSource(args, ['.fd']); } catch (m) { return toolError(m); }
|
|
173
|
+
|
|
174
|
+
const opts = args.with_title === true ? { title: true } : undefined;
|
|
175
|
+
const { svg, errors } = figdown.artifact(s.src, opts);
|
|
176
|
+
if (errors.length) return parseFailed(s.label, errors);
|
|
177
|
+
|
|
178
|
+
const wrote = args.write === true && s.file
|
|
179
|
+
? s.file.replace(/\.fd$/, '') + '.svg'
|
|
180
|
+
: null;
|
|
181
|
+
if (args.write === true && !s.file) {
|
|
182
|
+
return toolError('`write` needs `path` — the sidecar is written beside the source and nowhere else');
|
|
183
|
+
}
|
|
184
|
+
if (wrote) fs.writeFileSync(wrote, svg);
|
|
185
|
+
|
|
186
|
+
// What a build returns, decided rather than defaulted: the SVG comes back
|
|
187
|
+
// UNLESS it was just written to disk, in which case the path comes back and
|
|
188
|
+
// the caller is spared 5-50 KB of markup it can already open. `return_svg`
|
|
189
|
+
// overrides in both directions.
|
|
190
|
+
const sendSvg = typeof args.return_svg === 'boolean' ? args.return_svg : !wrote;
|
|
191
|
+
const sha = (svg.match(/data-sha256="([0-9a-f]{64})"/) || [])[1] || '';
|
|
192
|
+
|
|
193
|
+
const summary = [
|
|
194
|
+
'ok: true (' + svg.length + ' bytes)',
|
|
195
|
+
'engine: ' + figdown.version + (opts ? ' render options: with-title' : ''),
|
|
196
|
+
'source sha256: ' + sha,
|
|
197
|
+
wrote ? 'written: ' + wrote : 'written: no (pass `path` + `write:true` to emit the sidecar)',
|
|
198
|
+
sendSvg ? 'The SVG follows. It is self-carrying: it embeds its own source, that source\'s'
|
|
199
|
+
+ '\nSHA-256 and the engine version, so it round-trips back to .fd (spec core §7).'
|
|
200
|
+
: 'SVG not returned (it is on disk). Pass `return_svg:true` to receive it inline.',
|
|
201
|
+
].join('\n');
|
|
202
|
+
|
|
203
|
+
// The model is opt-in here and the default in figdown_read: an author who
|
|
204
|
+
// just wrote the source does not need it read back.
|
|
205
|
+
let model = null;
|
|
206
|
+
if (args.include_model === true) {
|
|
207
|
+
const p = figdown.parse(s.src);
|
|
208
|
+
model = 'MODEL (spec §12 semantic surface)\n' + JSON.stringify(p.docs, null, 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return text(summary, sendSvg ? svg : null, model);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// TOOL 2 — figdown_check
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
function collect(dir, acc) {
|
|
218
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
219
|
+
if (name === 'node_modules' || name === '.git') continue;
|
|
220
|
+
const p = path.join(dir, name);
|
|
221
|
+
if (fs.statSync(p).isDirectory()) collect(p, acc);
|
|
222
|
+
else if (name.endsWith('.fd')) acc.push(p);
|
|
223
|
+
}
|
|
224
|
+
return acc;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function toolCheck(args) {
|
|
228
|
+
const files = [];
|
|
229
|
+
let inline = null;
|
|
230
|
+
if (typeof args.source === 'string' && typeof args.path === 'string') {
|
|
231
|
+
return toolError('give either `source` or `path`, not both');
|
|
232
|
+
}
|
|
233
|
+
if (typeof args.source === 'string') {
|
|
234
|
+
inline = args.source;
|
|
235
|
+
} else if (typeof args.path === 'string') {
|
|
236
|
+
const p = path.resolve(args.path);
|
|
237
|
+
if (!fs.existsSync(p)) return toolError('no such path: ' + args.path);
|
|
238
|
+
if (fs.statSync(p).isDirectory()) collect(p, files);
|
|
239
|
+
else files.push(p);
|
|
240
|
+
} else {
|
|
241
|
+
return toolError('one of `source` or `path` is required');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const units = inline !== null
|
|
245
|
+
? [{ label: '<source>', src: inline }]
|
|
246
|
+
: files.map(f => ({ label: path.relative(process.cwd(), f), src: fs.readFileSync(f, 'utf8') }));
|
|
247
|
+
|
|
248
|
+
if (!units.length) return text('ok: true 0 .fd file(s) found under ' + args.path + ' — nothing to check.');
|
|
249
|
+
|
|
250
|
+
const bad = [];
|
|
251
|
+
for (const u of units) {
|
|
252
|
+
const errs = figdown.parse(u.src).errors;
|
|
253
|
+
if (errs.length) bad.push(u.label + ':\n' + errs.map(e => ' ' + e).join('\n'));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// State the count. A checker that does not say how many files it looked at
|
|
257
|
+
// is a checker that can silently look at none (tools/README §3.1(d)).
|
|
258
|
+
const head = 'ok: ' + (bad.length === 0) + ' checked ' + units.length + ' document(s), '
|
|
259
|
+
+ (units.length - bad.length) + ' clean, ' + bad.length + ' with diagnostics'
|
|
260
|
+
+ '\nengine: ' + figdown.version;
|
|
261
|
+
return bad.length ? text(head, bad.join('\n\n')) : text(head);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// TOOL 3 — figdown_read
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
const META_RE = /<metadata id="figdown-source"([^>]*)><!\[CDATA\[\n?([\s\S]*?)\n?\]\]><\/metadata>/;
|
|
268
|
+
|
|
269
|
+
function toolRead(args) {
|
|
270
|
+
let s;
|
|
271
|
+
try { s = takeSource(args, ['.fd', '.svg']); } catch (m) { return toolError(m); }
|
|
272
|
+
|
|
273
|
+
// Recovering source from an artifact is the documented procedure when the
|
|
274
|
+
// .fd has gone missing (SKILL.md), and while we are in the metadata we get
|
|
275
|
+
// the staleness check for nothing: the artifact records the SHA-256 of the
|
|
276
|
+
// source it was built from and the engine that built it.
|
|
277
|
+
const notes = [];
|
|
278
|
+
let src = s.src;
|
|
279
|
+
if (/^\s*<(\?xml|svg)/.test(src) || (s.file && s.file.endsWith('.svg'))) {
|
|
280
|
+
const m = src.match(META_RE);
|
|
281
|
+
if (!m) return toolError(s.label + ' is an SVG with no <metadata id="figdown-source"> block — '
|
|
282
|
+
+ 'it was not produced by FigDown, so there is no source to read. Never OCR the picture.');
|
|
283
|
+
src = m[2].replace(/]]]]><!\[CDATA\[>/g, ']]>');
|
|
284
|
+
const attrs = m[1];
|
|
285
|
+
const recordedSha = (attrs.match(/data-sha256="([0-9a-f]{64})"/) || [])[1];
|
|
286
|
+
const recordedEngine = (attrs.match(/data-engine-version="([^"]*)"/) || [])[1];
|
|
287
|
+
notes.push('source recovered from the artifact\'s <metadata id="figdown-source"> block');
|
|
288
|
+
if (recordedEngine && recordedEngine !== figdown.version) {
|
|
289
|
+
notes.push('engine skew: artifact records ' + recordedEngine + ', this server runs '
|
|
290
|
+
+ figdown.version + ' — same source may not give a byte-identical render (RENDERING-DETERMINISM)');
|
|
291
|
+
}
|
|
292
|
+
const sidecar = s.file ? s.file.replace(/\.svg$/, '.fd') : null;
|
|
293
|
+
if (sidecar && fs.existsSync(sidecar) && recordedSha) {
|
|
294
|
+
const live = figdown.artifact(fs.readFileSync(sidecar, 'utf8')).svg;
|
|
295
|
+
const liveSha = live && (live.match(/data-sha256="([0-9a-f]{64})"/) || [])[1];
|
|
296
|
+
notes.push(liveSha === recordedSha
|
|
297
|
+
? 'sidecar ' + path.basename(sidecar) + ' matches the artifact\'s recorded hash'
|
|
298
|
+
: 'STALE ARTIFACT: ' + path.basename(sidecar) + ' has changed since this .svg was built. '
|
|
299
|
+
+ 'The .fd is truth — rebuild.');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const p = figdown.parse(src);
|
|
304
|
+
if (p.errors.length) return parseFailed(s.label, p.errors);
|
|
305
|
+
|
|
306
|
+
const docs = p.docs;
|
|
307
|
+
const shape = docs.map((d, i) =>
|
|
308
|
+
' section ' + (i + 1) + ': figdown ' + d.version + ' ' + d.genre
|
|
309
|
+
+ (d.title ? ' title ' + JSON.stringify(d.title) : '')
|
|
310
|
+
+ ' [' + ['nodes', 'edges', 'groups', 'classes', 'blocks', 'boundaries']
|
|
311
|
+
.filter(k => Array.isArray(d[k]) && d[k].length)
|
|
312
|
+
.map(k => d[k].length + ' ' + k).join(', ') + ']').join('\n');
|
|
313
|
+
|
|
314
|
+
// The reading CONTRACT travels with the model. A model handed over bare is
|
|
315
|
+
// an invitation to over-infer, and reference/reading.md exists precisely
|
|
316
|
+
// because the tempting inferences (colour means a category, an empty class
|
|
317
|
+
// meaning means something, a note= is parsable) are the wrong ones.
|
|
318
|
+
const contract = [
|
|
319
|
+
'READING CONTRACT — the short form. Full text: figdown_reference {"name":"reading"}',
|
|
320
|
+
' * Nodes are participants; edges are relationships, direction from the operator',
|
|
321
|
+
' (-> <- <->; -- asserts a relationship and NO direction).',
|
|
322
|
+
' * Category comes from a `class` reference PLUS that class\'s stated meaning.',
|
|
323
|
+
' A class whose meaning is "" asserts NO category — do not invent one from the',
|
|
324
|
+
' id or the shared colour. Colour alone never carries meaning.',
|
|
325
|
+
' * Absence is meaning: label absent (null) and label "" are different facts.',
|
|
326
|
+
' * `description=` and `note=` are authored prose. Quotable, displayable, NEVER',
|
|
327
|
+
' parsable — infer no participant, edge or category from them.',
|
|
328
|
+
' * Array order is not ranking or priority (§12.7).',
|
|
329
|
+
' * Everything below `layout` is geometry with no meaning. Skip it.',
|
|
330
|
+
].join('\n');
|
|
331
|
+
|
|
332
|
+
const head = [
|
|
333
|
+
'ok: true ' + docs.length + ' section(s) in ' + s.label,
|
|
334
|
+
'engine: ' + figdown.version,
|
|
335
|
+
shape,
|
|
336
|
+
notes.length ? '\n' + notes.map(n => '! ' + n).join('\n') : null,
|
|
337
|
+
].filter(Boolean).join('\n');
|
|
338
|
+
|
|
339
|
+
return text(head, contract,
|
|
340
|
+
'MODEL (spec §12 semantic surface — this is the figure\'s meaning; the SVG is not)\n'
|
|
341
|
+
+ JSON.stringify(docs, null, 1));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// TOOL 4 — figdown_reference
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
function serveFiles(rels, header) {
|
|
348
|
+
const parts = [header];
|
|
349
|
+
for (const rel of rels) {
|
|
350
|
+
const f = path.join(SKILL_DIR, rel);
|
|
351
|
+
if (!fs.existsSync(f)) return toolError('reference file missing: ' + rel
|
|
352
|
+
+ ' (the router in SKILL.md names it; the file is not on disk)');
|
|
353
|
+
parts.push('===== ' + rel + ' =====\n\n' + fs.readFileSync(f, 'utf8'));
|
|
354
|
+
}
|
|
355
|
+
return text(...parts);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function toolReference(args) {
|
|
359
|
+
if (!SKILL_DIR) return toolError('skill/figdown/ not found — set FIGDOWN_SKILL to its directory');
|
|
360
|
+
const name = typeof args.name === 'string' ? args.name.trim().toLowerCase() : '';
|
|
361
|
+
|
|
362
|
+
if (name === 'skill' || (!name && args.experimental === undefined)) {
|
|
363
|
+
const router = readRouter();
|
|
364
|
+
if (!name) {
|
|
365
|
+
const rows = router
|
|
366
|
+
? [...router.entries()].map(([g, r]) =>
|
|
367
|
+
' ' + g.padEnd(11) + ' -> ' + (r.frozen.join(', ') || '(none)')
|
|
368
|
+
+ (r.exp.length ? ' [EXPERIMENTAL: ' + r.exp.join(', ') + ']' : '')).join('\n')
|
|
369
|
+
: ' (router table unavailable)';
|
|
370
|
+
return text(
|
|
371
|
+
'FigDown reference index. Call again with `name` set to one of:\n\n'
|
|
372
|
+
+ 'GENRES — the value on line 1 of a .fd, after the version:\n' + rows + '\n\n'
|
|
373
|
+
+ 'TASKS:\n reading -> ' + TASKS.reading + ' (what you MAY and MAY NOT conclude)\n'
|
|
374
|
+
+ ' transcribe -> ' + TASKS.transcribe + ' (turning an existing drawing into .fd)\n'
|
|
375
|
+
+ ' skill -> SKILL.md, the whole genre-independent language\n\n'
|
|
376
|
+
+ 'Pass `experimental:true` with a genre to add its EXPERIMENTAL files. The parser NEVER\n'
|
|
377
|
+
+ 'warns, so a line that parses tells you nothing about its portability status —\n'
|
|
378
|
+
+ 'this router is the only signal you get.\n\n'
|
|
379
|
+
+ 'engine: ' + figdown.version);
|
|
380
|
+
}
|
|
381
|
+
return serveFiles(['SKILL.md'], 'SKILL.md — the whole genre-independent language.\n'
|
|
382
|
+
+ 'Load the genre file BEFORE writing line 2, not only when something fails.');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (TASKS[name]) {
|
|
386
|
+
return serveFiles([TASKS[name]], 'FigDown reference: ' + name + '.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const router = readRouter();
|
|
390
|
+
if (!router) return toolError('cannot read the router table in SKILL.md');
|
|
391
|
+
if (!router.has(name)) {
|
|
392
|
+
return toolError('unknown reference `' + name + '`. Genres: ' + [...router.keys()].join(', ')
|
|
393
|
+
+ '. Tasks: ' + Object.keys(TASKS).join(', ') + ', skill.');
|
|
394
|
+
}
|
|
395
|
+
const row = router.get(name);
|
|
396
|
+
const rels = args.experimental === true ? row.frozen.concat(row.exp) : row.frozen;
|
|
397
|
+
if (!rels.length) {
|
|
398
|
+
return text('Genre `' + name + '` has no frozen reference file: everything it can express is\n'
|
|
399
|
+
+ 'EXPERIMENTAL (outside the v0.1 conformance surface and its compatibility promise).\n'
|
|
400
|
+
+ 'Call again with {"name":"' + name + '","experimental":true} to load '
|
|
401
|
+
+ (row.exp.join(', ') || 'nothing') + '.');
|
|
402
|
+
}
|
|
403
|
+
return serveFiles(rels,
|
|
404
|
+
'Genre `' + name + '` — ' + (args.experimental === true ? 'frozen + EXPERIMENTAL' : 'frozen (v0.1 surface)')
|
|
405
|
+
+ ' load set, from SKILL.md\'s router.'
|
|
406
|
+
+ (args.experimental !== true && row.exp.length
|
|
407
|
+
? '\nThis genre also has EXPERIMENTAL files (' + row.exp.join(', ') + '); pass `experimental:true` for them.'
|
|
408
|
+
: ''));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Tool registry
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
const SRC_OR_PATH = {
|
|
415
|
+
source: { type: 'string', description: 'FigDown source text. Mutually exclusive with `path`.' },
|
|
416
|
+
path: { type: 'string', description: 'Path to a file. Mutually exclusive with `source`.' },
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const TOOLS = [
|
|
420
|
+
{
|
|
421
|
+
name: 'figdown_build',
|
|
422
|
+
title: 'Build a FigDown figure to SVG',
|
|
423
|
+
description:
|
|
424
|
+
'Render one .fd document to a deterministic, self-carrying SVG (it embeds its own '
|
|
425
|
+
+ 'source, that source\'s SHA-256 and the engine version, so it round-trips back to text). '
|
|
426
|
+
+ 'Returns the SVG inline, or writes the sidecar X.fd -> X.svg beside the source and returns '
|
|
427
|
+
+ 'the path. If the document does not parse, this returns the diagnostics instead — that is '
|
|
428
|
+
+ 'a normal result, not an error.',
|
|
429
|
+
inputSchema: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
properties: {
|
|
432
|
+
source: SRC_OR_PATH.source,
|
|
433
|
+
path: { type: 'string', description: 'Path to a .fd file. Mutually exclusive with `source`. Required for `write`.' },
|
|
434
|
+
write: { type: 'boolean', description: 'Write the sidecar X.svg beside the source .fd. Default false. There is no arbitrary output path: the sidecar is the only file this server writes.' },
|
|
435
|
+
with_title: { type: 'boolean', description: 'Draw the title in the SVG. Default false — the embedding Markdown normally supplies the caption.' },
|
|
436
|
+
return_svg: { type: 'boolean', description: 'Force the SVG into the result (true) or out of it (false). Default: returned unless it was written to disk.' },
|
|
437
|
+
include_model: { type: 'boolean', description: 'Also return the parsed semantic model. Default false; use figdown_read when the model is what you want.' },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: 'figdown_check',
|
|
443
|
+
title: 'Validate FigDown without rendering',
|
|
444
|
+
description:
|
|
445
|
+
'Parse one document or a whole tree of .fd files and return the diagnostics — line number, '
|
|
446
|
+
+ 'reason, and for a retired spelling the replacement. Renders nothing and writes nothing. '
|
|
447
|
+
+ 'This is the tool for the write -> validate -> fix loop and for sweeping a corpus; use '
|
|
448
|
+
+ 'figdown_build when you actually want the SVG.',
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
source: SRC_OR_PATH.source,
|
|
453
|
+
path: { type: 'string', description: 'A .fd file, or a directory to walk recursively for .fd files. Mutually exclusive with `source`.' },
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: 'figdown_read',
|
|
459
|
+
title: 'Read a figure\'s meaning',
|
|
460
|
+
description:
|
|
461
|
+
'Return the parsed semantic model of a figure — participants, relationships and their '
|
|
462
|
+
+ 'direction, containment, and the declared meaning of every class — with the reading '
|
|
463
|
+
+ 'contract that says what you may and may not conclude from it. Accepts a .fd, or a '
|
|
464
|
+
+ 'FigDown .svg whose source is recovered from its embedded metadata (and checked against '
|
|
465
|
+
+ 'the sidecar for staleness). Use this instead of looking at the picture: never OCR an SVG.',
|
|
466
|
+
inputSchema: {
|
|
467
|
+
type: 'object',
|
|
468
|
+
properties: {
|
|
469
|
+
source: { type: 'string', description: 'FigDown source text, or the text of a FigDown-produced SVG. Mutually exclusive with `path`.' },
|
|
470
|
+
path: { type: 'string', description: 'Path to a .fd or .svg file. Mutually exclusive with `source`.' },
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: 'figdown_reference',
|
|
476
|
+
title: 'FigDown genre reference',
|
|
477
|
+
description:
|
|
478
|
+
'Fetch the reference for a genre or a task. The grammar is CLOSED — an unknown line is an '
|
|
479
|
+
+ 'error — and each genre spells things with its own domain\'s words, so read the genre '
|
|
480
|
+
+ 'reference BEFORE writing line 2. Call with no arguments for the index of genres and '
|
|
481
|
+
+ 'tasks. The parser never warns about portability, so this is the only place the '
|
|
482
|
+
+ 'frozen/EXPERIMENTAL split is visible.',
|
|
483
|
+
inputSchema: {
|
|
484
|
+
type: 'object',
|
|
485
|
+
properties: {
|
|
486
|
+
name: { type: 'string', description: 'A genre (block, bitfield, table, topology, flowchart, statechart, timing), a task (reading, transcribe), or "skill". Omit for the index.' },
|
|
487
|
+
experimental: { type: 'boolean', description: 'Include the genre\'s EXPERIMENTAL (experimental) files — outside the v0.1 conformance surface and its compatibility promise.' },
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
const HANDLERS = {
|
|
494
|
+
figdown_build: toolBuild,
|
|
495
|
+
figdown_check: toolCheck,
|
|
496
|
+
figdown_read: toolRead,
|
|
497
|
+
figdown_reference: toolReference,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// JSON-RPC 2.0 over stdio (newline-delimited). No SDK: see the header.
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// The transport requires one JSON message per line with no embedded newline;
|
|
504
|
+
// JSON.stringify escapes newlines, so this holds for SVG and reference prose
|
|
505
|
+
// alike.
|
|
506
|
+
const DEFAULT_PROTOCOL = '2025-06-18';
|
|
507
|
+
|
|
508
|
+
function handle(msg) {
|
|
509
|
+
const { id, method, params } = msg || {};
|
|
510
|
+
const isNotification = id === undefined || id === null;
|
|
511
|
+
const ok = result => (isNotification ? null : { jsonrpc: '2.0', id, result });
|
|
512
|
+
const err = (code, message) => (isNotification ? null : { jsonrpc: '2.0', id, error: { code, message } });
|
|
513
|
+
|
|
514
|
+
switch (method) {
|
|
515
|
+
case 'initialize':
|
|
516
|
+
// Echo the client's protocol revision. Every payload here is plain text
|
|
517
|
+
// content blocks, which every revision carries identically, so there is
|
|
518
|
+
// nothing to negotiate; a client that asked for a revision we cannot
|
|
519
|
+
// serve would have to be serving something we do not use.
|
|
520
|
+
return ok({
|
|
521
|
+
protocolVersion: (params && typeof params.protocolVersion === 'string')
|
|
522
|
+
? params.protocolVersion : DEFAULT_PROTOCOL,
|
|
523
|
+
capabilities: { tools: { listChanged: false } },
|
|
524
|
+
serverInfo: SERVER_INFO,
|
|
525
|
+
instructions:
|
|
526
|
+
'FigDown keeps a figure as text (.fd) and treats the SVG as a build artifact. '
|
|
527
|
+
+ 'Read the .fd for meaning — never OCR the SVG. Author or edit the .fd, validate with '
|
|
528
|
+
+ 'figdown_check until clean, then figdown_build. figdown_reference first: the grammar '
|
|
529
|
+
+ 'is closed, so an unknown line is an error rather than something ignored.',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
case 'notifications/initialized':
|
|
533
|
+
case 'notifications/cancelled':
|
|
534
|
+
return null;
|
|
535
|
+
|
|
536
|
+
case 'ping':
|
|
537
|
+
return ok({});
|
|
538
|
+
|
|
539
|
+
case 'tools/list':
|
|
540
|
+
return ok({ tools: TOOLS });
|
|
541
|
+
|
|
542
|
+
case 'tools/call': {
|
|
543
|
+
const name = params && params.name;
|
|
544
|
+
const fn = HANDLERS[name];
|
|
545
|
+
if (!fn) return err(-32602, 'unknown tool: ' + name);
|
|
546
|
+
try {
|
|
547
|
+
return ok(fn((params && params.arguments) || {}));
|
|
548
|
+
} catch (e) {
|
|
549
|
+
// An unexpected fault is a TOOL error, not a protocol error: the
|
|
550
|
+
// caller gets the message and can act on it.
|
|
551
|
+
return ok(toolError((e && e.message) || String(e)));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
default:
|
|
556
|
+
return err(-32601, 'method not found: ' + method);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function main() {
|
|
561
|
+
let buf = '';
|
|
562
|
+
process.stdin.setEncoding('utf8');
|
|
563
|
+
process.stdin.on('data', chunk => {
|
|
564
|
+
buf += chunk;
|
|
565
|
+
let nl;
|
|
566
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
567
|
+
const line = buf.slice(0, nl).trim();
|
|
568
|
+
buf = buf.slice(nl + 1);
|
|
569
|
+
if (!line) continue;
|
|
570
|
+
let msg;
|
|
571
|
+
try {
|
|
572
|
+
msg = JSON.parse(line);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Older revisions permitted a batch array; 2025-06-18 removed it. Accept
|
|
578
|
+
// one either way — it costs three lines and an old client is not a bug.
|
|
579
|
+
const msgs = Array.isArray(msg) ? msg : [msg];
|
|
580
|
+
const out = msgs.map(handle).filter(Boolean);
|
|
581
|
+
for (const r of out) write(r);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
process.stdin.on('end', () => process.exit(0));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function write(obj) {
|
|
588
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
module.exports = { handle, TOOLS, HANDLERS };
|
|
592
|
+
|
|
593
|
+
if (require.main === module) main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figdown",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "A missing edge still looks fine. Text doesn't. A closed, deterministic figure language for Markdown whose source states the meaning, so the next reader can check the figure instead of only looking at it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "FigDown <hello@figdown.org>",
|
|
7
7
|
"homepage": "https://figdown.org",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"gate:shape": "node tools/shape-check.js --strict",
|
|
34
34
|
"gate:strip": "node tools/strip-check.js --strict",
|
|
35
35
|
"gate:layout": "node tools/layout-lint.js --strict",
|
|
36
|
+
"gate:namespace": "node tools/namespace-check.js --strict",
|
|
36
37
|
"gate:artifact": "node tools/artifact-check.js --strict",
|
|
37
38
|
"gate:dist": "node tools/dist-check.js --strict",
|
|
38
39
|
"gate:isolation": "node tools/isolation-check.js --strict",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"gate:capability": "node tools/capability-coverage.js --strict",
|
|
46
47
|
"gate:archive": "node tools/archive-check.js --strict",
|
|
47
48
|
"gate:plugin": "node tools/plugin-check.js --strict",
|
|
49
|
+
"gate:mcp": "node integrations/mcp-server/test.js",
|
|
48
50
|
"gates:list": "node -e \"const s=require(process.cwd()+'/package.json').scripts||{};const g=Object.keys(s).filter(k=>k.startsWith('gate:'));if(!g.length){console.error('gates:list — no gate:* scripts found');process.exit(2);}if(process.env.GATES_JSON)console.log(JSON.stringify(g));else g.forEach(k=>console.log(k+' -> '+s[k]));\"",
|
|
49
51
|
"test": "node -e \"const s=require(process.cwd()+'/package.json').scripts||{};const sp=require('child_process').spawnSync;const g=Object.keys(s).filter(k=>k.startsWith('gate:'));if(!g.length){console.error('npm test — no gate:* scripts found; refusing to report success');process.exit(2);}const bad=[];g.forEach(k=>{const a=s[k].split(' ');const bin=a[0]==='node'?process.execPath:a[0];console.log('');console.log('=== GATE '+k+' ['+s[k]+'] ===');const r=sp(bin,a.slice(1),{stdio:'inherit'});if(r.error)console.error(' spawn error: '+r.error.message);const c=(r.status===null||r.status===undefined)?1:r.status;console.log('--- '+k+': '+(c===0?'PASS':'FAIL (exit '+c+')')+' ---');if(c!==0)bad.push(k+' ['+s[k]+'] exit '+c);});console.log('');console.log('=== GATE SUMMARY: '+(g.length-bad.length)+'/'+g.length+' passed ===');if(bad.length){console.log('FAILED GATES:');bad.forEach(b=>console.log(' '+b));process.exit(1);}console.log('ALL GATES GREEN');\"",
|
|
50
52
|
"gate:reference": "node tools/reference-gate.js"
|
|
@@ -58,12 +60,15 @@
|
|
|
58
60
|
}
|
|
59
61
|
},
|
|
60
62
|
"bin": {
|
|
61
|
-
"figdown-svg": "skill/figdown/build-svg.js"
|
|
63
|
+
"figdown-svg": "skill/figdown/build-svg.js",
|
|
64
|
+
"figdown-mcp": "integrations/mcp-server/server.js"
|
|
62
65
|
},
|
|
63
66
|
"files": [
|
|
64
67
|
".claude-plugin/plugin.json",
|
|
65
68
|
"dist/figdown.js",
|
|
66
69
|
"dist/figdown.mjs",
|
|
70
|
+
"integrations/mcp-server/server.js",
|
|
71
|
+
"integrations/mcp-server/README.md",
|
|
67
72
|
"skill/figdown/SKILL.md",
|
|
68
73
|
"skill/figdown/reference/",
|
|
69
74
|
"skill/figdown/figdown.html",
|