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:
parent
793746fcd0
commit
d379931bd1
11 changed files with 925 additions and 6 deletions
|
|
@ -22,7 +22,7 @@ cd verae-mcp-timestamping-server-go
|
||||||
go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server
|
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
|
## Usage
|
||||||
|
|
||||||
|
|
|
||||||
82
cmd/verae-mcp-timestamping-server/main.go
Normal file
82
cmd/verae-mcp-timestamping-server/main.go
Normal 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
18
go.mod
|
|
@ -1,13 +1,23 @@
|
||||||
module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go
|
module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go
|
||||||
|
|
||||||
go 1.22.0
|
go 1.25.0
|
||||||
|
|
||||||
require github.com/nats-io/nats.go v1.39.1
|
|
||||||
|
|
||||||
require (
|
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/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.9 // indirect
|
github.com/nats-io/nkeys v0.4.9 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // 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/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
26
go.sum
|
|
@ -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 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
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 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk=
|
||||||
github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM=
|
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 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0=
|
||||||
github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE=
|
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 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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
189
internal/server/server.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
107
internal/server/server_test.go
Normal file
107
internal/server/server_test.go
Normal 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
62
internal/tools/bearer.go
Normal 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
|
||||||
|
}
|
||||||
22
internal/tools/bearer_test.go
Normal file
22
internal/tools/bearer_test.go
Normal 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
285
internal/tools/handlers.go
Normal 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
|
||||||
|
}
|
||||||
56
internal/tools/register.go
Normal file
56
internal/tools/register.go
Normal 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
84
internal/tools/types.go
Normal 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"`
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue