feat: MCP Streamable HTTP server with full tool set

Wire go-sdk Streamable HTTP at /mcp with stamp, verify, hash_report,
signup, login, session, create_user. Route global/CID to VeraeTime and
local|shared to FileLedger; resolve Authorization bearer via authctx.
Flags/env for transport, host, port, require-bearer, URLs, ledger path.
This commit is contained in:
George Lambert 2026-09-29 12:07:44 -04:00
commit d379931bd1
11 changed files with 925 additions and 6 deletions

View file

@ -22,7 +22,7 @@ cd verae-mcp-timestamping-server-go
go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server
```
Requires Go 1.22+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`.
Requires Go 1.25+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`.
## Usage

View file

@ -0,0 +1,82 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
transport := flag.String("transport", "streamable-http", "MCP transport: streamable-http or stdio")
host := flag.String("host", "127.0.0.1", "HTTP bind host")
port := flag.Int("port", 8000, "HTTP bind port (shadow often uses 8001)")
flag.Parse()
cfg := server.LoadConfig(*transport, *host, *port)
led, err := ledger.Open(cfg.LedgerPath)
if err != nil {
log.Fatalf("ledger: %v", err)
}
global := veraetime.NewClient(cfg.GlobalURL, veraetime.WithAuthPath(cfg.AuthPath))
keeper := veraetime.NewKeeper(global, cfg.AuthPath)
switch cfg.Transport {
case "streamable-http", "http":
srv, err := server.New(server.Options{
Ledger: led,
Global: global,
Keeper: keeper,
AuthPath: cfg.AuthPath,
GlobalURL: cfg.GlobalURL,
RequireBearer: cfg.RequireBearer,
JSONResponse: true,
Stateless: true,
})
if err != nil {
log.Fatalf("server: %v", err)
}
handler := server.WithCORS(srv.Handler(), cfg.CORS)
httpSrv := &http.Server{Addr: cfg.Addr(), Handler: handler}
go func() {
log.Printf("verae-mcp-timestamping-server listening on http://%s/mcp", cfg.Addr())
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
waitSignal()
_ = httpSrv.Shutdown(context.Background())
case "stdio":
mcpServer := tools.NewServer(&tools.Deps{
Ledger: led,
Global: global,
Keeper: keeper,
RequireBearer: cfg.RequireBearer,
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
fmt.Fprintf(os.Stderr, "stdio: %v\n", err)
os.Exit(1)
}
default:
log.Fatalf("unknown transport %q (use streamable-http or stdio)", cfg.Transport)
}
}
func waitSignal() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
<-ch
}

18
go.mod
View file

@ -1,13 +1,23 @@
module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go
go 1.22.0
require github.com/nats-io/nats.go v1.39.1
go 1.25.0
require (
github.com/modelcontextprotocol/go-sdk v1.8.0
github.com/nats-io/nats.go v1.39.1
)
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/nats-io/nkeys v0.4.9 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
)

26
go.sum
View file

@ -1,12 +1,34 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/nats-io/nats.go v1.39.1 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk=
github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM=
github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0=
github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

189
internal/server/server.go Normal file
View file

@ -0,0 +1,189 @@
package server
import (
"fmt"
"net/http"
"os"
"strconv"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/tools"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Options configures the MCP HTTP server.
type Options struct {
Ledger *ledger.FileLedger
Global *veraetime.Client
Keeper *veraetime.Keeper
AuthPath string
GlobalURL string
RequireBearer bool
JSONResponse bool
Stateless bool
}
// Server wraps the MCP Streamable HTTP handler mounted at /mcp.
type Server struct {
mcp *mcp.Server
handler http.Handler
deps *tools.Deps
}
// New builds an MCP server and Streamable HTTP handler.
func New(opts Options) (*Server, error) {
globalURL := strings.TrimSpace(opts.GlobalURL)
if globalURL == "" {
globalURL = veraetime.DefaultBaseURL
}
authPath := strings.TrimSpace(opts.AuthPath)
if authPath == "" {
authPath = veraetime.DefaultAuthPath()
}
global := opts.Global
if global == nil {
global = veraetime.NewClient(globalURL, veraetime.WithAuthPath(authPath))
}
keeper := opts.Keeper
if keeper == nil {
keeper = veraetime.NewKeeper(global, authPath)
}
led := opts.Ledger
if led == nil {
var err error
led, err = ledger.Open(ledger.DefaultPath())
if err != nil {
return nil, fmt.Errorf("open ledger: %w", err)
}
}
deps := &tools.Deps{
Ledger: led,
Global: global,
Keeper: keeper,
RequireBearer: opts.RequireBearer,
}
mcpServer := tools.NewServer(deps)
stream := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return mcpServer
}, &mcp.StreamableHTTPOptions{
JSONResponse: opts.JSONResponse,
Stateless: opts.Stateless,
})
mux := http.NewServeMux()
mux.Handle("/mcp", stream)
mux.Handle("/mcp/", stream)
return &Server{mcp: mcpServer, handler: mux, deps: deps}, nil
}
// Handler returns the HTTP handler (routes /mcp).
func (s *Server) Handler() http.Handler {
if s == nil {
return http.NotFoundHandler()
}
return s.handler
}
// MCP returns the underlying MCP server.
func (s *Server) MCP() *mcp.Server {
if s == nil {
return nil
}
return s.mcp
}
// Config is runtime configuration from flags and env.
type Config struct {
Transport string
Host string
Port int
RequireBearer bool
GlobalURL string
AuthPath string
LedgerPath string
CORS bool
}
// LoadConfig merges flag defaults with environment overrides.
// Env wins when set: VERAE_MCP_TRANSPORT, VERAE_MCP_HOST, VERAE_MCP_PORT,
// VERAE_MCP_REQUIRE_BEARER, VERAE_GLOBAL_URL, VERAE_AUTH_PROPERTIES, VERAE_LEDGER_PATH, VERAE_MCP_CORS.
func LoadConfig(transport, host string, port int) Config {
cfg := Config{
Transport: transport,
Host: host,
Port: port,
GlobalURL: veraetime.DefaultBaseURL,
AuthPath: veraetime.DefaultAuthPath(),
LedgerPath: ledger.DefaultPath(),
}
if v := strings.TrimSpace(os.Getenv("VERAE_MCP_TRANSPORT")); v != "" {
cfg.Transport = v
}
if v := strings.TrimSpace(os.Getenv("VERAE_MCP_HOST")); v != "" {
cfg.Host = v
}
if v := strings.TrimSpace(os.Getenv("VERAE_MCP_PORT")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.Port = n
}
}
if v := strings.TrimSpace(os.Getenv("VERAE_GLOBAL_URL")); v != "" {
cfg.GlobalURL = v
}
if v := strings.TrimSpace(os.Getenv("VERAE_AUTH_PROPERTIES")); v != "" {
cfg.AuthPath = v
}
if v := strings.TrimSpace(os.Getenv("VERAE_LEDGER_PATH")); v != "" {
cfg.LedgerPath = v
}
cfg.RequireBearer = envTruthy("VERAE_MCP_REQUIRE_BEARER")
cfg.CORS = envTruthy("VERAE_MCP_CORS")
if cfg.Transport == "" {
cfg.Transport = "streamable-http"
}
if cfg.Host == "" {
cfg.Host = "127.0.0.1"
}
if cfg.Port == 0 {
cfg.Port = 8000
}
return cfg
}
func envTruthy(key string) bool {
v := strings.TrimSpace(os.Getenv(key))
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// Addr returns host:port.
func (c Config) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
// WithCORS wraps h with permissive CORS when enabled (Python VERAE_MCP_CORS=1).
func WithCORS(h http.Handler, enabled bool) http.Handler {
if !enabled {
return h
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Expose-Headers", "mcp-session-id, Mcp-Session-Id")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,107 @@
package server_test
import (
"context"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) {
t.Parallel()
dir := t.TempDir()
led, err := ledger.Open(filepath.Join(dir, "ledger.json"))
if err != nil {
t.Fatal(err)
}
authPath := filepath.Join(dir, "auth.properties")
if err := os.WriteFile(authPath, []byte("username=x\npassword=y\n"), 0o600); err != nil {
t.Fatal(err)
}
srv, err := server.New(server.Options{
Ledger: led,
AuthPath: authPath,
GlobalURL: "http://127.0.0.1:1", // unused for local stamp
RequireBearer: false,
JSONResponse: true,
Stateless: true,
})
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx := context.Background()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: httpServer.URL + "/mcp"}, nil)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = session.Close() })
tools, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
wantTools := []string{"stamp", "verify", "hash_report", "signup", "login", "session", "create_user"}
got := map[string]bool{}
for _, tool := range tools.Tools {
got[tool.Name] = true
}
for _, name := range wantTools {
if !got[name] {
t.Fatalf("tools/list missing %q; got %#v", name, got)
}
}
res, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "stamp",
Arguments: map[string]any{
"sha256": sha256Empty,
"scope": "local",
},
})
if err != nil {
t.Fatalf("tools/call stamp: %v", err)
}
if res.IsError {
t.Fatalf("stamp tool error: %+v", res.Content)
}
raw, err := json.Marshal(res.StructuredContent)
if err != nil {
t.Fatal(err)
}
var out struct {
Receipts []struct {
Hash string `json:"hash"`
Scope string `json:"scope"`
} `json:"receipts"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode structured: %v raw=%s", err, raw)
}
if len(out.Receipts) != 1 {
t.Fatalf("want 1 receipt, got %s", raw)
}
if out.Receipts[0].Hash != sha256Empty {
t.Fatalf("hash=%q", out.Receipts[0].Hash)
}
if out.Receipts[0].Scope != "local" {
t.Fatalf("scope=%q", out.Receipts[0].Scope)
}
if !strings.Contains(string(raw), "receipt_id") {
t.Fatalf("missing receipt_id in %s", raw)
}
}

62
internal/tools/bearer.go Normal file
View file

@ -0,0 +1,62 @@
package tools
import (
"encoding/json"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// BearerFromRequest extracts Authorization: Bearer from the HTTP request headers.
func BearerFromRequest(req *mcp.CallToolRequest) string {
if req == nil || req.Extra == nil || req.Extra.Header == nil {
return ""
}
auth := strings.TrimSpace(req.Extra.Header.Get("Authorization"))
if auth == "" {
return ""
}
const prefix = "bearer "
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
return strings.TrimSpace(auth[len(prefix):])
}
return ""
}
// assertDigestArgs rejects forbidden content/bytes keys on raw tool arguments.
func assertDigestArgs(req *mcp.CallToolRequest) error {
if req == nil || req.Params == nil || len(req.Params.Arguments) == 0 {
return nil
}
var args map[string]any
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return nil
}
return digest.AssertDigestOnly(args)
}
// looksLikeCID reports whether receiptID looks like a VeraeTime/IPFS CID
// rather than a local hex64 ledger receipt id.
func looksLikeCID(receiptID string) bool {
id := strings.TrimSpace(receiptID)
if id == "" {
return false
}
lower := strings.ToLower(id)
if strings.HasPrefix(lower, "qm") ||
strings.HasPrefix(lower, "bafy") ||
strings.HasPrefix(lower, "bafk") ||
strings.HasPrefix(lower, "bagu") {
return true
}
if len(id) == 64 {
for _, c := range lower {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return true
}
}
return false
}
return true
}

View file

@ -0,0 +1,22 @@
package tools
import "testing"
func TestLooksLikeCID(t *testing.T) {
t.Parallel()
cases := []struct {
id string
want bool
}{
{"", false},
{"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", false},
{"bafyreigdyrzt5sfp7udm7hu76uh7b314167c576e3mgkudqjceaaeeooma", true},
{"QmYwAPJzv5CZsnA625s3Xf2nemtYkPpHdWQz79JREaLEkF", true},
{"not-a-local-receipt", true},
}
for _, tc := range cases {
if got := looksLikeCID(tc.id); got != tc.want {
t.Fatalf("looksLikeCID(%q)=%v want %v", tc.id, got, tc.want)
}
}
}

285
internal/tools/handlers.go Normal file
View file

@ -0,0 +1,285 @@
package tools
import (
"context"
"fmt"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Deps are shared backends for MCP tools.
type Deps struct {
Ledger *ledger.FileLedger
Global *veraetime.Client
Keeper *veraetime.Keeper
RequireBearer bool
}
func (d *Deps) resolveBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
return authctx.Resolve(ctx, BearerFromRequest(req), d.RequireBearer, d.Keeper)
}
func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs) (*mcp.CallToolResult, ReceiptsOut, error) {
if err := assertDigestArgs(req); err != nil {
return nil, ReceiptsOut{}, err
}
scope := strings.TrimSpace(in.Scope)
if scope == "" {
scope = "local"
}
switch scope {
case "global":
if d.Global == nil {
return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured")
}
tok, err := d.resolveBearer(ctx, req)
if err != nil {
return nil, ReceiptsOut{}, err
}
rec, err := d.Global.Stamp(ctx, in.SHA256, tok)
if err != nil {
return nil, ReceiptsOut{}, err
}
if in.CubeID != "" {
rec.CubeID = in.CubeID
}
if in.OrgID != "" {
rec.OrgID = in.OrgID
}
return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil
case "local", "shared":
if d.Ledger == nil {
return nil, ReceiptsOut{}, fmt.Errorf("ledger not configured")
}
recs, err := d.Ledger.Stamp(in.SHA256, scope, in.CubeID, in.OrgID)
if err != nil {
return nil, ReceiptsOut{}, err
}
return nil, ReceiptsOut{Receipts: fromLocalList(recs)}, nil
default:
return nil, ReceiptsOut{}, fmt.Errorf("scope must be local, shared, or global")
}
}
func (d *Deps) verify(ctx context.Context, req *mcp.CallToolRequest, in VerifyArgs) (*mcp.CallToolResult, VerifyOut, error) {
if err := assertDigestArgs(req); err != nil {
return nil, VerifyOut{}, err
}
want := strings.TrimSpace(in.WantScope)
useGlobal := want == "global" || looksLikeCID(in.ReceiptID)
if useGlobal {
return d.verifyGlobal(ctx, req, in.ReceiptID)
}
if d.Ledger != nil {
vr := d.Ledger.Verify(in.ReceiptID, want)
if vr.Valid {
return nil, fromLocalVerify(vr), nil
}
// Local miss: try VeraeTime when want_scope is empty (Python RoutingLedger).
if want == "" && d.Global != nil {
_, gOut, gErr := d.verifyGlobal(ctx, req, in.ReceiptID)
if gErr == nil && gOut.Valid {
return nil, gOut, nil
}
}
return nil, fromLocalVerify(vr), nil
}
return d.verifyGlobal(ctx, req, in.ReceiptID)
}
func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, receiptID string) (*mcp.CallToolResult, VerifyOut, error) {
if d.Global == nil {
return nil, VerifyOut{Valid: false, Error: "global VeraeTime client not configured"}, nil
}
tok, err := d.resolveBearer(ctx, req)
if err != nil {
return nil, VerifyOut{}, err
}
vr, err := d.Global.Verify(ctx, receiptID, tok)
if err != nil {
return nil, VerifyOut{}, err
}
return nil, fromGlobalVerify(vr), nil
}
func (d *Deps) hashReport(ctx context.Context, req *mcp.CallToolRequest, in HashReportArgs) (*mcp.CallToolResult, ReceiptsOut, error) {
if err := assertDigestArgs(req); err != nil {
return nil, ReceiptsOut{}, err
}
want := strings.TrimSpace(in.WantScope)
if want == "global" {
return d.hashReportGlobal(ctx, req, in.SHA256)
}
var local []ReceiptDTO
if d.Ledger != nil {
recs, err := d.Ledger.HashReport(in.SHA256, in.OrgID, want)
if err != nil {
return nil, ReceiptsOut{}, err
}
local = fromLocalList(recs)
}
if want != "" {
return nil, ReceiptsOut{Receipts: local}, nil
}
// Merge local + global when want_scope is empty.
central, err := d.hashReportGlobalList(ctx, req, in.SHA256)
if err != nil {
return nil, ReceiptsOut{Receipts: local}, nil
}
seen := make(map[string]struct{}, len(local))
for _, r := range local {
seen[r.ReceiptID] = struct{}{}
}
merged := append([]ReceiptDTO{}, local...)
for _, r := range central {
if _, ok := seen[r.ReceiptID]; ok {
continue
}
merged = append(merged, r)
seen[r.ReceiptID] = struct{}{}
}
return nil, ReceiptsOut{Receipts: merged}, nil
}
func (d *Deps) hashReportGlobal(ctx context.Context, req *mcp.CallToolRequest, dig string) (*mcp.CallToolResult, ReceiptsOut, error) {
recs, err := d.hashReportGlobalList(ctx, req, dig)
if err != nil {
return nil, ReceiptsOut{}, err
}
return nil, ReceiptsOut{Receipts: recs}, nil
}
func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolRequest, dig string) ([]ReceiptDTO, error) {
if d.Global == nil {
return nil, fmt.Errorf("global VeraeTime client not configured")
}
tok, err := d.resolveBearer(ctx, req)
if err != nil {
return nil, err
}
recs, err := d.Global.HashReport(ctx, dig, tok)
if err != nil {
return nil, err
}
out := make([]ReceiptDTO, 0, len(recs))
for i := range recs {
out = append(out, fromGlobal(&recs[i]))
}
return out, nil
}
func (d *Deps) signup(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) {
if d.Global == nil {
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
}
res, err := d.Global.Signup(ctx, in.Username, in.Password)
if err != nil {
return nil, CreateUserOut{}, err
}
return nil, CreateUserOut{ID: res.ID, Username: res.Username, Role: res.Role}, nil
}
func (d *Deps) login(ctx context.Context, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, LoginOut, error) {
if d.Global == nil {
return nil, LoginOut{}, fmt.Errorf("global VeraeTime client not configured")
}
res, err := d.Global.Login(ctx, in.Username, in.Password)
if err != nil {
return nil, LoginOut{}, err
}
return nil, LoginOut{Username: res.Username, Role: res.Role, ExpiresAt: res.ExpiresAt}, nil
}
func (d *Deps) session(ctx context.Context, req *mcp.CallToolRequest, _ EmptyArgs) (*mcp.CallToolResult, SessionOut, error) {
if d.Global == nil {
return nil, SessionOut{}, fmt.Errorf("global VeraeTime client not configured")
}
tok, err := d.resolveBearer(ctx, req)
if err != nil {
return nil, SessionOut{Valid: false}, nil
}
res, err := d.Global.Session(ctx, tok)
if err != nil {
return nil, SessionOut{}, err
}
return nil, SessionOut{
Valid: res.Valid,
Username: res.Username,
Role: res.Role,
ExpiresAt: res.ExpiresAt,
}, nil
}
func (d *Deps) createUser(ctx context.Context, req *mcp.CallToolRequest, in CreateUserArgs) (*mcp.CallToolResult, CreateUserOut, error) {
if d.Global == nil {
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
}
tok, err := d.resolveBearer(ctx, req)
if err != nil {
return nil, CreateUserOut{}, err
}
res, err := d.Global.CreateUser(ctx, in.Username, in.Password, in.Role, tok)
if err != nil {
return nil, CreateUserOut{}, err
}
return nil, CreateUserOut{ID: res.ID, Username: res.Username, Role: res.Role}, nil
}
func fromLocal(r ledger.Receipt) ReceiptDTO {
return ReceiptDTO{
ReceiptID: r.ReceiptID,
Hash: r.Hash,
Scope: r.Scope,
CubeID: r.CubeID,
OrgID: r.OrgID,
CreatedAt: r.CreatedAt,
Merkle: r.Merkle,
RestampOf: r.RestampOf,
}
}
func fromLocalList(recs []ledger.Receipt) []ReceiptDTO {
out := make([]ReceiptDTO, len(recs))
for i := range recs {
out[i] = fromLocal(recs[i])
}
return out
}
func fromLocalVerify(vr ledger.VerifyResult) VerifyOut {
out := VerifyOut{Valid: vr.Valid, Error: vr.Error}
if vr.Receipt != nil {
dto := fromLocal(*vr.Receipt)
out.Receipt = &dto
}
return out
}
func fromGlobal(r *veraetime.Receipt) ReceiptDTO {
if r == nil {
return ReceiptDTO{}
}
return ReceiptDTO{
ReceiptID: r.ReceiptID,
Hash: r.Hash,
Scope: r.Scope,
CubeID: r.CubeID,
OrgID: r.OrgID,
CreatedAt: r.CreatedAt,
Merkle: r.Merkle,
RestampOf: r.RestampOf,
}
}
func fromGlobalVerify(vr veraetime.VerifyResult) VerifyOut {
out := VerifyOut{Valid: vr.Valid, Error: vr.Error}
if vr.Receipt != nil {
dto := fromGlobal(vr.Receipt)
out.Receipt = &dto
}
return out
}

View file

@ -0,0 +1,56 @@
package tools
import "github.com/modelcontextprotocol/go-sdk/mcp"
const instructions = "Global stamps need a VeraeTime login on the machine that runs this server, " +
"or a bearer token from the browser signup on the public server. " +
"If session is not valid, ask the user to run `uvx verae-mcp-timestamping-server setup`. " +
"Do not ask them to paste a token into the chat or into mcp.json. " +
"The token file is ~/.verae/auth.properties, mode 600."
// NewServer builds an MCP server with the full Verae timestamping tool set.
func NewServer(d *Deps) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{
Name: "verae-timestamping",
Version: "0.1.0",
}, &mcp.ServerOptions{
Instructions: instructions,
})
Register(s, d)
return s
}
// Register adds stamp, verify, hash_report, signup, login, session, create_user.
func Register(s *mcp.Server, d *Deps) {
if d == nil {
d = &Deps{}
}
mcp.AddTool(s, &mcp.Tool{
Name: "stamp",
Description: "Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID.",
}, d.stamp)
mcp.AddTool(s, &mcp.Tool{
Name: "verify",
Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth).",
}, d.verify)
mcp.AddTool(s, &mcp.Tool{
Name: "hash_report",
Description: "JSON-array history of receipts for one SHA-256 (Carfax).",
}, d.hashReport)
mcp.AddTool(s, &mcp.Tool{
Name: "signup",
Description: "Create a VeraeTime user account. Role is user. Does not replace the stored JWT.",
}, d.signup)
mcp.AddTool(s, &mcp.Tool{
Name: "login",
Description: "Log in to api.veraetime.net. Stores the JWT on this host and does not return it.",
}, d.login)
mcp.AddTool(s, &mcp.Tool{
Name: "session",
Description: "Validate the stored VeraeTime JWT. Returns valid, username, role, and expires_at.",
}, d.session)
mcp.AddTool(s, &mcp.Tool{
Name: "create_user",
Description: "Create a VeraeTime user. The stored JWT must belong to an admin.",
}, d.createUser)
}

84
internal/tools/types.go Normal file
View file

@ -0,0 +1,84 @@
package tools
// StampArgs is the stamp tool input.
type StampArgs struct {
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"`
}
// VerifyArgs is the verify tool input.
type VerifyArgs struct {
ReceiptID string `json:"receipt_id" jsonschema:"receipt or certificate id"`
WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope (local|shared|global)"`
}
// HashReportArgs is the hash_report tool input.
type HashReportArgs struct {
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id filter"`
WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope filter"`
}
// AuthUserPass are credentials for signup/login.
type AuthUserPass struct {
Username string `json:"username" jsonschema:"VeraeTime username"`
Password string `json:"password" jsonschema:"VeraeTime password"`
}
// CreateUserArgs creates a VeraeTime user (admin bearer required).
type CreateUserArgs struct {
Username string `json:"username" jsonschema:"new username"`
Password string `json:"password" jsonschema:"new password"`
Role string `json:"role" jsonschema:"user or admin"`
}
// EmptyArgs is used by session.
type EmptyArgs struct{}
// ReceiptDTO is the MCP receipt shape (snake_case JSON).
type ReceiptDTO struct {
ReceiptID string `json:"receipt_id"`
Hash string `json:"hash"`
Scope string `json:"scope"`
CubeID string `json:"cube_id,omitempty"`
OrgID string `json:"org_id,omitempty"`
CreatedAt string `json:"created_at"`
Merkle string `json:"merkle"`
RestampOf string `json:"restamp_of,omitempty"`
}
// ReceiptsOut wraps a receipt list.
type ReceiptsOut struct {
Receipts []ReceiptDTO `json:"receipts"`
}
// VerifyOut is the verify tool result.
type VerifyOut struct {
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Receipt *ReceiptDTO `json:"receipt,omitempty"`
}
// LoginOut is returned by login (never includes JWT).
type LoginOut struct {
Username string `json:"username"`
Role string `json:"role"`
ExpiresAt string `json:"expires_at"`
}
// SessionOut is returned by session.
type SessionOut struct {
Valid bool `json:"valid"`
Username string `json:"username,omitempty"`
Role string `json:"role,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// CreateUserOut is returned by signup and create_user.
type CreateUserOut struct {
ID string `json:"id,omitempty"`
Username string `json:"username"`
Role string `json:"role"`
}