@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.
- package/dist/__tests__/carrier.test.d.ts +2 -0
- package/dist/__tests__/carrier.test.d.ts.map +1 -0
- package/dist/__tests__/carrier.test.js +243 -0
- package/dist/__tests__/carrier.test.js.map +1 -0
- package/dist/__tests__/format.test.js +3 -3
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/proof-beside.test.js +1 -1
- package/dist/__tests__/proof-beside.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +6 -5
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/carrier-io.d.ts +52 -0
- package/dist/carrier-io.d.ts.map +1 -0
- package/dist/carrier-io.js +91 -0
- package/dist/carrier-io.js.map +1 -0
- package/dist/format.d.ts +27 -4
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +31 -11
- package/dist/format.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +9 -0
- package/dist/instructions.js.map +1 -0
- package/dist/scan.d.ts +4 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +43 -0
- package/dist/scan.js.map +1 -1
- package/dist/server.d.ts +3 -38
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +124 -93
- package/dist/server.js.map +1 -1
- package/package.json +6 -5
- package/src/__tests__/carrier.test.ts +271 -0
- package/src/__tests__/format.test.ts +4 -4
- package/src/__tests__/proof-beside.test.ts +1 -1
- package/src/__tests__/tools.test.ts +6 -5
- package/src/format.ts +51 -16
- package/src/index.ts +1 -1
- package/src/instructions.ts +11 -0
- package/src/server.ts +129 -155
- package/src/__tests__/encoding.test.ts +0 -64
- package/src/__tests__/scan.test.ts +0 -80
- package/src/api.ts +0 -170
- package/src/encoding.ts +0 -72
- package/src/scan.ts +0 -173
- package/src/task.ts +0 -187
- 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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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
|
|
327
|
-
const
|
|
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
|
|
400
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
721
|
-
|
|
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
|
|
740
|
-
|
|
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
|
-
"('
|
|
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
|
|
836
|
-
|
|
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
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
});
|