diff --git a/README.md b/README.md index ea9f1ff..7abd0cc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Go MCP timestamping server for Verae. Digests only; sibling to the Python server Implements the same tools as https://mcp.verae.ai/mcp (`stamp`, `verify`, `hash_report`, auth tools) using the official MCP Go SDK. Designed for shadow deploy beside Python, then cutover. -AI assistants use MCP. Bulk app workers can import `pkg/tsnats` for core NATS request/reply on `verae.ts.*` (job-complete stays ephemeral; no JetStream for those events). +AI assistants use MCP. Bulk app workers can import `pkg/tsnats` for core NATS request/reply on `verae.ts.*` (job-complete stays ephemeral; no JetStream for those events). Cutover steps: [`docs/cutover.md`](docs/cutover.md). ## Table of Contents @@ -22,19 +22,81 @@ cd verae-mcp-timestamping-server-go go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server ``` -Requires Go 1.26+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. +Requires Go 1.26+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. Shadow systemd unit (not installed by this build): `deploy/verae-mcp-timestamping-go.service`. ## Usage +Shadow beside Python (`:8000` stays live): + ```sh VERAE_MCP_TRANSPORT=streamable-http \ VERAE_MCP_HOST=127.0.0.1 \ VERAE_MCP_PORT=8001 \ VERAE_NATS_LISTEN=127.0.0.1:4223 \ +VERAE_MCP_REQUIRE_BEARER=0 \ ./bin/verae-mcp-timestamping-server ``` -Point clients at `http://127.0.0.1:8001/mcp`. Global stamps use `https://api.veraetime.net` with auth from `~/.verae/auth.properties` (or `VERAE_AUTH_PROPERTIES`). Embedded NATS (default `127.0.0.1:4223`; set `VERAE_NATS_LISTEN=` empty to disable) serves `verae.ts.*` with the same ledger/VeraeTime routing; job-complete is core `verae.ts.completed` only. For stdio MCP: `-transport stdio` or `VERAE_MCP_TRANSPORT=stdio`. +Point MCP clients at `http://127.0.0.1:8001/mcp`. For stdio: `-transport stdio` or `VERAE_MCP_TRANSPORT=stdio`. + +| Variable | Default | Notes | +| --- | --- | --- | +| `VERAE_MCP_TRANSPORT` | `streamable-http` | or `stdio` | +| `VERAE_MCP_HOST` | `127.0.0.1` | HTTP bind | +| `VERAE_MCP_PORT` | `8000` | shadow examples use `8001` | +| `VERAE_NATS_LISTEN` | `127.0.0.1:4223` | embedded NATS; set empty to disable | +| `VERAE_MCP_REQUIRE_BEARER` | `0` | `0` = host-token keeper when no caller bearer; `1` = require caller bearer | +| `VERAE_GLOBAL_URL` | `https://api.veraetime.net` | | +| `VERAE_AUTH_PROPERTIES` | `$HOME/.verae/auth.properties` | | +| `VERAE_GLOBAL_TOKEN` | empty | optional static host bearer | +| `VERAE_LEDGER_PATH` | `$HOME/.verae/mcp-timestamp-local.json` | | +| `VERAE_MCP_CORS` | off | set `1` for browser CORS | + +Global stamps use VeraeTime with auth from `auth.properties` (or caller Bearer). Embedded NATS serves `verae.ts.*`; job-complete is core `verae.ts.completed` only. + +Parity smoke when Python `:8000` and Go `:8001` are both up: + +```sh +./scripts/parity_smoke.sh +``` + +### pkg/tsnats + +Bulk workers talk to the embedded (or any) NATS URL. Digests only; `SubscribeCompleted` is core NATS, not JetStream. + +```go +package main + +import ( + "context" + "log" + "os" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/tsnats" +) + +func main() { + url := os.Getenv("NATS_URL") + if url == "" { + url = "nats://127.0.0.1:4223" + } + client, err := tsnats.Connect(url) + if err != nil { + log.Fatal(err) + } + defer client.Close() + + ctx := context.Background() + digest := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + receipts, err := client.Stamp(ctx, digest, tsnats.StampOptions{Scope: "global"}) + if err != nil { + log.Fatal(err) + } + log.Printf("receipts: %+v", receipts) +} +``` + +Runnable sketch: `examples/tsnats-bulk/main.go`. ## Contributing diff --git a/docs/cutover.md b/docs/cutover.md new file mode 100644 index 0000000..b042f61 --- /dev/null +++ b/docs/cutover.md @@ -0,0 +1,67 @@ +# Shadow deploy and cutover (Go MCP) + +Python stays on `127.0.0.1:8000`. Go shadows on `127.0.0.1:8001` with embedded NATS on `127.0.0.1:4223`. Do **not** install or enable the Go unit on ns1 until an explicit publish/deploy order. + +## Shadow layout + +| Role | Bind | Notes | +| --- | --- | --- | +| Python MCP (live) | `127.0.0.1:8000` | Caddy → public `/mcp` | +| Go MCP (shadow) | `127.0.0.1:8001` | Internal smoke only until cutover | +| Go embedded NATS | `127.0.0.1:4223` | `pkg/tsnats` / bulk workers | + +Install tree (when deploying): `/opt/verae/verae-mcp-timestamping-server-go/` (does not overwrite the Python venv). Unit file in-repo: `deploy/verae-mcp-timestamping-go.service`. + +## Bearer / host-token (shadow default) + +| `VERAE_MCP_REQUIRE_BEARER` | Behavior | +| --- | --- | +| `0` or unset (**default**) | Caller `Authorization: Bearer` wins when present. Otherwise the process keeps a host token from `~/.verae/auth.properties` (or `VERAE_AUTH_PROPERTIES` / `VERAE_GLOBAL_TOKEN`) and refreshes before expiry. | +| `1` | Caller bearer required for global VeraeTime paths; no host-token fallback. | + +Shadow unit ships `VERAE_MCP_REQUIRE_BEARER=0` so operators can smoke without every client supplying a JWT. + +## Embedded NATS / job-complete + +- Listen: `VERAE_NATS_LISTEN=127.0.0.1:4223` (set empty to disable embed). +- Request/reply: `verae.ts.request`, `verae.ts.verify`, `verae.hash.report`. +- Job-complete / jobId notify: **core NATS only** on `verae.ts.completed` (and receipt notify if used). **No JetStream** (or other persistent stream) for job-complete. Publishers fire-and-forget; listeners subscribe. + +## Env cheat sheet (shadow) + +```sh +VERAE_MCP_TRANSPORT=streamable-http +VERAE_MCP_HOST=127.0.0.1 +VERAE_MCP_PORT=8001 +VERAE_NATS_LISTEN=127.0.0.1:4223 +VERAE_MCP_REQUIRE_BEARER=0 +# optional: VERAE_AUTH_PROPERTIES, VERAE_LEDGER_PATH, VERAE_GLOBAL_URL, VERAE_GLOBAL_TOKEN, VERAE_MCP_CORS +``` + +Service user on ns1 is `verae-mcp` with `HOME=/var/lib/verae-mcp` (share or isolate `auth.properties` vs Python as chosen for the soak). + +## Local parity before soak + +With Python on `:8000` and Go on `:8001`: + +```sh +./scripts/parity_smoke.sh +``` + +Compares `tools/list` name sets. Optionally stamp/verify/hash_report the same digests against both ports during soak. + +## Cutover checklist + +1. Shadow green for the agreed soak (tools/list parity, global stamp/verify/hash_report, local ledger, NATS stamp + `verae.ts.completed` observe). +2. Stop Python unit **or** switch Caddy upstream away from `:8000` Python. +3. Point Go at public port: set `VERAE_MCP_PORT=8000` (or leave Go on `8001` and point Caddy at Go). +4. Confirm `https://mcp.verae.ai/mcp` `tools/list` and a known digest `hash_report`. +5. Keep the Python tree on disk for one release cycle. +6. Do not enable JetStream for job-complete as part of cutover. + +## Rollback checklist + +1. Restore Python `ExecStart` / Caddy upstream to `127.0.0.1:8000` Python. +2. Stop or disable `verae-mcp-timestamping-go.service` (or move Go back to `:8001` only). +3. Restart Python; confirm public `tools/list` and a known `hash_report`. +4. Leave Go binary under `/opt/verae/verae-mcp-timestamping-server-go/` for a later retry; do not delete until soak of the rollback is confirmed. diff --git a/examples/tsnats-bulk/main.go b/examples/tsnats-bulk/main.go new file mode 100644 index 0000000..3fde996 --- /dev/null +++ b/examples/tsnats-bulk/main.go @@ -0,0 +1,50 @@ +// Command tsnats-bulk is a minimal digests-only stamp client against embedded NATS. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/tsnats" +) + +func main() { + natsURL := flag.String("nats", envOr("NATS_URL", "nats://127.0.0.1:4223"), "NATS URL (embedded default :4223)") + digest := flag.String("digest", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "SHA-256 hex") + scope := flag.String("scope", "local", "stamp scope: local|shared|global") + flag.Parse() + + client, err := tsnats.Connect(*natsURL, tsnats.WithTimeout(5*time.Second)) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer client.Close() + + ctx := context.Background() + sub, err := client.SubscribeCompleted(ctx, func(ev tsnats.CompletedEvent) { + fmt.Printf("completed jobId=%s receipt_id=%s hash=%s\n", ev.JobID, ev.ReceiptID, ev.Hash) + }) + if err != nil { + log.Fatalf("subscribe completed: %v", err) + } + defer func() { _ = sub.Unsubscribe() }() + + receipts, err := client.Stamp(ctx, *digest, tsnats.StampOptions{Scope: *scope}) + if err != nil { + log.Fatalf("stamp: %v", err) + } + for _, r := range receipts { + fmt.Printf("receipt_id=%s hash=%s scope=%s\n", r.ReceiptID, r.Hash, r.Scope) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +}