verify-proof 0.3.0__tar.gz → 0.3.2__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: verify-proof
3
- Version: 0.3.0
4
- Summary: Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server
3
+ Version: 0.3.2
4
+ Summary: Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server
5
5
  Author-email: Craig Solomon <craig@fulcrumenterprises.tech>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Fulcrum-Enterprises/verify-proof
@@ -102,6 +102,16 @@ verify-proof verify document.pdf --proof proof.json
102
102
  # Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
103
103
  ```
104
104
 
105
+ ### Quiet by default
106
+
107
+ A successful `verify` prints one line to stderr saying where proofs like it come
108
+ from. It is suppressed whenever the output is piped or redirected, so scripts
109
+ never see it, and stdout is unchanged either way. To silence it everywhere:
110
+
111
+ ```bash
112
+ export VERIFY_PROOF_NO_HINT=1
113
+ ```
114
+
105
115
  ### Create a proof (needs a free API key)
106
116
 
107
117
  Everything above works offline. This is the one command that does not: to prove
@@ -118,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
118
128
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
119
129
  unless you pass `--no-filename`. The file itself is never uploaded, on any
120
130
  command. Free accounts include 25 API proofs a month and unlimited Polygon
121
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
131
+ anchoring through the web app:
132
+
133
+ [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
122
134
 
123
135
  ### Proof file format
124
136
 
@@ -159,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
159
171
  | `verify_file` | Verify a local file against a proof JSON file |
160
172
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
161
173
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
162
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
174
+ | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
175
+ | `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
176
+ | `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
177
+ | `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
163
178
 
164
179
  ### Connect it to Claude Desktop
165
180
 
@@ -67,6 +67,16 @@ verify-proof verify document.pdf --proof proof.json
67
67
  # Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
68
68
  ```
69
69
 
70
+ ### Quiet by default
71
+
72
+ A successful `verify` prints one line to stderr saying where proofs like it come
73
+ from. It is suppressed whenever the output is piped or redirected, so scripts
74
+ never see it, and stdout is unchanged either way. To silence it everywhere:
75
+
76
+ ```bash
77
+ export VERIFY_PROOF_NO_HINT=1
78
+ ```
79
+
70
80
  ### Create a proof (needs a free API key)
71
81
 
72
82
  Everything above works offline. This is the one command that does not: to prove
@@ -83,7 +93,9 @@ verify-proof create document.pdf --no-filename # send only the hash
83
93
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
84
94
  unless you pass `--no-filename`. The file itself is never uploaded, on any
85
95
  command. Free accounts include 25 API proofs a month and unlimited Polygon
86
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
96
+ anchoring through the web app:
97
+
98
+ [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
87
99
 
88
100
  ### Proof file format
89
101
 
@@ -124,7 +136,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
124
136
  | `verify_file` | Verify a local file against a proof JSON file |
125
137
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
126
138
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
127
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
139
+ | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
140
+ | `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
141
+ | `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
142
+ | `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
128
143
 
129
144
  ### Connect it to Claude Desktop
130
145
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """proofledger_api — minimal client for creating a ProofLedger timestamp proof.
2
+ """proofledger_api — minimal client for the ProofLedger API.
3
3
 
4
4
  This is the one part of verify-proof that talks to a network. Everything else
5
5
  in this package (hashing, verification, explanation) runs entirely offline and
@@ -17,9 +17,14 @@ Standard library only, so installing verify-proof still pulls in nothing.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import hashlib
21
+ import hmac
20
22
  import json
23
+ import re
21
24
  import os
25
+ import time
22
26
  import urllib.error
27
+ import urllib.parse
23
28
  import urllib.request
24
29
 
25
30
  DEFAULT_BASE_URL = "https://proofledger.io"
@@ -31,6 +36,11 @@ API_KEY_ENV = "PROOFLEDGER_API_KEY"
31
36
  _UTM = "utm_source=verify-proof&utm_medium=cli"
32
37
  SIGNUP_URL = f"{DEFAULT_BASE_URL}/login.html?{_UTM}&utm_campaign=api-key"
33
38
  PRICING_URL = f"{DEFAULT_BASE_URL}/pricing.html?{_UTM}&utm_campaign=quota"
39
+ # The one printed after a successful `verify`, which is the command almost every
40
+ # install actually runs. Its own campaign so the scoreboard can tell the people who
41
+ # arrived after verifying somebody else's proof from the ones who went looking for
42
+ # an API key.
43
+ HOME_URL = f"{DEFAULT_BASE_URL}/?{_UTM}&utm_campaign=verified"
34
44
 
35
45
 
36
46
  class ProofLedgerError(RuntimeError):
@@ -111,6 +121,121 @@ def submit_proof(
111
121
  ) from exc
112
122
 
113
123
 
124
+ def _request(
125
+ method: str,
126
+ path: str,
127
+ payload: dict | None = None,
128
+ key: str | None = None,
129
+ base_url: str = DEFAULT_BASE_URL,
130
+ timeout: int = 30,
131
+ ) -> dict:
132
+ """One authenticated call to the v1 API. Errors become ProofLedgerError."""
133
+ request = urllib.request.Request(
134
+ f"{base_url.rstrip('/')}{path}",
135
+ data=None if payload is None else json.dumps(payload).encode("utf-8"),
136
+ headers={
137
+ "Authorization": f"Bearer {key or api_key()}",
138
+ "Content-Type": "application/json",
139
+ "User-Agent": "verify-proof (https://pypi.org/project/verify-proof/)",
140
+ },
141
+ method=method,
142
+ )
143
+ try:
144
+ with urllib.request.urlopen(request, timeout=timeout) as response:
145
+ body = response.read().decode("utf-8")
146
+ return json.loads(body) if body else {}
147
+ except urllib.error.HTTPError as exc:
148
+ raise ProofLedgerError(_http_message(exc)) from exc
149
+ except urllib.error.URLError as exc:
150
+ raise ProofLedgerError(
151
+ f"Could not reach {base_url}: {exc.reason}\n"
152
+ "Hashing and verification still work offline."
153
+ ) from exc
154
+
155
+
156
+ def _digest(sha256: str) -> str:
157
+ digest = sha256.strip().lower().removeprefix("0x")
158
+ if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
159
+ raise ProofLedgerError(f"Not a SHA-256 hex digest: {sha256!r}")
160
+ return digest
161
+
162
+
163
+ def submit_batch(
164
+ items: list[dict],
165
+ key: str | None = None,
166
+ base_url: str = DEFAULT_BASE_URL,
167
+ timeout: int = 60,
168
+ ) -> dict:
169
+ """Anchor up to 100 hashes in one call (POST /api/v1/proofs/batch).
170
+
171
+ Each item: {"sha256": ..., "external_id": optional, "filename": optional}.
172
+ Items are checked first and written together; Polygon only. The response
173
+ has a `summary` and one entry per item with `result` created / exists /
174
+ invalid and, for the first two, the `proof`.
175
+ """
176
+ if not items or len(items) > 100:
177
+ raise ProofLedgerError("submit_batch takes 1 to 100 items.")
178
+ payload = {"items": []}
179
+ for it in items:
180
+ out = {"sha256": _digest(it["sha256"])}
181
+ if it.get("external_id") is not None:
182
+ out["external_id"] = str(it["external_id"])
183
+ if it.get("filename"):
184
+ out["filename"] = str(it["filename"])[:500]
185
+ payload["items"].append(out)
186
+ return _request("POST", "/api/v1/proofs/batch", payload, key, base_url, timeout)
187
+
188
+
189
+ def get_proof(proof_id: int | str, key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict:
190
+ """One of your proofs by ProofLedger id (GET /api/v1/proof/:id)."""
191
+ return _request("GET", f"/api/v1/proof/{urllib.parse.quote(str(proof_id))}", None, key, base_url)
192
+
193
+
194
+ def get_proof_by_external_id(external_id: str, key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict | None:
195
+ """One of your proofs by your own record id, or None when there is none."""
196
+ try:
197
+ r = _request("GET", "/api/v1/proofs?" + urllib.parse.urlencode({"external_id": external_id}), None, key, base_url)
198
+ except ProofLedgerError as exc:
199
+ if "404" in str(exc):
200
+ return None
201
+ raise
202
+ return r.get("proof")
203
+
204
+
205
+ def get_bundle(proof_id: int | str, chain: str = "polygon", key: str | None = None, base_url: str = DEFAULT_BASE_URL) -> dict:
206
+ """The bundle for a proof (GET /api/v1/proof/:id/bundle): everything needed
207
+ to verify it without ProofLedger, in this package's proof-file format.
208
+ Save it next to the file and `verify-proof verify <file> --proof bundle.json`
209
+ works offline, for as long as the chain exists."""
210
+ if chain not in ("polygon", "bitcoin"):
211
+ raise ProofLedgerError("chain must be 'polygon' or 'bitcoin'")
212
+ return _request("GET", f"/api/v1/proof/{urllib.parse.quote(str(proof_id))}/bundle?chain={chain}", None, key, base_url)
213
+
214
+
215
+ def verify_webhook_signature(
216
+ secret: str,
217
+ header: str,
218
+ raw_body: bytes | str,
219
+ tolerance_seconds: int = 300,
220
+ now: int | None = None,
221
+ ) -> bool:
222
+ """Check a ProofLedger webhook delivery before trusting it.
223
+
224
+ ProofLedger sends `ProofLedger-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>`.
225
+ Pass the RAW request body exactly as received; re-serialising it breaks the MAC.
226
+ Pure computation, no network.
227
+ """
228
+ m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", str(header or ""))
229
+ if not m:
230
+ return False
231
+ t = int(m.group(1))
232
+ if abs((now if now is not None else int(time.time())) - t) > tolerance_seconds:
233
+ return False
234
+ body = raw_body if isinstance(raw_body, bytes) else str(raw_body).encode("utf-8")
235
+ expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
236
+ return hmac.compare_digest(expected, m.group(2))
237
+
238
+
114
239
  def _http_message(exc: urllib.error.HTTPError) -> str:
115
240
  """Turn an API error into something the reader can act on."""
116
241
  detail = ""
@@ -127,6 +252,12 @@ def _http_message(exc: urllib.error.HTTPError) -> str:
127
252
  + (f"\n({detail})" if detail else "")
128
253
  )
129
254
  if exc.code == 429:
255
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
256
+ if retry_after:
257
+ return (
258
+ f"Rate limit: 120 requests per minute per key (429). Retry after {retry_after} s.\n"
259
+ "One batch call carries up to 100 hashes." + (f"\n({detail})" if detail else "")
260
+ )
130
261
  return (
131
262
  "Monthly API proof limit reached (429).\n"
132
263
  "Free and Standard include 25 proofs a month, Professional 500, "
@@ -178,7 +309,8 @@ def summarize(proof: dict, base_url: str = DEFAULT_BASE_URL) -> str:
178
309
  lines += [
179
310
  "",
180
311
  "Anchoring is not instant. Polygon usually settles within minutes; "
181
- "Bitcoin is Merkle-batched daily. Re-check the proof, or verify the "
312
+ "Bitcoin, when requested, is anchored in hand-signed batches with no fixed "
313
+ "timeline. Re-check the proof, or verify the "
182
314
  "downloaded proof file offline with: verify-proof verify <file> "
183
315
  "--proof <proof.json>",
184
316
  ]
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "verify-proof"
7
- version = "0.3.0"
8
- description = "Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server"
7
+ version = "0.3.2"
8
+ description = "Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: verify-proof
3
- Version: 0.3.0
4
- Summary: Verify blockchain-anchored timestamp proofs (ProofLedger, OpenTimestamps) — CLI + MCP server
3
+ Version: 0.3.2
4
+ Summary: Verify blockchain-anchored timestamp proofs offline, and create them through the ProofLedger API — CLI + MCP server
5
5
  Author-email: Craig Solomon <craig@fulcrumenterprises.tech>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Fulcrum-Enterprises/verify-proof
@@ -102,6 +102,16 @@ verify-proof verify document.pdf --proof proof.json
102
102
  # Transaction: abc123... Anchored at: 2026-03-15T10:30:00Z
103
103
  ```
104
104
 
105
+ ### Quiet by default
106
+
107
+ A successful `verify` prints one line to stderr saying where proofs like it come
108
+ from. It is suppressed whenever the output is piped or redirected, so scripts
109
+ never see it, and stdout is unchanged either way. To silence it everywhere:
110
+
111
+ ```bash
112
+ export VERIFY_PROOF_NO_HINT=1
113
+ ```
114
+
105
115
  ### Create a proof (needs a free API key)
106
116
 
107
117
  Everything above works offline. This is the one command that does not: to prove
@@ -118,7 +128,9 @@ verify-proof create document.pdf --no-filename # send only the hash
118
128
  Only the 64-character SHA-256 digest leaves your machine, plus the filename
119
129
  unless you pass `--no-filename`. The file itself is never uploaded, on any
120
130
  command. Free accounts include 25 API proofs a month and unlimited Polygon
121
- anchoring through the web app: [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create).
131
+ anchoring through the web app:
132
+
133
+ [proofledger.io](https://proofledger.io/login.html?utm_source=verify-proof&utm_medium=readme&utm_campaign=create)
122
134
 
123
135
  ### Proof file format
124
136
 
@@ -159,7 +171,10 @@ The base install stays dependency-free; the `[mcp]` extra adds the MCP SDK and i
159
171
  | `verify_file` | Verify a local file against a proof JSON file |
160
172
  | `verify_hash` | Verify a known hash against inline or file-based proof data |
161
173
  | `explain_proof` | Describe, in plain language, what a proof asserts and how to check it on a block explorer |
162
- | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. The only tool that uses the network, and the only one needing an API key |
174
+ | `create_proof` | Anchor a file's hash on ProofLedger to create a new proof. Uses the network and needs an API key |
175
+ | `create_proofs_batch` | Anchor up to 100 files in one API call; file names double as external ids, so re-running returns the existing proofs. Network, API key |
176
+ | `get_proof_status` | Current anchoring status of one of your proofs. Network, API key |
177
+ | `save_proof_bundle` | Download a proof's bundle (this package's proof format) and save it next to the file, so verification needs nothing from ProofLedger later. Network, API key |
163
178
 
164
179
  ### Connect it to Claude Desktop
165
180
 
@@ -8,7 +8,7 @@ Supports verification of:
8
8
  - SHA-256 file hashes against Bitcoin and Polygon blockchain anchors
9
9
  - Proof of creation timestamps for digital content
10
10
  - Pre-loss evidence timestamps for legal/insurance records (ProofLedger)
11
- - Any OpenTimestamps (.ots) compatible proof file
11
+ - JSON proof files carrying hash, blockchain, tx_id and merkle_path (ProofLedger bundles; binary OpenTimestamps .ots files are a different format and are not parsed here, use the ots tool)
12
12
 
13
13
  Usage:
14
14
  python verify_proof.py <file> [--proof <proof.json>] [--algorithm sha256]
@@ -36,12 +36,14 @@ immutable, independently verifiable timestamps that prove what existed and when.
36
36
  import argparse
37
37
  import hashlib
38
38
  import json
39
+ import os
39
40
  import sys
40
41
  from pathlib import Path
41
42
 
42
43
  from proofledger_api import (
43
44
  API_KEY_ENV,
44
45
  DEFAULT_BASE_URL,
46
+ HOME_URL,
45
47
  ProofLedgerError,
46
48
  submit_proof,
47
49
  summarize,
@@ -122,12 +124,41 @@ def load_proof(proof_path: str) -> dict:
122
124
  return json.load(f)
123
125
 
124
126
 
127
+
128
+ def _hint() -> None:
129
+ """One quiet line after a successful verify, pointing at where proofs come from.
130
+
131
+ Almost everybody who installs this runs `verify`, and `verify` never touches
132
+ the network, which is the product. So the only tagged link the package ever
133
+ printed lived in the missing-key error on `create`, a path most users never
134
+ reach: 417 installs in a month produced 10 tagged arrivals in 120 days. The
135
+ tool was doing its job and saying nothing about who made it.
136
+
137
+ Three manners rules, because a dev tool that nags gets uninstalled and the
138
+ goodwill is the whole asset:
139
+
140
+ * **stderr, never stdout.** Somebody is parsing `VERIFIED: ...` in a script
141
+ and a marketing line in that stream would break them.
142
+ * **Only when a human is watching.** Not a TTY means piped or scripted, and
143
+ a pipeline does not need to hear from us.
144
+ * **One env var turns it off for good** (`VERIFY_PROOF_NO_HINT=1`).
145
+ """
146
+ if os.environ.get("VERIFY_PROOF_NO_HINT"):
147
+ return
148
+ try:
149
+ if not sys.stdout.isatty():
150
+ return
151
+ except Exception: # pragma: no cover - exotic stdout
152
+ return
153
+ print(f"\n(proofs like this are made with ProofLedger: {HOME_URL})", file=sys.stderr)
154
+
155
+
125
156
  def main():
126
157
  parser = argparse.ArgumentParser(
127
158
  prog="verify-proof",
128
159
  description=(
129
160
  "Verify blockchain-anchored timestamp proofs. "
130
- "Works with ProofLedger, OpenTimestamps, and compatible proofs."
161
+ "Reads JSON proofs (ProofLedger bundles and compatible); .ots files are not parsed."
131
162
  ),
132
163
  epilog=(
133
164
  "Examples:\n"
@@ -211,6 +242,7 @@ def main():
211
242
 
212
243
  if result["verified"]:
213
244
  print(f"VERIFIED: {result['message']}")
245
+ _hint()
214
246
  sys.exit(0)
215
247
  else:
216
248
  print(f"FAILED: {result.get('error', 'Unknown error')}")
@@ -3,19 +3,22 @@
3
3
 
4
4
  A Model Context Protocol (MCP) server that lets MCP-compatible clients
5
5
  (Claude Desktop, Cursor, and any other MCP host) verify blockchain-anchored
6
- timestamp proofs directly. It exposes the `verify-proof` library as four tools
6
+ timestamp proofs directly. It exposes the `verify-proof` library as tools
7
7
  so an assistant can, on the user's own machine:
8
8
 
9
9
  - compute the SHA-256 hash of a local file (the file is never uploaded),
10
10
  - verify a local file against a blockchain-anchored proof JSON,
11
11
  - verify a known hash against inline proof data,
12
12
  - explain in plain language what a proof file asserts,
13
- - create a new proof by anchoring a file's hash on ProofLedger.
13
+ - create a new proof by anchoring a file's hash on ProofLedger,
14
+ - create proofs for up to 100 files in one call, check a proof's status,
15
+ and save a proof's bundle so it verifies later without ProofLedger.
14
16
 
15
17
  The first four run entirely offline and always will: verification that depends
16
- on the issuing service is not verification. `create_proof` is the exception and
17
- says so in its own description - anchoring requires a service, and it sends the
18
- hash (never the file) to ProofLedger, with a free API key.
18
+ on the issuing service is not verification. The ProofLedger tools (create,
19
+ batch, status, bundle) are the exception and say so in their own descriptions:
20
+ anchoring requires a service, and they send hashes (never files) to
21
+ ProofLedger, with a free API key.
19
22
 
20
23
  Blockchain timestamping proves a file existed at a point in time by anchoring
21
24
  its SHA-256 hash to a public blockchain (Polygon or Bitcoin). ProofLedger
@@ -47,7 +50,15 @@ except ImportError as exc: # pragma: no cover - depends on installed SDK
47
50
  f"(underlying import error: {exc})"
48
51
  )
49
52
 
50
- from proofledger_api import ProofLedgerError, submit_proof, summarize
53
+ from proofledger_api import (
54
+ DEFAULT_BASE_URL,
55
+ ProofLedgerError,
56
+ get_bundle,
57
+ get_proof,
58
+ submit_batch,
59
+ submit_proof,
60
+ summarize,
61
+ )
51
62
  from verify_proof import hash_file, load_proof
52
63
  from verify_proof import verify_proof as _verify_proof
53
64
 
@@ -269,6 +280,108 @@ def create_proof(file_path: str, bitcoin: bool = False, send_filename: bool = Tr
269
280
  return summarize(proof)
270
281
 
271
282
 
283
+ @mcp.tool()
284
+ def create_proofs_batch(file_paths: list[str], send_filenames: bool = True) -> str:
285
+ """Create proofs for several local files in ONE ProofLedger API call (up to 100).
286
+
287
+ Uses the network and needs PROOFLEDGER_API_KEY, like create_proof. Each
288
+ file is hashed locally; only the hashes (and filenames, if allowed) are
289
+ sent. The file name is also used as the external_id, so running this twice
290
+ on the same files returns the existing proofs instead of creating more.
291
+ Polygon anchoring only.
292
+
293
+ Args:
294
+ file_paths: Paths of the files to timestamp, 1 to 100.
295
+ send_filenames: Send each file's name as its label. Set false to send
296
+ only the hashes (external ids are still the names).
297
+ """
298
+ paths = [Path(p) for p in file_paths]
299
+ missing = [str(p) for p in paths if not p.is_file()]
300
+ if missing:
301
+ return "File not found: " + ", ".join(missing)
302
+ if not paths or len(paths) > 100:
303
+ return "Give between 1 and 100 files."
304
+ items = [
305
+ {"sha256": hash_file(str(p)), "external_id": p.name, "filename": p.name if send_filenames else None}
306
+ for p in paths
307
+ ]
308
+ try:
309
+ r = submit_batch(items)
310
+ except ProofLedgerError as exc:
311
+ return str(exc)
312
+ summary = r.get("summary", {})
313
+ lines = [
314
+ f"Batch submitted: {summary.get('created', 0)} created, "
315
+ f"{summary.get('exists', 0)} already existed, {summary.get('invalid', 0)} invalid.",
316
+ "",
317
+ ]
318
+ for it, p in zip(r.get("items", []), paths):
319
+ proof = it.get("proof") or {}
320
+ if it.get("result") in ("created", "exists"):
321
+ lines.append(
322
+ f" {p.name}: proof {proof.get('id')} ({it['result']}) "
323
+ f"{DEFAULT_BASE_URL}{proof.get('certificate_url', '')}"
324
+ )
325
+ else:
326
+ lines.append(f" {p.name}: {it.get('result')} - {it.get('error', '')}")
327
+ return "\n".join(lines)
328
+
329
+
330
+ @mcp.tool()
331
+ def get_proof_status(proof_id: int) -> str:
332
+ """Fetch the current status of one of YOUR ProofLedger proofs by id.
333
+
334
+ Uses the network and needs PROOFLEDGER_API_KEY. Shows whether the Polygon
335
+ anchor has landed (usually within a minute of creation) and the Bitcoin
336
+ status when it was requested. For checking somebody else's proof, use
337
+ verify_file with their bundle instead; that needs no key and no network.
338
+ """
339
+ try:
340
+ p = get_proof(proof_id)
341
+ except ProofLedgerError as exc:
342
+ return str(exc)
343
+ lines = [
344
+ f"Proof {p.get('id')} ({p.get('filename') or 'no filename'})",
345
+ f" SHA-256 : {p.get('sha256')}",
346
+ f" Polygon : {p.get('polygon_status')}" + (f" tx {p.get('chain_tx')}" if p.get("chain_tx") else ""),
347
+ f" Bitcoin : {p.get('bitcoin_status')}" + (f" tx {p.get('bitcoin_txid')}" if p.get("bitcoin_txid") else ""),
348
+ f" Created : {p.get('created_at')}",
349
+ f" Certificate : {DEFAULT_BASE_URL}{p.get('certificate_url', '')}",
350
+ ]
351
+ if p.get("issued_via"):
352
+ lines.append(f" Recorded through {p['issued_via']} \u00b7 Anchored by ProofLedger")
353
+ return "\n".join(lines)
354
+
355
+
356
+ @mcp.tool()
357
+ def save_proof_bundle(proof_id: int, output_path: str, chain: str = "polygon") -> str:
358
+ """Download the bundle for one of YOUR proofs and save it as a JSON file.
359
+
360
+ Uses the network and needs PROOFLEDGER_API_KEY. The bundle is everything
361
+ needed to verify the proof WITHOUT ProofLedger: hash, chain, transaction
362
+ id, anchor time and, for Bitcoin, the Merkle path. Save it next to the
363
+ file; `verify_file` (or `verify-proof verify`) then works offline, for as
364
+ long as the blockchain exists. Before the anchor lands the bundle says
365
+ anchored: false; fetch it again afterwards.
366
+
367
+ Args:
368
+ proof_id: The ProofLedger proof id.
369
+ output_path: Where to write the JSON (e.g. song.mp3.proof.json).
370
+ chain: "polygon" (default) or "bitcoin" for the top-level fields.
371
+ """
372
+ try:
373
+ bundle = get_bundle(proof_id, chain=chain)
374
+ except ProofLedgerError as exc:
375
+ return str(exc)
376
+ out = Path(output_path)
377
+ out.write_text(json.dumps(bundle, indent=2), encoding="utf-8")
378
+ state = "anchored" if bundle.get("anchored") else "NOT YET ANCHORED (fetch again later)"
379
+ return (
380
+ f"Saved bundle for proof {proof_id} to {out} ({bundle.get('blockchain')}, {state}). "
381
+ f"Verify offline with: verify-proof verify <file> --proof {out}"
382
+ )
383
+
384
+
272
385
  def main() -> None:
273
386
  """Entry point — run the server over stdio (the transport MCP hosts use)."""
274
387
  mcp.run()
File without changes
File without changes