verae-mcp-timestamping-serv.../internal/tools/handlers.go
George Lambert d379931bd1 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.
2026-09-29 12:07:44 -04:00

285 lines
8.1 KiB
Go

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
}