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 // Public: the public HTTP server (DEC-654): writes need the caller's own credential; anonymous reads are // never limited (DEC-660). Public bool Checker CredentialChecker } 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" } if d.Public { if _, err := d.writeBearer(ctx, req); err != nil { return nil, ReceiptsOut{}, err } } switch scope { case "global": if d.Global == nil { return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured") } tok, err := d.writeBearer(ctx, req) if err != nil { return nil, ReceiptsOut{}, err } rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{ Metadata: in.Metadata, Restamp: in.Restamp, Message: in.Message, }) 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 len(in.Metadata) > 0 || in.Restamp { return nil, ReceiptsOut{}, fmt.Errorf("metadata history is stored on global stamps") } 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") } } // stampBatch stamps up to 500 digests in one call (DEC-659): global items are charged per new digest to the // caller's own Verae account (own repeats free; the balance is checked for the whole batch first). func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in StampBatchArgs) (*mcp.CallToolResult, StampBatchOut, error) { if len(in.SHA256s) == 0 || len(in.SHA256s) > 500 { return nil, StampBatchOut{}, fmt.Errorf("sha256s must hold 1..500 digests") } scope := strings.TrimSpace(in.Scope) if scope == "" { scope = "global" } if d.Public { if _, err := d.writeBearer(ctx, req); err != nil { return nil, StampBatchOut{}, err } } switch scope { case "global": if d.Global == nil { return nil, StampBatchOut{}, fmt.Errorf("global VeraeTime client not configured") } tok, err := d.writeBearer(ctx, req) if err != nil { return nil, StampBatchOut{}, err } var metas []veraetime.StampMeta if len(in.Metadata) > 0 || in.Restamp { if len(in.Metadata) > 0 && len(in.Metadata) != len(in.SHA256s) { return nil, StampBatchOut{}, fmt.Errorf("metadata must have one entry per digest") } metas = make([]veraetime.StampMeta, len(in.SHA256s)) for i := range in.SHA256s { metas[i].Restamp = in.Restamp metas[i].Message = in.Message if i < len(in.Metadata) { metas[i].Metadata = in.Metadata[i] } } } br, err := d.Global.StampBatchWithMeta(ctx, in.SHA256s, tok, metas) if err != nil { return nil, StampBatchOut{}, err } out := StampBatchOut{Count: br.Count, Created: br.Created, Existing: br.Existing, Failed: br.Failed, TotalCredits: br.TotalCredits, BalanceCredits: br.BalanceCredits} for _, it := range br.Results { out.Items = append(out.Items, BatchItemDTO{Index: it.Index, Hash: it.Digest, JobID: it.JobID, Status: it.Status, Existing: it.Existing, ReceiptID: it.Certificate, Credits: it.Credits, Error: it.Error}) } return nil, out, nil case "local", "shared": if len(in.Metadata) > 0 || in.Restamp { return nil, StampBatchOut{}, fmt.Errorf("metadata history is stored on global stamps") } if d.Ledger == nil { return nil, StampBatchOut{}, fmt.Errorf("ledger not configured") } out := StampBatchOut{Count: len(in.SHA256s)} for i, h := range in.SHA256s { recs, err := d.Ledger.Stamp(h, scope, "", "") it := BatchItemDTO{Index: i, Hash: h, Status: "submitted"} if err != nil { it.Status, it.Error = "failed", err.Error() out.Failed++ } else if len(recs) > 0 { it.ReceiptID = recs[len(recs)-1].ReceiptID out.Created++ } out.Items = append(out.Items, it) } return nil, out, nil default: return nil, StampBatchOut{}, fmt.Errorf("scope must be global, local, or shared") } } 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.readBearer(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.readBearer(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, req *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) { if d.Global == nil { return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured") } if _, err := d.writeBearer(ctx, req); d.Public && err != nil { return nil, CreateUserOut{}, err } 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.Public { return nil, LoginOut{}, ErrLoginDisabled } 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") } // Public: never report the host login. Missing caller bearer is an explicit tool error so agents // see how to attach Authorization (silent valid:false hid the fix from Grok/Claude). if d.Public && BearerFromRequest(req) == "" { return nil, SessionOut{Valid: false}, ErrWriteNeedsKey } 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.writeBearer(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 }