@mikeargento/bitgraph-mcp 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/__tests__/encoding.test.d.ts +2 -0
  4. package/dist/__tests__/encoding.test.d.ts.map +1 -0
  5. package/dist/__tests__/encoding.test.js +51 -0
  6. package/dist/__tests__/encoding.test.js.map +1 -0
  7. package/dist/__tests__/format.test.d.ts +2 -0
  8. package/dist/__tests__/format.test.d.ts.map +1 -0
  9. package/dist/__tests__/format.test.js +59 -0
  10. package/dist/__tests__/format.test.js.map +1 -0
  11. package/dist/__tests__/tools.test.d.ts +2 -0
  12. package/dist/__tests__/tools.test.d.ts.map +1 -0
  13. package/dist/__tests__/tools.test.js +304 -0
  14. package/dist/__tests__/tools.test.js.map +1 -0
  15. package/dist/api.d.ts +54 -0
  16. package/dist/api.d.ts.map +1 -0
  17. package/dist/api.js +144 -0
  18. package/dist/api.js.map +1 -0
  19. package/dist/encoding.d.ts +19 -0
  20. package/dist/encoding.d.ts.map +1 -0
  21. package/dist/encoding.js +64 -0
  22. package/dist/encoding.js.map +1 -0
  23. package/dist/format.d.ts +44 -0
  24. package/dist/format.d.ts.map +1 -0
  25. package/dist/format.js +164 -0
  26. package/dist/format.js.map +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +25 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/server.d.ts +11 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +375 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/types.d.ts +88 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +3 -0
  38. package/dist/types.js.map +1 -0
  39. package/package.json +47 -0
  40. package/src/__tests__/encoding.test.ts +64 -0
  41. package/src/__tests__/format.test.ts +69 -0
  42. package/src/__tests__/tools.test.ts +334 -0
  43. package/src/api.ts +211 -0
  44. package/src/encoding.ts +72 -0
  45. package/src/format.ts +201 -0
  46. package/src/index.ts +30 -0
  47. package/src/server.ts +463 -0
  48. package/src/types.ts +74 -0
@@ -0,0 +1,69 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. 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 { toUrlSafeB64 } from "../encoding.js";
7
+ import { capJson, proofUrl, renderProofMarkdown, renderRecordMarkdown } from "../format.js";
8
+ import type { ProofDetailResponse } from "../types.js";
9
+
10
+ const DIGEST = createHash("sha256").update("bitgraph").digest("base64");
11
+ const EPOCH = createHash("sha256").update("epoch").digest("base64");
12
+
13
+ test("proofUrl pins a position with url-safe encodings", () => {
14
+ const url = proofUrl("https://bitgraph.ing", DIGEST, "42", EPOCH);
15
+ assert.ok(url.startsWith(`https://bitgraph.ing/proof/${encodeURIComponent(toUrlSafeB64(DIGEST))}`));
16
+ assert.ok(url.includes("counter=42"));
17
+ assert.ok(url.includes(`epoch=${encodeURIComponent(toUrlSafeB64(EPOCH))}`));
18
+ assert.ok(!url.includes("+") && !url.includes("=="), "no raw standard-b64 leaks into the URL");
19
+ });
20
+
21
+ test("proofUrl without a position has no query string", () => {
22
+ assert.ok(!proofUrl("https://bitgraph.ing", DIGEST).includes("?"));
23
+ });
24
+
25
+ test("record markdown mentions again=true only when something was already on record", () => {
26
+ const base = {
27
+ digest: toUrlSafeB64(DIGEST),
28
+ counter: "7",
29
+ epoch: toUrlSafeB64(EPOCH),
30
+ total_positions: 1,
31
+ proof_url: "https://bitgraph.ing/proof/x",
32
+ };
33
+ const fresh = renderRecordMarkdown([{ ...base, path: "/a", outcome: "recorded" }]);
34
+ assert.ok(!fresh.includes("again=true"));
35
+ const mixed = renderRecordMarkdown([
36
+ { ...base, path: "/a", outcome: "recorded" },
37
+ { ...base, path: "/b", outcome: "on record" },
38
+ ]);
39
+ assert.ok(mixed.includes("again=true"));
40
+ assert.ok(mixed.startsWith("1 recorded, 1 already on record."));
41
+ });
42
+
43
+ test("proof markdown states the window as between lower and upper", () => {
44
+ const detail: ProofDetailResponse = {
45
+ proofs: [{ proof: { artifact: { digestB64: DIGEST }, commit: { counter: "42", epochId: EPOCH } } }],
46
+ positions: [{ counter: "42", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null }],
47
+ causalWindow: {
48
+ anchorBefore: { blockTime: "2026-07-01T00:00:00.000Z", blockNumber: 100 },
49
+ anchorAfter: { blockTime: "2026-07-01T00:00:12.000Z", blockNumber: 101 },
50
+ },
51
+ };
52
+ const md = renderProofMarkdown(detail, "https://bitgraph.ing");
53
+ assert.ok(
54
+ md.includes("BitGraphed between 2026-07-01T00:00:00.000Z (Ethereum block 100) and 2026-07-01T00:00:12.000Z (block 101)"),
55
+ md
56
+ );
57
+ assert.ok(md.includes("# BitGraph #42"));
58
+ });
59
+
60
+ test("capJson elides large attestation reports before truncating", () => {
61
+ const value = {
62
+ proof: { environment: { attestation: { reportB64: "A".repeat(40_000) } }, note: "keep me" },
63
+ };
64
+ const { text, truncated } = capJson(value);
65
+ assert.ok(truncated);
66
+ assert.ok(text.includes("elided 40000 base64 chars"));
67
+ assert.ok(text.includes("keep me"));
68
+ assert.ok(text.length <= 25_000 + 200);
69
+ });
@@ -0,0 +1,334 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * End-to-end tool tests: a real MCP client drives the real server over an
5
+ * in-memory transport, against a mock bitgraph.ing that asserts the exact
6
+ * wire shapes. No real ledger writes ever happen here.
7
+ */
8
+
9
+ import { test, before, after } from "node:test";
10
+ import { strict as assert } from "node:assert";
11
+ import { createHash } from "node:crypto";
12
+ import { createServer, type Server } from "node:http";
13
+ import { writeFile, mkdtemp } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
18
+ import { buildServer } from "../server.js";
19
+ import { toUrlSafeB64 } from "../encoding.js";
20
+
21
+ interface Recorded {
22
+ method: string;
23
+ path: string;
24
+ headers: Record<string, string | string[] | undefined>;
25
+ body: unknown;
26
+ }
27
+
28
+ const requests: Recorded[] = [];
29
+ let mock: Server;
30
+ let fileA = "";
31
+ let fileB = "";
32
+ let fileC = "";
33
+ let digestA = ""; // standard b64 of fileA bytes
34
+ let digestB = "";
35
+ let commitMode: "ok" | "429" | "short" = "ok";
36
+ const EPOCH = createHash("sha256").update("test-epoch").digest("base64");
37
+ let mintCounter = 100;
38
+
39
+ function proofFor(digestB64: string): Record<string, unknown> {
40
+ mintCounter += 2; // slot consumes one position, commit lands on the next
41
+ return {
42
+ version: "bitgraph/1",
43
+ artifact: { hashAlg: "sha256", digestB64 },
44
+ commit: { counter: String(mintCounter), epochId: EPOCH },
45
+ signer: { publicKeyB64: "pk", signatureB64: "sig" },
46
+ environment: { enforcement: "measured-tee", measurement: "m" },
47
+ };
48
+ }
49
+
50
+ before(async () => {
51
+ const dir = await mkdtemp(join(tmpdir(), "bitgraph-mcp-e2e-"));
52
+ fileA = join(dir, "a.txt");
53
+ fileB = join(dir, "b.txt");
54
+ fileC = join(dir, "c.txt");
55
+ await writeFile(fileA, "alpha bytes");
56
+ await writeFile(fileB, "beta bytes");
57
+ await writeFile(fileC, "gamma bytes");
58
+ digestA = createHash("sha256").update("alpha bytes").digest("base64");
59
+ digestB = createHash("sha256").update("beta bytes").digest("base64");
60
+
61
+ mock = createServer((req, res) => {
62
+ const chunks: Buffer[] = [];
63
+ req.on("data", (c: Buffer) => chunks.push(c));
64
+ req.on("end", () => {
65
+ const raw = Buffer.concat(chunks).toString("utf8");
66
+ const body = raw.length > 0 ? JSON.parse(raw) : undefined;
67
+ const url = new URL(req.url ?? "/", "http://localhost");
68
+ requests.push({ method: req.method ?? "", path: url.pathname + url.search, headers: req.headers, body });
69
+
70
+ const send = (status: number, payload: unknown) => {
71
+ res.writeHead(status, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify(payload));
73
+ };
74
+
75
+ if (url.pathname === "/api/proofs/batch") {
76
+ // fileA is already on record (two positions); fileB is not.
77
+ const digests = (body as { digests: string[] }).digests;
78
+ const results: Record<string, { proofs: Array<{ proof: unknown }> }> = {};
79
+ for (const d of digests) {
80
+ if (d === toUrlSafeB64(digestA)) {
81
+ results[d] = {
82
+ proofs: [
83
+ { proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } } },
84
+ { proof: { artifact: { digestB64: digestA }, commit: { counter: "55", epochId: EPOCH } } },
85
+ ],
86
+ };
87
+ } else {
88
+ results[d] = { proofs: [] };
89
+ }
90
+ }
91
+ send(200, { results });
92
+ } else if (url.pathname === "/api/commit") {
93
+ if (commitMode === "429") {
94
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "30" });
95
+ res.end(JSON.stringify({ error: "Rate limit exceeded: per-client proof rate limit exceeded" }));
96
+ return;
97
+ }
98
+ const digests = (body as { digests: Array<{ digestB64: string }> }).digests;
99
+ if (commitMode === "short") {
100
+ send(200, digests.slice(1).map((d) => proofFor(d.digestB64)));
101
+ return;
102
+ }
103
+ send(200, digests.map((d) => proofFor(d.digestB64)));
104
+ } else if (url.pathname.startsWith("/api/proofs/digest/")) {
105
+ const digest = decodeURIComponent(url.pathname.split("/").pop() ?? "");
106
+ if (digest === toUrlSafeB64(digestA)) {
107
+ send(200, {
108
+ proofs: [{ proof: { artifact: { digestB64: digestA }, commit: { counter: "10", epochId: EPOCH } } }],
109
+ positions: [
110
+ { counter: "10", epoch: toUrlSafeB64(EPOCH), lowerTime: "2026-07-01T00:00:00.000Z", upperTime: "2026-07-01T00:00:12.000Z" },
111
+ { counter: "55", epoch: toUrlSafeB64(EPOCH), lowerTime: null, upperTime: null },
112
+ ],
113
+ causalWindow: {
114
+ anchorBefore: { blockTime: "2026-07-01T00:00:00.000Z", blockNumber: 1 },
115
+ anchorAfter: { blockTime: "2026-07-01T00:00:12.000Z", blockNumber: 2 },
116
+ },
117
+ });
118
+ } else {
119
+ send(200, { proofs: [] });
120
+ }
121
+ } else if (url.pathname === "/api/search") {
122
+ const q = url.searchParams.get("q") ?? "";
123
+ if (q.replace(/[#,\s]/g, "") === "10") {
124
+ send(200, { found: true, digest: toUrlSafeB64(digestA), counter: "10" });
125
+ } else {
126
+ send(200, { found: false });
127
+ }
128
+ } else {
129
+ send(404, { error: "not found" });
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
+ async function connectedClient(): Promise<Client> {
147
+ const server = buildServer();
148
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
149
+ const client = new Client({ name: "test-client", version: "0.0.0" });
150
+ await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
151
+ return client;
152
+ }
153
+
154
+ test("lists the three tools", async () => {
155
+ const client = await connectedClient();
156
+ const tools = await client.listTools();
157
+ const names = tools.tools.map((t) => t.name).sort();
158
+ assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
159
+ });
160
+
161
+ test("record dedups on-record files and mints only fresh ones", async () => {
162
+ const client = await connectedClient();
163
+ requests.length = 0;
164
+ const result = await client.callTool({
165
+ name: "bitgraph_record",
166
+ arguments: { paths: [fileA, fileB] },
167
+ });
168
+ assert.ok(!result.isError, JSON.stringify(result.content));
169
+
170
+ const commit = requests.find((r) => r.path === "/api/commit");
171
+ if (!commit) throw new Error("no commit request was sent");
172
+ const commitBody = commit.body as { digests: Array<{ digestB64: string; hashAlg: string }>; chainId: string };
173
+ assert.equal(commitBody.chainId, "bitgraph:main");
174
+ assert.deepEqual(commitBody.digests, [{ digestB64: digestB, hashAlg: "sha256" }], "only fileB minted, standard b64");
175
+ assert.equal(commit.headers["authorization"], "Bearer test-key-123", "api key forwarded");
176
+
177
+ const batch = requests.find((r) => r.path === "/api/proofs/batch");
178
+ if (!batch) throw new Error("no batch check preceded the commit");
179
+ const batchBody = batch.body as { digests: string[] };
180
+ assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
181
+
182
+ const structured = result.structuredContent as {
183
+ results: Array<{ path: string; outcome: string; counter: string | null }>;
184
+ summary: { recorded: number; on_record: number };
185
+ };
186
+ assert.equal(structured.summary.recorded, 1);
187
+ assert.equal(structured.summary.on_record, 1);
188
+ const a = structured.results.find((r) => r.path === fileA);
189
+ const b = structured.results.find((r) => r.path === fileB);
190
+ assert.equal(a?.outcome, "on record");
191
+ assert.equal(a?.counter, "10", "on-record outcome reports the EARLIEST position");
192
+ assert.equal(b?.outcome, "recorded");
193
+ });
194
+
195
+ test("record with again=true mints even on-record digests", async () => {
196
+ const client = await connectedClient();
197
+ requests.length = 0;
198
+ const result = await client.callTool({
199
+ name: "bitgraph_record",
200
+ arguments: { paths: [fileA], again: true },
201
+ });
202
+ assert.ok(!result.isError, JSON.stringify(result.content));
203
+ const commit = requests.find((r) => r.path === "/api/commit");
204
+ const commitBody = commit?.body as { digests: Array<{ digestB64: string }> };
205
+ assert.deepEqual(commitBody.digests.map((d) => d.digestB64), [digestA]);
206
+ const structured = result.structuredContent as {
207
+ results: Array<{ outcome: string; total_positions: number }>;
208
+ };
209
+ assert.equal(structured.results[0]?.outcome, "recorded");
210
+ assert.equal(structured.results[0]?.total_positions, 3, "two prior positions plus the new one");
211
+ });
212
+
213
+ test("check reports positions without ever committing", async () => {
214
+ const client = await connectedClient();
215
+ requests.length = 0;
216
+ const result = await client.callTool({
217
+ name: "bitgraph_check",
218
+ arguments: { paths: [fileB], digests: [toUrlSafeB64(digestA)] },
219
+ });
220
+ assert.ok(!result.isError, JSON.stringify(result.content));
221
+ assert.ok(!requests.some((r) => r.path === "/api/commit"), "check never commits");
222
+ const structured = result.structuredContent as {
223
+ results: Array<{ input: string; on_record: boolean; positions: unknown[] }>;
224
+ };
225
+ const a = structured.results.find((r) => r.input === toUrlSafeB64(digestA));
226
+ const b = structured.results.find((r) => r.input === fileB);
227
+ assert.equal(a?.on_record, true);
228
+ assert.equal(a?.positions.length, 2);
229
+ assert.equal(b?.on_record, false);
230
+ });
231
+
232
+ test("check rejects malformed digests with an actionable error", async () => {
233
+ const client = await connectedClient();
234
+ const result = await client.callTool({
235
+ name: "bitgraph_check",
236
+ arguments: { digests: ["zzz"] },
237
+ });
238
+ assert.ok(result.isError);
239
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
240
+ assert.ok(text.includes("not a base64 SHA-256 digest"));
241
+ });
242
+
243
+ test("get_proof by number resolves through search and renders the window", async () => {
244
+ const client = await connectedClient();
245
+ const result = await client.callTool({
246
+ name: "bitgraph_get_proof",
247
+ arguments: { number: "#10" },
248
+ });
249
+ assert.ok(!result.isError, JSON.stringify(result.content));
250
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
251
+ assert.ok(text.includes("# BitGraph #10"));
252
+ assert.ok(text.includes("BitGraphed between 2026-07-01T00:00:00.000Z"));
253
+ assert.ok(text.includes("Causal positions (2)"));
254
+ assert.ok(text.includes("/proof/"), "includes the proof page url");
255
+ });
256
+
257
+ test("get_proof for an unknown digest fails with guidance", async () => {
258
+ const client = await connectedClient();
259
+ const result = await client.callTool({
260
+ name: "bitgraph_get_proof",
261
+ arguments: { digest: toUrlSafeB64(digestB) },
262
+ });
263
+ assert.ok(result.isError);
264
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
265
+ assert.ok(text.includes("Not on record"));
266
+ });
267
+
268
+ test("get_proof requires exactly one selector", async () => {
269
+ const client = await connectedClient();
270
+ const result = await client.callTool({
271
+ name: "bitgraph_get_proof",
272
+ arguments: { digest: toUrlSafeB64(digestA), number: "10" },
273
+ });
274
+ assert.ok(result.isError);
275
+ });
276
+
277
+ test("commit failure labels unminted files as 'not recorded', never 'on record'", async () => {
278
+ const client = await connectedClient();
279
+ commitMode = "429";
280
+ try {
281
+ const result = await client.callTool({
282
+ name: "bitgraph_record",
283
+ arguments: { paths: [fileC] },
284
+ });
285
+ assert.ok(result.isError, "partial failure must be an error result");
286
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
287
+ assert.ok(text.includes("Retry after 30 seconds"), text);
288
+ assert.ok(text.includes("0 of 1"), text);
289
+ const structured = result.structuredContent as {
290
+ results: Array<{ outcome: string; proof_url: string | null; counter: string | null }>;
291
+ summary: { recorded: number; on_record: number; not_recorded: number };
292
+ };
293
+ assert.equal(structured.results[0]?.outcome, "not recorded");
294
+ assert.equal(structured.results[0]?.proof_url, null);
295
+ assert.equal(structured.summary.not_recorded, 1);
296
+ assert.equal(structured.summary.recorded, 0);
297
+ assert.equal(structured.summary.on_record, 0);
298
+ } finally {
299
+ commitMode = "ok";
300
+ }
301
+ });
302
+
303
+ test("a 200 with fewer proofs than digests is treated as a partial failure", async () => {
304
+ const client = await connectedClient();
305
+ commitMode = "short";
306
+ try {
307
+ const result = await client.callTool({
308
+ name: "bitgraph_record",
309
+ arguments: { paths: [fileC] },
310
+ });
311
+ assert.ok(result.isError, "shortfall must not reach the success path");
312
+ const structured = result.structuredContent as {
313
+ results: Array<{ outcome: string }>;
314
+ };
315
+ assert.equal(structured.results[0]?.outcome, "not recorded");
316
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
317
+ assert.ok(text.includes("fewer proofs than digests"), text);
318
+ } finally {
319
+ commitMode = "ok";
320
+ }
321
+ });
322
+
323
+ test("record surfaces unreadable paths before any network call", async () => {
324
+ const client = await connectedClient();
325
+ requests.length = 0;
326
+ const result = await client.callTool({
327
+ name: "bitgraph_record",
328
+ arguments: { paths: [fileA, "/definitely/missing/file.bin"] },
329
+ });
330
+ assert.ok(result.isError);
331
+ assert.equal(requests.length, 0, "no API call happened; nothing was minted");
332
+ const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
333
+ assert.ok(text.includes("nothing was recorded"));
334
+ });
package/src/api.ts ADDED
@@ -0,0 +1,211 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * @mikeargento/bitgraph-mcp: HTTP client for the hosted BitGraph API.
5
+ *
6
+ * All recording goes through the website's /api/commit proxy rather than the
7
+ * TEE directly: the proxy is what maintains the per-position by-digest index
8
+ * that makes every causal position of a file discoverable afterward.
9
+ */
10
+
11
+ import type {
12
+ BatchCheckResponse,
13
+ BitGraphProof,
14
+ ProofDetailResponse,
15
+ SearchResponse,
16
+ } from "./types.js";
17
+
18
+ export interface ApiConfig {
19
+ baseUrl: string;
20
+ apiKey?: string;
21
+ }
22
+
23
+ /** Read configuration from the environment at call time (testable via env). */
24
+ export function configFromEnv(): ApiConfig {
25
+ const baseUrl = (process.env["BITGRAPH_API_URL"] ?? "https://bitgraph.ing").replace(/\/+$/, "");
26
+ const apiKey = process.env["BITGRAPH_API_KEY"];
27
+ return apiKey ? { baseUrl, apiKey } : { baseUrl };
28
+ }
29
+
30
+ /** Matches the website client: 50 digests per commit request (~1s of TEE work each). */
31
+ export const COMMIT_CHUNK_SIZE = 50;
32
+ /** Matches the batch endpoint's MAX_DIGESTS. */
33
+ export const BATCH_CHECK_LIMIT = 500;
34
+
35
+ const CHECK_TIMEOUT_MS = 30_000;
36
+ const COMMIT_TIMEOUT_MS = 120_000;
37
+
38
+ export class ApiError extends Error {
39
+ readonly status: number;
40
+ readonly retryAfterSec: number | null;
41
+
42
+ constructor(status: number, message: string, retryAfterSec: number | null = null) {
43
+ super(message);
44
+ this.status = status;
45
+ this.retryAfterSec = retryAfterSec;
46
+ }
47
+ }
48
+
49
+ async function parseError(res: Response): Promise<ApiError> {
50
+ let message = res.statusText || `HTTP ${res.status}`;
51
+ try {
52
+ const body = (await res.json()) as { error?: string };
53
+ if (typeof body.error === "string" && body.error.length > 0) message = body.error;
54
+ } catch {
55
+ /* non-JSON error body */
56
+ }
57
+ const retryAfterRaw = res.headers.get("retry-after");
58
+ const retryAfterSec = retryAfterRaw !== null ? Number.parseInt(retryAfterRaw, 10) : null;
59
+ return new ApiError(res.status, message, Number.isFinite(retryAfterSec) ? retryAfterSec : null);
60
+ }
61
+
62
+ /**
63
+ * Redirects are refused everywhere: an Authorization header must never follow
64
+ * one to a different host, and the canonical endpoint serves without them.
65
+ */
66
+ function redirectHint(err: unknown): never {
67
+ if (err instanceof TypeError && /redirect/i.test(err.message)) {
68
+ throw new Error(
69
+ "The BitGraph endpoint redirected. Set BITGRAPH_API_URL to the canonical https://bitgraph.ing (apex, no www)."
70
+ );
71
+ }
72
+ throw err;
73
+ }
74
+
75
+ async function getJson<T>(config: ApiConfig, path: string, timeoutMs: number): Promise<T> {
76
+ const res = await fetch(`${config.baseUrl}${path}`, {
77
+ method: "GET",
78
+ redirect: "error",
79
+ signal: AbortSignal.timeout(timeoutMs),
80
+ }).catch(redirectHint);
81
+ if (!res.ok) throw await parseError(res);
82
+ return (await res.json()) as T;
83
+ }
84
+
85
+ async function postJson<T>(
86
+ config: ApiConfig,
87
+ path: string,
88
+ body: unknown,
89
+ timeoutMs: number,
90
+ authenticated: boolean
91
+ ): Promise<T> {
92
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
93
+ if (authenticated && config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
94
+ const res = await fetch(`${config.baseUrl}${path}`, {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify(body),
98
+ redirect: "error",
99
+ signal: AbortSignal.timeout(timeoutMs),
100
+ }).catch(redirectHint);
101
+ if (!res.ok) throw await parseError(res);
102
+ return (await res.json()) as T;
103
+ }
104
+
105
+ /**
106
+ * Look up which digests are on record. Input digests must be URL-safe base64;
107
+ * the response is keyed by the exact strings sent. Batches of up to 500.
108
+ */
109
+ export async function batchCheck(
110
+ config: ApiConfig,
111
+ urlSafeDigests: readonly string[]
112
+ ): Promise<BatchCheckResponse> {
113
+ const merged: BatchCheckResponse = { results: {} };
114
+ for (let offset = 0; offset < urlSafeDigests.length; offset += BATCH_CHECK_LIMIT) {
115
+ const chunk = urlSafeDigests.slice(offset, offset + BATCH_CHECK_LIMIT);
116
+ const page = await postJson<BatchCheckResponse>(
117
+ config,
118
+ "/api/proofs/batch",
119
+ { digests: chunk },
120
+ CHECK_TIMEOUT_MS,
121
+ false
122
+ );
123
+ Object.assign(merged.results, page.results);
124
+ }
125
+ return merged;
126
+ }
127
+
128
+ /**
129
+ * Record digests at new causal positions. Input digests must be STANDARD
130
+ * base64 (as stored in proofs). Commits sequentially in chunks of 50, matching
131
+ * the website client; the TEE serializes commits anyway.
132
+ *
133
+ * On a mid-batch failure the error carries how many proofs were already
134
+ * minted (those are permanent); the caller must report partial results
135
+ * honestly rather than pretending all-or-nothing.
136
+ */
137
+ export async function commitDigests(
138
+ config: ApiConfig,
139
+ standardDigests: readonly string[],
140
+ attribution?: { name?: string | undefined; title?: string | undefined; message?: string | undefined }
141
+ ): Promise<BitGraphProof[]> {
142
+ const proofs: BitGraphProof[] = [];
143
+ for (let offset = 0; offset < standardDigests.length; offset += COMMIT_CHUNK_SIZE) {
144
+ const chunk = standardDigests.slice(offset, offset + COMMIT_CHUNK_SIZE);
145
+ const body: Record<string, unknown> = {
146
+ digests: chunk.map((digestB64) => ({ digestB64, hashAlg: "sha256" })),
147
+ chainId: "bitgraph:main",
148
+ };
149
+ if (attribution) body["attribution"] = attribution;
150
+ try {
151
+ const raw = await postJson<BitGraphProof[] | BitGraphProof>(
152
+ config,
153
+ "/api/commit",
154
+ body,
155
+ COMMIT_TIMEOUT_MS,
156
+ true
157
+ );
158
+ proofs.push(...(Array.isArray(raw) ? raw : [raw]));
159
+ } catch (err) {
160
+ throw new PartialCommitError(proofs, standardDigests.length, err);
161
+ }
162
+ }
163
+ return proofs;
164
+ }
165
+
166
+ /** A commit batch failed partway: `minted` proofs are already permanent. */
167
+ export class PartialCommitError extends Error {
168
+ readonly minted: BitGraphProof[];
169
+ readonly requested: number;
170
+ readonly cause2: unknown;
171
+
172
+ constructor(minted: BitGraphProof[], requested: number, cause: unknown) {
173
+ const reason = cause instanceof Error ? cause.message : String(cause);
174
+ super(
175
+ `Recording stopped after ${minted.length} of ${requested} digests: ${reason}` +
176
+ (cause instanceof ApiError && cause.retryAfterSec !== null
177
+ ? ` (retry after ${cause.retryAfterSec}s)`
178
+ : "")
179
+ );
180
+ this.minted = minted;
181
+ this.requested = requested;
182
+ this.cause2 = cause;
183
+ }
184
+ }
185
+
186
+ /** Full detail for one digest: proof, all causal positions, anchor window. */
187
+ export async function getProofDetail(
188
+ config: ApiConfig,
189
+ urlSafeDigest: string,
190
+ counter?: string,
191
+ urlSafeEpoch?: string
192
+ ): Promise<ProofDetailResponse> {
193
+ const params = new URLSearchParams();
194
+ if (counter !== undefined) params.set("counter", counter);
195
+ if (urlSafeEpoch !== undefined) params.set("epoch", urlSafeEpoch);
196
+ const query = params.size > 0 ? `?${params.toString()}` : "";
197
+ return getJson<ProofDetailResponse>(
198
+ config,
199
+ `/api/proofs/digest/${encodeURIComponent(urlSafeDigest)}${query}`,
200
+ CHECK_TIMEOUT_MS
201
+ );
202
+ }
203
+
204
+ /** Resolve a BitGraph number (or digest string) to a digest via /api/search. */
205
+ export async function search(config: ApiConfig, q: string): Promise<SearchResponse> {
206
+ return getJson<SearchResponse>(
207
+ config,
208
+ `/api/search?q=${encodeURIComponent(q)}`,
209
+ CHECK_TIMEOUT_MS
210
+ );
211
+ }
@@ -0,0 +1,72 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * @mikeargento/bitgraph-mcp: digest encoding and file hashing.
5
+ *
6
+ * BitGraph uses two base64 forms for the same SHA-256 digest:
7
+ * standard base64 with padding inside proof objects and commit requests,
8
+ * URL-safe unpadded base64 in API lookups, S3 keys, and proof page URLs.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream } from "node:fs";
13
+ import { stat } from "node:fs/promises";
14
+
15
+ /** Standard base64 → URL-safe unpadded base64. */
16
+ export function toUrlSafeB64(b64: string): string {
17
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }
19
+
20
+ /**
21
+ * URL-safe base64 → standard base64 with padding.
22
+ * A no-op (apart from re-padding) when the input is already standard base64,
23
+ * so it is safe to call on either form.
24
+ */
25
+ export function fromUrlSafeB64(urlSafe: string): string {
26
+ let b64 = urlSafe.replace(/-/g, "+").replace(/_/g, "/");
27
+ while (b64.length % 4 !== 0) b64 += "=";
28
+ return b64;
29
+ }
30
+
31
+ /** True if the string plausibly encodes 32 bytes of SHA-256 in either base64 form. */
32
+ export function looksLikeDigest(s: string): boolean {
33
+ const normalized = fromUrlSafeB64(s.trim());
34
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(normalized)) return false;
35
+ return Buffer.from(normalized, "base64").length === 32;
36
+ }
37
+
38
+ /**
39
+ * SHA-256 of a file's bytes as standard base64.
40
+ * Streams from disk, so file size is not a memory concern. Only this digest
41
+ * ever leaves the machine; file contents are never uploaded.
42
+ */
43
+ export async function sha256FileB64(path: string): Promise<string> {
44
+ const info = await stat(path);
45
+ if (!info.isFile()) {
46
+ throw new Error(`Not a regular file: ${path}`);
47
+ }
48
+ const hash = createHash("sha256");
49
+ for await (const chunk of createReadStream(path)) {
50
+ hash.update(chunk as Buffer);
51
+ }
52
+ return hash.digest("base64");
53
+ }
54
+
55
+ /** Run an async mapper over items with bounded concurrency, preserving order. */
56
+ export async function mapConcurrent<T, R>(
57
+ items: readonly T[],
58
+ concurrency: number,
59
+ fn: (item: T, index: number) => Promise<R>
60
+ ): Promise<R[]> {
61
+ const results: R[] = new Array(items.length);
62
+ let next = 0;
63
+ async function worker(): Promise<void> {
64
+ while (next < items.length) {
65
+ const index = next++;
66
+ results[index] = await fn(items[index] as T, index);
67
+ }
68
+ }
69
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
70
+ await Promise.all(workers);
71
+ return results;
72
+ }