feat: route scope=global stamp/verify to api.veraetime.net
Evercycle-style local facade: GlobalClient posts /api/timestamp and /api/verify with Bearer from VERAE_GLOBAL_TOKEN or ~/.verae/auth.properties.
This commit is contained in:
parent
77872a720c
commit
107fa785b0
5 changed files with 341 additions and 7 deletions
12
README.md
12
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS.
|
||||
|
||||
The MCP process never hashes content. Clients send a SHA-256 hex digest. Tools `stamp`, `verify`, and `hash_report` use an in-memory ledger over stdio, or `NatsLedger` against `verae.ts.request` / `verae.ts.verify` / `verae.hash.report` (leaf `locald`). Plan and product model: [verae-mcp-research](../verae-mcp-research).
|
||||
The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. Bearer token: `VERAE_GLOBAL_TOKEN` or `~/.verae/auth.properties`.
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -33,9 +33,15 @@ Tests:
|
|||
uv run pytest
|
||||
```
|
||||
|
||||
Point a later HTTP deploy at a leaf with `NatsLedger` and `VERAE_NATS_URL=nats://verae-nats-bus:4222`. Do not send file bytes to `stamp`.
|
||||
Global stamp (central VeraeTime):
|
||||
|
||||
Local receipts persist at `~/.verae/mcp-timestamp-local.json` (override with `VERAE_LEDGER_PATH`).
|
||||
```sh
|
||||
export VERAE_GLOBAL_URL=https://api.veraetime.net
|
||||
export VERAE_GLOBAL_TOKEN= # or put access_token= in ~/.verae/auth.properties
|
||||
# then call tool stamp with scope=global
|
||||
```
|
||||
|
||||
Do not send file bytes to `stamp`. Local receipts persist at `~/.verae/mcp-timestamp-local.json` (`VERAE_LEDGER_PATH`).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
173
src/verae_mcp_timestamping_server/global_client.py
Normal file
173
src/verae_mcp_timestamping_server/global_client.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""HTTPS client for api.veraetime.net — same shape as verae-timestamp/global.Client.
|
||||
|
||||
Evercycle-style: local process holds a bearer token and posts to the central API.
|
||||
The global server is the source of truth for scope=global.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from verae_mcp_timestamping_server.ledger import (
|
||||
Receipt,
|
||||
VerifyResult,
|
||||
validate_sha256,
|
||||
)
|
||||
|
||||
DEFAULT_GLOBAL_URL = "https://api.veraetime.net"
|
||||
|
||||
|
||||
class HttpTransport(Protocol):
|
||||
def request(
|
||||
self, method: str, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||
) -> Any: ...
|
||||
|
||||
|
||||
class _UrllibResp:
|
||||
def __init__(self, status_code: int, body: Any, text: str):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._body
|
||||
|
||||
|
||||
class UrllibHttp:
|
||||
def request(
|
||||
self, method: str, url: str, json: dict[str, Any], headers: dict[str, str]
|
||||
) -> _UrllibResp:
|
||||
data = json_dumps(json).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
for key, value in headers.items():
|
||||
req.add_header(key, value)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as res:
|
||||
raw = res.read().decode("utf-8", errors="replace")
|
||||
status = getattr(res, "status", 200)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
status = exc.code
|
||||
try:
|
||||
body = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
body = {"error": raw}
|
||||
return _UrllibResp(status, body, raw)
|
||||
|
||||
|
||||
def json_dumps(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def load_auth_token() -> str:
|
||||
env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
|
||||
if env:
|
||||
return env
|
||||
path = Path(os.environ.get("VERAE_AUTH_PROPERTIES", Path.home() / ".verae" / "auth.properties"))
|
||||
if not path.is_file():
|
||||
return ""
|
||||
props: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
props[key.strip()] = value.strip()
|
||||
return props.get("access_token") or props.get("id_token") or props.get("auth_token") or ""
|
||||
|
||||
|
||||
class GlobalClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_GLOBAL_URL,
|
||||
token: str = "",
|
||||
http: HttpTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.http = http or UrllibHttp()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> GlobalClient:
|
||||
return cls(
|
||||
base_url=os.environ.get("VERAE_GLOBAL_URL", DEFAULT_GLOBAL_URL),
|
||||
token=load_auth_token(),
|
||||
)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def submit(self, digest: str, cube_id: str = "") -> Receipt:
|
||||
url = f"{self.base_url}/api/timestamp"
|
||||
res = self.http.request(
|
||||
"POST", url, {"hash": digest, "sync": True}, self._headers()
|
||||
)
|
||||
if res.status_code >= 300:
|
||||
raise ValueError(f"global timestamp HTTP {res.status_code}: {res.text}")
|
||||
parsed = res.json()
|
||||
job_id = parsed.get("jobId") or parsed.get("blockHash") or ""
|
||||
if not job_id:
|
||||
raise ValueError("global timestamp response missing jobId")
|
||||
return Receipt(
|
||||
receipt_id=str(job_id),
|
||||
hash=digest,
|
||||
scope="global",
|
||||
cube_id=cube_id,
|
||||
created_at=str(parsed.get("timestamp") or ""),
|
||||
merkle=str(parsed.get("blockHash") or ""),
|
||||
)
|
||||
|
||||
def verify_certificate(self, receipt_id: str) -> VerifyResult:
|
||||
url = f"{self.base_url}/api/verify"
|
||||
res = self.http.request(
|
||||
"POST", url, {"certificate": receipt_id}, self._headers()
|
||||
)
|
||||
if res.status_code >= 300:
|
||||
return VerifyResult(valid=False, error=f"verify HTTP {res.status_code}")
|
||||
parsed = res.json()
|
||||
if not parsed.get("valid"):
|
||||
return VerifyResult(valid=False, error="not valid")
|
||||
return VerifyResult(
|
||||
valid=True,
|
||||
receipt=Receipt(
|
||||
receipt_id=receipt_id,
|
||||
hash=str(parsed.get("hash") or ""),
|
||||
scope="global",
|
||||
created_at=str(parsed.get("timestamp") or ""),
|
||||
merkle=str(parsed.get("blockHash") or parsed.get("blockIndex") or ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GlobalLedger:
|
||||
def __init__(self, client: GlobalClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def stamp(
|
||||
self,
|
||||
sha256: str,
|
||||
scope: str = "global",
|
||||
cube_id: str = "",
|
||||
org_id: str = "",
|
||||
) -> list[Receipt]:
|
||||
del org_id
|
||||
digest = validate_sha256(sha256)
|
||||
return [self.client.submit(digest, cube_id=cube_id)]
|
||||
|
||||
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||
del want_scope
|
||||
return self.client.verify_certificate(receipt_id)
|
||||
|
||||
async def hash_report(
|
||||
self, sha256: str, org_id: str = "", want_scope: str = ""
|
||||
) -> list[Receipt]:
|
||||
del sha256, org_id, want_scope
|
||||
return []
|
||||
|
|
@ -148,6 +148,12 @@ class MemoryLedger:
|
|||
out.append(rec)
|
||||
return out
|
||||
|
||||
def remember(self, rec: Receipt) -> None:
|
||||
if rec.receipt_id in self._by_id:
|
||||
return
|
||||
self._by_id[rec.receipt_id] = rec
|
||||
self._order.append(rec.receipt_id)
|
||||
|
||||
|
||||
def default_ledger_path() -> Path:
|
||||
override = os.environ.get("VERAE_LEDGER_PATH")
|
||||
|
|
@ -191,3 +197,50 @@ class FileLedger(MemoryLedger):
|
|||
if receipts[0].receipt_id not in before:
|
||||
self._save()
|
||||
return receipts
|
||||
|
||||
def remember(self, rec: Receipt) -> None:
|
||||
super().remember(rec)
|
||||
self._save()
|
||||
|
||||
|
||||
class RoutingLedger:
|
||||
"""Local/shared stay on the leaf ledger; global goes to VeraeTime HTTP."""
|
||||
|
||||
def __init__(self, local: Ledger, global_ledger: Ledger) -> None:
|
||||
self.local = local
|
||||
self.global_ledger = global_ledger
|
||||
|
||||
async def stamp(
|
||||
self,
|
||||
sha256: str,
|
||||
scope: str = "local",
|
||||
cube_id: str = "",
|
||||
org_id: str = "",
|
||||
) -> list[Receipt]:
|
||||
if scope == "global":
|
||||
receipts = await self.global_ledger.stamp(
|
||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
||||
)
|
||||
remember = getattr(self.local, "remember", None)
|
||||
if callable(remember):
|
||||
for rec in receipts:
|
||||
remember(rec)
|
||||
return receipts
|
||||
return await self.local.stamp(
|
||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
||||
)
|
||||
|
||||
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||
if want_scope == "global":
|
||||
return await self.global_ledger.verify(receipt_id, want_scope=want_scope)
|
||||
local_result = await self.local.verify(receipt_id, want_scope=want_scope)
|
||||
if local_result.valid:
|
||||
return local_result
|
||||
return await self.global_ledger.verify(receipt_id, want_scope=want_scope)
|
||||
|
||||
async def hash_report(
|
||||
self, sha256: str, org_id: str = "", want_scope: str = ""
|
||||
) -> list[Receipt]:
|
||||
return await self.local.hash_report(
|
||||
sha256, org_id=org_id, want_scope=want_scope
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import os
|
|||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger
|
||||
from verae_mcp_timestamping_server.ledger import (
|
||||
FileLedger,
|
||||
Ledger,
|
||||
MemoryLedger,
|
||||
Receipts,
|
||||
RoutingLedger,
|
||||
VerifyResult,
|
||||
)
|
||||
|
||||
|
|
@ -19,9 +21,16 @@ mcp = MCPServer("verae-timestamping")
|
|||
_ledger: Ledger = MemoryLedger()
|
||||
|
||||
|
||||
def default_ledger() -> Ledger:
|
||||
return RoutingLedger(
|
||||
local=FileLedger(),
|
||||
global_ledger=GlobalLedger(GlobalClient.from_env()),
|
||||
)
|
||||
|
||||
|
||||
def build_mcp(ledger: Ledger | None = None) -> MCPServer:
|
||||
global _ledger
|
||||
_ledger = ledger or FileLedger()
|
||||
_ledger = ledger or default_ledger()
|
||||
return mcp
|
||||
|
||||
|
||||
|
|
@ -33,7 +42,7 @@ def _tool_error(exc: ValueError) -> ToolError:
|
|||
async def stamp(
|
||||
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
|
||||
) -> Receipts:
|
||||
"""Commit a client-computed SHA-256 to the Verae leaf ledger. Hex only."""
|
||||
"""Commit a SHA-256. scope=local is the leaf file ledger; scope=global is api.veraetime.net."""
|
||||
try:
|
||||
receipts = await _ledger.stamp(
|
||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
||||
|
|
@ -45,7 +54,7 @@ async def stamp(
|
|||
|
||||
@mcp.tool()
|
||||
async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||
"""Check a timestamp receipt. want_scope=global rejects a weaker local receipt."""
|
||||
"""Check a receipt. want_scope=global asks api.veraetime.net (central source of truth)."""
|
||||
return await _ledger.verify(receipt_id, want_scope=want_scope)
|
||||
|
||||
|
||||
|
|
@ -64,7 +73,7 @@ async def hash_report(
|
|||
|
||||
|
||||
def main() -> None:
|
||||
build_mcp(FileLedger())
|
||||
build_mcp(default_ledger())
|
||||
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
||||
if transport == "streamable-http":
|
||||
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
|
||||
|
|
|
|||
93
tests/test_global_client.py
Normal file
93
tests/test_global_client.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Global VeraeTime client: same wire shape as verae-timestamp/global.Client."""
|
||||
|
||||
import pytest
|
||||
|
||||
from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger
|
||||
from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger, RoutingLedger
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
self.text = str(body)
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
class FakeHttp:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, str, dict, dict]] = []
|
||||
|
||||
def request(self, method: str, url: str, json: dict, headers: dict) -> _Resp:
|
||||
self.calls.append((method, url, json, headers))
|
||||
if url.endswith("/api/timestamp"):
|
||||
return _Resp(
|
||||
200,
|
||||
{
|
||||
"jobId": "job-1",
|
||||
"hash": json["hash"],
|
||||
"timestamp": "2026-08-15T00:00:00Z",
|
||||
"blockHash": "0xdead",
|
||||
},
|
||||
)
|
||||
if url.endswith("/api/verify"):
|
||||
if json.get("certificate") == "job-1":
|
||||
return _Resp(200, {"valid": True, "hash": SHA256_EMPTY})
|
||||
return _Resp(200, {"valid": False})
|
||||
return _Resp(404, {"error": "nope"})
|
||||
|
||||
|
||||
async def test_global_stamp_posts_hash_sync_and_maps_receipt():
|
||||
http = FakeHttp()
|
||||
client = GlobalClient("https://api.veraetime.net", token="secret", http=http)
|
||||
ledger = GlobalLedger(client)
|
||||
[rec] = await ledger.stamp(SHA256_EMPTY, scope="global", cube_id="cube-1")
|
||||
method, url, payload, headers = http.calls[0]
|
||||
assert method == "POST"
|
||||
assert url == "https://api.veraetime.net/api/timestamp"
|
||||
assert payload == {"hash": SHA256_EMPTY, "sync": True}
|
||||
assert headers["Authorization"] == "Bearer secret"
|
||||
assert rec.scope == "global"
|
||||
assert rec.receipt_id == "job-1"
|
||||
assert rec.merkle == "0xdead"
|
||||
assert rec.cube_id == "cube-1"
|
||||
|
||||
|
||||
async def test_global_verify_posts_certificate():
|
||||
http = FakeHttp()
|
||||
ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", http=http))
|
||||
ok = await ledger.verify("job-1", want_scope="global")
|
||||
assert ok.valid is True
|
||||
assert ok.receipt is not None
|
||||
assert ok.receipt.hash == SHA256_EMPTY
|
||||
assert ok.receipt.scope == "global"
|
||||
_, url, payload, _ = http.calls[0]
|
||||
assert url.endswith("/api/verify")
|
||||
assert payload == {"certificate": "job-1"}
|
||||
|
||||
|
||||
async def test_routing_sends_only_global_scope_to_central():
|
||||
http = FakeHttp()
|
||||
local = MemoryLedger()
|
||||
routed = RoutingLedger(
|
||||
local=local,
|
||||
global_ledger=GlobalLedger(GlobalClient("https://api.veraetime.net", http=http)),
|
||||
)
|
||||
[local_rec] = await routed.stamp(SHA256_EMPTY, scope="local")
|
||||
assert local_rec.scope == "local"
|
||||
assert http.calls == []
|
||||
|
||||
[g] = await routed.stamp(SHA256_EMPTY, scope="global", cube_id="cube-1")
|
||||
assert g.scope == "global"
|
||||
assert g.receipt_id == "job-1"
|
||||
assert len(http.calls) == 1
|
||||
|
||||
cached = await routed.hash_report(SHA256_EMPTY, want_scope="global")
|
||||
assert cached[0].receipt_id == "job-1"
|
||||
|
||||
weak = await routed.verify(local_rec.receipt_id, want_scope="global")
|
||||
assert weak.valid is False
|
||||
Loading…
Reference in a new issue