@mikeargento/bitgraph-mcp 0.3.0 → 0.4.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/src/server.ts CHANGED
@@ -3,22 +3,30 @@
3
3
  /**
4
4
  * @mikeargento/bitgraph-mcp: tool definitions.
5
5
  *
6
- * Three gestures, the same three the website has: make a BitGraph of a file,
7
- * check whether bytes are on record, fetch a proof. Making a BitGraph builds a
8
- * fused artifact from the file in memory, on this machine, and commits its
9
- * digest under a slot allocated for it; only SHA-256 digests and slot records
10
- * ever leave the machine. File contents are never uploaded.
6
+ * Three gestures, the same three the website has: make a BitGraph, check
7
+ * whether bytes are on record, fetch a proof. Making a BitGraph is one
8
+ * gesture for any number of files: everything in a call becomes a member of
9
+ * ONE set under ONE slot, the way a drop on the site does. Each file is read
10
+ * once, on this machine, for its digest and a hasher state; the new fused
11
+ * bytes are never written and never held, their digest is finished from that
12
+ * state once the slot exists; and the set's committed artifact is hashed
13
+ * and committed under the same slot. Only digests, that artifact and slot
14
+ * records leave the machine. File contents are never uploaded and files are
15
+ * never modified.
11
16
  */
12
17
 
13
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
20
+ import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
14
21
  import { z } from "zod";
15
22
  import { readFile } from "node:fs/promises";
16
- import { fuse, builderFor, placementForBytes, fusedNamesFor } from "@mikeargento/bitgraph";
23
+ import { FuseError, MAX_SET_MEMBERS, fuseSet, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
17
24
  import {
18
25
  ApiError,
19
26
  batchCheck,
20
27
  configFromEnv,
21
28
  getProofDetail,
29
+ indexSetMembers,
22
30
  search,
23
31
  type ApiConfig,
24
32
  } from "./api.js";
@@ -38,76 +46,119 @@ import {
38
46
  renderRecordMarkdown,
39
47
  type CheckOutcome,
40
48
  type RecordOutcome,
49
+ type SetOutcome,
41
50
  } from "./format.js";
51
+ import { expandPaths, fusedDigestFor, scanFile, type ScannedFile } from "./scan.js";
42
52
  import type { BitGraphProof } from "./types.js";
43
53
 
44
- export const SERVER_VERSION = "0.2.0";
54
+ export const SERVER_VERSION = "0.4.0";
45
55
 
46
- const HASH_CONCURRENCY = 4;
47
- const MAX_FILES = 500;
48
- /** Files above this are refused: the fused artifact is built in memory. */
49
- const MAX_FUSE_BYTES = 256 * 1024 * 1024;
56
+ const SCAN_CONCURRENCY = 4;
57
+ /** Paths per call; a directory counts once and expands to its files. */
58
+ const MAX_PATHS = 2000;
59
+ /** Files one call may BitGraph after directories expand: one set. The site's own ceiling for a set/2. */
60
+ export const MAX_MEMBERS = 100_000;
61
+ /** Files one check may cover after directories expand. */
62
+ const MAX_CHECK_FILES = 10_000;
63
+ /** A file whose length changed while it was read is fused from its bytes instead; above this it is left out rather than held in memory. */
64
+ const MAX_LOADED_BYTES = 256 * 1024 * 1024;
65
+ /** Rows the structured result lists in full; every fused row shares the set's position. */
66
+ export const ROW_CAP = 500;
67
+ /** Members' evidence per set-index request: the site's own chunk. */
68
+ export const SET_INDEX_CHUNK = 2500;
50
69
 
51
- /** What making a BitGraph of one file yields. */
52
- export interface FusedSummary {
70
+ /** What one set yields, in the shape the tool reports; tests inject a stand-in. */
71
+ export interface SetSummary {
72
+ set: "set/1" | "set/2";
53
73
  proof: BitGraphProof;
54
- frame: unknown;
55
- placement: string;
74
+ /** Standard base64: the committed artifact's digest. */
56
75
  artifactDigestB64: string;
57
- originDigestB64: string;
76
+ count: number;
77
+ manifestEchoed: boolean;
78
+ recovered: boolean;
79
+ /** In the order the files were given. */
80
+ members: Array<{
81
+ index: number;
82
+ /** The row's ordinal in the committed artifact. */
83
+ manifestIndex: number;
84
+ placement: string;
85
+ originDigestB64: string;
86
+ artifactDigestB64: string;
87
+ /** set/2 only: the member's evidence, for the site's index. */
88
+ memberProof?: unknown;
89
+ }>;
58
90
  }
59
- export type FuseFileFn = (bytes: Uint8Array, name: string, config: ApiConfig) => Promise<FusedSummary>;
91
+ export type FuseSetFn = (
92
+ files: readonly ScannedFile[],
93
+ config: ApiConfig,
94
+ opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
95
+ ) => Promise<SetSummary>;
60
96
  export interface ServerDeps {
61
- /** The fuse pipeline; tests inject a stand-in. Default: the core package's fuse() against the configured site. */
62
- fuseFile?: FuseFileFn;
97
+ /** The set pipeline; tests inject a stand-in. Default: the core package's fuseSet() against the configured site. */
98
+ fuseSet?: FuseSetFn;
63
99
  }
64
100
 
65
101
  /**
66
- * The default pipeline, the same one the site's drop and the bitgraph-fuse
67
- * command run: choose the placement from the bytes, allocate a slot, derive
68
- * the commitment, build the fused bytes, hash them, commit under that exact
69
- * slot, verify the returned proof against the bytes.
102
+ * The default pipeline, the one the site's drop runs: one slot for the set,
103
+ * every member a hashed member whose fused digest is finished from the
104
+ * scan's open hasher with its placement's suffix for that slot, the set's
105
+ * manifest (or, for a set/2, its root document) committed under the same
106
+ * slot, and the returned proof verified against the committed artifact with
107
+ * every member bound to it by digest. A file whose length changed during the
108
+ * scan is a loaded member: read again when it is its turn, checked against
109
+ * the scan's digest, fused in memory, hashed and released.
70
110
  */
71
- async function fuseFileDefault(bytes: Uint8Array, name: string, config: ApiConfig): Promise<FusedSummary> {
72
- const placement = placementForBytes(bytes);
73
- const { fusedName } = fusedNamesFor(name, placement);
74
- const r = await fuse(builderFor(placement, bytes), {
75
- placement,
76
- original: bytes,
77
- fusedFile: fusedName,
111
+ async function fuseSetDefault(
112
+ files: readonly ScannedFile[],
113
+ config: ApiConfig,
114
+ opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
115
+ ): Promise<SetSummary> {
116
+ const members: FuseSetMember[] = files.map((f) =>
117
+ f.state !== null
118
+ ? { originDigest: f.originDigest, placement: f.placement, name: f.name, fusedDigest: ({ commitment }) => fusedDigestFor(f, commitment) }
119
+ : { load: async () => new Uint8Array(await readFile(f.path)), originDigest: f.originDigest, placement: f.placement, name: f.name }
120
+ );
121
+ const r = await fuseSet(members, {
122
+ set: opts.set,
78
123
  keepFused: false,
124
+ ...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
79
125
  transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
80
126
  });
81
127
  return {
128
+ set: r.set,
82
129
  proof: r.proof as unknown as BitGraphProof,
83
- frame: r.frame,
84
- placement,
85
130
  artifactDigestB64: r.artifactDigestB64,
86
- originDigestB64: r.originDigestB64 ?? "",
131
+ count: r.members.length,
132
+ manifestEchoed: r.manifestEchoed,
133
+ recovered: r.recovered,
134
+ members: r.members.map((m) => ({
135
+ index: m.index,
136
+ manifestIndex: m.manifestIndex,
137
+ placement: m.placement,
138
+ originDigestB64: m.originDigestB64,
139
+ artifactDigestB64: m.artifactDigestB64,
140
+ ...(m.memberProof !== undefined ? { memberProof: m.memberProof } : {}),
141
+ })),
87
142
  };
88
143
  }
89
144
 
90
- /** Read the given paths whole (bounded concurrency). Throws before any network call. */
91
- async function readPaths(paths: readonly string[]): Promise<Array<{ bytes: Uint8Array; digestB64: string }>> {
145
+ /** Hash the given paths (bounded concurrency). Throws before any network call. */
146
+ async function hashPaths(paths: readonly string[]): Promise<string[]> {
92
147
  const failures: string[] = [];
93
- const out = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
148
+ const digests = await mapConcurrent(paths, SCAN_CONCURRENCY, async (p) => {
94
149
  try {
95
- const buf = await readFile(p);
96
- if (buf.length > MAX_FUSE_BYTES) throw new Error(`larger than ${MAX_FUSE_BYTES / (1024 * 1024)} MB; the fused artifact is built in memory`);
97
- const bytes = new Uint8Array(buf);
98
- const digestB64 = await sha256FileB64(p);
99
- return { bytes, digestB64 };
150
+ return await sha256FileB64(p);
100
151
  } catch (err) {
101
152
  failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
102
- return { bytes: new Uint8Array(0), digestB64: "" };
153
+ return "";
103
154
  }
104
155
  });
105
156
  if (failures.length > 0) {
106
157
  throw new Error(
107
- `Could not read ${failures.length} file(s); nothing was BitGraphed.\n${failures.join("\n")}\nUse absolute paths to existing regular files under ${MAX_FUSE_BYTES / (1024 * 1024)} MB.`
158
+ `Could not read ${failures.length} file(s); nothing was checked.\n${failures.join("\n")}\nUse absolute paths to existing regular files.`
108
159
  );
109
160
  }
110
- return out;
161
+ return digests;
111
162
  }
112
163
 
113
164
  const responseFormatSchema = z
@@ -139,55 +190,134 @@ function errorText(err: unknown): string {
139
190
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
140
191
  }
141
192
 
142
- /** Hash the given paths (bounded concurrency). Throws before any network call. */
143
- async function hashPaths(paths: readonly string[]): Promise<string[]> {
144
- const failures: string[] = [];
145
- const digests = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
146
- try {
147
- return await sha256FileB64(p);
148
- } catch (err) {
149
- failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
150
- return "";
193
+ /** Why the set was not made, and what to do next. Never a success-looking line. */
194
+ function setFailureText(err: unknown): string {
195
+ if (err instanceof FuseError) {
196
+ const where = err.member !== null ? ` (member ${err.member})` : "";
197
+ switch (err.code) {
198
+ case "tee-restarting":
199
+ return `Nothing was BitGraphed: ${err.message}. The boundary restarts once a day at 23:59 UTC; run bitgraph_record again with the same paths in a minute.`;
200
+ case "network":
201
+ case "transport":
202
+ return `Nothing is known to be BitGraphed: ${err.message}. Run bitgraph_record again with the same paths: files a set did land come back as on record and are not made again.`;
203
+ default:
204
+ return `Nothing was BitGraphed${where}: ${err.message} (${err.code}). Run bitgraph_record again with the same paths.`;
151
205
  }
152
- });
153
- if (failures.length > 0) {
154
- throw new Error(
155
- `Could not read ${failures.length} file(s); nothing was recorded.\n${failures.join("\n")}\nUse absolute paths to existing regular files.`
156
- );
157
206
  }
158
- return digests;
207
+ return `Nothing was BitGraphed: ${err instanceof Error ? err.message : String(err)}. Run bitgraph_record again with the same paths.`;
208
+ }
209
+
210
+ type Extra = RequestHandlerExtra<ServerRequest, ServerNotification>;
211
+ type Report = (progress: number, total: number, message: string) => void;
212
+
213
+ /** Progress notifications, when the client asked for them with a progress token; a no-op otherwise. */
214
+ function progressReporter(extra: Extra): Report {
215
+ const token = extra._meta?.progressToken;
216
+ if (token === undefined) return () => {};
217
+ return (progress, total, message) => {
218
+ void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress, total, message } }).catch(() => {});
219
+ };
220
+ }
221
+
222
+ const PHASES: Record<FuseSetProgress["phase"], string> = {
223
+ hash: "checking members",
224
+ fuse: "fusing",
225
+ tree: "building the tree",
226
+ commit: "committing",
227
+ verify: "verifying",
228
+ };
229
+
230
+ /**
231
+ * A set/2's members are indexed on the site after the commit, evidence by
232
+ * evidence, so a lookup by any member's own digest finds the set. Evidence
233
+ * the site could not take waits here for the life of this process and is
234
+ * sent again before anything else is made: a member the site cannot find
235
+ * by hash would otherwise look new and be made again.
236
+ */
237
+ interface PendingIndex {
238
+ setDigest: string;
239
+ epoch: string;
240
+ counter: string;
241
+ members: unknown[];
242
+ }
243
+ const pendingIndex: PendingIndex[] = [];
244
+
245
+ /** How many members' evidence is waiting to be indexed (tests read it). */
246
+ export function pendingIndexCount(): number {
247
+ return pendingIndex.reduce((n, p) => n + p.members.length, 0);
248
+ }
249
+
250
+ /** Send pending evidence in chunks; what fails stays pending. */
251
+ async function flushIndex(config: ApiConfig, report: Report): Promise<{ written: number; pending: number }> {
252
+ const total = pendingIndexCount();
253
+ let written = 0;
254
+ let stopped = false;
255
+ for (const set of pendingIndex) {
256
+ while (set.members.length > 0 && !stopped) {
257
+ const chunk = set.members.slice(0, SET_INDEX_CHUNK);
258
+ let ok = false;
259
+ for (let attempt = 0; attempt < 2 && !ok; attempt++) {
260
+ try {
261
+ await indexSetMembers(config, { setDigest: set.setDigest, epoch: set.epoch, counter: set.counter, members: chunk });
262
+ ok = true;
263
+ } catch {
264
+ ok = false;
265
+ }
266
+ }
267
+ if (!ok) {
268
+ stopped = true;
269
+ break;
270
+ }
271
+ set.members.splice(0, chunk.length);
272
+ written += chunk.length;
273
+ report(written, total, `indexing ${written} of ${total}`);
274
+ }
275
+ if (stopped) break;
276
+ }
277
+ const left = pendingIndex.filter((s) => s.members.length > 0);
278
+ pendingIndex.length = 0;
279
+ pendingIndex.push(...left);
280
+ return { written, pending: total - written };
159
281
  }
160
282
 
161
283
  export function buildServer(deps: ServerDeps = {}): McpServer {
162
- const fuseFile = deps.fuseFile ?? fuseFileDefault;
163
- const server = new McpServer({
164
- name: "bitgraph-mcp-server",
165
- version: SERVER_VERSION,
166
- });
284
+ const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
285
+ const server = new McpServer(
286
+ {
287
+ name: "bitgraph-mcp-server",
288
+ version: SERVER_VERSION,
289
+ },
290
+ {
291
+ instructions:
292
+ "BitGraph gives a file's bytes a causal position in a public ledger bracketed by Ethereum anchors. bitgraph_record makes ONE BitGraph of everything in a call, files and folders alike: one set under one slot, one position, every file's new fused bytes listed by digest in the committed artifact. " +
293
+ "Files are read on this machine and never uploaded or modified; the new bytes are virtual and never written. Recordings are permanent: only make BitGraphs of files the user asked for, and never generate content just to record it. bitgraph_check and bitgraph_get_proof are read-only.",
294
+ }
295
+ );
167
296
 
168
297
  server.registerTool(
169
298
  "bitgraph_record",
170
299
  {
171
300
  title: "Make a BitGraph",
172
301
  description:
173
- "Make a BitGraph of one or more files. For each file, on this machine: hash it (the origin), allocate an unused slot in the BitGraph ledger (bitgraph.ing) before any artifact exists, build a new fused artifact from the file in memory with a registered placement (a 48-byte trailer for formats that ignore trailing bytes such as JPEG, PNG, TIFF, WebP; a small tar container otherwise), hash it, and commit that digest under the same slot. " +
174
- "The file is never modified and never uploaded; only digests and slot records leave the machine. The fused bytes are not kept: the original plus the proof rebuilds them, and the Frame for each file is returned in the structured result. " +
175
- "Files whose bytes are already on record, as a recording or as the origin of a fused artifact, are NOT BitGraphed again by default; they come back as 'on record' with their earliest position. " +
176
- "Pass again=true to deliberately make a new fused artifact from a file already on record. " +
177
- "BitGraphs are permanent: the ledger has 10-year retention and no deletes, so only BitGraph files the user asked to. " +
178
- "Returns one outcome per file: 'fused' (a new fused artifact, with its placement, position and proof page URL), 'on record', or 'not fused' (with the error). " +
179
- "Use bitgraph_check instead when the user only wants to know whether a file is on record.",
302
+ "Make a BitGraph of files or folders. Everything in one call becomes ONE BitGraph: a set under a single slot in the BitGraph ledger (bitgraph.ing), one position for all of it, the way a drop on the site works. " +
303
+ "On this machine each file is read once for its SHA-256 (the origin) and a hasher state; an unused slot is allocated before any new file exists; every file's new fused bytes (the original plus a registered placement carrying the slot's commitment: a 48-byte trailer for JPEG, PNG, GIF, TIFF and TIFF-based raws, BMP, WebP, WAV and AVI, a small tar container with the original first for everything else) are hashed from that state without being written or held; and the canonical list of those digests (above 2,000 files, a Merkle root over it) is committed under the same slot. " +
304
+ "Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
305
+ "Give file paths, directory paths, or both (absolute paths preferred): a directory is every regular file under it, recursively, with hidden entries and symbolic links left out. " +
306
+ "Files already on record (recorded, or the origin of a fused file) are NOT made again by default; they come back as 'on record' with their earliest position. Pass again=true to make a new BitGraph of them deliberately. " +
307
+ "BitGraphs are permanent: the ledger has 10-year retention and no deletes, so only BitGraph files the user asked to, and never generate content just to record it. " +
308
+ "Returns the set's position and proof page, and one outcome per file: 'fused' (its row in the set, one of N), 'on record', or 'not fused' (with the reason). A lookup by any file's own digest finds the set. " +
309
+ "Use bitgraph_check instead when the user only wants to know whether files are on record.",
180
310
  inputSchema: {
181
311
  paths: z
182
312
  .array(z.string().min(1))
183
313
  .min(1)
184
- .max(MAX_FILES)
185
- .describe(`File paths to BitGraph (absolute paths preferred), up to ${MAX_FILES}.`),
314
+ .max(MAX_PATHS)
315
+ .describe(`File or directory paths to BitGraph, up to ${MAX_PATHS}; a directory expands to its files, up to ${MAX_MEMBERS} in all.`),
186
316
  again: z
187
317
  .boolean()
188
318
  .default(false)
189
319
  .describe(
190
- "false (default): files already on record are returned as-is, nothing minted. true: make a new fused artifact from every file even if its bytes are already on record. Outcomes are per unique file content: two paths with identical bytes yield one artifact."
320
+ "false (default): files already on record are returned as-is, nothing made. true: put every file in the set even if its bytes are already on record. Outcomes are per unique file content: two paths with identical bytes are one member."
191
321
  ),
192
322
  response_format: responseFormatSchema,
193
323
  },
@@ -198,107 +328,194 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
198
328
  openWorldHint: true,
199
329
  },
200
330
  },
201
- async ({ paths, again, response_format }) => {
331
+ async ({ paths, again, response_format }, extra) => {
202
332
  const config = configFromEnv();
333
+ const report = progressReporter(extra);
203
334
  try {
204
- const read = await readPaths(paths);
205
- // Unique by content, first path wins for the artifact's name; extra paths listed too.
206
- const byDigest = new Map<string, { bytes: Uint8Array; paths: string[] }>();
207
- read.forEach((r, i) => {
208
- const entry = byDigest.get(r.digestB64) ?? { bytes: r.bytes, paths: [] };
209
- entry.paths.push(paths[i] as string);
210
- byDigest.set(r.digestB64, entry);
335
+ // 0. Paths to files, before any network call.
336
+ const expanded = await expandPaths(paths, MAX_MEMBERS);
337
+ const files = expanded.files;
338
+ if (files.length === 0) {
339
+ return fail("Error: nothing to BitGraph: the given directories hold no regular files (hidden entries and symbolic links are left out).");
340
+ }
341
+
342
+ // 1. Evidence from an earlier set that the site has not indexed yet goes first.
343
+ if (pendingIndexCount() > 0) {
344
+ const flushed = await flushIndex(config, report);
345
+ if (flushed.pending > 0) {
346
+ return fail(
347
+ `Error: ${flushed.pending} members of an earlier set are still waiting to be indexed and the site could not take them; nothing was BitGraphed. Run bitgraph_record again in a moment: the waiting evidence is sent first.`
348
+ );
349
+ }
350
+ }
351
+
352
+ // 2. The scan: one pass per file.
353
+ let scanned = 0;
354
+ report(0, files.length, `hashing ${files.length} files`);
355
+ const scans = await mapConcurrent(files, SCAN_CONCURRENCY, async (p) => {
356
+ const s = await scanFile(p);
357
+ scanned += 1;
358
+ report(scanned, files.length, `hashed ${scanned} of ${files.length}`);
359
+ return s;
211
360
  });
361
+
362
+ // 3. Unique by content; the first path names the member, every path is reported.
363
+ const byDigest = new Map<string, { file: ScannedFile; paths: string[] }>();
364
+ for (const s of scans) {
365
+ const entry = byDigest.get(s.digestB64) ?? { file: s, paths: [] };
366
+ entry.paths.push(s.path);
367
+ byDigest.set(s.digestB64, entry);
368
+ }
212
369
  const unique = [...byDigest.keys()];
370
+
371
+ // 4. What is on record already.
372
+ report(0, 1, "checking the ledger");
213
373
  const checked = await batchCheck(config, unique.map(toUrlSafeB64));
214
374
  const existing = new Map<string, Array<{ proof: BitGraphProof }>>();
215
375
  for (const d of unique) {
216
376
  const entry = checked.results[toUrlSafeB64(d)];
217
377
  if (entry && entry.proofs.length > 0) existing.set(d, entry.proofs);
218
378
  }
219
- const toMint = again ? unique : unique.filter((d) => !existing.has(d));
220
- const fused = new Map<string, FusedSummary>();
221
- const failed = new Map<string, string>();
222
- for (const d of toMint) {
223
- const entry = byDigest.get(d) as { bytes: Uint8Array; paths: string[] };
224
- const name = (entry.paths[0] as string).split(/[\\/]/).pop() ?? "file";
379
+
380
+ // 5. The set: every fresh file (every file, with again), one call.
381
+ const excluded = new Map<string, string>();
382
+ const toMint: ScannedFile[] = [];
383
+ for (const d of again ? unique : unique.filter((x) => !existing.has(x))) {
384
+ const f = (byDigest.get(d) as { file: ScannedFile }).file;
385
+ if (f.state === null && f.size > MAX_LOADED_BYTES) {
386
+ excluded.set(d, "the file changed while it was read and is too large to read again in memory; run bitgraph_record again for it");
387
+ continue;
388
+ }
389
+ toMint.push(f);
390
+ }
391
+ const attempted = new Set(toMint.map((f) => f.digestB64));
392
+ let made: SetSummary | null = null;
393
+ let failure: string | null = null;
394
+ if (toMint.length > 0) {
395
+ const kind: "set/1" | "set/2" = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
225
396
  try {
226
- fused.set(d, await fuseFile(entry.bytes, name, config));
397
+ made = await fuseSetPipeline(toMint, config, {
398
+ set: kind,
399
+ onProgress: (p) => report(p.done, p.total, `${PHASES[p.phase]} ${p.done} of ${p.total}`),
400
+ });
227
401
  } catch (err) {
228
- failed.set(d, err instanceof Error ? err.message : String(err));
402
+ failure = setFailureText(err);
403
+ }
404
+ }
405
+
406
+ // 6. A set/2 lands with only its root on the ledger; its members are indexed afterwards.
407
+ let index: SetOutcome["index"] = null;
408
+ if (made !== null && made.set === "set/2") {
409
+ const counter = made.proof.commit?.counter;
410
+ const epochId = made.proof.commit?.epochId;
411
+ const members = made.members.map((m) => m.memberProof).filter((e) => e !== undefined);
412
+ if (counter !== undefined && epochId !== undefined && members.length > 0) {
413
+ pendingIndex.push({ setDigest: toUrlSafeB64(made.artifactDigestB64), epoch: toUrlSafeB64(epochId), counter, members });
414
+ index = await flushIndex(config, report);
229
415
  }
230
416
  }
417
+
418
+ // 7. Outcomes.
419
+ let setOutcome: SetOutcome | null = null;
420
+ const memberOf = new Map<string, SetSummary["members"][number]>();
421
+ if (made !== null) {
422
+ for (const m of made.members) memberOf.set(m.originDigestB64, m);
423
+ const { counter, epoch } = positionOf(made.proof);
424
+ setOutcome = {
425
+ set: made.set,
426
+ count: made.count,
427
+ counter,
428
+ epoch,
429
+ artifact_digest: toUrlSafeB64(made.artifactDigestB64),
430
+ proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
431
+ manifest_echoed: made.manifestEchoed,
432
+ recovered: made.recovered,
433
+ index,
434
+ };
435
+ }
231
436
  const outcomes: RecordOutcome[] = [];
232
- const frames: Record<string, unknown> = {};
233
437
  for (const [digest, entry] of byDigest) {
234
- const made = fused.get(digest);
438
+ const m = memberOf.get(digest);
235
439
  const prior = existing.get(digest);
236
440
  for (const path of entry.paths) {
237
- if (made) {
238
- const { counter, epoch } = positionOf(made.proof);
239
- frames[toUrlSafeB64(made.artifactDigestB64)] = made.frame;
441
+ const base = { path, digest: toUrlSafeB64(digest) };
442
+ if (m !== undefined && made !== null && setOutcome !== null) {
240
443
  outcomes.push({
241
- path,
242
- digest: toUrlSafeB64(digest),
444
+ ...base,
243
445
  outcome: "fused",
244
- artifact_digest: toUrlSafeB64(made.artifactDigestB64),
245
- placement: made.placement,
246
- counter,
247
- epoch,
446
+ artifact_digest: toUrlSafeB64(m.artifactDigestB64),
447
+ placement: m.placement,
448
+ counter: setOutcome.counter,
449
+ epoch: setOutcome.epoch,
450
+ member: m.manifestIndex + 1,
451
+ member_count: made.count,
248
452
  total_positions: (prior?.length ?? 0) + 1,
249
- proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
453
+ proof_url: proofUrl(config.baseUrl, digest, setOutcome.counter ?? undefined, made.proof.commit?.epochId),
250
454
  });
251
- } else if (prior && !failed.has(digest)) {
252
- const first = prior[0]?.proof;
253
- const { counter, epoch } = first ? positionOf(first) : { counter: null, epoch: null };
455
+ } else if (prior && !attempted.has(digest) && !excluded.has(digest)) {
456
+ const first = prior[0];
457
+ const { counter, epoch } = first ? positionOf(first.proof) : { counter: null, epoch: null };
458
+ const row = (first as { member?: { index: number; count: number } } | undefined)?.member;
254
459
  outcomes.push({
255
- path,
256
- digest: toUrlSafeB64(digest),
460
+ ...base,
257
461
  outcome: "on record",
258
462
  artifact_digest: null,
259
463
  placement: null,
260
464
  counter,
261
465
  epoch,
466
+ member: row ? row.index + 1 : null,
467
+ member_count: row ? row.count : null,
262
468
  total_positions: prior.length,
263
469
  proof_url: proofUrl(config.baseUrl, digest),
264
470
  });
265
471
  } else {
266
472
  outcomes.push({
267
- path,
268
- digest: toUrlSafeB64(digest),
473
+ ...base,
269
474
  outcome: "not fused",
270
475
  artifact_digest: null,
271
476
  placement: null,
272
477
  counter: null,
273
478
  epoch: null,
479
+ member: null,
480
+ member_count: null,
274
481
  total_positions: prior?.length ?? 0,
275
482
  proof_url: null,
276
- error: failed.get(digest) ?? "not attempted",
483
+ error: excluded.get(digest) ?? failure ?? "not attempted",
277
484
  });
278
485
  }
279
486
  }
280
487
  }
488
+ // Rows that need reading come first, so a cap drops fused rows, which all share one position.
489
+ const order = { "not fused": 0, "on record": 1, fused: 2 } as const;
490
+ outcomes.sort((a, b) => order[a.outcome] - order[b.outcome]);
491
+ const listed = outcomes.slice(0, ROW_CAP);
492
+ const omitted = outcomes.length - listed.length;
493
+ const summary = {
494
+ files: files.length,
495
+ directories: expanded.directories,
496
+ fused: outcomes.filter((o) => o.outcome === "fused").length,
497
+ on_record: outcomes.filter((o) => o.outcome === "on record").length,
498
+ not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
499
+ };
281
500
  const structured = {
282
- results: outcomes as unknown as Record<string, unknown>[],
283
- frames,
284
- summary: {
285
- fused: outcomes.filter((o) => o.outcome === "fused").length,
286
- on_record: outcomes.filter((o) => o.outcome === "on record").length,
287
- not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
288
- },
501
+ set: setOutcome,
502
+ results: listed as unknown as Record<string, unknown>[],
503
+ omitted,
504
+ summary,
289
505
  };
290
- if (failed.size > 0) {
291
- const guidance = again
292
- ? "A file marked 'not fused' MAY still have been BitGraphed if the failure was a timeout: run bitgraph_check on it first, then re-run bitgraph_record with again=true for only the files still missing."
293
- : "Re-run bitgraph_record with the same paths: files already on record come back as 'on record' and only the missing ones are BitGraphed.";
506
+ const markdown = renderRecordMarkdown(outcomes, setOutcome, omitted);
507
+ if (summary.not_fused > 0) {
294
508
  return {
295
509
  isError: true,
296
- content: [{ type: "text", text: `${fused.size} of ${toMint.length} files were BitGraphed; ${failed.size} failed. ${guidance}\n\n${renderRecordMarkdown(outcomes)}` }],
510
+ content: [{ type: "text", text: `${failure ?? `${summary.not_fused} file(s) were left out.`}\n\n${markdown}` }],
297
511
  structuredContent: structured,
298
512
  };
299
513
  }
300
- const text = response_format === "json" ? capJson(structured).text : renderRecordMarkdown(outcomes);
301
- return ok(text, structured);
514
+ if (response_format === "json") {
515
+ const full = { ...structured, set: setOutcome !== null && made !== null ? { ...setOutcome, proof: made.proof } : null };
516
+ return ok(capJson(full).text, structured);
517
+ }
518
+ return ok(markdown, structured);
302
519
  } catch (err) {
303
520
  return fail(errorText(err));
304
521
  }
@@ -311,18 +528,18 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
311
528
  title: "Check for BitGraphs",
312
529
  description:
313
530
  "Check whether files or digests are on record in the BitGraph ledger, without recording anything. " +
314
- "Accepts file paths (hashed locally; only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. " +
315
- "Returns, per item: on_record (the bytes are on record, as an exact recording or as the original a new file was made from), every position by counter, and the proof page URL. " +
531
+ `Accepts file paths and directory paths (every regular file under them, up to ${MAX_CHECK_FILES} in all; hashed locally, only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. ` +
532
+ "Returns, per item: on_record (the bytes are on record, as an exact recording, as the original a new file was made from, or as a member of a set), every position by counter, and the proof page URL. " +
316
533
  "Read-only. Use bitgraph_record to BitGraph files that turn out not to be on record.",
317
534
  inputSchema: {
318
535
  paths: z
319
536
  .array(z.string().min(1))
320
- .max(MAX_FILES)
537
+ .max(MAX_PATHS)
321
538
  .optional()
322
- .describe("File paths to check."),
539
+ .describe("File or directory paths to check."),
323
540
  digests: z
324
541
  .array(z.string().min(1).max(100))
325
- .max(MAX_FILES)
542
+ .max(MAX_CHECK_FILES)
326
543
  .optional()
327
544
  .describe("SHA-256 digests, base64 (standard or URL-safe form)."),
328
545
  response_format: responseFormatSchema,
@@ -339,8 +556,9 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
339
556
  try {
340
557
  const inputs: Array<{ label: string; standardDigest: string }> = [];
341
558
  if (paths && paths.length > 0) {
342
- const hashed = await hashPaths(paths);
343
- hashed.forEach((d, i) => inputs.push({ label: paths[i] as string, standardDigest: d }));
559
+ const { files } = await expandPaths(paths, MAX_CHECK_FILES);
560
+ const hashed = await hashPaths(files);
561
+ hashed.forEach((d, i) => inputs.push({ label: files[i] as string, standardDigest: d }));
344
562
  }
345
563
  for (const d of digests ?? []) {
346
564
  const trimmed = d.trim();
@@ -354,6 +572,9 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
354
572
  if (inputs.length === 0) {
355
573
  return fail("Error: provide at least one of paths or digests.");
356
574
  }
575
+ if (inputs.length > MAX_CHECK_FILES) {
576
+ return fail(`Error: ${inputs.length} items; check at most ${MAX_CHECK_FILES} at a time.`);
577
+ }
357
578
 
358
579
  const checked = await batchCheck(
359
580
  config,
@@ -363,7 +584,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
363
584
  const outcomes: CheckOutcome[] = inputs.map((input) => {
364
585
  const entry = checked.results[toUrlSafeB64(input.standardDigest)];
365
586
  const proofs = entry?.proofs ?? [];
366
- const positions = proofs.map((p) => positionOf(p.proof));
587
+ const positions = proofs.map((p) => ({ ...positionOf(p.proof), ...(p.member ? { member: p.member } : {}) }));
367
588
  return {
368
589
  input: input.label,
369
590
  digest: toUrlSafeB64(input.standardDigest),
@@ -396,7 +617,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
396
617
  {
397
618
  title: "Get a BitGraph proof",
398
619
  description:
399
- "Fetch a BitGraph proof and its context: causal position, every position the same bytes occupy, and the two-sided Ethereum anchor window " +
620
+ "Fetch a BitGraph proof and its context: causal position, every position the same bytes occupy, the row a set member holds (one of N), and the two-sided Ethereum anchor window " +
400
621
  "('BitGraphed between X and Y'). Look up by digest (base64, either form), by BitGraph number (e.g. '4523' or '#4,523', current epoch), or by file path (hashed locally). " +
401
622
  "Exactly one of digest, number, or path is required. Read-only. " +
402
623
  "markdown returns a summary; json returns the full proof object with positions and anchor window.",
@@ -468,7 +689,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
468
689
  const detail = await getProofDetail(config, urlSafeDigest, selCounter, selEpoch);
469
690
  if (detail.proofs.length === 0) {
470
691
  return fail(
471
- `Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to record the file.`
692
+ `Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to make a BitGraph of the file.`
472
693
  );
473
694
  }
474
695