verae-mcp-timestamping-serv.../internal/tools/handlers.go
George Lambert 3ec1f0516b
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled
feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF;
JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt.

Stamp always returns receipts[] and adds artifacts[] in the chosen format.
Stdio writes ~/.verae/receipts (0600); public MCP returns content_text/content_base64 only.
Verify accepts inline receipt or receipt_path (stdio), parses id, and re-emits an artifact.

Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool
and email-username helpers already wired into register/public paths.
2026-10-20 16:02:20 -04:00

574 lines
18 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"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)
}
// isCommercialEdgeKey reports tokens that edge apiKeyAuth can map to a prepaid customer.
func isCommercialEdgeKey(tok string) bool {
t := strings.TrimSpace(tok)
return strings.HasPrefix(t, "xVerae-") ||
strings.HasPrefix(t, "key-") ||
strings.HasPrefix(t, "zmw_")
}
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
}
kind, err := normalizeReceiptFormat(in.ReceiptFormat)
if 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
}
}
var out ReceiptsOut
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 services.verae.ai.
// Caller must present a commercial key (xVerae- / key- / zmw_), not a bare chain JWT,
// so settleStampBilling attributes the customer prepaid ledger.
if d.Edge != nil {
if !isCommercialEdgeKey(tok) {
return nil, ReceiptsOut{}, fmt.Errorf(
"commercial stamp requires portal API key (xVerae-… or key-…); get one at https://customer.verae.ai/portal/ — bare VeraeTime JWT is not billed on MCP when VERAE_MCP_EDGE_URL is set",
)
}
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,
}
out = ReceiptsOut{Receipts: []ReceiptDTO{dto}}
} else {
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
}
out = ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}
}
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
}
out = ReceiptsOut{Receipts: fromLocalList(recs)}
default:
return nil, ReceiptsOut{}, fmt.Errorf("scope must be local, shared, or global")
}
out, err = attachArtifacts(out, kind, !d.Public)
if err != nil {
return nil, ReceiptsOut{}, err
}
return nil, out, nil
}
// 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":
tok, err := d.writeBearer(ctx, req)
if err != nil {
return nil, StampBatchOut{}, err
}
// Prefer edge meter (per-digest) when configured — same prepaid path as single stamp.
if d.Edge != nil {
if !isCommercialEdgeKey(tok) {
return nil, StampBatchOut{}, fmt.Errorf(
"commercial stamp requires portal API key (xVerae-… or key-…); get one at https://customer.verae.ai/portal/",
)
}
if len(in.Metadata) > 0 || in.Restamp {
return nil, StampBatchOut{}, fmt.Errorf("metadata/restamp on edge-billed batch not supported yet")
}
out := StampBatchOut{Count: len(in.SHA256s)}
for i, h := range in.SHA256s {
it := BatchItemDTO{Index: i, Hash: h, Status: "submitted"}
er, err := d.Edge.Stamp(ctx, h, tok)
if err != nil {
it.Status, it.Error = "failed", err.Error()
out.Failed++
} else {
it.ReceiptID = er.ReceiptID
it.Status = "completed"
out.Created++
}
out.Items = append(out.Items, it)
}
return nil, out, nil
}
if d.Global == nil {
return nil, StampBatchOut{}, fmt.Errorf("global VeraeTime client not configured")
}
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 := d.requireAccount(ctx, req); err != nil {
return nil, VerifyOut{}, err
}
if err := assertDigestArgs(req); err != nil {
return nil, VerifyOut{}, err
}
kind, err := normalizeReceiptFormat(in.ReceiptFormat)
if err != nil {
return nil, VerifyOut{}, err
}
receiptID := strings.TrimSpace(in.ReceiptID)
if body, err := d.resolveVerifyReceiptBody(in); err != nil {
return nil, VerifyOut{}, err
} else if body != "" {
id, err := parseReceiptIDFromBody(kind, body)
if err != nil {
return nil, VerifyOut{}, err
}
receiptID = id
}
if receiptID == "" {
return nil, VerifyOut{}, fmt.Errorf("receipt_id is required (or supply receipt / receipt_path)")
}
want := strings.TrimSpace(in.WantScope)
useGlobal := want == "global" || looksLikeCID(receiptID)
var (
toolRes *mcp.CallToolResult
out VerifyOut
)
if useGlobal {
toolRes, out, err = d.verifyGlobal(ctx, req, receiptID)
} else if d.Ledger != nil {
vr := d.Ledger.Verify(receiptID, want)
if vr.Valid {
out = fromLocalVerify(vr)
} else if want == "" && d.Global != nil {
// Local miss: try VeraeTime when want_scope is empty (Python RoutingLedger).
_, gOut, gErr := d.verifyGlobal(ctx, req, receiptID)
if gErr == nil && gOut.Valid {
out = gOut
} else {
out = fromLocalVerify(vr)
}
} else {
out = fromLocalVerify(vr)
}
} else {
toolRes, out, err = d.verifyGlobal(ctx, req, receiptID)
}
if err != nil {
return toolRes, out, err
}
if out.Valid && out.Receipt != nil {
art, aerr := buildArtifact(*out.Receipt, kind, !d.Public)
if aerr != nil {
return nil, VerifyOut{}, aerr
}
out.Artifact = &art
}
return toolRes, out, nil
}
// resolveVerifyReceiptBody returns inline receipt content from Receipt or ReceiptPath.
// Public MCP rejects ReceiptPath (no host-disk reads for callers).
func (d *Deps) resolveVerifyReceiptBody(in VerifyArgs) (string, error) {
path := strings.TrimSpace(in.ReceiptPath)
inline := strings.TrimSpace(in.Receipt)
if path != "" {
if d.Public {
return "", fmt.Errorf("receipt_path is not supported on the public MCP; pass receipt inline or receipt_id")
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("receipt_path: %w", err)
}
return string(b), nil
}
return inline, nil
}
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 := d.requireAccount(ctx, req); err != nil {
return nil, ReceiptsOut{}, err
}
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, _ *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) {
if d.Global == nil {
return nil, CreateUserOut{}, fmt.Errorf("global VeraeTime client not configured")
}
if err := requireEmailUsername(in.Username); err != nil {
return nil, CreateUserOut{}, err
}
// 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
}
lr, token, err := d.Global.Authenticate(ctx, in.Username, in.Password)
if err != nil {
return nil, CreateUserOut{}, fmt.Errorf("account created but login failed: %w", err)
}
if !d.Public {
if err := d.persistCallerToken(in.Username, in.Password, token, lr.ExpiresAt); err != nil {
return nil, CreateUserOut{}, err
}
}
out := CreateUserOut{
ID: res.ID,
Username: res.Username,
Role: res.Role,
AccessToken: token,
ExpiresAt: lr.ExpiresAt,
HowToUse: tokenHowToUse,
}
if lr.Username != "" {
out.Username = lr.Username
}
if lr.Role != "" {
out.Role = lr.Role
}
return nil, out, 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")
}
// Always authenticate and return access_token so MCP clients (esp. public HTTP) can
// set Authorization / bearer_token_file. Persist to host auth.properties only when
// not Public — public login must never replace the ns1 service credential.
res, token, err := d.Global.Authenticate(ctx, in.Username, in.Password)
if err != nil {
return nil, LoginOut{}, err
}
if !d.Public {
if err := d.persistCallerToken(in.Username, in.Password, token, res.ExpiresAt); err != nil {
return nil, LoginOut{}, err
}
}
return nil, LoginOut{
Username: res.Username,
Role: res.Role,
ExpiresAt: res.ExpiresAt,
AccessToken: token,
HowToUse: tokenHowToUse,
}, nil
}
func (d *Deps) persistCallerToken(username, password, token, expiresAt string) error {
path := d.Global.AuthPath()
if path == "" {
path = veraetime.DefaultAuthPath()
}
return veraetime.WriteProperties(path, map[string]string{
"access_token": token,
"id_token": token,
"auth_token": token,
"expires_at": expiresAt,
"username": username,
"password": password,
})
}
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")
}
if err := requireEmailUsername(in.Username); err != nil {
return nil, CreateUserOut{}, err
}
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
}