@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
package/src/format.ts ADDED
@@ -0,0 +1,201 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * @mikeargento/bitgraph-mcp: output shaping.
5
+ *
6
+ * Markdown for human-facing summaries, JSON for complete structured data.
7
+ * Time statements come from the Ethereum anchor bracket ("BitGraphed between
8
+ * X and Y"), never from advisory clock fields.
9
+ */
10
+
11
+ import { toUrlSafeB64 } from "./encoding.js";
12
+ import type { BitGraphProof, PositionView, ProofDetailResponse } from "./types.js";
13
+
14
+ export const CHARACTER_LIMIT = 25_000;
15
+
16
+ /** Public proof page URL for a digest, optionally pinned to one causal position. */
17
+ export function proofUrl(
18
+ baseUrl: string,
19
+ standardDigest: string,
20
+ counter?: string,
21
+ standardEpochId?: string
22
+ ): string {
23
+ let url = `${baseUrl}/proof/${encodeURIComponent(toUrlSafeB64(standardDigest))}`;
24
+ if (counter !== undefined) {
25
+ url += `?counter=${encodeURIComponent(counter)}`;
26
+ if (standardEpochId !== undefined) {
27
+ url += `&epoch=${encodeURIComponent(toUrlSafeB64(standardEpochId))}`;
28
+ }
29
+ }
30
+ return url;
31
+ }
32
+
33
+ /**
34
+ * One recording outcome, in the product's own vocabulary. "not recorded" is
35
+ * the honest label for a file lost to a partial commit failure: never claim
36
+ * "on record" for a digest that has no proof.
37
+ */
38
+ export interface RecordOutcome {
39
+ path: string;
40
+ digest: string; // URL-safe
41
+ outcome: "recorded" | "on record" | "not recorded";
42
+ counter: string | null;
43
+ epoch: string | null; // URL-safe
44
+ total_positions: number;
45
+ proof_url: string | null;
46
+ }
47
+
48
+ export interface CheckOutcome {
49
+ input: string;
50
+ digest: string; // URL-safe
51
+ on_record: boolean;
52
+ positions: Array<{ counter: string | null; epoch: string | null }>;
53
+ proof_url: string | null;
54
+ }
55
+
56
+ export function positionOf(proof: BitGraphProof): { counter: string | null; epoch: string | null } {
57
+ const counter = proof.commit?.counter ?? null;
58
+ const epochId = proof.commit?.epochId;
59
+ return { counter, epoch: epochId !== undefined ? toUrlSafeB64(epochId) : null };
60
+ }
61
+
62
+ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[]): string {
63
+ const recorded = outcomes.filter((o) => o.outcome === "recorded");
64
+ const onRecord = outcomes.filter((o) => o.outcome === "on record");
65
+ const notRecorded = outcomes.filter((o) => o.outcome === "not recorded");
66
+ const lines: string[] = [];
67
+ let headline = `${recorded.length} recorded, ${onRecord.length} already on record.`;
68
+ if (notRecorded.length > 0) {
69
+ headline = `${recorded.length} recorded, ${onRecord.length} already on record, ${notRecorded.length} NOT recorded.`;
70
+ }
71
+ lines.push(headline);
72
+ for (const o of outcomes) {
73
+ if (o.outcome === "not recorded") {
74
+ lines.push(`- not recorded (commit failed) · ${o.path}`);
75
+ continue;
76
+ }
77
+ const positionNote =
78
+ o.outcome === "on record"
79
+ ? o.total_positions > 1
80
+ ? ` (${o.total_positions} causal positions, earliest shown)`
81
+ : ""
82
+ : "";
83
+ lines.push(
84
+ `- ${o.outcome} · #${o.counter ?? "?"} · ${o.path}${positionNote}\n ${o.proof_url}`
85
+ );
86
+ }
87
+ if (onRecord.length > 0) {
88
+ lines.push(
89
+ `\nAlready-recorded files were not re-recorded. To record one of them at a new causal position deliberately, call bitgraph_record with again=true.`
90
+ );
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+
95
+ export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
96
+ const found = outcomes.filter((o) => o.on_record).length;
97
+ const lines: string[] = [`${found} of ${outcomes.length} on record.`];
98
+ for (const o of outcomes) {
99
+ if (o.on_record) {
100
+ const first = o.positions[0];
101
+ const extra = o.positions.length > 1 ? ` and ${o.positions.length - 1} more position(s)` : "";
102
+ lines.push(`- on record · #${first?.counter ?? "?"}${extra} · ${o.input}\n ${o.proof_url}`);
103
+ } else {
104
+ lines.push(`- not on record · ${o.input}`);
105
+ }
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+
110
+ function renderWindow(detail: ProofDetailResponse): string | null {
111
+ const w = detail.causalWindow;
112
+ if (!w) return null;
113
+ const lower = w.anchorBefore?.blockTime ?? null; // earlier block: BitGraphed after it
114
+ const upper = w.anchorAfter?.blockTime ?? null; // later block: BitGraphed before it
115
+ const lowerBlock = w.anchorBefore?.blockNumber ?? null;
116
+ const upperBlock = w.anchorAfter?.blockNumber ?? null;
117
+ if (lower && upper) {
118
+ return `BitGraphed between ${lower} (Ethereum block ${lowerBlock ?? "?"}) and ${upper} (block ${upperBlock ?? "?"}).`;
119
+ }
120
+ if (upper) return `BitGraphed before ${upper} (Ethereum block ${upperBlock ?? "?"}).`;
121
+ if (lower) return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}).`;
122
+ return null;
123
+ }
124
+
125
+ export function renderProofMarkdown(
126
+ detail: ProofDetailResponse,
127
+ baseUrl: string
128
+ ): string {
129
+ const proof = detail.proofs[0]?.proof;
130
+ if (!proof) return "No proof found for that digest.";
131
+ const digest = proof.artifact?.digestB64 ?? "";
132
+ const { counter, epoch } = positionOf(proof);
133
+ const lines: string[] = [];
134
+ lines.push(`# BitGraph #${counter ?? "?"}`);
135
+ lines.push("");
136
+ lines.push(`- Digest (SHA-256): ${toUrlSafeB64(digest)}`);
137
+ if (epoch) lines.push(`- Position: counter ${counter ?? "?"} in epoch ${epoch.slice(0, 12)}…`);
138
+ const window = renderWindow(detail);
139
+ if (window) lines.push(`- ${window}`);
140
+ const etherscan =
141
+ detail.causalWindow?.anchorAfter?.etherscanUrl ??
142
+ detail.causalWindow?.anchorBefore?.etherscanUrl;
143
+ if (etherscan) lines.push(`- Anchor block on Etherscan: ${etherscan}`);
144
+ if (proof.environment?.enforcement) {
145
+ lines.push(`- Enforcement: ${proof.environment.enforcement}`);
146
+ }
147
+ if (proof.attribution?.name || proof.attribution?.message) {
148
+ const note = [proof.attribution.name, proof.attribution.message]
149
+ .filter(Boolean)
150
+ .join(": ");
151
+ lines.push(`- Submitter's note (self-attributed, not verified): ${note}`);
152
+ }
153
+ const positions: PositionView[] = detail.positions ?? [];
154
+ if (positions.length > 1) {
155
+ lines.push("");
156
+ lines.push(`## Causal positions (${positions.length})`);
157
+ positions.forEach((p, i) => {
158
+ const label = i === 0 ? " · original" : "";
159
+ const bracket =
160
+ p.lowerTime && p.upperTime ? ` · between ${p.lowerTime} and ${p.upperTime}` : "";
161
+ lines.push(`- #${p.counter ?? "?"}${label}${bracket}`);
162
+ });
163
+ }
164
+ lines.push("");
165
+ lines.push(`Proof page: ${proofUrl(baseUrl, digest, counter ?? undefined, proof.commit?.epochId)}`);
166
+ return lines.join("\n");
167
+ }
168
+
169
+ /**
170
+ * Cap a JSON payload at CHARACTER_LIMIT. Attestation reports are the usual
171
+ * culprit; elide them first, then fall back to hard truncation with a notice.
172
+ */
173
+ export function capJson(value: unknown): { text: string; truncated: boolean } {
174
+ let text = JSON.stringify(value, null, 2);
175
+ if (text.length <= CHARACTER_LIMIT) return { text, truncated: false };
176
+ const elided = JSON.parse(JSON.stringify(value)) as unknown;
177
+ elideReports(elided);
178
+ text = JSON.stringify(elided, null, 2);
179
+ if (text.length <= CHARACTER_LIMIT) return { text, truncated: true };
180
+ return {
181
+ text: `${text.slice(0, CHARACTER_LIMIT)}\n… truncated at ${CHARACTER_LIMIT} characters. Request a single item or use markdown format for a summary.`,
182
+ truncated: true,
183
+ };
184
+ }
185
+
186
+ function elideReports(value: unknown): void {
187
+ if (Array.isArray(value)) {
188
+ for (const item of value) elideReports(item);
189
+ return;
190
+ }
191
+ if (value !== null && typeof value === "object") {
192
+ const obj = value as Record<string, unknown>;
193
+ for (const [key, v] of Object.entries(obj)) {
194
+ if (key === "reportB64" && typeof v === "string" && v.length > 256) {
195
+ obj[key] = `<elided ${v.length} base64 chars; fetch the proof page for the full attestation>`;
196
+ } else {
197
+ elideReports(v);
198
+ }
199
+ }
200
+ }
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
3
+
4
+ /**
5
+ * @mikeargento/bitgraph-mcp: stdio entry point.
6
+ *
7
+ * Environment:
8
+ * BITGRAPH_API_URL optional, defaults to https://bitgraph.ing
9
+ * BITGRAPH_API_KEY optional; sent as a Bearer token on recordings
10
+ */
11
+
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { configFromEnv } from "./api.js";
14
+ import { buildServer, SERVER_VERSION } from "./server.js";
15
+
16
+ async function main(): Promise<void> {
17
+ const config = configFromEnv();
18
+ const server = buildServer();
19
+ const transport = new StdioServerTransport();
20
+ await server.connect(transport);
21
+ // stdio servers must never write to stdout; stderr only.
22
+ console.error(
23
+ `bitgraph-mcp ${SERVER_VERSION} running (endpoint: ${config.baseUrl}, api key: ${config.apiKey ? "set" : "not set"})`
24
+ );
25
+ }
26
+
27
+ main().catch((err) => {
28
+ console.error("bitgraph-mcp failed to start:", err);
29
+ process.exit(1);
30
+ });
package/src/server.ts ADDED
@@ -0,0 +1,463 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * @mikeargento/bitgraph-mcp: tool definitions.
5
+ *
6
+ * Three gestures, the same three the website has: record a file (take a
7
+ * BitGraph), check whether bytes are on record, fetch a proof. Only SHA-256
8
+ * digests ever leave the machine; file contents are never uploaded.
9
+ */
10
+
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { z } from "zod";
13
+ import {
14
+ ApiError,
15
+ PartialCommitError,
16
+ batchCheck,
17
+ commitDigests,
18
+ configFromEnv,
19
+ getProofDetail,
20
+ search,
21
+ } from "./api.js";
22
+ import {
23
+ fromUrlSafeB64,
24
+ looksLikeDigest,
25
+ mapConcurrent,
26
+ sha256FileB64,
27
+ toUrlSafeB64,
28
+ } from "./encoding.js";
29
+ import {
30
+ capJson,
31
+ positionOf,
32
+ proofUrl,
33
+ renderCheckMarkdown,
34
+ renderProofMarkdown,
35
+ renderRecordMarkdown,
36
+ type CheckOutcome,
37
+ type RecordOutcome,
38
+ } from "./format.js";
39
+ import type { BitGraphProof } from "./types.js";
40
+
41
+ export const SERVER_VERSION = "0.1.1";
42
+
43
+ const HASH_CONCURRENCY = 4;
44
+ const MAX_FILES = 500;
45
+
46
+ const responseFormatSchema = z
47
+ .enum(["markdown", "json"])
48
+ .default("markdown")
49
+ .describe("markdown (default): human-readable summary. json: complete structured data.");
50
+
51
+ type ToolResult = {
52
+ content: Array<{ type: "text"; text: string }>;
53
+ structuredContent?: Record<string, unknown>;
54
+ isError?: boolean;
55
+ };
56
+
57
+ function ok(text: string, structured?: Record<string, unknown>): ToolResult {
58
+ const result: ToolResult = { content: [{ type: "text", text }] };
59
+ if (structured !== undefined) result.structuredContent = structured;
60
+ return result;
61
+ }
62
+
63
+ function fail(message: string): ToolResult {
64
+ return { isError: true, content: [{ type: "text", text: message }] };
65
+ }
66
+
67
+ function errorText(err: unknown): string {
68
+ if (err instanceof ApiError) {
69
+ const retry = err.retryAfterSec !== null ? ` Retry after ${err.retryAfterSec} seconds.` : "";
70
+ return `Error: BitGraph API responded ${err.status}: ${err.message}.${retry}`;
71
+ }
72
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
73
+ }
74
+
75
+ /** Hash the given paths (bounded concurrency). Throws before any network call. */
76
+ async function hashPaths(paths: readonly string[]): Promise<string[]> {
77
+ const failures: string[] = [];
78
+ const digests = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
79
+ try {
80
+ return await sha256FileB64(p);
81
+ } catch (err) {
82
+ failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
83
+ return "";
84
+ }
85
+ });
86
+ if (failures.length > 0) {
87
+ throw new Error(
88
+ `Could not read ${failures.length} file(s); nothing was recorded.\n${failures.join("\n")}\nUse absolute paths to existing regular files.`
89
+ );
90
+ }
91
+ return digests;
92
+ }
93
+
94
+ export function buildServer(): McpServer {
95
+ const server = new McpServer({
96
+ name: "bitgraph-mcp-server",
97
+ version: SERVER_VERSION,
98
+ });
99
+
100
+ server.registerTool(
101
+ "bitgraph_record",
102
+ {
103
+ title: "Take a BitGraph",
104
+ description:
105
+ "Take a BitGraph of one or more files: record each file's SHA-256 digest at a new causal position in the BitGraph ledger (bitgraph.ing). " +
106
+ "Only the digest leaves the machine; file contents are never uploaded. " +
107
+ "Files whose bytes are already on record are NOT re-recorded by default; they come back as 'on record' with their existing proof. " +
108
+ "Pass again=true to deliberately record already-recorded bytes at a new causal position (BitGraph Again). " +
109
+ "Recordings are permanent: the ledger has 10-year retention and no deletes, so only record files the user asked to record. " +
110
+ "Returns one outcome per file: 'recorded' (newly minted) or 'on record' (was already there), with its position number and proof page URL. " +
111
+ "Use bitgraph_check instead when the user only wants to know whether a file is on record.",
112
+ inputSchema: {
113
+ paths: z
114
+ .array(z.string().min(1))
115
+ .min(1)
116
+ .max(MAX_FILES)
117
+ .describe(`File paths to record (absolute paths preferred), up to ${MAX_FILES}.`),
118
+ attribution: z
119
+ .object({
120
+ name: z.string().max(200).optional().describe("Submitter's name (self-attributed)."),
121
+ title: z.string().max(200).optional(),
122
+ message: z.string().max(2000).optional(),
123
+ })
124
+ .optional()
125
+ .describe(
126
+ "Optional self-attributed submitter's note, stored in the signed proof. Rendered as a note, never as verified identity."
127
+ ),
128
+ again: z
129
+ .boolean()
130
+ .default(false)
131
+ .describe(
132
+ "false (default): files already on record are returned as-is, nothing minted. true: record every file at a new causal position even if already on record. Positions are per unique file content: two paths with identical bytes yield one position."
133
+ ),
134
+ response_format: responseFormatSchema,
135
+ },
136
+ annotations: {
137
+ readOnlyHint: false,
138
+ destructiveHint: false,
139
+ idempotentHint: false,
140
+ openWorldHint: true,
141
+ },
142
+ },
143
+ async ({ paths, attribution, again, response_format }) => {
144
+ const config = configFromEnv();
145
+ try {
146
+ const digests = await hashPaths(paths);
147
+
148
+ // Unique digests, first path wins for display; extra paths listed too.
149
+ const byDigest = new Map<string, string[]>();
150
+ digests.forEach((d, i) => {
151
+ const list = byDigest.get(d) ?? [];
152
+ list.push(paths[i] as string);
153
+ byDigest.set(d, list);
154
+ });
155
+ const unique = [...byDigest.keys()];
156
+
157
+ const checked = await batchCheck(config, unique.map(toUrlSafeB64));
158
+ const existing = new Map<string, Array<{ proof: BitGraphProof }>>();
159
+ for (const d of unique) {
160
+ const entry = checked.results[toUrlSafeB64(d)];
161
+ if (entry && entry.proofs.length > 0) existing.set(d, entry.proofs);
162
+ }
163
+
164
+ const toMint = again ? unique : unique.filter((d) => !existing.has(d));
165
+
166
+ let minted: BitGraphProof[] = [];
167
+ let partial: PartialCommitError | null = null;
168
+ if (toMint.length > 0) {
169
+ try {
170
+ minted = await commitDigests(config, toMint, attribution);
171
+ // A 200 with fewer proofs than digests is still a partial failure;
172
+ // never let it reach the success path looking complete.
173
+ if (minted.length < toMint.length) {
174
+ partial = new PartialCommitError(
175
+ minted,
176
+ toMint.length,
177
+ new Error("commit returned fewer proofs than digests sent")
178
+ );
179
+ }
180
+ } catch (err) {
181
+ if (err instanceof PartialCommitError) {
182
+ minted = err.minted;
183
+ partial = err;
184
+ } else {
185
+ throw err;
186
+ }
187
+ }
188
+ }
189
+
190
+ const mintedByDigest = new Map<string, BitGraphProof>();
191
+ for (const p of minted) {
192
+ const d = p.artifact?.digestB64;
193
+ if (d !== undefined) mintedByDigest.set(d, p);
194
+ }
195
+
196
+ const outcomes: RecordOutcome[] = [];
197
+ for (const [digest, pathList] of byDigest) {
198
+ const mintedProof = mintedByDigest.get(digest);
199
+ const prior = existing.get(digest);
200
+ for (const path of pathList) {
201
+ if (mintedProof) {
202
+ const { counter, epoch } = positionOf(mintedProof);
203
+ outcomes.push({
204
+ path,
205
+ digest: toUrlSafeB64(digest),
206
+ outcome: "recorded",
207
+ counter,
208
+ epoch,
209
+ total_positions: (prior?.length ?? 0) + 1,
210
+ proof_url: proofUrl(
211
+ config.baseUrl,
212
+ digest,
213
+ counter ?? undefined,
214
+ mintedProof.commit?.epochId
215
+ ),
216
+ });
217
+ } else if (prior) {
218
+ const first = prior[0]?.proof;
219
+ const { counter, epoch } = first ? positionOf(first) : { counter: null, epoch: null };
220
+ outcomes.push({
221
+ path,
222
+ digest: toUrlSafeB64(digest),
223
+ outcome: "on record",
224
+ counter,
225
+ epoch,
226
+ total_positions: prior.length,
227
+ proof_url: proofUrl(config.baseUrl, digest),
228
+ });
229
+ } else {
230
+ // Neither minted nor previously on record: lost to a partial
231
+ // failure. The honest outcome is "not recorded", never a claim.
232
+ outcomes.push({
233
+ path,
234
+ digest: toUrlSafeB64(digest),
235
+ outcome: "not recorded",
236
+ counter: null,
237
+ epoch: null,
238
+ total_positions: 0,
239
+ proof_url: null,
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ const structured = {
246
+ results: outcomes as unknown as Record<string, unknown>[],
247
+ summary: {
248
+ recorded: outcomes.filter((o) => o.outcome === "recorded").length,
249
+ on_record: outcomes.filter((o) => o.outcome === "on record").length,
250
+ not_recorded: outcomes.filter((o) => o.outcome === "not recorded").length,
251
+ },
252
+ };
253
+
254
+ if (partial) {
255
+ const unrecorded = toMint.length - minted.length;
256
+ const retryGuidance = again
257
+ ? `Some 'not recorded' files MAY still have been recorded server-side if the failure was a timeout. Run bitgraph_check on the 'not recorded' paths first, then re-run bitgraph_record with again=true for only the paths still missing.`
258
+ : `Re-run bitgraph_record with the same paths: already-recorded files will come back as 'on record' and only the missing ones will mint.`;
259
+ return {
260
+ isError: true,
261
+ content: [
262
+ {
263
+ type: "text",
264
+ text:
265
+ `${errorText(partial.cause2)}\n` +
266
+ `${minted.length} of ${toMint.length} digests were recorded before the failure (those recordings are permanent); ${unrecorded} were not. ` +
267
+ `${retryGuidance}\n\n` +
268
+ renderRecordMarkdown(outcomes),
269
+ },
270
+ ],
271
+ structuredContent: structured,
272
+ };
273
+ }
274
+
275
+ const text =
276
+ response_format === "json"
277
+ ? capJson(structured).text
278
+ : renderRecordMarkdown(outcomes);
279
+ return ok(text, structured);
280
+ } catch (err) {
281
+ return fail(errorText(err));
282
+ }
283
+ }
284
+ );
285
+
286
+ server.registerTool(
287
+ "bitgraph_check",
288
+ {
289
+ title: "Check for BitGraphs",
290
+ description:
291
+ "Check whether files or digests are on record in the BitGraph ledger, without recording anything. " +
292
+ "Accepts file paths (hashed locally; only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. " +
293
+ "Returns, per item: on record or not, every causal position (a file recorded more than once has several), and the proof page URL. " +
294
+ "Read-only. Use bitgraph_record to record files that turn out not to be on record.",
295
+ inputSchema: {
296
+ paths: z
297
+ .array(z.string().min(1))
298
+ .max(MAX_FILES)
299
+ .optional()
300
+ .describe("File paths to check."),
301
+ digests: z
302
+ .array(z.string().min(1).max(100))
303
+ .max(MAX_FILES)
304
+ .optional()
305
+ .describe("SHA-256 digests, base64 (standard or URL-safe form)."),
306
+ response_format: responseFormatSchema,
307
+ },
308
+ annotations: {
309
+ readOnlyHint: true,
310
+ destructiveHint: false,
311
+ idempotentHint: true,
312
+ openWorldHint: true,
313
+ },
314
+ },
315
+ async ({ paths, digests, response_format }) => {
316
+ const config = configFromEnv();
317
+ try {
318
+ const inputs: Array<{ label: string; standardDigest: string }> = [];
319
+ if (paths && paths.length > 0) {
320
+ const hashed = await hashPaths(paths);
321
+ hashed.forEach((d, i) => inputs.push({ label: paths[i] as string, standardDigest: d }));
322
+ }
323
+ for (const d of digests ?? []) {
324
+ const trimmed = d.trim();
325
+ if (!looksLikeDigest(trimmed)) {
326
+ return fail(
327
+ `Error: "${d}" is not a base64 SHA-256 digest. Pass 32-byte digests in standard or URL-safe base64, or use paths to hash files locally.`
328
+ );
329
+ }
330
+ inputs.push({ label: d, standardDigest: fromUrlSafeB64(trimmed) });
331
+ }
332
+ if (inputs.length === 0) {
333
+ return fail("Error: provide at least one of paths or digests.");
334
+ }
335
+
336
+ const checked = await batchCheck(
337
+ config,
338
+ inputs.map((i) => toUrlSafeB64(i.standardDigest))
339
+ );
340
+
341
+ const outcomes: CheckOutcome[] = inputs.map((input) => {
342
+ const entry = checked.results[toUrlSafeB64(input.standardDigest)];
343
+ const proofs = entry?.proofs ?? [];
344
+ const positions = proofs.map((p) => positionOf(p.proof));
345
+ return {
346
+ input: input.label,
347
+ digest: toUrlSafeB64(input.standardDigest),
348
+ on_record: proofs.length > 0,
349
+ positions,
350
+ proof_url: proofs.length > 0 ? proofUrl(config.baseUrl, input.standardDigest) : null,
351
+ };
352
+ });
353
+
354
+ const structured = {
355
+ results: outcomes as unknown as Record<string, unknown>[],
356
+ summary: {
357
+ on_record: outcomes.filter((o) => o.on_record).length,
358
+ not_on_record: outcomes.filter((o) => !o.on_record).length,
359
+ },
360
+ };
361
+ const text =
362
+ response_format === "json" ? capJson(structured).text : renderCheckMarkdown(outcomes);
363
+ return ok(text, structured);
364
+ } catch (err) {
365
+ return fail(errorText(err));
366
+ }
367
+ }
368
+ );
369
+
370
+ server.registerTool(
371
+ "bitgraph_get_proof",
372
+ {
373
+ title: "Get a BitGraph proof",
374
+ description:
375
+ "Fetch a BitGraph proof and its context: causal position, every position the same bytes occupy, and the two-sided Ethereum anchor window " +
376
+ "('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). " +
377
+ "Exactly one of digest, number, or path is required. Read-only. " +
378
+ "markdown returns a summary; json returns the full proof object with positions and anchor window.",
379
+ inputSchema: {
380
+ digest: z
381
+ .string()
382
+ .min(1)
383
+ .max(100)
384
+ .optional()
385
+ .describe("SHA-256 digest, standard or URL-safe base64."),
386
+ number: z
387
+ .string()
388
+ .min(1)
389
+ .max(30)
390
+ .optional()
391
+ .describe("BitGraph counter number in the current epoch, e.g. '4523' or '#4,523'."),
392
+ path: z.string().min(1).optional().describe("File path; hashed locally."),
393
+ counter: z
394
+ .string()
395
+ .optional()
396
+ .describe("Select a specific causal position by commit counter (with epoch)."),
397
+ epoch: z
398
+ .string()
399
+ .optional()
400
+ .describe("URL-safe epoch id qualifying the counter."),
401
+ response_format: responseFormatSchema,
402
+ },
403
+ annotations: {
404
+ readOnlyHint: true,
405
+ destructiveHint: false,
406
+ idempotentHint: true,
407
+ openWorldHint: true,
408
+ },
409
+ },
410
+ async ({ digest, number, path, counter, epoch, response_format }) => {
411
+ const config = configFromEnv();
412
+ try {
413
+ const given = [digest, number, path].filter((v) => v !== undefined);
414
+ if (given.length !== 1) {
415
+ return fail("Error: pass exactly one of digest, number, or path.");
416
+ }
417
+
418
+ let urlSafeDigest: string;
419
+ let selCounter = counter;
420
+ if (number !== undefined) {
421
+ const result = await search(config, number);
422
+ if (!result.found || result.digest === undefined) {
423
+ return fail(
424
+ `Error: no BitGraph found for number "${number}" in the current epoch. Numbers reset each epoch; look up older recordings by digest or path instead.`
425
+ );
426
+ }
427
+ urlSafeDigest = result.digest;
428
+ if (selCounter === undefined && result.counter != null) selCounter = result.counter;
429
+ } else if (path !== undefined) {
430
+ const hashed = await hashPaths([path]);
431
+ urlSafeDigest = toUrlSafeB64(hashed[0] as string);
432
+ } else {
433
+ const trimmed = (digest as string).trim();
434
+ if (!looksLikeDigest(trimmed)) {
435
+ return fail(
436
+ `Error: "${digest}" is not a base64 SHA-256 digest. Pass a 32-byte digest in standard or URL-safe base64.`
437
+ );
438
+ }
439
+ urlSafeDigest = toUrlSafeB64(fromUrlSafeB64(trimmed));
440
+ }
441
+
442
+ // The route compares epochs in URL-safe form; accept either form here.
443
+ const selEpoch = epoch !== undefined ? toUrlSafeB64(fromUrlSafeB64(epoch)) : undefined;
444
+ const detail = await getProofDetail(config, urlSafeDigest, selCounter, selEpoch);
445
+ if (detail.proofs.length === 0) {
446
+ return fail(
447
+ `Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to record the file.`
448
+ );
449
+ }
450
+
451
+ if (response_format === "json") {
452
+ const capped = capJson(detail);
453
+ return ok(capped.text, detail as unknown as Record<string, unknown>);
454
+ }
455
+ return ok(renderProofMarkdown(detail, config.baseUrl), detail as unknown as Record<string, unknown>);
456
+ } catch (err) {
457
+ return fail(errorText(err));
458
+ }
459
+ }
460
+ );
461
+
462
+ return server;
463
+ }