@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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BitGraphed files through the MCP tools, end to end: a real client over an
|
|
5
|
+
* in-memory transport, a mock bitgraph.ing, and REAL carriers built from the
|
|
6
|
+
* verify package's fixture material (real proofs, real anchors, real block
|
|
7
|
+
* headers; nothing minted, no network). The two rules under test:
|
|
8
|
+
*
|
|
9
|
+
* 1. The lookups use the digest of the COMMITTED bytes inside, never the
|
|
10
|
+
* envelope's, and the carried proof is judged offline (verdict, window).
|
|
11
|
+
* 2. bitgraph_record NEVER mints carrier bytes: the envelope is not the
|
|
12
|
+
* recorded thing. The fake pipelines fail the test if a carrier reaches
|
|
13
|
+
* them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test, before, after } from "node:test";
|
|
17
|
+
import { strict as assert } from "node:assert";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { createServer, type Server } from "node:http";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { writeFile, mkdtemp } from "node:fs/promises";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
26
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
27
|
+
import {
|
|
28
|
+
buildCarrier,
|
|
29
|
+
type CarrierPayload,
|
|
30
|
+
type CarrierProof,
|
|
31
|
+
type CarrierWitness,
|
|
32
|
+
} from "@mikeargento/bitgraph-verify";
|
|
33
|
+
import { buildServer, type FuseFileFn, type FuseSetFn } from "../server.js";
|
|
34
|
+
import { toUrlSafeB64 } from "@mikeargento/bitgraph-sdk";
|
|
35
|
+
import { sniffC2paBytes, scanFile } from "@mikeargento/bitgraph-sdk";
|
|
36
|
+
|
|
37
|
+
const fixtures = fileURLToPath(new URL("../../../verify/src/__tests__/fixtures/carrier/", import.meta.url));
|
|
38
|
+
const readJson = <T>(name: string): T => JSON.parse(readFileSync(join(fixtures, name), "utf-8")) as T;
|
|
39
|
+
|
|
40
|
+
let mock: Server;
|
|
41
|
+
let completePath = ""; // demo2: floor + ceiling inside
|
|
42
|
+
let undevPath = ""; // demo1: floor inside, ceiling unfetched
|
|
43
|
+
let corruptPath = ""; // demo2 carrier with a broken payload byte
|
|
44
|
+
let plainPath = ""; // an ordinary file that should still mint
|
|
45
|
+
let c2paPath = ""; // an ordinary file carrying a synthetic C2PA marker
|
|
46
|
+
let innerDigest2 = ""; // standard b64 of demo2's committed bytes
|
|
47
|
+
let innerDigest1 = "";
|
|
48
|
+
const EPOCH = createHash("sha256").update("carrier-epoch").digest("base64");
|
|
49
|
+
|
|
50
|
+
before(async () => {
|
|
51
|
+
const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-carrier-"));
|
|
52
|
+
|
|
53
|
+
const demo2 = new Uint8Array(readFileSync(join(fixtures, "demo2.txt")));
|
|
54
|
+
const demo1 = new Uint8Array(readFileSync(join(fixtures, "demo1.txt")));
|
|
55
|
+
innerDigest2 = createHash("sha256").update(demo2).digest("base64");
|
|
56
|
+
innerDigest1 = createHash("sha256").update(demo1).digest("base64");
|
|
57
|
+
|
|
58
|
+
const complete: CarrierPayload = {
|
|
59
|
+
carrier: "bitgraph-carrier/1",
|
|
60
|
+
proof: readJson<CarrierProof>("demo2.proof.json"),
|
|
61
|
+
floor: { status: "present", anchor: readJson<CarrierProof>("floor2.anchor.json"), witness: readJson<CarrierWitness>("floor2.witness.json") },
|
|
62
|
+
ceiling: { status: "present", basis: "counter-order", anchor: readJson<CarrierProof>("ceiling2.anchor.json"), witness: readJson<CarrierWitness>("ceiling2.witness.json") },
|
|
63
|
+
};
|
|
64
|
+
const undeveloped: CarrierPayload = {
|
|
65
|
+
carrier: "bitgraph-carrier/1",
|
|
66
|
+
proof: readJson<CarrierProof>("demo1.proof.json"),
|
|
67
|
+
floor: { status: "present", anchor: readJson<CarrierProof>("floor1.anchor.json"), witness: readJson<CarrierWitness>("floor1.witness.json") },
|
|
68
|
+
ceiling: { status: "unfetched" },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
completePath = join(root, "photo.bitgraph.txt");
|
|
72
|
+
await writeFile(completePath, buildCarrier(demo2, complete));
|
|
73
|
+
undevPath = join(root, "doc.bitgraph.txt");
|
|
74
|
+
await writeFile(undevPath, buildCarrier(demo1, undeveloped));
|
|
75
|
+
|
|
76
|
+
// Corrupt: the frame intact (tail magic still matches), one byte of the
|
|
77
|
+
// JSON payload broken, so the block is found but does not parse.
|
|
78
|
+
const corrupt = Buffer.from(buildCarrier(demo2, complete));
|
|
79
|
+
corrupt[demo2.length + 12] = 0x58; // the payload's first byte, "{" -> "X"
|
|
80
|
+
corruptPath = join(root, "broken.bitgraph.txt");
|
|
81
|
+
await writeFile(corruptPath, corrupt);
|
|
82
|
+
|
|
83
|
+
plainPath = join(root, "fresh.txt");
|
|
84
|
+
await writeFile(plainPath, "fresh bytes that should mint");
|
|
85
|
+
|
|
86
|
+
// A JUMBF description box shape: "jumd", a 16-byte type, a toggle, the "c2pa" label.
|
|
87
|
+
const c2pa = Buffer.concat([
|
|
88
|
+
Buffer.from("leading bytes "),
|
|
89
|
+
Buffer.from("jumd"),
|
|
90
|
+
Buffer.alloc(17, 3),
|
|
91
|
+
Buffer.from("c2pa\0and the rest of the file"),
|
|
92
|
+
]);
|
|
93
|
+
c2paPath = join(root, "credentialed.bin");
|
|
94
|
+
await writeFile(c2paPath, c2pa);
|
|
95
|
+
|
|
96
|
+
mock = createServer((req, res) => {
|
|
97
|
+
let raw = "";
|
|
98
|
+
req.on("data", (d) => (raw += d));
|
|
99
|
+
req.on("end", () => {
|
|
100
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
101
|
+
const body = raw.length > 0 ? (JSON.parse(raw) as unknown) : null;
|
|
102
|
+
const send = (status: number, payload: unknown) => {
|
|
103
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify(payload));
|
|
105
|
+
};
|
|
106
|
+
if (url.pathname === "/api/proofs/batch") {
|
|
107
|
+
const digests = (body as { digests: string[] }).digests;
|
|
108
|
+
const results: Record<string, { proofs: Array<{ proof: unknown; member?: unknown }> }> = {};
|
|
109
|
+
for (const d of digests) {
|
|
110
|
+
// Only demo2's COMMITTED bytes are in this ledger; the envelope digests never are.
|
|
111
|
+
results[d] =
|
|
112
|
+
d === toUrlSafeB64(innerDigest2)
|
|
113
|
+
? { proofs: [{ proof: { artifact: { digestB64: innerDigest2 }, commit: { counter: "2874", epochId: EPOCH } } }] }
|
|
114
|
+
: { proofs: [] };
|
|
115
|
+
}
|
|
116
|
+
send(200, { results });
|
|
117
|
+
} else if (url.pathname.startsWith("/api/proofs/digest/")) {
|
|
118
|
+
const digest = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
119
|
+
if (digest === toUrlSafeB64(innerDigest2)) {
|
|
120
|
+
send(200, {
|
|
121
|
+
proofs: [{ proof: { artifact: { digestB64: innerDigest2 }, commit: { counter: "2874", epochId: EPOCH } } }],
|
|
122
|
+
positions: [{ counter: "2874", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null }],
|
|
123
|
+
causalWindow: null,
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
send(200, { proofs: [] });
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
send(404, { error: `unexpected ${url.pathname}` });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
await new Promise<void>((resolve) => mock.listen(0, "127.0.0.1", resolve));
|
|
134
|
+
const address = mock.address();
|
|
135
|
+
if (address === null || typeof address === "string") throw new Error("no port");
|
|
136
|
+
process.env["BITGRAPH_API_URL"] = `http://127.0.0.1:${address.port}`;
|
|
137
|
+
process.env["BITGRAPH_API_KEY"] = "test-key-123";
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
after(() => {
|
|
141
|
+
mock.close();
|
|
142
|
+
delete process.env["BITGRAPH_API_URL"];
|
|
143
|
+
delete process.env["BITGRAPH_API_KEY"];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** The pipelines fail the test when carrier bytes reach them. */
|
|
147
|
+
const fusedNames: string[] = [];
|
|
148
|
+
const guardFuseFile: FuseFileFn = async (file) => {
|
|
149
|
+
assert.ok(!file.name.includes(".bitgraph."), `a BitGraphed file reached the mint: ${file.name}`);
|
|
150
|
+
fusedNames.push(file.name);
|
|
151
|
+
const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
|
|
152
|
+
return {
|
|
153
|
+
proof: { version: "bitgraph/1", artifact: { digestB64: artifactDigestB64 }, commit: { counter: "500", epochId: EPOCH } },
|
|
154
|
+
frame: { type: "bitgraph-fuse/1" },
|
|
155
|
+
placement: file.placement,
|
|
156
|
+
artifactDigestB64,
|
|
157
|
+
originDigestB64: file.digestB64,
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
const guardFuseSet: FuseSetFn = async (files) => {
|
|
161
|
+
for (const f of files) assert.ok(!f.name.includes(".bitgraph."), `a BitGraphed file reached the set mint: ${f.name}`);
|
|
162
|
+
throw new Error("no set expected in these tests");
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async function connectedClient(): Promise<Client> {
|
|
166
|
+
const server = buildServer({ fuseSet: guardFuseSet, fuseFile: guardFuseFile });
|
|
167
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
168
|
+
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
169
|
+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
170
|
+
return client;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const textOf = (result: unknown): string => ((result as { content: Array<{ text: string }> }).content[0] as { text: string }).text;
|
|
174
|
+
const structuredOf = <T>(result: unknown): T => (result as { structuredContent: T }).structuredContent;
|
|
175
|
+
|
|
176
|
+
test("check: a BitGraphed file is judged offline and looked up by its committed bytes", async () => {
|
|
177
|
+
const client = await connectedClient();
|
|
178
|
+
const result = await client.callTool({ name: "bitgraph_check", arguments: { paths: [completePath, undevPath, corruptPath, c2paPath] } });
|
|
179
|
+
const text = textOf(result);
|
|
180
|
+
const { results } = structuredOf<{ results: Array<Record<string, unknown>> }>(result);
|
|
181
|
+
|
|
182
|
+
const complete = results.find((r) => r["input"] === completePath) as Record<string, unknown>;
|
|
183
|
+
assert.equal(complete["on_record"], true);
|
|
184
|
+
assert.equal(complete["digest"], toUrlSafeB64(innerDigest2), "the lookup digest is the committed bytes', not the envelope's");
|
|
185
|
+
const view = complete["carrier"] as { verdict: string; not_before: { block: number }; not_after: { block: number } };
|
|
186
|
+
assert.equal(view.verdict, "TRUE");
|
|
187
|
+
assert.ok(view.not_before.block > 0 && view.not_after.block > view.not_before.block);
|
|
188
|
+
|
|
189
|
+
const undev = results.find((r) => r["input"] === undevPath) as Record<string, unknown>;
|
|
190
|
+
assert.equal(undev["on_record"], false);
|
|
191
|
+
const undevView = undev["carrier"] as { verdict: string; not_after: unknown; ceiling: string };
|
|
192
|
+
assert.equal(undevView.verdict, "TRUE");
|
|
193
|
+
assert.equal(undevView.not_after, null);
|
|
194
|
+
assert.equal(undevView.ceiling, "unfetched");
|
|
195
|
+
assert.ok(text.includes("not in this ledger"), "a foreign carrier is stated, not called unrecorded");
|
|
196
|
+
assert.ok(text.includes("NOT FETCHED"), "an absent ceiling is stated in those words");
|
|
197
|
+
|
|
198
|
+
const corrupt = results.find((r) => r["input"] === corruptPath) as Record<string, unknown>;
|
|
199
|
+
assert.equal(corrupt["on_record"], false);
|
|
200
|
+
assert.ok(String(corrupt["note"]).includes("unreadable"), "a corrupt block is a stated condition");
|
|
201
|
+
assert.ok(text.includes("not judged"), "a corrupt carrier is not judged, not 'not on record'");
|
|
202
|
+
|
|
203
|
+
const credentialed = results.find((r) => r["input"] === c2paPath) as Record<string, unknown>;
|
|
204
|
+
assert.equal(credentialed["c2pa"], true);
|
|
205
|
+
assert.ok(text.includes("Content Credentials (C2PA) detected"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("record: carrier bytes never mint; the plain file still does", async () => {
|
|
209
|
+
fusedNames.length = 0;
|
|
210
|
+
const client = await connectedClient();
|
|
211
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [completePath, undevPath, plainPath] } });
|
|
212
|
+
const text = textOf(result);
|
|
213
|
+
const { summary, results } = structuredOf<{ summary: Record<string, number>; results: Array<Record<string, unknown>> }>(result);
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(fusedNames, ["fresh.txt"], "exactly the plain file reached the mint");
|
|
216
|
+
assert.equal(summary["fused"], 1);
|
|
217
|
+
assert.equal(summary["on_record"], 1, "the ledger-confirmed carrier reports as on record");
|
|
218
|
+
assert.equal(summary["carried"], 1, "the foreign carrier reports as carried, nothing minted");
|
|
219
|
+
assert.equal(summary["not_fused"], 0);
|
|
220
|
+
|
|
221
|
+
const onRecord = results.find((r) => r["outcome"] === "on record") as Record<string, unknown>;
|
|
222
|
+
assert.equal(onRecord["digest"], toUrlSafeB64(innerDigest2));
|
|
223
|
+
assert.equal((onRecord["carrier"] as { verdict: string }).verdict, "TRUE");
|
|
224
|
+
|
|
225
|
+
const carried = results.find((r) => r["outcome"] === "carried") as Record<string, unknown>;
|
|
226
|
+
assert.equal(carried["digest"], toUrlSafeB64(innerDigest1));
|
|
227
|
+
assert.ok(text.includes("nothing minted"), "the markdown states the envelope rule");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("record: a corrupt carrier is refused loudly and never minted", async () => {
|
|
231
|
+
fusedNames.length = 0;
|
|
232
|
+
const client = await connectedClient();
|
|
233
|
+
const result = await client.callTool({ name: "bitgraph_record", arguments: { paths: [corruptPath] } });
|
|
234
|
+
assert.equal((result as { isError?: boolean }).isError, true);
|
|
235
|
+
assert.deepEqual(fusedNames, [], "nothing reached the mint");
|
|
236
|
+
assert.ok(textOf(result).includes("unreadable"), "the reason names the corrupt block");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("get_proof: a path to a foreign BitGraphed file answers offline from the proof inside", async () => {
|
|
240
|
+
const client = await connectedClient();
|
|
241
|
+
const result = await client.callTool({ name: "bitgraph_get_proof", arguments: { path: undevPath } });
|
|
242
|
+
const text = textOf(result);
|
|
243
|
+
assert.ok(text.includes("Judged offline from the proof this BitGraphed file carries"), text);
|
|
244
|
+
assert.ok(text.includes("carried proof TRUE"));
|
|
245
|
+
assert.ok(text.includes("NOT FETCHED"));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("get_proof: a ledger-known BitGraphed file uses the ledger and states the carried verdict", async () => {
|
|
249
|
+
const client = await connectedClient();
|
|
250
|
+
const result = await client.callTool({ name: "bitgraph_get_proof", arguments: { path: completePath } });
|
|
251
|
+
const text = textOf(result);
|
|
252
|
+
assert.ok(text.includes("#2874"), text);
|
|
253
|
+
assert.ok(text.includes("carries its own proof (carried proof TRUE"), text);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("c2pa sniff: the pair must be jumd then c2pa, near, in that order", async () => {
|
|
257
|
+
assert.equal(sniffC2paBytes(Buffer.concat([Buffer.from("jumd"), Buffer.alloc(17), Buffer.from("c2pa")])), true);
|
|
258
|
+
assert.equal(sniffC2paBytes(Buffer.from("c2pa then jumd the wrong way round")), false);
|
|
259
|
+
assert.equal(sniffC2paBytes(Buffer.from("jumd alone")), false);
|
|
260
|
+
assert.equal(sniffC2paBytes(Buffer.concat([Buffer.from("jumd"), Buffer.alloc(200), Buffer.from("c2pa")])), false, "too far apart");
|
|
261
|
+
// Streaming: the pair split across a 64 KiB read boundary is still seen.
|
|
262
|
+
const size = 64 * 1024;
|
|
263
|
+
const split = Buffer.alloc(size + 40);
|
|
264
|
+
Buffer.from("jumd").copy(split, size - 2);
|
|
265
|
+
Buffer.from("c2pa").copy(split, size + 19);
|
|
266
|
+
const root = await mkdtemp(join(tmpdir(), "bitgraph-mcp-c2pa-"));
|
|
267
|
+
const p = join(root, "split.bin");
|
|
268
|
+
await writeFile(p, split);
|
|
269
|
+
const scanned = await scanFile(p);
|
|
270
|
+
assert.equal(scanned.c2pa, true, "a pair straddling the chunk boundary is detected");
|
|
271
|
+
});
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import { test } from "node:test";
|
|
4
4
|
import { strict as assert } from "node:assert";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { toUrlSafeB64 } from "
|
|
6
|
+
import { toUrlSafeB64 } from "@mikeargento/bitgraph-sdk";
|
|
7
7
|
import { capJson, proofUrl, renderProofMarkdown, renderRecordMarkdown } from "../format.js";
|
|
8
|
-
import type { ProofDetailResponse } from "
|
|
8
|
+
import type { ProofDetailResponse } from "@mikeargento/bitgraph-sdk";
|
|
9
9
|
|
|
10
10
|
const DIGEST = createHash("sha256").update("bitgraph").digest("base64");
|
|
11
11
|
const EPOCH = createHash("sha256").update("epoch").digest("base64");
|
|
@@ -62,7 +62,7 @@ test("record markdown names the set once and its members by row", () => {
|
|
|
62
62
|
assert.ok(waiting.includes("The set proof beside the originals is the record either way."), waiting);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test("proof markdown states the
|
|
65
|
+
test("proof markdown states the floor in time and the ceiling in position", () => {
|
|
66
66
|
const detail: ProofDetailResponse = {
|
|
67
67
|
proofs: [{ proof: { artifact: { digestB64: DIGEST }, commit: { counter: "42", epochId: EPOCH } } }],
|
|
68
68
|
positions: [{ counter: "42", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null }],
|
|
@@ -73,7 +73,7 @@ test("proof markdown states the window as between lower and upper", () => {
|
|
|
73
73
|
};
|
|
74
74
|
const md = renderProofMarkdown(detail, "https://bitgraph.ing");
|
|
75
75
|
assert.ok(
|
|
76
|
-
md.includes("BitGraphed
|
|
76
|
+
md.includes("BitGraphed after 2026-07-01T00:00:00.000Z (Ethereum block 100), and before the anchoring of block 101 (that block mined 2026-07-01T00:00:12.000Z)."),
|
|
77
77
|
md
|
|
78
78
|
);
|
|
79
79
|
assert.ok(md.includes("# BitGraph #42"));
|
|
@@ -3,7 +3,7 @@ import * as assert from "node:assert/strict";
|
|
|
3
3
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { writeProofBeside } from "
|
|
6
|
+
import { writeProofBeside } from "@mikeargento/bitgraph-sdk";
|
|
7
7
|
|
|
8
8
|
describe("the server writes the task proof beside the sealed file", () => {
|
|
9
9
|
test("named after the file, whole, and never over an existing one", async () => {
|
|
@@ -18,7 +18,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
18
18
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
19
19
|
import { FuseError } from "@mikeargento/bitgraph";
|
|
20
20
|
import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseFileFn, type FuseSetFn } from "../server.js";
|
|
21
|
-
import { toUrlSafeB64 } from "
|
|
21
|
+
import { toUrlSafeB64 } from "@mikeargento/bitgraph-sdk";
|
|
22
22
|
|
|
23
23
|
interface Recorded {
|
|
24
24
|
method: string;
|
|
@@ -253,7 +253,7 @@ test("record makes ONE set of the fresh files and leaves on-record ones alone",
|
|
|
253
253
|
const batchBody = batch.body as { digests: string[] };
|
|
254
254
|
assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
|
|
255
255
|
const structured = result.structuredContent as RecordStructured;
|
|
256
|
-
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
|
|
256
|
+
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, carried: 0, not_fused: 0 });
|
|
257
257
|
assert.ok(structured.set, "the set is reported");
|
|
258
258
|
assert.equal(structured.set?.count, 2);
|
|
259
259
|
assert.ok(structured.set?.proof_url.includes("/proof/") && structured.set?.proof_url.includes("counter="), "the set's proof page is pinned to its position");
|
|
@@ -311,7 +311,7 @@ test("a directory is its regular files, hidden entries and links left out", asyn
|
|
|
311
311
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
312
312
|
assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
|
|
313
313
|
const structured = result.structuredContent as RecordStructured;
|
|
314
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
|
|
314
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, carried: 0, not_fused: 0 });
|
|
315
315
|
assert.equal(structured.set?.count, 2);
|
|
316
316
|
});
|
|
317
317
|
|
|
@@ -414,7 +414,7 @@ test("a set failure labels every attempted file 'not fused', never 'on record'",
|
|
|
414
414
|
const structured = result.structuredContent as RecordStructured;
|
|
415
415
|
assert.equal(structured.set, null);
|
|
416
416
|
assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
|
|
417
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
|
|
417
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, carried: 0, not_fused: 2 });
|
|
418
418
|
} finally {
|
|
419
419
|
fuseMode = "ok";
|
|
420
420
|
}
|
|
@@ -485,7 +485,8 @@ test("get_proof by number resolves through search and renders the window", async
|
|
|
485
485
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
486
486
|
const text = textOf(result);
|
|
487
487
|
assert.ok(text.includes("# BitGraph #10"));
|
|
488
|
-
assert.ok(text.includes("BitGraphed
|
|
488
|
+
assert.ok(text.includes("BitGraphed after 2026-07-01T00:00:00.000Z"));
|
|
489
|
+
assert.ok(text.includes("before the anchoring of block 2"), "the ceiling is stated in position, never as the block's mine time");
|
|
489
490
|
assert.ok(text.includes("Causal positions (2)"));
|
|
490
491
|
assert.ok(text.includes("/proof/"), "includes the proof page url");
|
|
491
492
|
});
|
package/src/format.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* @mikeargento/bitgraph-mcp: output shaping.
|
|
5
5
|
*
|
|
6
6
|
* Markdown for human-facing summaries, JSON for complete structured data.
|
|
7
|
-
* Time statements come from the Ethereum anchor bracket
|
|
8
|
-
*
|
|
7
|
+
* Time statements come from the Ethereum anchor bracket, never from advisory
|
|
8
|
+
* clock fields, and they keep the canon's units: the floor is temporal
|
|
9
|
+
* ("after T", cryptographic), the ceiling is POSITIONAL ("before the
|
|
10
|
+
* anchoring of block M"), because an anchor is built after the block it
|
|
11
|
+
* carries, so a block's mine time is never an upper clock bound.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import { toUrlSafeB64 } from "
|
|
12
|
-
import type { BitGraphProof, PositionView, ProofDetailResponse, SetMemberView } from "./types.js";
|
|
14
|
+
import { toUrlSafeB64, carrierLine, type CarrierWindowView, type BitGraphProof, type PositionView, type ProofDetailResponse, type SetMemberView } from "@mikeargento/bitgraph-sdk";
|
|
13
15
|
|
|
14
16
|
export const CHARACTER_LIMIT = 25_000;
|
|
15
17
|
/** Rows per group a markdown summary lists before "and N more". */
|
|
@@ -38,13 +40,19 @@ export function proofUrl(
|
|
|
38
40
|
* committed artifact. "on record": the bytes already had a recording or a
|
|
39
41
|
* fused artifact naming them as origin, and nothing was made. "not fused":
|
|
40
42
|
* the attempt failed or the file was left out; never claim "on record" for
|
|
41
|
-
* bytes that have no proof.
|
|
43
|
+
* bytes that have no proof. "carried": the file is a BitGraphed file, its
|
|
44
|
+
* proof travels inside it; the committed bytes it holds were judged offline
|
|
45
|
+
* and NOTHING was minted, because the envelope is never the recorded thing.
|
|
42
46
|
*/
|
|
43
47
|
export interface RecordOutcome {
|
|
44
48
|
path: string;
|
|
45
|
-
/** The file's own digest (URL-safe): the origin of its fused bytes. */
|
|
49
|
+
/** The file's own digest (URL-safe): the origin of its fused bytes. For a "carried" row, the COMMITTED bytes' digest. */
|
|
46
50
|
digest: string;
|
|
47
|
-
outcome: "fused" | "on record" | "not fused";
|
|
51
|
+
outcome: "fused" | "on record" | "not fused" | "carried";
|
|
52
|
+
/** A BitGraphed file's offline judgment: the carried proof's verdict and window. */
|
|
53
|
+
carrier?: CarrierWindowView;
|
|
54
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest); the proof page displays them. */
|
|
55
|
+
c2pa?: boolean;
|
|
48
56
|
/** The member's fused digest (URL-safe), present on a "fused" outcome. */
|
|
49
57
|
artifact_digest: string | null;
|
|
50
58
|
placement: string | null;
|
|
@@ -78,10 +86,17 @@ export interface SetOutcome {
|
|
|
78
86
|
|
|
79
87
|
export interface CheckOutcome {
|
|
80
88
|
input: string;
|
|
81
|
-
digest
|
|
89
|
+
/** URL-safe. For a BitGraphed file, the digest of the COMMITTED bytes inside, never the envelope's. */
|
|
90
|
+
digest: string;
|
|
82
91
|
on_record: boolean;
|
|
83
92
|
positions: Array<{ counter: string | null; epoch: string | null; member?: SetMemberView }>;
|
|
84
93
|
proof_url: string | null;
|
|
94
|
+
/** Present when the input is a BitGraphed file: the carried proof's offline judgment. */
|
|
95
|
+
carrier?: CarrierWindowView;
|
|
96
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest). */
|
|
97
|
+
c2pa?: boolean;
|
|
98
|
+
/** A stated condition (an unreadable carrier block, an oversized file), never a verdict. */
|
|
99
|
+
note?: string;
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
export function positionOf(proof: BitGraphProof): { counter: string | null; epoch: string | null } {
|
|
@@ -96,9 +111,14 @@ function memberNote(m: SetMemberView | undefined): string {
|
|
|
96
111
|
return m ? ` (member ${fmt(m.index + 1)} of ${fmt(m.count)})` : "";
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/** The C2PA note a row earns, or an empty string. */
|
|
115
|
+
const c2paNote = (o: { c2pa?: boolean }): string =>
|
|
116
|
+
o.c2pa === true ? `\n Content Credentials (C2PA) detected in the file; the proof page displays them.` : "";
|
|
117
|
+
|
|
99
118
|
export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: SetOutcome | null = null, omitted = 0): string {
|
|
100
119
|
const fused = outcomes.filter((o) => o.outcome === "fused");
|
|
101
120
|
const onRecord = outcomes.filter((o) => o.outcome === "on record");
|
|
121
|
+
const carried = outcomes.filter((o) => o.outcome === "carried");
|
|
102
122
|
const notFused = outcomes.filter((o) => o.outcome === "not fused");
|
|
103
123
|
const lines: string[] = [];
|
|
104
124
|
const parts: string[] = [];
|
|
@@ -108,6 +128,7 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
108
128
|
parts.push(`${fmt(fused.length)} fused`);
|
|
109
129
|
}
|
|
110
130
|
parts.push(`${fmt(onRecord.length)} already on record`);
|
|
131
|
+
if (carried.length > 0) parts.push(`${fmt(carried.length)} BitGraphed file${carried.length === 1 ? "" : "s"} (proof inside, nothing minted)`);
|
|
111
132
|
if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
|
|
112
133
|
lines.push(`${parts.join(", ")}.`);
|
|
113
134
|
if (set !== null && fused.length > 0) {
|
|
@@ -133,16 +154,25 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
133
154
|
(o) => {
|
|
134
155
|
const note = o.total_positions > 1 ? ` (${fmt(o.total_positions)} positions, earliest shown)` : "";
|
|
135
156
|
const row = o.member !== null && o.member_count !== null ? ` (member ${fmt(o.member)} of ${fmt(o.member_count)})` : "";
|
|
136
|
-
|
|
157
|
+
const carrier = o.carrier ? `\n ${carrierLine(o.carrier)}` : "";
|
|
158
|
+
return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}${carrier}${c2paNote(o)}\n ${o.proof_url}`;
|
|
137
159
|
},
|
|
138
160
|
(n) => `and ${fmt(n)} more already on record`
|
|
139
161
|
);
|
|
162
|
+
group(
|
|
163
|
+
carried,
|
|
164
|
+
(o) => {
|
|
165
|
+
const where = o.proof_url !== null ? `\n ${o.proof_url}` : "\n Not in this ledger; the proof travels in the file itself.";
|
|
166
|
+
return `- BitGraphed file · ${o.path}\n ${o.carrier ? carrierLine(o.carrier) : "carried proof"} · nothing minted: the envelope is never the recorded thing, the bytes inside are${c2paNote(o)}${where}`;
|
|
167
|
+
},
|
|
168
|
+
(n) => `and ${fmt(n)} more BitGraphed files`
|
|
169
|
+
);
|
|
140
170
|
group(
|
|
141
171
|
fused,
|
|
142
172
|
(o) =>
|
|
143
173
|
o.member === null
|
|
144
|
-
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})\n ${o.proof_url}`
|
|
145
|
-
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
|
|
174
|
+
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})${c2paNote(o)}\n ${o.proof_url}`
|
|
175
|
+
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})${c2paNote(o)}`,
|
|
146
176
|
(n) => `and ${fmt(n)} more files in the same set`
|
|
147
177
|
);
|
|
148
178
|
if (set !== null && fused.length > 0) {
|
|
@@ -169,12 +199,17 @@ export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
|
|
|
169
199
|
const found = outcomes.filter((o) => o.on_record).length;
|
|
170
200
|
const lines: string[] = [`${fmt(found)} of ${fmt(outcomes.length)} on record.`];
|
|
171
201
|
for (const o of outcomes.slice(0, MARKDOWN_ROWS)) {
|
|
202
|
+
const carrier = o.carrier ? `\n BitGraphed file: ${carrierLine(o.carrier)} (judged offline from the proof inside)` : "";
|
|
172
203
|
if (o.on_record) {
|
|
173
204
|
const first = o.positions[0];
|
|
174
205
|
const extra = o.positions.length > 1 ? ` and ${fmt(o.positions.length - 1)} more position(s)` : "";
|
|
175
|
-
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}\n ${o.proof_url}`);
|
|
206
|
+
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}${carrier}${c2paNote(o)}\n ${o.proof_url}`);
|
|
207
|
+
} else if (o.carrier) {
|
|
208
|
+
lines.push(`- not in this ledger · ${o.input}${carrier}${c2paNote(o)}`);
|
|
209
|
+
} else if (o.note !== undefined) {
|
|
210
|
+
lines.push(`- not judged · ${o.input}\n ${o.note}`);
|
|
176
211
|
} else {
|
|
177
|
-
lines.push(`- not on record · ${o.input}`);
|
|
212
|
+
lines.push(`- not on record · ${o.input}${c2paNote(o)}`);
|
|
178
213
|
}
|
|
179
214
|
}
|
|
180
215
|
if (outcomes.length > MARKDOWN_ROWS) lines.push(`- and ${fmt(outcomes.length - MARKDOWN_ROWS)} more; the structured result lists every one`);
|
|
@@ -189,9 +224,9 @@ function renderWindow(detail: ProofDetailResponse): string | null {
|
|
|
189
224
|
const lowerBlock = w.anchorBefore?.blockNumber ?? null;
|
|
190
225
|
const upperBlock = w.anchorAfter?.blockNumber ?? null;
|
|
191
226
|
if (lower && upper) {
|
|
192
|
-
return `BitGraphed
|
|
227
|
+
return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}), and before the anchoring of block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
193
228
|
}
|
|
194
|
-
if (upper) return `BitGraphed before
|
|
229
|
+
if (upper) return `BitGraphed before the anchoring of Ethereum block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
195
230
|
if (lower) return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}).`;
|
|
196
231
|
return null;
|
|
197
232
|
}
|
|
@@ -236,7 +271,7 @@ export function renderProofMarkdown(
|
|
|
236
271
|
positions.forEach((p, i) => {
|
|
237
272
|
const label = i === 0 ? " · original" : "";
|
|
238
273
|
const bracket =
|
|
239
|
-
p.lowerTime && p.upperTime ? ` ·
|
|
274
|
+
p.lowerTime && p.upperTime ? ` · after ${p.lowerTime}, before the closing anchor (its block mined ${p.upperTime})` : "";
|
|
240
275
|
lines.push(`- #${p.counter ?? "?"}${label}${p.member ? ` · set of ${fmt(p.member.count)}` : ""}${bracket}`);
|
|
241
276
|
});
|
|
242
277
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
-
import { configFromEnv } from "
|
|
13
|
+
import { configFromEnv } from "@mikeargento/bitgraph-sdk";
|
|
14
14
|
import { buildServer, SERVER_VERSION } from "./server.js";
|
|
15
15
|
|
|
16
16
|
async function main(): Promise<void> {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/** The task-form guidance spoken to MCP callers; the mechanics live in the SDK. */
|
|
4
|
+
import { SLOT_TTL_SECONDS } from "@mikeargento/bitgraph-sdk";
|
|
5
|
+
|
|
6
|
+
export const TASK_INSTRUCTIONS =
|
|
7
|
+
"You hold a position and its commitment, and no work exists yet. Put the commitment string INSIDE THE OUTPUT you are about to make whenever its format can hold text (SVG, HTML, code, JSON, Markdown, CSV, a document): as a comment, a field, a line that stays in the file. " +
|
|
8
|
+
`When the output exists, call bitgraph_commit with this fuse_token and the output file's path, within ${SLOT_TTL_SECONDS} seconds of opening: the output itself is sealed under the position, and the proof is written beside it. ` +
|
|
9
|
+
"If the output cannot hold text (a PNG, audio, video) or will take longer than that to make, put the commitment into a task file instead (the exact prompt or request you send), seal that file the same way, and when the output exists record it with bitgraph_record: the task is sealed before the output existed, and the output is recorded after. " +
|
|
10
|
+
"Keep the exact bytes that were sealed unchanged: a verifier recomputes the commitment from the proof and looks for the string inside them. " +
|
|
11
|
+
"Leave the proof file as written, whole, every field, environment.attestation.reportB64 included: a proof missing slotAllocation, environment, or the attestation cannot be verified.";
|