@mikeargento/bitgraph-mcp 0.4.0 → 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.
@@ -37,7 +37,7 @@ test("record markdown mentions again=true only when something was already on rec
37
37
  { ...base, path: "/b", outcome: "on record", artifact_digest: null, placement: null, member: null, member_count: null },
38
38
  ]);
39
39
  assert.ok(mixed.includes("again=true"));
40
- assert.ok(mixed.startsWith("1 fused, 1 already on record."));
40
+ assert.ok(mixed.startsWith("1 fused, 1 already on record (indexed before 2026-09-08)."));
41
41
  });
42
42
 
43
43
  test("record markdown names the set once and its members by row", () => {
@@ -54,12 +54,12 @@ test("record markdown names the set once and its members by row", () => {
54
54
  };
55
55
  const row = { digest: toUrlSafeB64(DIGEST), counter: "1386", epoch: toUrlSafeB64(EPOCH), total_positions: 1, proof_url: "https://bitgraph.ing/proof/x", artifact_digest: "ZnVzZWQ", outcome: "fused" as const, member_count: 2 };
56
56
  const md = renderRecordMarkdown([{ ...row, path: "/a.png", placement: "trailer/1", member: 2 }, { ...row, path: "/b.txt", placement: "container/2", member: 1 }], set);
57
- assert.ok(md.startsWith("2 files BitGraphed as one set at #1386 (set of 2), 0 already on record."), md);
57
+ assert.ok(md.startsWith("2 files BitGraphed as one set at #1386 (set of 2), 0 already on record (indexed before 2026-09-08)."), md);
58
58
  assert.ok(md.includes("- #1386 · set of 2 · https://bitgraph.ing/proof/c2V0?counter=1386"), md);
59
59
  assert.ok(md.includes("- fused · /a.png (2 of 2, trailer/1)"), md);
60
60
  assert.ok(md.includes("- fused · /b.txt (1 of 2, container/2)"), md);
61
61
  const waiting = renderRecordMarkdown([{ ...row, path: "/a.png", placement: "trailer/1", member: 1 }], { ...set, set: "set/2", index: { written: 0, pending: 2 } });
62
- assert.ok(waiting.includes("not findable by hash"), waiting);
62
+ assert.ok(waiting.includes("does not index new proofs by hash"), waiting);
63
63
  });
64
64
 
65
65
  test("proof markdown states the window as between lower and upper", () => {
@@ -17,7 +17,7 @@ import { join } from "node:path";
17
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
18
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
19
19
  import { FuseError } from "@mikeargento/bitgraph";
20
- import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseSetFn } from "../server.js";
20
+ import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseFileFn, type FuseSetFn } from "../server.js";
21
21
  import { toUrlSafeB64 } from "../encoding.js";
22
22
 
23
23
  interface Recorded {
@@ -202,8 +202,16 @@ const fakeFuseSet: FuseSetFn = async (files, _config, opts) => {
202
202
  }),
203
203
  };
204
204
  };
205
+ /** A stand-in for the single-file pipeline: records what it was asked to fuse. */
206
+ const soloCalls: Array<{ name: string; digestB64: string }> = [];
207
+ const fakeFuseFile: FuseFileFn = async (file) => {
208
+ soloCalls.push({ name: file.name, digestB64: file.digestB64 });
209
+ if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
210
+ const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
211
+ return { proof: proofFor(artifactDigestB64), frame: { type: "bitgraph-fuse/1" }, placement: file.placement, artifactDigestB64, originDigestB64: file.digestB64 };
212
+ };
205
213
  async function connectedClient(): Promise<Client> {
206
- const server = buildServer({ fuseSet: fakeFuseSet });
214
+ const server = buildServer({ fuseSet: fakeFuseSet, fuseFile: fakeFuseFile });
207
215
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
208
216
  const client = new Client({ name: "test-client", version: "0.0.0" });
209
217
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -212,17 +220,18 @@ async function connectedClient(): Promise<Client> {
212
220
 
213
221
  interface RecordStructured {
214
222
  set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
223
+ frames: Record<string, unknown>;
215
224
  results: Array<{ path: string; outcome: string; counter: string | null; placement: string | null; artifact_digest: string | null; member: number | null; member_count: number | null; total_positions: number; proof_url: string | null; error?: string }>;
216
225
  omitted: number;
217
226
  summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
218
227
  }
219
228
  const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
220
229
 
221
- test("lists the three tools", async () => {
230
+ test("lists the five tools", async () => {
222
231
  const client = await connectedClient();
223
232
  const tools = await client.listTools();
224
233
  const names = tools.tools.map((t) => t.name).sort();
225
- assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
234
+ assert.deepEqual(names, ["bitgraph_check", "bitgraph_commit", "bitgraph_get_proof", "bitgraph_open", "bitgraph_record"]);
226
235
  });
227
236
 
228
237
  test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
@@ -268,18 +277,28 @@ test("record makes ONE set of the fresh files and leaves on-record ones alone",
268
277
  assert.ok(text.includes("set of 2"), text);
269
278
  });
270
279
 
271
- test("record with again=true puts on-record files in the set", async () => {
280
+ test("a single file is fused on its own, with its Frame; again=true fuses an on-record one", async () => {
272
281
  const client = await connectedClient();
273
282
  setCalls.length = 0;
283
+ soloCalls.length = 0;
274
284
  const result = await client.callTool({
275
285
  name: "bitgraph_record",
276
286
  arguments: { paths: [fileA], again: true },
277
287
  });
278
288
  assert.ok(!result.isError, JSON.stringify(result.content));
279
- assert.deepEqual(setCalls.map((c) => c.digests), [[digestA]]);
289
+ assert.equal(setCalls.length, 0, "one file is not a set");
290
+ assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestA]);
280
291
  const structured = result.structuredContent as RecordStructured;
281
- assert.equal(structured.results[0]?.outcome, "fused");
282
- assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new set");
292
+ assert.equal(structured.set, null);
293
+ const row = structured.results[0];
294
+ assert.equal(row?.outcome, "fused");
295
+ assert.equal(row?.member, null);
296
+ assert.equal(row?.total_positions, 3, "two prior positions plus the new BitGraph");
297
+ assert.ok(row?.artifact_digest && structured.frames[row.artifact_digest], "the Frame rides in the structured result under the fused digest");
298
+ assert.ok(row?.proof_url?.includes(encodeURIComponent(row.artifact_digest ?? "")), "a single file's proof page is its own");
299
+ const text = textOf(result);
300
+ assert.ok(text.startsWith("1 fused, 0 already on record (indexed before 2026-09-08)."), text);
301
+ assert.ok(text.includes("its Frame is in the structured result"), text);
283
302
  });
284
303
 
285
304
  test("a directory is its regular files, hidden entries and links left out", async () => {
@@ -296,17 +315,19 @@ test("a directory is its regular files, hidden entries and links left out", asyn
296
315
  assert.equal(structured.set?.count, 2);
297
316
  });
298
317
 
299
- test("the same bytes under two paths are one member, reported twice", async () => {
318
+ test("the same bytes under two paths are one file, fused once and reported twice", async () => {
300
319
  const client = await connectedClient();
301
320
  setCalls.length = 0;
321
+ soloCalls.length = 0;
302
322
  const result = await client.callTool({
303
323
  name: "bitgraph_record",
304
324
  arguments: { paths: [fileB, copyB] },
305
325
  });
306
326
  assert.ok(!result.isError, JSON.stringify(result.content));
307
- assert.deepEqual(setCalls[0]?.digests, [digestB]);
327
+ assert.equal(setCalls.length, 0, "one distinct file is fused on its own");
328
+ assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestB]);
308
329
  const structured = result.structuredContent as RecordStructured;
309
- assert.equal(structured.set?.count, 1);
330
+ assert.equal(structured.set, null);
310
331
  assert.equal(structured.summary.fused, 2);
311
332
  assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
312
333
  });
@@ -353,7 +374,7 @@ test("evidence the site cannot index waits, blocks a new set, and is sent first
353
374
  const s1 = first.structuredContent as RecordStructured;
354
375
  assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
355
376
  assert.equal(pendingIndexCount(), BIG);
356
- assert.ok(textOf(first).includes("not findable by hash"), textOf(first).slice(0, 400));
377
+ assert.ok(textOf(first).includes("does not index new proofs by hash"), textOf(first).slice(0, 400));
357
378
 
358
379
  // Still down: nothing new is made, so the members cannot be made again by mistake.
359
380
  requests.length = 0;
package/src/format.ts CHANGED
@@ -107,13 +107,13 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
107
107
  } else {
108
108
  parts.push(`${fmt(fused.length)} fused`);
109
109
  }
110
- parts.push(`${fmt(onRecord.length)} already on record`);
110
+ parts.push(`${fmt(onRecord.length)} already on record (indexed before 2026-09-08)`);
111
111
  if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
112
112
  lines.push(`${parts.join(", ")}.`);
113
113
  if (set !== null && fused.length > 0) {
114
114
  lines.push(`- #${set.counter ?? "?"} · set of ${fmt(set.count)} · ${set.proof_url}`);
115
115
  if (set.index !== null && set.index.pending > 0) {
116
- lines.push(` The set is on the ledger, but the evidence for ${fmt(set.index.pending)} of its ${fmt(set.count)} members is not indexed yet, so those files are not findable by hash until it is. It is sent again at the start of the next bitgraph_record call.`);
116
+ lines.push(` The set is made. The evidence for ${fmt(set.index.pending)} of its ${fmt(set.count)} members has not reached BitGraph yet and is sent again at the start of the next bitgraph_record call. BitGraph does not index new proofs by hash; the set proof beside the originals is the record.`);
117
117
  }
118
118
  if (!set.manifest_echoed) {
119
119
  lines.push(` The boundary did not echo the committed artifact; the ledger's copy of this proof carries no member list. Keep the set's proof page.`);
@@ -139,17 +139,24 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
139
139
  );
140
140
  group(
141
141
  fused,
142
- (o) => `- fused · ${o.path} (${fmt(o.member ?? 0)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
142
+ (o) =>
143
+ o.member === null
144
+ ? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})\n ${o.proof_url}`
145
+ : `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
143
146
  (n) => `and ${fmt(n)} more files in the same set`
144
147
  );
145
- if (fused.length > 0) {
148
+ if (set !== null && fused.length > 0) {
149
+ lines.push(
150
+ "\nOne BitGraph holds every file made here: one slot, one position, and the committed artifact lists each file's new fused bytes by digest. Those bytes were hashed on this machine and never written or uploaded; the file itself is unchanged, and the original plus the set proof rebuilds them. Keep the set proof beside the originals; BitGraph does not index it."
151
+ );
152
+ } else if (fused.length > 0) {
146
153
  lines.push(
147
- "\nOne BitGraph holds every file made here: one slot, one position, and the committed artifact lists each file's new fused bytes by digest. Those bytes were hashed on this machine and never written or uploaded; the file itself is unchanged, and the original plus the set proof rebuilds them. A lookup by any file's own digest finds the set."
154
+ "\nThe new fused file was built in memory from the file, hashed and committed under its own slot; the file itself is unchanged and was not uploaded. The original plus the proof rebuilds the new file; its Frame is in the structured result."
148
155
  );
149
156
  }
150
157
  if (onRecord.length > 0) {
151
158
  lines.push(
152
- "\nFiles already on record were left alone. To make a new BitGraph of them deliberately, call bitgraph_record with again=true."
159
+ "\nFiles BitGraph still indexes (recorded before 2026-09-08) were left alone. BitGraph does not index new proofs, so a file may already have a BitGraph in its holder's folder. To make a new one regardless, call bitgraph_record with again=true."
153
160
  );
154
161
  }
155
162
  if (omitted > 0) {
package/src/server.ts CHANGED
@@ -5,14 +5,14 @@
5
5
  *
6
6
  * Three gestures, the same three the website has: make a BitGraph, check
7
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.
8
+ * gesture for any number of files, the way a drop on the site is: a single
9
+ * file is fused on its own slot, and two or more become members of ONE set
10
+ * under ONE slot. For a set each file is read once, on this machine, for its
11
+ * digest and a hasher state; the new fused bytes are never written and never
12
+ * held, their digest is finished from that state once the slot exists; and
13
+ * the set's committed artifact is hashed and committed under the same slot.
14
+ * Only digests, that artifact and slot records leave the machine. File
15
+ * contents are never uploaded and files are never modified.
16
16
  */
17
17
 
18
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -20,7 +20,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto
20
20
  import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
21
21
  import { z } from "zod";
22
22
  import { readFile } from "node:fs/promises";
23
- import { FuseError, MAX_SET_MEMBERS, fuseSet, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
23
+ import { FuseError, MAX_SET_MEMBERS, builderFor, fuse, fuseSet, fusedNamesFor, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
24
24
  import {
25
25
  ApiError,
26
26
  batchCheck,
@@ -49,9 +49,10 @@ import {
49
49
  type SetOutcome,
50
50
  } from "./format.js";
51
51
  import { expandPaths, fusedDigestFor, scanFile, type ScannedFile } from "./scan.js";
52
+ import { SLOT_TTL_SECONDS, TASK_INSTRUCTIONS, beginTask, decodeTaskToken, sealTask } from "./task.js";
52
53
  import type { BitGraphProof } from "./types.js";
53
54
 
54
- export const SERVER_VERSION = "0.4.0";
55
+ export const SERVER_VERSION = "0.5.0";
55
56
 
56
57
  const SCAN_CONCURRENCY = 4;
57
58
  /** Paths per call; a directory counts once and expands to its files. */
@@ -62,6 +63,8 @@ export const MAX_MEMBERS = 100_000;
62
63
  const MAX_CHECK_FILES = 10_000;
63
64
  /** 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
65
  const MAX_LOADED_BYTES = 256 * 1024 * 1024;
66
+ /** A single file up to this size is fused on its own, in memory, with its Frame; a larger one is a set of one, never held. */
67
+ const MAX_SOLO_BYTES = 256 * 1024 * 1024;
65
68
  /** Rows the structured result lists in full; every fused row shares the set's position. */
66
69
  export const ROW_CAP = 500;
67
70
  /** Members' evidence per set-index request: the site's own chunk. */
@@ -93,9 +96,48 @@ export type FuseSetFn = (
93
96
  config: ApiConfig,
94
97
  opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
95
98
  ) => Promise<SetSummary>;
99
+
100
+ /** What fusing one file on its own yields. */
101
+ export interface FusedSummary {
102
+ proof: BitGraphProof;
103
+ frame: unknown;
104
+ placement: string;
105
+ artifactDigestB64: string;
106
+ originDigestB64: string;
107
+ }
108
+ export type FuseFileFn = (file: ScannedFile, config: ApiConfig) => Promise<FusedSummary>;
109
+
96
110
  export interface ServerDeps {
97
111
  /** The set pipeline; tests inject a stand-in. Default: the core package's fuseSet() against the configured site. */
98
112
  fuseSet?: FuseSetFn;
113
+ /** The single-file pipeline; tests inject a stand-in. Default: the core package's fuse() against the configured site. */
114
+ fuseFile?: FuseFileFn;
115
+ }
116
+
117
+ /**
118
+ * A single file, the way a single drop on the site goes: the bytes in hand,
119
+ * the placement chosen from them, one slot, the fused bytes built in memory
120
+ * and hashed, committed under that exact slot, verified against the bytes,
121
+ * and a Frame returned. The fused bytes are not kept.
122
+ */
123
+ async function fuseFileDefault(file: ScannedFile, config: ApiConfig): Promise<FusedSummary> {
124
+ const bytes = new Uint8Array(await readFile(file.path));
125
+ const placement = file.placement;
126
+ const { fusedName } = fusedNamesFor(file.name, placement);
127
+ const r = await fuse(builderFor(placement, bytes), {
128
+ placement,
129
+ original: bytes,
130
+ fusedFile: fusedName,
131
+ keepFused: false,
132
+ transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
133
+ });
134
+ return {
135
+ proof: r.proof as unknown as BitGraphProof,
136
+ frame: r.frame,
137
+ placement,
138
+ artifactDigestB64: r.artifactDigestB64,
139
+ originDigestB64: r.originDigestB64 ?? file.digestB64,
140
+ };
99
141
  }
100
142
 
101
143
  /**
@@ -282,6 +324,7 @@ async function flushIndex(config: ApiConfig, report: Report): Promise<{ written:
282
324
 
283
325
  export function buildServer(deps: ServerDeps = {}): McpServer {
284
326
  const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
327
+ const fuseFilePipeline = deps.fuseFile ?? fuseFileDefault;
285
328
  const server = new McpServer(
286
329
  {
287
330
  name: "bitgraph-mcp-server",
@@ -289,8 +332,9 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
289
332
  },
290
333
  {
291
334
  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.",
335
+ "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: a single file is fused on its own; two or more become one set under one slot, one position, every file's new fused bytes listed by digest in the committed artifact. " +
336
+ "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. " +
337
+ "To do work INSIDE a BitGraph, call bitgraph_open BEFORE starting: it returns a position and its commitment; put the commitment string into the task, seal the task with bitgraph_commit within 120 seconds, then record the outputs with bitgraph_record. The task then could not have existed before the position's floor block, and the outputs sit after it.",
294
338
  }
295
339
  );
296
340
 
@@ -299,13 +343,13 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
299
343
  {
300
344
  title: "Make a BitGraph",
301
345
  description:
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. " +
346
+ "Make a BitGraph of files or folders. Everything in one call becomes ONE BitGraph, the way a drop on the site works: a single file is fused on its own slot; two or more files become a set under a single slot in the BitGraph ledger (bitgraph.ing), one position for all of them. " +
347
+ "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 for a set the canonical list of those digests (above 2,000 files, a Merkle root over it) is committed under the same slot. " +
304
348
  "Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
305
349
  "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. " +
350
+ "Files BitGraph still indexes (recorded before 2026-09-08) are NOT made again by default; they come back as 'on record' with their earliest position. BitGraph does not index new proofs, so a file may already have a BitGraph its holder keeps. Pass again=true to make a new BitGraph regardless. " +
351
+ "Positions are permanent and the proof comes back to you to keep, so only BitGraph files the user asked to, and never generate content just to record it. " +
352
+ "Returns one outcome per file: 'fused' (for a set, its row, one of N, and the set's position and proof page; for a single file, its own position and Frame), 'on record', or 'not fused' (with the reason). Keep the proof beside the files; BitGraph does not index it. " +
309
353
  "Use bitgraph_check instead when the user only wants to know whether files are on record.",
310
354
  inputSchema: {
311
355
  paths: z
@@ -317,7 +361,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
317
361
  .boolean()
318
362
  .default(false)
319
363
  .describe(
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."
364
+ "false (default): files BitGraph still indexes are returned as-is, nothing made. true: put every file in the set regardless. Outcomes are per unique file content: two paths with identical bytes are one member."
321
365
  ),
322
366
  response_format: responseFormatSchema,
323
367
  },
@@ -390,8 +434,18 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
390
434
  }
391
435
  const attempted = new Set(toMint.map((f) => f.digestB64));
392
436
  let made: SetSummary | null = null;
437
+ let solo: (FusedSummary & { file: ScannedFile }) | null = null;
393
438
  let failure: string | null = null;
394
- if (toMint.length > 0) {
439
+ const one = toMint.length === 1 ? (toMint[0] as ScannedFile) : null;
440
+ if (one !== null && one.size <= MAX_SOLO_BYTES) {
441
+ // One file, as a single drop on the site goes: its own slot, its own Frame.
442
+ report(0, 1, "fusing");
443
+ try {
444
+ solo = { ...(await fuseFilePipeline(one, config)), file: one };
445
+ } catch (err) {
446
+ failure = setFailureText(err);
447
+ }
448
+ } else if (toMint.length > 0) {
395
449
  const kind: "set/1" | "set/2" = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
396
450
  try {
397
451
  made = await fuseSetPipeline(toMint, config, {
@@ -433,13 +487,29 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
433
487
  index,
434
488
  };
435
489
  }
490
+ const frames: Record<string, unknown> = {};
491
+ if (solo !== null) frames[toUrlSafeB64(solo.artifactDigestB64)] = solo.frame;
436
492
  const outcomes: RecordOutcome[] = [];
437
493
  for (const [digest, entry] of byDigest) {
438
494
  const m = memberOf.get(digest);
439
495
  const prior = existing.get(digest);
440
496
  for (const path of entry.paths) {
441
497
  const base = { path, digest: toUrlSafeB64(digest) };
442
- if (m !== undefined && made !== null && setOutcome !== null) {
498
+ if (solo !== null && solo.file.digestB64 === digest) {
499
+ const { counter, epoch } = positionOf(solo.proof);
500
+ outcomes.push({
501
+ ...base,
502
+ outcome: "fused",
503
+ artifact_digest: toUrlSafeB64(solo.artifactDigestB64),
504
+ placement: solo.placement,
505
+ counter,
506
+ epoch,
507
+ member: null,
508
+ member_count: null,
509
+ total_positions: (prior?.length ?? 0) + 1,
510
+ proof_url: proofUrl(config.baseUrl, solo.artifactDigestB64, counter ?? undefined, solo.proof.commit?.epochId),
511
+ });
512
+ } else if (m !== undefined && made !== null && setOutcome !== null) {
443
513
  outcomes.push({
444
514
  ...base,
445
515
  outcome: "fused",
@@ -500,6 +570,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
500
570
  const structured = {
501
571
  set: setOutcome,
502
572
  results: listed as unknown as Record<string, unknown>[],
573
+ frames,
503
574
  omitted,
504
575
  summary,
505
576
  };
@@ -522,6 +593,78 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
522
593
  }
523
594
  );
524
595
 
596
+ server.registerTool(
597
+ "bitgraph_open",
598
+ {
599
+ title: "Open a position before the work",
600
+ description:
601
+ "Ask for a BitGraph position BEFORE starting a task, so the task can be done inside the BitGraph. Returns a fuse_token, the position, its floor block, and the slot's commitment as a string. " +
602
+ "Put the commitment string into the task before running it (the prompt or request you send, a seed, a line in the document, text that must appear in the output), then call bitgraph_commit with the fuse_token and the task file's path within " + SLOT_TTL_SECONDS + " seconds. " +
603
+ "What that gives a stranger: the task could not have existed before the floor block (the commitment did not exist before the position did), and the outputs, recorded afterwards with bitgraph_record, sit at later positions. " +
604
+ "Nothing about the task is sent here; only its digest is, at commit. Positions are permanent: open one only when a task is about to run.",
605
+ inputSchema: {
606
+ response_format: responseFormatSchema,
607
+ },
608
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
609
+ },
610
+ async ({ response_format }) => {
611
+ const config = configFromEnv();
612
+ try {
613
+ const b = await beginTask(config);
614
+ const structured = { outcome: "opened", task: true, slot_counter: b.slotCounter, epoch: b.epoch, commitment: b.commitment, commitment_base64: b.commitmentB64, floor: b.floor, fuse_token: b.token, expires_in_seconds: SLOT_TTL_SECONDS, instructions: TASK_INSTRUCTIONS };
615
+ if (response_format === "json") return ok(JSON.stringify(structured, null, 2), structured);
616
+ return ok(
617
+ `Opened position ${b.slotCounter} before any work exists. Floor: ${b.floor ? `not before block ${b.floor.block}` : "the sealed proof will carry the signed floor"}.\n\n` +
618
+ `Commitment (put this string into the task): ${b.commitment}\n\n${TASK_INSTRUCTIONS}\n\n` +
619
+ "```json\n" + JSON.stringify({ fuse_token: b.token, commitment: b.commitment, slot_counter: b.slotCounter, epoch: b.epoch, floor: b.floor }, null, 2) + "\n```",
620
+ structured
621
+ );
622
+ } catch (err) {
623
+ return fail(errorText(err));
624
+ }
625
+ }
626
+ );
627
+
628
+ server.registerTool(
629
+ "bitgraph_commit",
630
+ {
631
+ title: "Seal the task under its position",
632
+ description:
633
+ "Step two of doing work inside a BitGraph: seal the task bytes that carry the commitment under the position bitgraph_open returned. Give the fuse_token and the path of the task file (the exact request, prompt or document that contains the commitment string), or its SHA-256 (base64) when the bytes are elsewhere. " +
634
+ "With a path, the file is read on this machine, the commitment string is looked for inside it BEFORE anything is sent (a task that does not carry it is refused and the slot stays held), and only the digest travels. " +
635
+ "Returns the proof: keep it beside the task bytes. Then record the outputs with bitgraph_record. Must be called within " + SLOT_TTL_SECONDS + " seconds of bitgraph_open.",
636
+ inputSchema: {
637
+ fuse_token: z.string().min(1).max(8000).describe("The fuse_token bitgraph_open returned."),
638
+ path: z.string().min(1).optional().describe("Path of the task file that contains the commitment string."),
639
+ digest: z.string().min(1).max(100).optional().describe("Instead of a path: SHA-256 of the task bytes, base64 (either form)."),
640
+ response_format: responseFormatSchema,
641
+ },
642
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
643
+ },
644
+ async ({ fuse_token, path, digest, response_format }) => {
645
+ const config = configFromEnv();
646
+ const state = decodeTaskToken(fuse_token);
647
+ if (state === null) return fail("Error: fuse_token is not one issued by bitgraph_open.");
648
+ if (path === undefined && digest === undefined) return fail("Error: give the task file's path, or its SHA-256 digest.");
649
+ try {
650
+ const sealed = await sealTask(config, state, path !== undefined ? { path } : { digestB64: fromUrlSafeB64(digest!.trim()) });
651
+ const counter = sealed.proof.commit?.counter ?? null;
652
+ const epoch = sealed.proof.commit?.epochId ?? null;
653
+ const floor = sealed.proof.commit?.slotAnchor?.blockNumber ?? null;
654
+ const structured = { outcome: "sealed", slot_counter: state.slot.counter, counter, epoch: epoch ? toUrlSafeB64(epoch) : null, floor_block: floor, artifact_digest: toUrlSafeB64(sealed.artifactDigestB64), commitment_offsets: sealed.offsets, proof: sealed.proof };
655
+ if (response_format === "json") return ok(JSON.stringify(structured, null, 2), structured);
656
+ return ok(
657
+ `Sealed the task at position ${counter ?? "?"} (slot ${state.slot.counter}).${floor !== null ? ` Not before block ${floor}.` : ""} ` +
658
+ (sealed.offsets !== null ? `The commitment string was found in the task bytes at offset ${sealed.offsets[0]}. ` : "The task bytes were not read here; a verifier looks for the commitment string in them. ") +
659
+ "Keep the proof beside the task bytes, and record the outputs with bitgraph_record when they exist.\n\n```json\n" + JSON.stringify(sealed.proof, null, 2) + "\n```",
660
+ structured
661
+ );
662
+ } catch (err) {
663
+ return fail(errorText(err));
664
+ }
665
+ }
666
+ );
667
+
525
668
  server.registerTool(
526
669
  "bitgraph_check",
527
670
  {
package/src/task.ts ADDED
@@ -0,0 +1,163 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * A position BEFORE the work: the task form of the stdio server.
5
+ *
6
+ * bitgraph_open with no files asks the site's own /api/fuse/allocate for a
7
+ * slot and returns its commitment, and nothing else. The caller puts the
8
+ * commitment into the task it is about to run (the prompt, the request, a
9
+ * seed, a line in the document) and, within the slot's life, commits the
10
+ * digest of those task bytes under the slot with the inline marker
11
+ * (bitgraph-fuse/1, title base64url): a verifier recomputes the commitment
12
+ * from the slot record and finds its base64url text inside the bytes.
13
+ * Outputs are recorded afterwards with bitgraph_record and sit later.
14
+ *
15
+ * The slot allocation and the commit go through the same two site routes
16
+ * the core package's fuse() uses; the recovery rule is the core's (read back
17
+ * by digest, refuse a proof under any other slot).
18
+ */
19
+ import { readFile } from "node:fs/promises";
20
+ import { createHash } from "node:crypto";
21
+ import { bytesToBase64, computeSlotCommitment, findCommitment, inlineAttribution, verifyProofIntegrity, ENCODING_BASE64URL } from "@mikeargento/bitgraph-verify";
22
+ import type { BitGraphProof, SlotAllocation } from "@mikeargento/bitgraph-verify";
23
+ import { ApiError, type ApiConfig } from "./api.js";
24
+ import { toUrlSafeB64 } from "./encoding.js";
25
+
26
+ export const SLOT_TTL_SECONDS = 120;
27
+ const CHAIN = "bitgraph:main";
28
+ const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
29
+ const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
30
+
31
+ export interface TaskState {
32
+ v: 1;
33
+ task: true;
34
+ slot: SlotAllocation;
35
+ }
36
+
37
+ function isSlotRecord(x: unknown): x is SlotAllocation {
38
+ if (x === null || typeof x !== "object" || Array.isArray(x)) return false;
39
+ const s = x as Record<string, unknown>;
40
+ return s.version === "bitgraph/slot/1" && typeof s.nonceB64 === "string" && B64_32.test(s.nonceB64) && typeof s.counter === "string" && /^(0|[1-9][0-9]*)$/.test(s.counter)
41
+ && typeof s.epochId === "string" && typeof s.publicKeyB64 === "string" && typeof s.signatureB64 === "string" && B64_64.test(s.signatureB64) && s.chainId === CHAIN;
42
+ }
43
+
44
+ export function encodeTaskToken(state: TaskState): string {
45
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
46
+ }
47
+
48
+ export function decodeTaskToken(token: string): TaskState | null {
49
+ let parsed: unknown;
50
+ try { parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8")); } catch { return null; }
51
+ if (typeof parsed !== "object" || parsed === null) return null;
52
+ const s = parsed as Record<string, unknown>;
53
+ if (s.v !== 1 || s.task !== true || !isSlotRecord(s.slot)) return null;
54
+ return { v: 1, task: true, slot: s.slot };
55
+ }
56
+
57
+ async function post(config: ApiConfig, path: string, body: unknown, timeoutMs: number): Promise<{ status: number; json: unknown; retryAfterSec: number | null }> {
58
+ const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
59
+ if (config.apiKey) headers["authorization"] = `Bearer ${config.apiKey}`;
60
+ const res = await fetch(`${config.baseUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
61
+ let json: unknown = null;
62
+ try { json = await res.json(); } catch { /* non-JSON */ }
63
+ const raw = res.headers.get("retry-after");
64
+ const retry = raw !== null ? Number.parseInt(raw, 10) : NaN;
65
+ return { status: res.status, json, retryAfterSec: Number.isFinite(retry) ? retry : null };
66
+ }
67
+
68
+ const messageOf = (json: unknown, fallback: string): string => {
69
+ const e = (json as { error?: unknown } | null)?.error;
70
+ return typeof e === "string" ? e : fallback;
71
+ };
72
+
73
+ export interface Begun {
74
+ token: string;
75
+ /** Unpadded base64url: the string to put into the task. */
76
+ commitment: string;
77
+ commitmentB64: string;
78
+ slotCounter: string;
79
+ epoch: string;
80
+ floor: { block: number } | null;
81
+ }
82
+
83
+ /** Step one of the task form: a held slot and its commitment, before any work exists. */
84
+ export async function beginTask(config: ApiConfig): Promise<Begun> {
85
+ const r = await post(config, "/api/fuse/allocate", {}, 20_000);
86
+ if (r.status !== 200) throw new ApiError(r.status, messageOf(r.json, `allocation refused (${r.status})`), r.retryAfterSec);
87
+ const slot = (r.json as { slot?: unknown } | null)?.slot;
88
+ if (!isSlotRecord(slot)) throw new ApiError(502, "the allocation response is not a slot record on bitgraph:main");
89
+ const commitmentB64 = bytesToBase64(computeSlotCommitment(slot));
90
+ let floor: Begun["floor"] = null;
91
+ try {
92
+ const res = await fetch(`${config.baseUrl}/api/proofs/anchors?counter=${encodeURIComponent(slot.counter)}&epoch=${encodeURIComponent(slot.epochId)}&before=1`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
93
+ const data = res.status === 200 ? ((await res.json()) as { anchors?: Array<{ commit?: { anchor?: { blockNumber?: number } } }> }) : null;
94
+ const b = data?.anchors?.[0]?.commit?.anchor?.blockNumber;
95
+ if (typeof b === "number") floor = { block: b };
96
+ } catch { floor = null; }
97
+ return { token: encodeTaskToken({ v: 1, task: true, slot }), commitment: toUrlSafeB64(commitmentB64), commitmentB64, slotCounter: slot.counter, epoch: toUrlSafeB64(slot.epochId), floor };
98
+ }
99
+
100
+ export interface SealedTask {
101
+ proof: BitGraphProof;
102
+ artifactDigestB64: string;
103
+ /** Byte offsets of the commitment string inside the task bytes, when the bytes were given. */
104
+ offsets: number[] | null;
105
+ }
106
+
107
+ /**
108
+ * Step two: the task bytes (a path, or the bytes themselves) sealed under
109
+ * the held slot with the inline marker. When the bytes are in hand the
110
+ * commitment is looked for BEFORE the commit, so a task that does not carry
111
+ * it is refused without spending the slot.
112
+ */
113
+ export async function sealTask(config: ApiConfig, state: TaskState, task: { path: string } | { bytes: Uint8Array } | { digestB64: string }): Promise<SealedTask> {
114
+ const { slot } = state;
115
+ const commitment = computeSlotCommitment(slot);
116
+ let bytes: Uint8Array | null = null;
117
+ let artifactDigestB64: string;
118
+ if ("digestB64" in task) {
119
+ if (!B64_32.test(task.digestB64)) throw new ApiError(400, "artifact digest must be a base64 SHA-256");
120
+ artifactDigestB64 = task.digestB64;
121
+ } else {
122
+ bytes = "path" in task ? new Uint8Array(await readFile(task.path)) : task.bytes;
123
+ artifactDigestB64 = createHash("sha256").update(bytes).digest("base64");
124
+ }
125
+ const offsets = bytes === null ? null : findCommitment(bytes, commitment, ENCODING_BASE64URL);
126
+ if (offsets !== null && offsets.length === 0) {
127
+ throw new ApiError(400, `the task bytes do not contain the commitment string ${toUrlSafeB64(bytesToBase64(commitment))}; put it in before sealing. Nothing was committed and the slot is still held.`);
128
+ }
129
+ const attribution = inlineAttribution();
130
+ const r = await post(config, "/api/fuse/commit", { digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }], slotId: slot.nonceB64, slot, chainId: CHAIN, attribution }, 40_000);
131
+ let proof: BitGraphProof | null = null;
132
+ if (r.status === 200) proof = ((r.json as { proof?: BitGraphProof } | null)?.proof ?? null);
133
+ else if (r.status === 409 || r.status === 503) proof = await recover(config, artifactDigestB64, slot);
134
+ if (proof === null) throw new ApiError(r.status, messageOf(r.json, `commit refused (${r.status})`), r.retryAfterSec);
135
+ if (proof.artifact?.digestB64 !== artifactDigestB64 || proof.commit?.nonceB64 !== slot.nonceB64 || proof.slotAllocation?.nonceB64 !== slot.nonceB64) throw new ApiError(502, "the boundary returned a proof under a different slot; nothing is labelled sealed");
136
+ const a = proof.attribution;
137
+ if (a?.name !== attribution.name || a?.title !== attribution.title || a?.message !== undefined) throw new ApiError(502, "the returned proof does not carry the inline marker that was sent; nothing is labelled sealed");
138
+ const integrity = await verifyProofIntegrity({ proof });
139
+ if (!integrity.valid) throw new ApiError(502, `the returned proof does not verify: ${integrity.reason ?? "unknown reason"}`);
140
+ return { proof, artifactDigestB64, offsets };
141
+ }
142
+
143
+ async function recover(config: ApiConfig, artifactDigestB64: string, slot: SlotAllocation): Promise<BitGraphProof | null> {
144
+ for (let attempt = 0; attempt < 4; attempt++) {
145
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 1500));
146
+ try {
147
+ const res = await fetch(`${config.baseUrl}/api/proofs/${encodeURIComponent(toUrlSafeB64(artifactDigestB64))}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
148
+ if (res.status !== 200) continue;
149
+ const j = (await res.json()) as { proofs?: Array<{ proof?: BitGraphProof }> } | null;
150
+ for (const e of j?.proofs ?? []) {
151
+ const p = e.proof;
152
+ if (p && p.artifact?.digestB64 === artifactDigestB64 && p.commit?.nonceB64 === slot.nonceB64) return p;
153
+ }
154
+ } catch { /* try again */ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export const TASK_INSTRUCTIONS =
160
+ "You hold a position and its commitment, and no work exists yet. Put the commitment string into the task before you run it: in the prompt or request you are about to send, as a seed, as a line in the document, as text that must appear in the output. " +
161
+ `Then call bitgraph_commit with this fuse_token and the path of the task file (or the SHA-256 of its bytes), within ${SLOT_TTL_SECONDS} seconds of opening: that seals the task under the position before its output exists. ` +
162
+ "Keep those exact bytes: a verifier recomputes the commitment from the proof and looks for the string inside them. " +
163
+ "When the output exists, record it with bitgraph_record; it will sit at a later position. What a stranger can then check: the task could not have existed before the position's floor block, and the output was recorded after it.";