verae-mcp-timestamping-serv.../internal/tools/handlers.go
George Lambert d49c51c349
Some checks failed
test-and-deploy / test (push) Successful in 14s
test-and-deploy / deploy-138 (push) Failing after 15m9s
feat: single-billing edge path for MCP + open public signup
- Public signup no longer requires a caller bearer (POST /auth/signup)
- create_user remains admin-only (POST /auth/users)
- Optional VERAE_MCP_EDGE_URL bills global stamps via zap.verae.ai
- Clearer public auth errors and instructions for Grok/clients
2026-10-12 14:59:31 -04:00

408 lines
13 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/edgebill"
"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
// Edge bills global stamps through zap.verae.ai when set (VERAE_MCP_EDGE_URL).
Edge *edgebill.Client
}
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":
tok, err := d.writeBearer(ctx, req)
if err != nil {
return nil, ReceiptsOut{}, err
}
// Single commercial meter: when Edge is configured, bill via zap.verae.ai.
if d.Edge != nil {
if len(in.Metadata) > 0 || in.Restamp {
return nil, ReceiptsOut{}, fmt.Errorf("metadata/restamp on edge-billed stamps is not supported yet; omit metadata or unset VERAE_MCP_EDGE_URL")
}
er, err := d.Edge.Stamp(ctx, in.SHA256, tok)
if err != nil {
return nil, ReceiptsOut{}, err
}
dto := ReceiptDTO{
ReceiptID: er.ReceiptID,
Hash: er.Hash,
Scope: "global",
CreatedAt: er.CreatedAt,
Merkle: er.Merkle,
CubeID: in.CubeID,
OrgID: in.OrgID,
}
return nil, ReceiptsOut{Receipts: []ReceiptDTO{dto}}, nil
}
if d.Global == nil {
return nil, ReceiptsOut{}, fmt.Errorf("global VeraeTime client not configured")
}
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")
}
// Public self-serve: POST /auth/signup needs no caller bearer (DEC-654 write lockdown
// does not apply — this creates the credential the caller will use next).
// Admin-provisioned users use create_user + admin bearer → POST /auth/users.
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
}