- 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
408 lines
13 KiB
Go
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
|
|
}
|