@mikeargento/bitgraph-mcp 0.1.2 → 0.2.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/format.ts CHANGED
@@ -31,18 +31,25 @@ export function proofUrl(
31
31
  }
32
32
 
33
33
  /**
34
- * One recording outcome, in the product's own vocabulary. "not recorded" is
35
- * the honest label for a file lost to a partial commit failure: never claim
36
- * "on record" for a digest that has no proof.
34
+ * One outcome per path, in the product's own vocabulary. "fused": a new fused
35
+ * artifact was built from the file and committed under its own slot. "on
36
+ * record": the bytes already had a recording or a fused artifact naming them
37
+ * as origin, and nothing was minted. "not fused": the attempt failed; never
38
+ * claim "on record" for bytes that have no proof.
37
39
  */
38
40
  export interface RecordOutcome {
39
41
  path: string;
40
- digest: string; // URL-safe
41
- outcome: "recorded" | "on record" | "not recorded";
42
+ /** The file's own digest (URL-safe): the origin of the fused artifact. */
43
+ digest: string;
44
+ outcome: "fused" | "on record" | "not fused";
45
+ /** The fused artifact's digest (URL-safe), present on a "fused" outcome. */
46
+ artifact_digest: string | null;
47
+ placement: string | null;
42
48
  counter: string | null;
43
49
  epoch: string | null; // URL-safe
44
50
  total_positions: number;
45
51
  proof_url: string | null;
52
+ error?: string;
46
53
  }
47
54
 
48
55
  export interface CheckOutcome {
@@ -60,33 +67,38 @@ export function positionOf(proof: BitGraphProof): { counter: string | null; epoc
60
67
  }
61
68
 
62
69
  export function renderRecordMarkdown(outcomes: readonly RecordOutcome[]): string {
63
- const recorded = outcomes.filter((o) => o.outcome === "recorded");
70
+ const fused = outcomes.filter((o) => o.outcome === "fused");
64
71
  const onRecord = outcomes.filter((o) => o.outcome === "on record");
65
- const notRecorded = outcomes.filter((o) => o.outcome === "not recorded");
72
+ const notFused = outcomes.filter((o) => o.outcome === "not fused");
66
73
  const lines: string[] = [];
67
- let headline = `${recorded.length} recorded, ${onRecord.length} already on record.`;
68
- if (notRecorded.length > 0) {
69
- headline = `${recorded.length} recorded, ${onRecord.length} already on record, ${notRecorded.length} NOT recorded.`;
74
+ let headline = `${fused.length} fused, ${onRecord.length} already on record.`;
75
+ if (notFused.length > 0) {
76
+ headline = `${fused.length} fused, ${onRecord.length} already on record, ${notFused.length} NOT fused.`;
70
77
  }
71
78
  lines.push(headline);
72
79
  for (const o of outcomes) {
73
- if (o.outcome === "not recorded") {
74
- lines.push(`- not recorded (commit failed) · ${o.path}`);
80
+ if (o.outcome === "not fused") {
81
+ lines.push(`- not fused · ${o.path}${o.error ? `: ${o.error}` : ""}`);
75
82
  continue;
76
83
  }
77
- const positionNote =
84
+ const note =
78
85
  o.outcome === "on record"
79
86
  ? o.total_positions > 1
80
- ? ` (${o.total_positions} causal positions, earliest shown)`
87
+ ? ` (${o.total_positions} positions, earliest shown)`
81
88
  : ""
82
- : "";
89
+ : o.placement
90
+ ? ` (${o.placement})`
91
+ : "";
92
+ lines.push(`- ${o.outcome} · #${o.counter ?? "?"} · ${o.path}${note}\n ${o.proof_url}`);
93
+ }
94
+ if (fused.length > 0) {
83
95
  lines.push(
84
- `- ${o.outcome} · #${o.counter ?? "?"} · ${o.path}${positionNote}\n ${o.proof_url}`
96
+ "\nEach fused artifact 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 fused bytes; the Frame for each is in the structured result."
85
97
  );
86
98
  }
87
99
  if (onRecord.length > 0) {
88
100
  lines.push(
89
- `\nAlready-recorded files were not re-recorded. To record one of them at a new causal position deliberately, call bitgraph_record with again=true.`
101
+ "\nFiles already on record were left alone. To make a new fused artifact from one of them deliberately, call bitgraph_record with again=true."
90
102
  );
91
103
  }
92
104
  return lines.join("\n");
package/src/server.ts CHANGED
@@ -3,21 +3,24 @@
3
3
  /**
4
4
  * @mikeargento/bitgraph-mcp: tool definitions.
5
5
  *
6
- * Three gestures, the same three the website has: record a file (take a
7
- * BitGraph), check whether bytes are on record, fetch a proof. Only SHA-256
8
- * digests ever leave the machine; file contents are never uploaded.
6
+ * Three gestures, the same three the website has: take a BitGraph of a file,
7
+ * check whether bytes are on record, fetch a proof. Taking 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.
9
11
  */
10
12
 
11
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
14
  import { z } from "zod";
15
+ import { readFile } from "node:fs/promises";
16
+ import { fuse, builderFor, placementForBytes, fusedNamesFor } from "@mikeargento/bitgraph";
13
17
  import {
14
18
  ApiError,
15
- PartialCommitError,
16
19
  batchCheck,
17
- commitDigests,
18
20
  configFromEnv,
19
21
  getProofDetail,
20
22
  search,
23
+ type ApiConfig,
21
24
  } from "./api.js";
22
25
  import {
23
26
  fromUrlSafeB64,
@@ -38,10 +41,74 @@ import {
38
41
  } from "./format.js";
39
42
  import type { BitGraphProof } from "./types.js";
40
43
 
41
- export const SERVER_VERSION = "0.1.2";
44
+ export const SERVER_VERSION = "0.2.0";
42
45
 
43
46
  const HASH_CONCURRENCY = 4;
44
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;
50
+
51
+ /** What taking a BitGraph of one file yields. */
52
+ export interface FusedSummary {
53
+ proof: BitGraphProof;
54
+ frame: unknown;
55
+ placement: string;
56
+ artifactDigestB64: string;
57
+ originDigestB64: string;
58
+ }
59
+ export type FuseFileFn = (bytes: Uint8Array, name: string, config: ApiConfig) => Promise<FusedSummary>;
60
+ 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;
63
+ }
64
+
65
+ /**
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.
70
+ */
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,
78
+ keepFused: false,
79
+ transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
80
+ });
81
+ return {
82
+ proof: r.proof as unknown as BitGraphProof,
83
+ frame: r.frame,
84
+ placement,
85
+ artifactDigestB64: r.artifactDigestB64,
86
+ originDigestB64: r.originDigestB64 ?? "",
87
+ };
88
+ }
89
+
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 }>> {
92
+ const failures: string[] = [];
93
+ const out = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
94
+ 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 };
100
+ } catch (err) {
101
+ failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
102
+ return { bytes: new Uint8Array(0), digestB64: "" };
103
+ }
104
+ });
105
+ if (failures.length > 0) {
106
+ 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.`
108
+ );
109
+ }
110
+ return out;
111
+ }
45
112
 
46
113
  const responseFormatSchema = z
47
114
  .enum(["markdown", "json"])
@@ -91,7 +158,8 @@ async function hashPaths(paths: readonly string[]): Promise<string[]> {
91
158
  return digests;
92
159
  }
93
160
 
94
- export function buildServer(): McpServer {
161
+ export function buildServer(deps: ServerDeps = {}): McpServer {
162
+ const fuseFile = deps.fuseFile ?? fuseFileDefault;
95
163
  const server = new McpServer({
96
164
  name: "bitgraph-mcp-server",
97
165
  version: SERVER_VERSION,
@@ -102,34 +170,24 @@ export function buildServer(): McpServer {
102
170
  {
103
171
  title: "Take a BitGraph",
104
172
  description:
105
- "Take a BitGraph of one or more files: record each file's SHA-256 digest at a new causal position in the BitGraph ledger (bitgraph.ing). " +
106
- "Only the digest leaves the machine; file contents are never uploaded. " +
107
- "Files whose bytes are already on record are NOT re-recorded by default; they come back as 'on record' with their existing proof. " +
108
- "Pass again=true to deliberately record already-recorded bytes at a new causal position (BitGraph Again). " +
109
- "Recordings are permanent: the ledger has 10-year retention and no deletes, so only record files the user asked to record. " +
110
- "Returns one outcome per file: 'recorded' (newly minted) or 'on record' (was already there), with its position number and proof page URL. " +
173
+ "Take 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). " +
111
179
  "Use bitgraph_check instead when the user only wants to know whether a file is on record.",
112
180
  inputSchema: {
113
181
  paths: z
114
182
  .array(z.string().min(1))
115
183
  .min(1)
116
184
  .max(MAX_FILES)
117
- .describe(`File paths to record (absolute paths preferred), up to ${MAX_FILES}.`),
118
- attribution: z
119
- .object({
120
- name: z.string().max(200).optional().describe("Submitter's name (self-attributed)."),
121
- title: z.string().max(200).optional(),
122
- message: z.string().max(2000).optional(),
123
- })
124
- .optional()
125
- .describe(
126
- "Optional self-attributed submitter's note, stored in the signed proof. Rendered as a note, never as verified identity."
127
- ),
185
+ .describe(`File paths to BitGraph (absolute paths preferred), up to ${MAX_FILES}.`),
128
186
  again: z
129
187
  .boolean()
130
188
  .default(false)
131
189
  .describe(
132
- "false (default): files already on record are returned as-is, nothing minted. true: record every file at a new causal position even if already on record. Positions are per unique file content: two paths with identical bytes yield one position."
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."
133
191
  ),
134
192
  response_format: responseFormatSchema,
135
193
  },
@@ -140,142 +198,106 @@ export function buildServer(): McpServer {
140
198
  openWorldHint: true,
141
199
  },
142
200
  },
143
- async ({ paths, attribution, again, response_format }) => {
201
+ async ({ paths, again, response_format }) => {
144
202
  const config = configFromEnv();
145
203
  try {
146
- const digests = await hashPaths(paths);
147
-
148
- // Unique digests, first path wins for display; extra paths listed too.
149
- const byDigest = new Map<string, string[]>();
150
- digests.forEach((d, i) => {
151
- const list = byDigest.get(d) ?? [];
152
- list.push(paths[i] as string);
153
- byDigest.set(d, list);
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);
154
211
  });
155
212
  const unique = [...byDigest.keys()];
156
-
157
213
  const checked = await batchCheck(config, unique.map(toUrlSafeB64));
158
214
  const existing = new Map<string, Array<{ proof: BitGraphProof }>>();
159
215
  for (const d of unique) {
160
216
  const entry = checked.results[toUrlSafeB64(d)];
161
217
  if (entry && entry.proofs.length > 0) existing.set(d, entry.proofs);
162
218
  }
163
-
164
219
  const toMint = again ? unique : unique.filter((d) => !existing.has(d));
165
-
166
- let minted: BitGraphProof[] = [];
167
- let partial: PartialCommitError | null = null;
168
- if (toMint.length > 0) {
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";
169
225
  try {
170
- minted = await commitDigests(config, toMint, attribution);
171
- // A 200 with fewer proofs than digests is still a partial failure;
172
- // never let it reach the success path looking complete.
173
- if (minted.length < toMint.length) {
174
- partial = new PartialCommitError(
175
- minted,
176
- toMint.length,
177
- new Error("commit returned fewer proofs than digests sent")
178
- );
179
- }
226
+ fused.set(d, await fuseFile(entry.bytes, name, config));
180
227
  } catch (err) {
181
- if (err instanceof PartialCommitError) {
182
- minted = err.minted;
183
- partial = err;
184
- } else {
185
- throw err;
186
- }
228
+ failed.set(d, err instanceof Error ? err.message : String(err));
187
229
  }
188
230
  }
189
-
190
- const mintedByDigest = new Map<string, BitGraphProof>();
191
- for (const p of minted) {
192
- const d = p.artifact?.digestB64;
193
- if (d !== undefined) mintedByDigest.set(d, p);
194
- }
195
-
196
231
  const outcomes: RecordOutcome[] = [];
197
- for (const [digest, pathList] of byDigest) {
198
- const mintedProof = mintedByDigest.get(digest);
232
+ const frames: Record<string, unknown> = {};
233
+ for (const [digest, entry] of byDigest) {
234
+ const made = fused.get(digest);
199
235
  const prior = existing.get(digest);
200
- for (const path of pathList) {
201
- if (mintedProof) {
202
- const { counter, epoch } = positionOf(mintedProof);
236
+ for (const path of entry.paths) {
237
+ if (made) {
238
+ const { counter, epoch } = positionOf(made.proof);
239
+ frames[toUrlSafeB64(made.artifactDigestB64)] = made.frame;
203
240
  outcomes.push({
204
241
  path,
205
242
  digest: toUrlSafeB64(digest),
206
- outcome: "recorded",
243
+ outcome: "fused",
244
+ artifact_digest: toUrlSafeB64(made.artifactDigestB64),
245
+ placement: made.placement,
207
246
  counter,
208
247
  epoch,
209
248
  total_positions: (prior?.length ?? 0) + 1,
210
- proof_url: proofUrl(
211
- config.baseUrl,
212
- digest,
213
- counter ?? undefined,
214
- mintedProof.commit?.epochId
215
- ),
249
+ proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
216
250
  });
217
- } else if (prior) {
251
+ } else if (prior && !failed.has(digest)) {
218
252
  const first = prior[0]?.proof;
219
253
  const { counter, epoch } = first ? positionOf(first) : { counter: null, epoch: null };
220
254
  outcomes.push({
221
255
  path,
222
256
  digest: toUrlSafeB64(digest),
223
257
  outcome: "on record",
258
+ artifact_digest: null,
259
+ placement: null,
224
260
  counter,
225
261
  epoch,
226
262
  total_positions: prior.length,
227
263
  proof_url: proofUrl(config.baseUrl, digest),
228
264
  });
229
265
  } else {
230
- // Neither minted nor previously on record: lost to a partial
231
- // failure. The honest outcome is "not recorded", never a claim.
232
266
  outcomes.push({
233
267
  path,
234
268
  digest: toUrlSafeB64(digest),
235
- outcome: "not recorded",
269
+ outcome: "not fused",
270
+ artifact_digest: null,
271
+ placement: null,
236
272
  counter: null,
237
273
  epoch: null,
238
- total_positions: 0,
274
+ total_positions: prior?.length ?? 0,
239
275
  proof_url: null,
276
+ error: failed.get(digest) ?? "not attempted",
240
277
  });
241
278
  }
242
279
  }
243
280
  }
244
-
245
281
  const structured = {
246
282
  results: outcomes as unknown as Record<string, unknown>[],
283
+ frames,
247
284
  summary: {
248
- recorded: outcomes.filter((o) => o.outcome === "recorded").length,
285
+ fused: outcomes.filter((o) => o.outcome === "fused").length,
249
286
  on_record: outcomes.filter((o) => o.outcome === "on record").length,
250
- not_recorded: outcomes.filter((o) => o.outcome === "not recorded").length,
287
+ not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
251
288
  },
252
289
  };
253
-
254
- if (partial) {
255
- const unrecorded = toMint.length - minted.length;
256
- const retryGuidance = again
257
- ? `Some 'not recorded' files MAY still have been recorded server-side if the failure was a timeout. Run bitgraph_check on the 'not recorded' paths first, then re-run bitgraph_record with again=true for only the paths still missing.`
258
- : `Re-run bitgraph_record with the same paths: already-recorded files will come back as 'on record' and only the missing ones will mint.`;
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.";
259
294
  return {
260
295
  isError: true,
261
- content: [
262
- {
263
- type: "text",
264
- text:
265
- `${errorText(partial.cause2)}\n` +
266
- `${minted.length} of ${toMint.length} digests were recorded before the failure (those recordings are permanent); ${unrecorded} were not. ` +
267
- `${retryGuidance}\n\n` +
268
- renderRecordMarkdown(outcomes),
269
- },
270
- ],
296
+ content: [{ type: "text", text: `${fused.size} of ${toMint.length} files were BitGraphed; ${failed.size} failed. ${guidance}\n\n${renderRecordMarkdown(outcomes)}` }],
271
297
  structuredContent: structured,
272
298
  };
273
299
  }
274
-
275
- const text =
276
- response_format === "json"
277
- ? capJson(structured).text
278
- : renderRecordMarkdown(outcomes);
300
+ const text = response_format === "json" ? capJson(structured).text : renderRecordMarkdown(outcomes);
279
301
  return ok(text, structured);
280
302
  } catch (err) {
281
303
  return fail(errorText(err));
@@ -290,8 +312,8 @@ export function buildServer(): McpServer {
290
312
  description:
291
313
  "Check whether files or digests are on record in the BitGraph ledger, without recording anything. " +
292
314
  "Accepts file paths (hashed locally; only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. " +
293
- "Returns, per item: on record or not, every causal position (a file recorded more than once has several), and the proof page URL. " +
294
- "Read-only. Use bitgraph_record to record files that turn out not to be on record.",
315
+ "Returns, per item: on_record (a recording of the exact bytes exists), fused_descendants (fused artifacts that name the bytes as their origin), every position by counter, and the proof page URL. " +
316
+ "Read-only. Use bitgraph_record to BitGraph files that turn out not to be on record.",
295
317
  inputSchema: {
296
318
  paths: z
297
319
  .array(z.string().min(1))