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.
|
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
|
## Install
|
||||||
|
|
||||||
|
|
@ -33,9 +33,15 @@ Tests:
|
||||||
uv run pytest
|
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
|
## 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)
|
out.append(rec)
|
||||||
return out
|
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:
|
def default_ledger_path() -> Path:
|
||||||
override = os.environ.get("VERAE_LEDGER_PATH")
|
override = os.environ.get("VERAE_LEDGER_PATH")
|
||||||
|
|
@ -191,3 +197,50 @@ class FileLedger(MemoryLedger):
|
||||||
if receipts[0].receipt_id not in before:
|
if receipts[0].receipt_id not in before:
|
||||||
self._save()
|
self._save()
|
||||||
return receipts
|
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 import MCPServer
|
||||||
from mcp.server.mcpserver.exceptions import ToolError
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger
|
||||||
from verae_mcp_timestamping_server.ledger import (
|
from verae_mcp_timestamping_server.ledger import (
|
||||||
FileLedger,
|
FileLedger,
|
||||||
Ledger,
|
Ledger,
|
||||||
MemoryLedger,
|
MemoryLedger,
|
||||||
Receipts,
|
Receipts,
|
||||||
|
RoutingLedger,
|
||||||
VerifyResult,
|
VerifyResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -19,9 +21,16 @@ mcp = MCPServer("verae-timestamping")
|
||||||
_ledger: Ledger = MemoryLedger()
|
_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:
|
def build_mcp(ledger: Ledger | None = None) -> MCPServer:
|
||||||
global _ledger
|
global _ledger
|
||||||
_ledger = ledger or FileLedger()
|
_ledger = ledger or default_ledger()
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,7 +42,7 @@ def _tool_error(exc: ValueError) -> ToolError:
|
||||||
async def stamp(
|
async def stamp(
|
||||||
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
|
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
|
||||||
) -> Receipts:
|
) -> 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:
|
try:
|
||||||
receipts = await _ledger.stamp(
|
receipts = await _ledger.stamp(
|
||||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
||||||
|
|
@ -45,7 +54,7 @@ async def stamp(
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
|
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)
|
return await _ledger.verify(receipt_id, want_scope=want_scope)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +73,7 @@ async def hash_report(
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
build_mcp(FileLedger())
|
build_mcp(default_ledger())
|
||||||
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
|
||||||
if transport == "streamable-http":
|
if transport == "streamable-http":
|
||||||
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
|
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