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