@mikeargento/bitgraph-mcp 0.5.2 → 0.7.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.
Files changed (48) hide show
  1. package/dist/__tests__/carrier.test.d.ts +2 -0
  2. package/dist/__tests__/carrier.test.d.ts.map +1 -0
  3. package/dist/__tests__/carrier.test.js +243 -0
  4. package/dist/__tests__/carrier.test.js.map +1 -0
  5. package/dist/__tests__/format.test.js +3 -3
  6. package/dist/__tests__/format.test.js.map +1 -1
  7. package/dist/__tests__/proof-beside.test.js +1 -1
  8. package/dist/__tests__/proof-beside.test.js.map +1 -1
  9. package/dist/__tests__/tools.test.js +6 -5
  10. package/dist/__tests__/tools.test.js.map +1 -1
  11. package/dist/carrier-io.d.ts +52 -0
  12. package/dist/carrier-io.d.ts.map +1 -0
  13. package/dist/carrier-io.js +91 -0
  14. package/dist/carrier-io.js.map +1 -0
  15. package/dist/format.d.ts +27 -4
  16. package/dist/format.d.ts.map +1 -1
  17. package/dist/format.js +31 -11
  18. package/dist/format.js.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/instructions.d.ts +2 -0
  22. package/dist/instructions.d.ts.map +1 -0
  23. package/dist/instructions.js +9 -0
  24. package/dist/instructions.js.map +1 -0
  25. package/dist/scan.d.ts +4 -0
  26. package/dist/scan.d.ts.map +1 -1
  27. package/dist/scan.js +43 -0
  28. package/dist/scan.js.map +1 -1
  29. package/dist/server.d.ts +3 -38
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +124 -93
  32. package/dist/server.js.map +1 -1
  33. package/package.json +6 -5
  34. package/src/__tests__/carrier.test.ts +271 -0
  35. package/src/__tests__/format.test.ts +4 -4
  36. package/src/__tests__/proof-beside.test.ts +1 -1
  37. package/src/__tests__/tools.test.ts +6 -5
  38. package/src/format.ts +51 -16
  39. package/src/index.ts +1 -1
  40. package/src/instructions.ts +11 -0
  41. package/src/server.ts +129 -155
  42. package/src/__tests__/encoding.test.ts +0 -64
  43. package/src/__tests__/scan.test.ts +0 -80
  44. package/src/api.ts +0 -170
  45. package/src/encoding.ts +0 -72
  46. package/src/scan.ts +0 -173
  47. package/src/task.ts +0 -187
  48. package/src/types.ts +0 -96
package/src/server.ts CHANGED
@@ -19,24 +19,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
19
  import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
20
20
  import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
21
21
  import { z } from "zod";
22
- import { readFile } from "node:fs/promises";
23
- import { FuseError, MAX_SET_MEMBERS, builderFor, fuse, fuseSet, fusedNamesFor, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
22
+ import { FuseError, MAX_SET_MEMBERS, type FuseSetProgress } from "@mikeargento/bitgraph";
24
23
  import {
25
- ApiError,
26
- batchCheck,
27
- configFromEnv,
28
- getProofDetail,
29
- indexSetMembers,
30
- search,
31
- type ApiConfig,
32
- } from "./api.js";
33
- import {
34
- fromUrlSafeB64,
35
- looksLikeDigest,
36
- mapConcurrent,
37
- sha256FileB64,
38
- toUrlSafeB64,
39
- } from "./encoding.js";
24
+ ApiError, batchCheck, configFromEnv, getProofDetail, indexSetMembers, search, type ApiConfig,
25
+ fromUrlSafeB64, looksLikeDigest, mapConcurrent, sha256FileB64, toUrlSafeB64,
26
+ expandPaths, scanFile, type ScannedFile,
27
+ type CarrierWindowView,
28
+ classifyPath, fuseFilePipeline, fuseSetPipeline, MAX_LOADED_BYTES,
29
+ type CarrierRow, type FuseFileFn, type FuseSetFn, type FusedSummary, type SetSummary,
30
+ SLOT_TTL_SECONDS, beginTask, decodeTaskToken, sealTask, writeProofBeside,
31
+ type BitGraphProof, type ProofDetailResponse,
32
+ } from "@mikeargento/bitgraph-sdk";
40
33
  import {
41
34
  capJson,
42
35
  positionOf,
@@ -48,11 +41,11 @@ import {
48
41
  type RecordOutcome,
49
42
  type SetOutcome,
50
43
  } from "./format.js";
51
- import { expandPaths, fusedDigestFor, scanFile, type ScannedFile } from "./scan.js";
52
- import { SLOT_TTL_SECONDS, TASK_INSTRUCTIONS, beginTask, decodeTaskToken, sealTask, writeProofBeside } from "./task.js";
53
- import type { BitGraphProof } from "./types.js";
44
+ import { TASK_INSTRUCTIONS } from "./instructions.js";
45
+
46
+ export type { FuseFileFn, FuseSetFn, FusedSummary, SetSummary } from "@mikeargento/bitgraph-sdk";
54
47
 
55
- export const SERVER_VERSION = "0.5.2";
48
+ export const SERVER_VERSION = "0.7.0";
56
49
 
57
50
  const SCAN_CONCURRENCY = 4;
58
51
  /** Paths per call; a directory counts once and expands to its files. */
@@ -62,7 +55,6 @@ export const MAX_MEMBERS = 100_000;
62
55
  /** Files one check may cover after directories expand. */
63
56
  const MAX_CHECK_FILES = 10_000;
64
57
  /** 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. */
65
- const MAX_LOADED_BYTES = 256 * 1024 * 1024;
66
58
  /** 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
59
  const MAX_SOLO_BYTES = 256 * 1024 * 1024;
68
60
  /** Rows the structured result lists in full; every fused row shares the set's position. */
@@ -71,42 +63,6 @@ export const ROW_CAP = 500;
71
63
  export const SET_INDEX_CHUNK = 2500;
72
64
 
73
65
  /** What one set yields, in the shape the tool reports; tests inject a stand-in. */
74
- export interface SetSummary {
75
- set: "set/1" | "set/2";
76
- proof: BitGraphProof;
77
- /** Standard base64: the committed artifact's digest. */
78
- artifactDigestB64: string;
79
- count: number;
80
- manifestEchoed: boolean;
81
- recovered: boolean;
82
- /** In the order the files were given. */
83
- members: Array<{
84
- index: number;
85
- /** The row's ordinal in the committed artifact. */
86
- manifestIndex: number;
87
- placement: string;
88
- originDigestB64: string;
89
- artifactDigestB64: string;
90
- /** set/2 only: the member's evidence, for the site's index. */
91
- memberProof?: unknown;
92
- }>;
93
- }
94
- export type FuseSetFn = (
95
- files: readonly ScannedFile[],
96
- config: ApiConfig,
97
- opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
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
-
110
66
  export interface ServerDeps {
111
67
  /** The set pipeline; tests inject a stand-in. Default: the core package's fuseSet() against the configured site. */
112
68
  fuseSet?: FuseSetFn;
@@ -114,76 +70,6 @@ export interface ServerDeps {
114
70
  fuseFile?: FuseFileFn;
115
71
  }
116
72
 
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
- };
141
- }
142
-
143
- /**
144
- * The default pipeline, the one the site's drop runs: one slot for the set,
145
- * every member a hashed member whose fused digest is finished from the
146
- * scan's open hasher with its placement's suffix for that slot, the set's
147
- * manifest (or, for a set/2, its root document) committed under the same
148
- * slot, and the returned proof verified against the committed artifact with
149
- * every member bound to it by digest. A file whose length changed during the
150
- * scan is a loaded member: read again when it is its turn, checked against
151
- * the scan's digest, fused in memory, hashed and released.
152
- */
153
- async function fuseSetDefault(
154
- files: readonly ScannedFile[],
155
- config: ApiConfig,
156
- opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
157
- ): Promise<SetSummary> {
158
- const members: FuseSetMember[] = files.map((f) =>
159
- f.state !== null
160
- ? { originDigest: f.originDigest, placement: f.placement, name: f.name, fusedDigest: ({ commitment }) => fusedDigestFor(f, commitment) }
161
- : { load: async () => new Uint8Array(await readFile(f.path)), originDigest: f.originDigest, placement: f.placement, name: f.name }
162
- );
163
- const r = await fuseSet(members, {
164
- set: opts.set,
165
- keepFused: false,
166
- ...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
167
- transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
168
- });
169
- return {
170
- set: r.set,
171
- proof: r.proof as unknown as BitGraphProof,
172
- artifactDigestB64: r.artifactDigestB64,
173
- count: r.members.length,
174
- manifestEchoed: r.manifestEchoed,
175
- recovered: r.recovered,
176
- members: r.members.map((m) => ({
177
- index: m.index,
178
- manifestIndex: m.manifestIndex,
179
- placement: m.placement,
180
- originDigestB64: m.originDigestB64,
181
- artifactDigestB64: m.artifactDigestB64,
182
- ...(m.memberProof !== undefined ? { memberProof: m.memberProof } : {}),
183
- })),
184
- };
185
- }
186
-
187
73
  /** Hash the given paths (bounded concurrency). Throws before any network call. */
188
74
  async function hashPaths(paths: readonly string[]): Promise<string[]> {
189
75
  const failures: string[] = [];
@@ -323,8 +209,8 @@ async function flushIndex(config: ApiConfig, report: Report): Promise<{ written:
323
209
  }
324
210
 
325
211
  export function buildServer(deps: ServerDeps = {}): McpServer {
326
- const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
327
- const fuseFilePipeline = deps.fuseFile ?? fuseFileDefault;
212
+ const runFuseSet = deps.fuseSet ?? fuseSetPipeline;
213
+ const runFuseFile = deps.fuseFile ?? fuseFilePipeline;
328
214
  const server = new McpServer(
329
215
  {
330
216
  name: "bitgraph-mcp-server",
@@ -334,6 +220,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
334
220
  instructions:
335
221
  "BitGraph gives a file's bytes a causal position in a public sequence 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
222
  "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. " +
223
+ "A BitGraphed file (one that carries its own proof, bitgraph-carrier/1) is recognized by its structure: bitgraph_check judges it offline from the proof inside and states the window, and bitgraph_record never re-mints it, because the envelope is not the recorded thing, the bytes inside are. " +
337
224
  "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.",
338
225
  }
339
226
  );
@@ -348,6 +235,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
348
235
  "Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
349
236
  "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. " +
350
237
  "Files already on record are NOT made again by default; they come back as 'on record' with their earliest position. A file can also hold a BitGraph its holder keeps, which no lookup sees. Pass again=true to make a new BitGraph regardless. " +
238
+ "A BitGraphed file (bitgraph-carrier/1, a file that carries its own proof) is never minted, with or without again: its carried proof is judged offline and reported, because the envelope is not the recorded thing, the bytes inside are. " +
351
239
  "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
240
  "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. " +
353
241
  "Use bitgraph_check instead when the user only wants to know whether files are on record.",
@@ -393,15 +281,18 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
393
281
  }
394
282
  }
395
283
 
396
- // 2. The scan: one pass per file.
284
+ // 2. The scan: one pass per file. A BitGraphed file is set aside here,
285
+ // judged from the proof it carries, and never enters the mint below.
397
286
  let scanned = 0;
398
287
  report(0, files.length, `hashing ${files.length} files`);
399
- const scans = await mapConcurrent(files, SCAN_CONCURRENCY, async (p) => {
400
- const s = await scanFile(p);
288
+ const classified = await mapConcurrent(files, SCAN_CONCURRENCY, async (p) => {
289
+ const c = await classifyPath(p);
401
290
  scanned += 1;
402
291
  report(scanned, files.length, `hashed ${scanned} of ${files.length}`);
403
- return s;
292
+ return c;
404
293
  });
294
+ const scans = classified.filter((c): c is { kind: "plain"; file: ScannedFile } => c.kind === "plain").map((c) => c.file);
295
+ const carriers = classified.filter((c): c is CarrierRow => c.kind === "carrier");
405
296
 
406
297
  // 3. Unique by content; the first path names the member, every path is reported.
407
298
  const byDigest = new Map<string, { file: ScannedFile; paths: string[] }>();
@@ -412,14 +303,22 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
412
303
  }
413
304
  const unique = [...byDigest.keys()];
414
305
 
415
- // 4. What is on record already.
306
+ // 4. What is on record already. A BitGraphed file is looked up by the
307
+ // digest of its committed bytes, never by the envelope's.
416
308
  report(0, 1, "checking BitGraph's copy");
417
- const checked = await batchCheck(config, unique.map(toUrlSafeB64));
309
+ const carrierInner = [...new Set(carriers.filter((c) => c.innerDigestB64 !== null).map((c) => c.innerDigestB64 as string))];
310
+ const lookups = [...new Set([...unique, ...carrierInner])];
311
+ const checked = lookups.length > 0 ? await batchCheck(config, lookups.map(toUrlSafeB64)) : { results: {} as Record<string, { proofs: Array<{ proof: BitGraphProof }> }> };
418
312
  const existing = new Map<string, Array<{ proof: BitGraphProof }>>();
419
313
  for (const d of unique) {
420
314
  const entry = checked.results[toUrlSafeB64(d)];
421
315
  if (entry && entry.proofs.length > 0) existing.set(d, entry.proofs);
422
316
  }
317
+ const carrierLedger = new Map<string, Array<{ proof: BitGraphProof }>>();
318
+ for (const d of carrierInner) {
319
+ const entry = checked.results[toUrlSafeB64(d)];
320
+ if (entry && entry.proofs.length > 0) carrierLedger.set(d, entry.proofs);
321
+ }
423
322
 
424
323
  // 5. The set: every fresh file (every file, with again), one call.
425
324
  const excluded = new Map<string, string>();
@@ -441,14 +340,14 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
441
340
  // One file, as a single drop on the site goes: its own slot, its own Frame.
442
341
  report(0, 1, "fusing");
443
342
  try {
444
- solo = { ...(await fuseFilePipeline(one, config)), file: one };
343
+ solo = { ...(await runFuseFile(one, config)), file: one };
445
344
  } catch (err) {
446
345
  failure = setFailureText(err);
447
346
  }
448
347
  } else if (toMint.length > 0) {
449
348
  const kind: "set/1" | "set/2" = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
450
349
  try {
451
- made = await fuseSetPipeline(toMint, config, {
350
+ made = await runFuseSet(toMint, config, {
452
351
  set: kind,
453
352
  onProgress: (p) => report(p.done, p.total, `${PHASES[p.phase]} ${p.done} of ${p.total}`),
454
353
  });
@@ -494,7 +393,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
494
393
  const m = memberOf.get(digest);
495
394
  const prior = existing.get(digest);
496
395
  for (const path of entry.paths) {
497
- const base = { path, digest: toUrlSafeB64(digest) };
396
+ const base = { path, digest: toUrlSafeB64(digest), ...(entry.file.c2pa ? { c2pa: true as const } : {}) };
498
397
  if (solo !== null && solo.file.digestB64 === digest) {
499
398
  const { counter, epoch } = positionOf(solo.proof);
500
399
  outcomes.push({
@@ -555,8 +454,36 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
555
454
  }
556
455
  }
557
456
  }
457
+ // 7b. BitGraphed files: judged from the proof inside, never minted.
458
+ for (const c of carriers) {
459
+ if (c.status !== "ok" || c.innerDigestB64 === null) {
460
+ outcomes.push({
461
+ path: c.path, digest: "", outcome: "not fused", artifact_digest: null, placement: null,
462
+ counter: null, epoch: null, member: null, member_count: null, total_positions: 0, proof_url: null,
463
+ error: c.reason ?? "unreadable BitGraphed file",
464
+ });
465
+ continue;
466
+ }
467
+ const inner = c.innerDigestB64;
468
+ const rows = carrierLedger.get(inner);
469
+ const extras = { ...(c.view ? { carrier: c.view } : {}), ...(c.c2pa ? { c2pa: true as const } : {}) };
470
+ if (rows !== undefined && rows.length > 0) {
471
+ const first = rows[0] as { proof: BitGraphProof; member?: { index: number; count: number } };
472
+ const { counter, epoch } = positionOf(first.proof);
473
+ outcomes.push({
474
+ path: c.path, digest: toUrlSafeB64(inner), outcome: "on record", artifact_digest: null, placement: null,
475
+ counter, epoch, member: first.member ? first.member.index + 1 : null, member_count: first.member ? first.member.count : null,
476
+ total_positions: rows.length, proof_url: proofUrl(config.baseUrl, inner), ...extras,
477
+ });
478
+ } else {
479
+ outcomes.push({
480
+ path: c.path, digest: toUrlSafeB64(inner), outcome: "carried", artifact_digest: null, placement: null,
481
+ counter: null, epoch: null, member: null, member_count: null, total_positions: 0, proof_url: null, ...extras,
482
+ });
483
+ }
484
+ }
558
485
  // Rows that need reading come first, so a cap drops fused rows, which all share one position.
559
- const order = { "not fused": 0, "on record": 1, fused: 2 } as const;
486
+ const order = { "not fused": 0, "on record": 1, carried: 2, fused: 3 } as const;
560
487
  outcomes.sort((a, b) => order[a.outcome] - order[b.outcome]);
561
488
  const listed = outcomes.slice(0, ROW_CAP);
562
489
  const omitted = outcomes.length - listed.length;
@@ -565,6 +492,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
565
492
  directories: expanded.directories,
566
493
  fused: outcomes.filter((o) => o.outcome === "fused").length,
567
494
  on_record: outcomes.filter((o) => o.outcome === "on record").length,
495
+ carried: outcomes.filter((o) => o.outcome === "carried").length,
568
496
  not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
569
497
  };
570
498
  const structured = {
@@ -690,6 +618,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
690
618
  "Check whether files or digests are on record in BitGraph's copy, without recording anything. " +
691
619
  `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. ` +
692
620
  "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. " +
621
+ "A BitGraphed file (bitgraph-carrier/1) is recognized by structure: the proof it carries is verified OFFLINE here (signatures, floor identity, block-header witnesses) and its verdict and window are reported, and the lookup uses the committed bytes inside, never the envelope. " +
693
622
  "Read-only. Use bitgraph_record to BitGraph files that turn out not to be on record.",
694
623
  inputSchema: {
695
624
  paths: z
@@ -714,11 +643,20 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
714
643
  async ({ paths, digests, response_format }) => {
715
644
  const config = configFromEnv();
716
645
  try {
717
- const inputs: Array<{ label: string; standardDigest: string }> = [];
646
+ const inputs: Array<{ label: string; standardDigest: string; carrier?: CarrierWindowView; c2pa?: boolean; note?: string }> = [];
718
647
  if (paths && paths.length > 0) {
719
648
  const { files } = await expandPaths(paths, MAX_CHECK_FILES);
720
- const hashed = await hashPaths(files);
721
- hashed.forEach((d, i) => inputs.push({ label: files[i] as string, standardDigest: d }));
649
+ const classified = await mapConcurrent(files, SCAN_CONCURRENCY, classifyPath);
650
+ classified.forEach((c, i) => {
651
+ const label = files[i] as string;
652
+ if (c.kind === "plain") {
653
+ inputs.push({ label, standardDigest: c.file.digestB64, ...(c.file.c2pa ? { c2pa: true } : {}) });
654
+ } else if (c.status === "ok" && c.innerDigestB64 !== null) {
655
+ inputs.push({ label, standardDigest: c.innerDigestB64, ...(c.view ? { carrier: c.view } : {}), ...(c.c2pa ? { c2pa: true } : {}) });
656
+ } else {
657
+ inputs.push({ label, standardDigest: "", note: c.reason ?? "unreadable BitGraphed file" });
658
+ }
659
+ });
722
660
  }
723
661
  for (const d of digests ?? []) {
724
662
  const trimmed = d.trim();
@@ -736,23 +674,24 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
736
674
  return fail(`Error: ${inputs.length} items; check at most ${MAX_CHECK_FILES} at a time.`);
737
675
  }
738
676
 
739
- const checked = await batchCheck(
740
- config,
741
- inputs.map((i) => toUrlSafeB64(i.standardDigest))
742
- );
677
+ const lookable = [...new Set(inputs.filter((i) => i.standardDigest !== "").map((i) => toUrlSafeB64(i.standardDigest)))];
678
+ const checked = lookable.length > 0 ? await batchCheck(config, lookable) : { results: {} };
743
679
 
744
680
  const outcomes: CheckOutcome[] = inputs.map((input) => {
745
- const entry = checked.results[toUrlSafeB64(input.standardDigest)];
681
+ const entry = input.standardDigest === "" ? undefined : checked.results[toUrlSafeB64(input.standardDigest)];
746
682
  const proofs = entry?.proofs ?? [];
747
683
  const positions = proofs.map((p) => ({ ...positionOf(p.proof), ...(p.member ? { member: p.member } : {}) }));
748
684
  return {
749
685
  input: input.label,
750
- digest: toUrlSafeB64(input.standardDigest),
686
+ digest: input.standardDigest === "" ? "" : toUrlSafeB64(input.standardDigest),
751
687
  // A fused descendant that names these bytes as origin is not a recording of them.
752
688
  // The original and the new file made from it find the same proof.
753
689
  on_record: proofs.length > 0,
754
690
  positions,
755
691
  proof_url: proofs.length > 0 ? proofUrl(config.baseUrl, input.standardDigest) : null,
692
+ ...(input.carrier ? { carrier: input.carrier } : {}),
693
+ ...(input.c2pa ? { c2pa: true } : {}),
694
+ ...(input.note !== undefined ? { note: input.note } : {}),
756
695
  };
757
696
  });
758
697
 
@@ -778,7 +717,8 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
778
717
  title: "Get a BitGraph proof",
779
718
  description:
780
719
  "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 " +
781
- "('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). " +
720
+ "(after the floor block's time; before the ANCHORING of the later block, a position bound, never that block's mine time). Look up by digest (base64, either form), by BitGraph number (e.g. '4523' or '#4,523', current epoch), or by file path (hashed locally). " +
721
+ "A path to a BitGraphed file (bitgraph-carrier/1) looks up the committed bytes inside it, and when the ledger has no row the proof the file carries answers offline. " +
782
722
  "Exactly one of digest, number, or path is required. Read-only. " +
783
723
  "markdown returns a summary; json returns the full proof object with positions and anchor window.",
784
724
  inputSchema: {
@@ -822,6 +762,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
822
762
 
823
763
  let urlSafeDigest: string;
824
764
  let selCounter = counter;
765
+ let carrierRow: CarrierRow | null = null;
825
766
  if (number !== undefined) {
826
767
  const result = await search(config, number);
827
768
  if (!result.found || result.digest === undefined) {
@@ -832,8 +773,16 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
832
773
  urlSafeDigest = result.digest;
833
774
  if (selCounter === undefined && result.counter != null) selCounter = result.counter;
834
775
  } else if (path !== undefined) {
835
- const hashed = await hashPaths([path]);
836
- urlSafeDigest = toUrlSafeB64(hashed[0] as string);
776
+ const c = await classifyPath(path);
777
+ if (c.kind === "carrier") {
778
+ if (c.status !== "ok" || c.innerDigestB64 === null) {
779
+ return fail(`Error: ${c.reason ?? "unreadable BitGraphed file"}`);
780
+ }
781
+ carrierRow = c;
782
+ urlSafeDigest = toUrlSafeB64(c.innerDigestB64);
783
+ } else {
784
+ urlSafeDigest = toUrlSafeB64(c.file.digestB64);
785
+ }
837
786
  } else {
838
787
  const trimmed = (digest as string).trim();
839
788
  if (!looksLikeDigest(trimmed)) {
@@ -848,16 +797,41 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
848
797
  const selEpoch = epoch !== undefined ? toUrlSafeB64(fromUrlSafeB64(epoch)) : undefined;
849
798
  const detail = await getProofDetail(config, urlSafeDigest, selCounter, selEpoch);
850
799
  if (detail.proofs.length === 0) {
800
+ if (carrierRow !== null && carrierRow.view !== null && carrierRow.proof !== null) {
801
+ // The ledger has no row, and the file answers for itself.
802
+ const v = carrierRow.view;
803
+ const synth: ProofDetailResponse = {
804
+ proofs: [{ proof: carrierRow.proof as BitGraphProof }],
805
+ positions: [],
806
+ causalWindow: {
807
+ anchorBefore: v.not_before
808
+ ? { blockNumber: v.not_before.block, blockHash: v.not_before.hash, etherscanUrl: `https://etherscan.io/block/${v.not_before.block}`, blockTime: v.not_before.time }
809
+ : null,
810
+ anchorAfter: v.not_after
811
+ ? { blockNumber: v.not_after.block, blockHash: v.not_after.hash, etherscanUrl: `https://etherscan.io/block/${v.not_after.block}`, blockTime: v.not_after.time }
812
+ : null,
813
+ },
814
+ };
815
+ const offlineNote = `Judged offline from the proof this BitGraphed file carries: carried proof ${v.verdict}${v.not_after === null ? ", closing anchor NOT FETCHED" : ""}. Not found in this ledger.`;
816
+ const structured = { ...synth, carrier: v } as unknown as Record<string, unknown>;
817
+ if (response_format === "json") return ok(capJson(structured).text, structured);
818
+ return ok(`${renderProofMarkdown(synth, config.baseUrl)}\n\n${offlineNote}`, structured);
819
+ }
851
820
  return fail(
852
821
  `Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to make a BitGraph of the file.`
853
822
  );
854
823
  }
855
824
 
856
825
  if (response_format === "json") {
857
- const capped = capJson(detail);
858
- return ok(capped.text, detail as unknown as Record<string, unknown>);
826
+ const withCarrier = carrierRow?.view ? ({ ...detail, carrier: carrierRow.view } as unknown) : detail;
827
+ const capped = capJson(withCarrier);
828
+ return ok(capped.text, withCarrier as Record<string, unknown>);
859
829
  }
860
- return ok(renderProofMarkdown(detail, config.baseUrl), detail as unknown as Record<string, unknown>);
830
+ const md = renderProofMarkdown(detail, config.baseUrl);
831
+ const carrierNote = carrierRow?.view
832
+ ? `\n\nThis file carries its own proof (carried proof ${carrierRow.view.verdict}, judged offline).`
833
+ : "";
834
+ return ok(`${md}${carrierNote}`, detail as unknown as Record<string, unknown>);
861
835
  } catch (err) {
862
836
  return fail(errorText(err));
863
837
  }
@@ -1,64 +0,0 @@
1
- // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
-
3
- import { test } from "node:test";
4
- import { strict as assert } from "node:assert";
5
- import { createHash } from "node:crypto";
6
- import { writeFile, mkdtemp } from "node:fs/promises";
7
- import { tmpdir } from "node:os";
8
- import { join } from "node:path";
9
- import {
10
- fromUrlSafeB64,
11
- looksLikeDigest,
12
- mapConcurrent,
13
- sha256FileB64,
14
- toUrlSafeB64,
15
- } from "../encoding.js";
16
-
17
- // A real 32-byte digest with + and / to exercise both substitutions.
18
- const STANDARD = createHash("sha256").update("bitgraph").digest("base64");
19
-
20
- test("url-safe round trip preserves the digest", () => {
21
- const safe = toUrlSafeB64(STANDARD);
22
- assert.ok(!safe.includes("+") && !safe.includes("/") && !safe.includes("="));
23
- assert.equal(fromUrlSafeB64(safe), STANDARD);
24
- });
25
-
26
- test("fromUrlSafeB64 is a no-op on standard base64", () => {
27
- assert.equal(fromUrlSafeB64(STANDARD), STANDARD);
28
- });
29
-
30
- test("looksLikeDigest accepts both forms, rejects junk", () => {
31
- assert.ok(looksLikeDigest(STANDARD));
32
- assert.ok(looksLikeDigest(toUrlSafeB64(STANDARD)));
33
- assert.ok(!looksLikeDigest("not-a-digest"));
34
- assert.ok(!looksLikeDigest(""));
35
- assert.ok(!looksLikeDigest(Buffer.alloc(16).toString("base64"))); // 16 bytes, not 32
36
- });
37
-
38
- test("sha256FileB64 matches crypto over the same bytes", async () => {
39
- const dir = await mkdtemp(join(tmpdir(), "bitgraph-mcp-"));
40
- const file = join(dir, "sample.bin");
41
- const bytes = Buffer.from("BitGraph proves causal ordering.\n");
42
- await writeFile(file, bytes);
43
- const expected = createHash("sha256").update(bytes).digest("base64");
44
- assert.equal(await sha256FileB64(file), expected);
45
- });
46
-
47
- test("sha256FileB64 rejects directories and missing files", async () => {
48
- await assert.rejects(() => sha256FileB64(tmpdir()), /Not a regular file/);
49
- await assert.rejects(() => sha256FileB64(join(tmpdir(), "definitely-missing-9x7")));
50
- });
51
-
52
- test("mapConcurrent preserves order and honors the limit", async () => {
53
- let inFlight = 0;
54
- let peak = 0;
55
- const out = await mapConcurrent([1, 2, 3, 4, 5, 6, 7, 8], 3, async (n) => {
56
- inFlight += 1;
57
- peak = Math.max(peak, inFlight);
58
- await new Promise((r) => setTimeout(r, 5));
59
- inFlight -= 1;
60
- return n * 2;
61
- });
62
- assert.deepEqual(out, [2, 4, 6, 8, 10, 12, 14, 16]);
63
- assert.ok(peak <= 3, `peak concurrency ${peak} exceeded 3`);
64
- });
@@ -1,80 +0,0 @@
1
- // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
-
3
- /**
4
- * The scan's two promises: the digest is the file's own, and a state
5
- * finished with the placement's suffix for a slot is the hash of the
6
- * placement's own build for that slot. Both are pinned across sizes on both
7
- * sides of every boundary that matters (the 64-byte sniff, the tar block,
8
- * the 64 KB stream chunk) for both placements the scan makes.
9
- */
10
-
11
- import { test, before } from "node:test";
12
- import { strict as assert } from "node:assert";
13
- import { createHash, randomBytes } from "node:crypto";
14
- import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import { placementForBytes } from "@mikeargento/bitgraph";
18
- import { getPlacement } from "@mikeargento/bitgraph-verify";
19
- import { expandPaths, fusedDigestFor, scanFile } from "../scan.js";
20
-
21
- let root = "";
22
- before(async () => {
23
- root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-scan-"));
24
- });
25
-
26
- const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
27
- const SIZES = [0, 1, 8, 63, 64, 65, 511, 512, 513, 1024, 65_535, 65_536, 65_537, 200_000];
28
-
29
- test("a scan's digest is the file's own and its state finishes to the placement's own build", async () => {
30
- let checked = 0;
31
- for (const size of SIZES) {
32
- for (const kind of ["png", "txt"] as const) {
33
- const lead = kind === "png" ? PNG : Buffer.from("txt:");
34
- const bytes = Buffer.concat([lead, randomBytes(Math.max(0, size - lead.length))]).subarray(0, size);
35
- const path = join(root, `${kind}-${size}.bin`);
36
- await writeFile(path, bytes);
37
- const s = await scanFile(path);
38
- assert.equal(s.size, size);
39
- assert.equal(s.digestB64, createHash("sha256").update(bytes).digest("base64"), `${kind} ${size}: digest`);
40
- assert.equal(s.placement, placementForBytes(new Uint8Array(bytes)), `${kind} ${size}: the core's own placement choice`);
41
- assert.ok(s.state !== null, "a file that did not change keeps its state");
42
- const placement = getPlacement(s.placement);
43
- if (placement === undefined) throw new Error("placement missing");
44
- // Two slots, both finished from the same saved state: the copy leaves the state intact.
45
- for (const commitment of [randomBytes(32), randomBytes(32)]) {
46
- const built = placement.build({ original: new Uint8Array(bytes), originDigest: s.originDigest, commitment: new Uint8Array(commitment) });
47
- const expected = createHash("sha256").update(built).digest();
48
- assert.deepEqual(Buffer.from(fusedDigestFor(s, new Uint8Array(commitment))), expected, `${kind} ${size}: state finished == sha256(build)`);
49
- checked += 1;
50
- }
51
- }
52
- }
53
- assert.equal(checked, SIZES.length * 4);
54
- });
55
-
56
- test("expandPaths: files, directories, hidden entries, links, duplicates, limits, missing paths", async () => {
57
- const lone = join(root, "lone.txt");
58
- await writeFile(lone, "lone");
59
- const dir = join(root, "tree");
60
- await mkdir(join(dir, "sub", ".secret"), { recursive: true });
61
- await writeFile(join(dir, "a.txt"), "a");
62
- await writeFile(join(dir, ".DS_Store"), "junk");
63
- await writeFile(join(dir, "sub", "b.txt"), "b");
64
- await writeFile(join(dir, "sub", ".secret", "c.txt"), "c");
65
- await symlink(join(dir, "a.txt"), join(dir, "link.txt"));
66
-
67
- const e = await expandPaths([lone, dir], 100);
68
- assert.deepEqual(e.files, [lone, join(dir, "a.txt"), join(dir, "sub", "b.txt")]);
69
- assert.equal(e.directories, 1);
70
-
71
- const twice = await expandPaths([lone, lone, dir, join(dir, "a.txt")], 100);
72
- assert.deepEqual(twice.files, [lone, join(dir, "a.txt"), join(dir, "sub", "b.txt")], "the same file twice is one file");
73
-
74
- await assert.rejects(expandPaths([dir], 1), /more than 1 files/);
75
- await assert.rejects(expandPaths([lone, join(root, "missing.txt")], 100), (err: Error) => {
76
- assert.ok(err.message.includes("nothing was BitGraphed"));
77
- assert.ok(err.message.includes(join(root, "missing.txt")));
78
- return true;
79
- });
80
- });