feat(mcp): optional receipt_format on stamp/verify (text/PDF/JSON/MD/CSV/TSV)
Some checks failed
test-and-deploy / test (push) Failing after 12m55s
test-and-deploy / deploy-138 (push) Has been cancelled

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.
This commit is contained in:
George Lambert 2026-10-20 16:02:20 -04:00
commit 3ec1f0516b
10 changed files with 2040 additions and 132 deletions

View file

@ -52,7 +52,7 @@ func TestBearerFromRequest(t *testing.T) {
func TestErrWriteNeedsKeyMentionsJWTAndGrok(t *testing.T) {
t.Parallel()
msg := ErrWriteNeedsKey.Error()
for _, want := range []string{"Authorization: Bearer", "JWT", "bearer_token_file", "stdio"} {
for _, want := range []string{"account and auth are required", "Authorization: Bearer", "JWT", "bearer_token_file", "stdio", "signup", "login"} {
if !strings.Contains(msg, want) {
t.Fatalf("ErrWriteNeedsKey missing %q: %s", want, msg)
}

View file

@ -0,0 +1,20 @@
package tools
import (
"fmt"
"strings"
)
// requireEmailUsername enforces that Verae usernames are email addresses.
func requireEmailUsername(username string) error {
s := strings.TrimSpace(username)
at := strings.IndexByte(s, '@')
if at <= 0 || at >= len(s)-1 {
return fmt.Errorf("username must be an email address")
}
dot := strings.LastIndexByte(s, '.')
if dot <= at+1 || dot >= len(s)-1 {
return fmt.Errorf("username must be an email address")
}
return nil
}

View file

@ -3,6 +3,7 @@ package tools
import (
"context"
"fmt"
"os"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx"
@ -42,6 +43,10 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
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"
@ -51,6 +56,7 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
return nil, ReceiptsOut{}, err
}
}
var out ReceiptsOut
switch scope {
case "global":
tok, err := d.writeBearer(ctx, req)
@ -82,26 +88,27 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
CubeID: in.CubeID,
OrgID: in.OrgID,
}
return nil, ReceiptsOut{Receipts: []ReceiptDTO{dto}}, nil
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)}}
}
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")
@ -113,10 +120,15 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
if err != nil {
return nil, ReceiptsOut{}, err
}
return nil, ReceiptsOut{Receipts: fromLocalList(recs)}, nil
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
@ -219,29 +231,84 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam
}
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(in.ReceiptID)
useGlobal := want == "global" || looksLikeCID(receiptID)
var (
toolRes *mcp.CallToolResult
out VerifyOut
)
if useGlobal {
return d.verifyGlobal(ctx, req, in.ReceiptID)
}
if d.Ledger != nil {
vr := d.Ledger.Verify(in.ReceiptID, want)
toolRes, out, err = d.verifyGlobal(ctx, req, receiptID)
} else if d.Ledger != nil {
vr := d.Ledger.Verify(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)
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 {
return nil, gOut, nil
out = gOut
} else {
out = fromLocalVerify(vr)
}
} else {
out = fromLocalVerify(vr)
}
return nil, fromLocalVerify(vr), nil
} else {
toolRes, out, err = d.verifyGlobal(ctx, req, receiptID)
}
return d.verifyGlobal(ctx, req, in.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) {
@ -260,6 +327,9 @@ func (d *Deps) verifyGlobal(ctx context.Context, req *mcp.CallToolRequest, recei
}
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
}
@ -325,10 +395,13 @@ func (d *Deps) hashReportGlobalList(ctx context.Context, req *mcp.CallToolReques
return out, nil
}
func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUserPass) (*mcp.CallToolResult, CreateUserOut, error) {
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.
@ -336,21 +409,70 @@ func (d *Deps) signup(ctx context.Context, req *mcp.CallToolRequest, in AuthUser
if err != nil {
return nil, CreateUserOut{}, err
}
return nil, CreateUserOut{ID: res.ID, Username: res.Username, Role: res.Role}, nil
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.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)
// 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
}
return nil, LoginOut{Username: res.Username, Role: res.Role, ExpiresAt: res.ExpiresAt}, nil
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) {
@ -382,6 +504,9 @@ func (d *Deps) createUser(ctx context.Context, req *mcp.CallToolRequest, in Crea
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

View file

@ -1,13 +1,14 @@
package tools
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a VeraeTime
// JWT (access_token from login) or a Verae API key - checked at api.veraetime.net - and never falls back to this
// host's stored service login. Writes that need a caller bearer: stamp (any scope), create_user, session,
// stamp_batch. Public signup is open (POST /auth/signup, no bearer) so new customers can register, then stamp
// with their own JWT. login is refused on the public server (it would replace the host credential).
// Read-only verify and hash_report stay anonymous; they may use the host login and are never rate limited (DEC-660).
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) account and auth are required for use. Every tool except
// welcome, signup, and login needs the caller's own credential — a VeraeTime JWT (access_token from signup/login)
// or a Verae API key — checked at api.veraetime.net — and never falls back to this host's stored service login.
// Tools that need a caller bearer: stamp, stamp_batch, verify, hash_report, session, create_user.
// Public signup and login are open (no bearer): they create/authenticate the account and return access_token so the
// client can set Authorization / bearer_token_file. They never write the host auth.properties on the public server.
// welcome is open so new users can read onboarding before signing up.
//
// AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call. Grok Build:
// AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call after signup/login. Grok Build:
// bearer_token_file = "~/.verae/mcp-bearer" # raw JWT only, not auth.properties
// or point a local stdio MCP at this binary (Public=false) so the laptop host token is used.
@ -22,23 +23,21 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// ErrWriteNeedsKey is returned for an anonymous write on the public server.
// ErrWriteNeedsKey is returned when the public server is used without a caller account/credential.
var ErrWriteNeedsKey = errors.New(
"this tool needs your own Verae credential on Authorization: Bearer. " +
"Use a VeraeTime JWT (access_token from `verae login` / ~/.verae/auth.properties) " +
"or a Verae API key from https://zap.verae.ai/portal/. " +
"Grok Build: set mcp_servers.verae-timestamping.bearer_token_file to a file that holds only the raw token " +
"(e.g. ~/.verae/mcp-bearer), then reconnect the MCP server; " +
"or run this binary with -transport stdio so the laptop host login is used",
"account and auth are required for use. " +
"Call tool welcome for onboarding, then signup (new account) or login (existing) to receive a VeraeTime JWT as access_token. " +
"Write that token to your client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) " +
"or use a Verae API key from https://zap.verae.ai/portal/, " +
"then send Authorization: Bearer on each tools/call. " +
"Or run this binary with -transport stdio so the laptop host login is used",
)
// ErrLoginDisabled is returned by the login tool on the public server.
var ErrLoginDisabled = errors.New(
"login is not available on the public HTTP server (it would replace the host credential). " +
"Sign in with `verae login` or at https://zap.verae.ai/portal/, put the JWT or API key in " +
"Authorization: Bearer on each MCP request (Grok: bearer_token_file), " +
"or use a local stdio MCP transport",
)
// tokenHowToUse is guidance returned with access_token from public signup/login.
const tokenHowToUse = "Store access_token as the only contents of your MCP bearer file " +
"(Grok: ~/.verae/mcp-bearer with mcp_servers.<name>.bearer_token_file), " +
"or send Authorization: Bearer <access_token> on each tools/call. " +
"Do not paste the token into chat. Commercial global stamps also accept an xVerae- portal API key."
// CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential).
type CredentialChecker interface {
@ -60,11 +59,24 @@ func (d *Deps) writeBearer(ctx context.Context, req *mcp.CallToolRequest) (strin
return tok, nil
}
// readBearer is the caller's bearer, or (anonymous, never limited - DEC-660) the host login for read-only lookups.
// readBearer requires the caller's own credential on the public server (account/auth required for use).
// On stdio/local (Public=false) it may fall back to the host login via resolveBearer.
func (d *Deps) readBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
if d.Public {
return d.writeBearer(ctx, req)
}
return d.resolveBearer(ctx, req)
}
// requireAccount gates any public tool that is not welcome/signup/login.
func (d *Deps) requireAccount(ctx context.Context, req *mcp.CallToolRequest) error {
if !d.Public {
return nil
}
_, err := d.writeBearer(ctx, req)
return err
}
type credEntry struct {
ok bool
exp time.Time

View file

@ -0,0 +1,462 @@
package tools
import (
"encoding/base64"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/pdfcert"
)
// Canonical receipt_format kinds after normalizeReceiptFormat.
const (
KindText = "text"
KindPDF = "pdf"
KindJSON = "json"
KindMD = "md"
KindCSV = "csv"
KindTSV = "tsv"
)
var allowedReceiptFormats = []string{
"(omit)", "text",
"receipt", "PDF-Receipt", "pdf",
"JSON-Receipt", "json",
"MD", "MD-Receipt", "markdown",
"CSV", "CSV-Receipt",
"TSV", "TSV-Receipt",
}
// normalizeReceiptFormat maps user aliases to a canonical kind.
// Empty → text. Case-insensitive. Unknown → error listing allowed values.
func normalizeReceiptFormat(s string) (string, error) {
v := strings.TrimSpace(s)
if v == "" {
return KindText, nil
}
switch strings.ToLower(v) {
case "text", "txt", "plain", "plain-text", "plaintext":
return KindText, nil
case "receipt", "pdf", "pdf-receipt", "pdf_receipt":
return KindPDF, nil
case "json", "json-receipt", "json_receipt":
return KindJSON, nil
case "md", "md-receipt", "md_receipt", "markdown", "markdown-receipt":
return KindMD, nil
case "csv", "csv-receipt", "csv_receipt":
return KindCSV, nil
case "tsv", "tsv-receipt", "tsv_receipt":
return KindTSV, nil
default:
return "", fmt.Errorf("invalid receipt_format %q; allowed: %s", s, strings.Join(allowedReceiptFormats, ", "))
}
}
func mediaTypeForKind(kind string) string {
switch kind {
case KindPDF:
return "application/pdf"
case KindJSON:
return "application/json"
case KindMD:
return "text/markdown"
case KindCSV:
return "text/csv"
case KindTSV:
return "text/tab-separated-values"
default:
return "text/plain"
}
}
func extForKind(kind string) string {
switch kind {
case KindPDF:
return ".pdf"
case KindJSON:
return ".json"
case KindMD:
return ".md"
case KindCSV:
return ".csv"
case KindTSV:
return ".tsv"
default:
return ".txt"
}
}
func defaultVerifyURL(dto ReceiptDTO) string {
if dto.Hash != "" {
return pdfcert.LookupURL(dto.Hash, dto.ReceiptID)
}
if dto.ReceiptID != "" {
return "https://verify.verae.ai/?cid=" + dto.ReceiptID
}
return "https://verify.verae.ai/"
}
// receiptFieldOrder is the stable column/row order for text/md/csv/tsv.
var receiptFieldOrder = []struct {
Key string
Value func(ReceiptDTO, string) string
}{
{"receipt_id", func(d ReceiptDTO, _ string) string { return d.ReceiptID }},
{"hash", func(d ReceiptDTO, _ string) string { return d.Hash }},
{"scope", func(d ReceiptDTO, _ string) string { return d.Scope }},
{"created_at", func(d ReceiptDTO, _ string) string { return d.CreatedAt }},
{"merkle", func(d ReceiptDTO, _ string) string { return d.Merkle }},
{"cube_id", func(d ReceiptDTO, _ string) string { return d.CubeID }},
{"org_id", func(d ReceiptDTO, _ string) string { return d.OrgID }},
{"restamp_of", func(d ReceiptDTO, _ string) string { return d.RestampOf }},
{"verify_url", func(d ReceiptDTO, u string) string { return u }},
}
func renderText(dto ReceiptDTO) string {
verify := defaultVerifyURL(dto)
var b strings.Builder
b.WriteString("Verae Timestamp Receipt\n")
b.WriteString("=======================\n")
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
fmt.Fprintf(&b, "%s: %s\n", f.Key, v)
}
return b.String()
}
func renderJSON(dto ReceiptDTO) (string, error) {
verify := defaultVerifyURL(dto)
doc := map[string]any{
"format": "JSON-Receipt",
"sha256": dto.Hash,
"verify_url": verify,
"receipts": []ReceiptDTO{dto},
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
func renderMD(dto ReceiptDTO) string {
verify := defaultVerifyURL(dto)
var headers, seps, vals []string
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
headers = append(headers, f.Key)
seps = append(seps, "---")
vals = append(vals, mdEscapeCell(v))
}
var b strings.Builder
b.WriteString("# Verae Timestamp Receipt\n\n")
b.WriteString("| " + strings.Join(headers, " | ") + " |\n")
b.WriteString("| " + strings.Join(seps, " | ") + " |\n")
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
return b.String()
}
func mdEscapeCell(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
return s
}
func renderDelimited(dto ReceiptDTO, comma rune) (string, error) {
verify := defaultVerifyURL(dto)
var headers, vals []string
for _, f := range receiptFieldOrder {
v := f.Value(dto, verify)
if v == "" && (f.Key == "cube_id" || f.Key == "org_id" || f.Key == "restamp_of") {
continue
}
headers = append(headers, f.Key)
vals = append(vals, v)
}
var buf strings.Builder
w := csv.NewWriter(&buf)
w.Comma = comma
if err := w.Write(headers); err != nil {
return "", err
}
if err := w.Write(vals); err != nil {
return "", err
}
w.Flush()
if err := w.Error(); err != nil {
return "", err
}
return buf.String(), nil
}
func renderPDF(dto ReceiptDTO) ([]byte, error) {
return pdfcert.Build(pdfcert.Input{
SHA256: dto.Hash,
ReceiptID: dto.ReceiptID,
CreatedAt: dto.CreatedAt,
Merkle: dto.Merkle,
Scope: dto.Scope,
VerifyURL: defaultVerifyURL(dto),
Channel: "mcp",
})
}
// buildArtifact renders one receipt into the requested kind and optionally writes
// under ~/.verae/receipts when writeDisk is true (stdio). Public MCP passes false.
func buildArtifact(dto ReceiptDTO, kind string, writeDisk bool) (ReceiptArtifact, error) {
art := ReceiptArtifact{
Format: kind,
MediaType: mediaTypeForKind(kind),
Filename: safeReceiptFilename(dto.ReceiptID, dto.Hash) + extForKind(kind),
}
switch kind {
case KindText:
art.ContentText = renderText(dto)
case KindJSON:
s, err := renderJSON(dto)
if err != nil {
return art, err
}
art.ContentText = s
case KindMD:
art.ContentText = renderMD(dto)
case KindCSV:
s, err := renderDelimited(dto, ',')
if err != nil {
return art, err
}
art.ContentText = s
case KindTSV:
s, err := renderDelimited(dto, '\t')
if err != nil {
return art, err
}
art.ContentText = s
case KindPDF:
b, err := renderPDF(dto)
if err != nil {
return art, err
}
art.ContentB64 = base64.StdEncoding.EncodeToString(b)
default:
return art, fmt.Errorf("unsupported receipt kind %q", kind)
}
if writeDisk {
path, err := writeReceiptFile(art)
if err != nil {
return art, err
}
art.Path = path
}
return art, nil
}
func safeReceiptFilename(receiptID, hash string) string {
base := strings.TrimSpace(receiptID)
if base == "" {
base = strings.TrimSpace(hash)
}
if base == "" {
base = "receipt"
}
var b strings.Builder
for _, r := range base {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
s := b.String()
if len(s) > 80 {
s = s[:80]
}
return s
}
func writeReceiptFile(art ReceiptArtifact) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".verae", "receipts")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, art.Filename)
var data []byte
if art.ContentB64 != "" {
data, err = base64.StdEncoding.DecodeString(art.ContentB64)
if err != nil {
return "", err
}
} else {
data = []byte(art.ContentText)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func attachArtifacts(out ReceiptsOut, kind string, writeDisk bool) (ReceiptsOut, error) {
if len(out.Receipts) == 0 {
return out, nil
}
arts := make([]ReceiptArtifact, 0, len(out.Receipts))
for _, dto := range out.Receipts {
art, err := buildArtifact(dto, kind, writeDisk)
if err != nil {
return out, err
}
arts = append(arts, art)
}
out.Artifacts = arts
return out, nil
}
// --- verify: parse receipt body to extract receipt_id ---
var (
reReceiptIDLine = regexp.MustCompile(`(?i)(?:receipt[_ ]?id|certificate|cid)\s*[:=]\s*(\S+)`)
reCIDToken = regexp.MustCompile(`\b(Qm[1-9A-HJ-NP-Za-km-z]{44}|bafy[a-z0-9]{50,})\b`)
)
// parseReceiptIDFromBody extracts a receipt/certificate id from inline content.
func parseReceiptIDFromBody(kind, body string) (string, error) {
body = strings.TrimSpace(body)
if body == "" {
return "", fmt.Errorf("empty receipt body")
}
switch kind {
case KindJSON:
return parseReceiptIDJSON(body)
case KindCSV, KindTSV:
comma := ','
if kind == KindTSV {
comma = '\t'
}
return parseReceiptIDDelimited(body, comma)
case KindPDF:
// body may be raw PDF bytes as string, or base64
text := body
if strings.HasPrefix(body, "%PDF") {
text = extractPDFTextBestEffort([]byte(body))
} else if b, err := base64.StdEncoding.DecodeString(body); err == nil && len(b) > 4 && string(b[:4]) == "%PDF" {
text = extractPDFTextBestEffort(b)
}
return parseReceiptIDText(text)
default: // text, md
return parseReceiptIDText(body)
}
}
func parseReceiptIDJSON(body string) (string, error) {
var raw any
if err := json.Unmarshal([]byte(body), &raw); err != nil {
return "", fmt.Errorf("JSON-Receipt parse: %w", err)
}
switch v := raw.(type) {
case map[string]any:
if id := stringField(v, "receipt_id", "certificate", "cid", "ReceiptID"); id != "" {
return id, nil
}
if recs, ok := v["receipts"].([]any); ok && len(recs) > 0 {
if m, ok := recs[0].(map[string]any); ok {
if id := stringField(m, "receipt_id", "certificate", "cid"); id != "" {
return id, nil
}
}
}
}
return "", fmt.Errorf("JSON-Receipt: no receipt_id found")
}
func stringField(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
return ""
}
func parseReceiptIDDelimited(body string, comma rune) (string, error) {
r := csv.NewReader(strings.NewReader(body))
r.Comma = comma
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
if err != nil {
return "", fmt.Errorf("delimited receipt parse: %w", err)
}
if len(rows) < 2 {
// single row of values without header — try first cell that looks like a CID
if len(rows) == 1 {
for _, cell := range rows[0] {
if id := strings.TrimSpace(cell); looksLikeCID(id) {
return id, nil
}
}
}
return "", fmt.Errorf("delimited receipt: need header + data row")
}
headers := rows[0]
data := rows[1]
idx := -1
for i, h := range headers {
switch strings.ToLower(strings.TrimSpace(h)) {
case "receipt_id", "certificate", "cid":
idx = i
}
}
if idx >= 0 && idx < len(data) && strings.TrimSpace(data[idx]) != "" {
return strings.TrimSpace(data[idx]), nil
}
for _, cell := range data {
if id := strings.TrimSpace(cell); looksLikeCID(id) {
return id, nil
}
}
return "", fmt.Errorf("delimited receipt: no receipt_id column")
}
func parseReceiptIDText(body string) (string, error) {
if m := reReceiptIDLine.FindStringSubmatch(body); len(m) > 1 {
return strings.TrimSpace(m[1]), nil
}
if m := reCIDToken.FindStringSubmatch(body); len(m) > 1 {
return m[1], nil
}
return "", fmt.Errorf("could not find receipt_id in receipt body")
}
// extractPDFTextBestEffort pulls printable ASCII runs from a PDF byte stream.
// Enough for CERT-01 label/value pairs; not a full PDF parser.
func extractPDFTextBestEffort(pdf []byte) string {
var b strings.Builder
run := 0
for _, c := range pdf {
if c >= 32 && c < 127 {
b.WriteByte(c)
run++
} else if run > 0 {
b.WriteByte('\n')
run = 0
}
}
return b.String()
}

View file

@ -0,0 +1,187 @@
package tools
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeReceiptFormat(t *testing.T) {
cases := []struct {
in string
want string
err bool
}{
{"", KindText, false},
{" ", KindText, false},
{"text", KindText, false},
{"receipt", KindPDF, false},
{"PDF-Receipt", KindPDF, false},
{"pdf-receipt", KindPDF, false},
{"PDF", KindPDF, false},
{"JSON-Receipt", KindJSON, false},
{"json", KindJSON, false},
{"MD", KindMD, false},
{"MD-Receipt", KindMD, false},
{"markdown", KindMD, false},
{"CSV", KindCSV, false},
{"CSV-Receipt", KindCSV, false},
{"csv", KindCSV, false},
{"TSV", KindTSV, false},
{"TSV-Receipt", KindTSV, false},
{"tsv", KindTSV, false},
{"docx", "", true},
{"xml", "", true},
}
for _, tc := range cases {
got, err := normalizeReceiptFormat(tc.in)
if tc.err {
if err == nil {
t.Fatalf("normalizeReceiptFormat(%q) expected error", tc.in)
}
continue
}
if err != nil {
t.Fatalf("normalizeReceiptFormat(%q): %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeReceiptFormat(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
func sampleDTO() ReceiptDTO {
return ReceiptDTO{
ReceiptID: "QmWRJoKLTs56xFQLkhtvSQnWiQvebXGRqRdtbsjodm5HVX",
Hash: "01ec26e0bcdaf0271af7bb7810b1eae22b3220bc3c59b9cc7abfa1519edcbae1",
Scope: "global",
CreatedAt: "2026-09-23T14:46:08Z",
Merkle: "merkle-root-example",
}
}
func TestBuildArtifactFormats(t *testing.T) {
dto := sampleDTO()
home := t.TempDir()
t.Setenv("HOME", home)
for _, kind := range []string{KindText, KindJSON, KindMD, KindCSV, KindTSV, KindPDF} {
art, err := buildArtifact(dto, kind, true)
if err != nil {
t.Fatalf("buildArtifact(%s): %v", kind, err)
}
if art.Format != kind {
t.Fatalf("format=%q want %q", art.Format, kind)
}
if art.MediaType != mediaTypeForKind(kind) {
t.Fatalf("%s media_type=%q", kind, art.MediaType)
}
if art.Path == "" {
t.Fatalf("%s: expected path under HOME", kind)
}
if _, err := os.Stat(art.Path); err != nil {
t.Fatalf("%s path missing: %v", kind, err)
}
switch kind {
case KindPDF:
if art.ContentB64 == "" {
t.Fatal("pdf: empty content_base64")
}
b, err := base64.StdEncoding.DecodeString(art.ContentB64)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(b), "%PDF") {
t.Fatalf("pdf does not start with %%PDF")
}
case KindJSON:
if !strings.Contains(art.ContentText, `"format": "JSON-Receipt"`) {
t.Fatalf("json missing format marker: %s", art.ContentText[:min(80, len(art.ContentText))])
}
if !strings.Contains(art.ContentText, dto.ReceiptID) {
t.Fatal("json missing receipt_id")
}
case KindMD:
if !strings.Contains(art.ContentText, "| receipt_id |") {
t.Fatalf("md missing table header: %s", art.ContentText)
}
case KindCSV:
if !strings.HasPrefix(art.ContentText, "receipt_id,") {
t.Fatalf("csv header: %q", art.ContentText)
}
if !strings.Contains(art.ContentText, dto.ReceiptID) {
t.Fatal("csv missing id")
}
case KindTSV:
if !strings.HasPrefix(art.ContentText, "receipt_id\t") {
t.Fatalf("tsv header: %q", art.ContentText)
}
case KindText:
if !strings.Contains(art.ContentText, "receipt_id: "+dto.ReceiptID) {
t.Fatal("text missing receipt_id line")
}
}
}
}
func TestBuildArtifactPublicNoDisk(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
art, err := buildArtifact(sampleDTO(), KindJSON, false)
if err != nil {
t.Fatal(err)
}
if art.Path != "" {
t.Fatalf("public must not set path, got %q", art.Path)
}
ents, _ := os.ReadDir(filepath.Join(home, ".verae", "receipts"))
if len(ents) != 0 {
t.Fatalf("public wrote %d files under receipts", len(ents))
}
if art.ContentText == "" {
t.Fatal("expected content_text")
}
}
func TestParseReceiptIDRoundTrip(t *testing.T) {
dto := sampleDTO()
cases := []string{KindText, KindJSON, KindMD, KindCSV, KindTSV}
for _, kind := range cases {
art, err := buildArtifact(dto, kind, false)
if err != nil {
t.Fatal(err)
}
id, err := parseReceiptIDFromBody(kind, art.ContentText)
if err != nil {
t.Fatalf("parse %s: %v\nbody:\n%s", kind, err, art.ContentText)
}
if id != dto.ReceiptID {
t.Fatalf("parse %s: got %q want %q", kind, id, dto.ReceiptID)
}
}
// PDF via base64
art, err := buildArtifact(dto, KindPDF, false)
if err != nil {
t.Fatal(err)
}
id, err := parseReceiptIDFromBody(KindPDF, art.ContentB64)
if err != nil {
t.Fatalf("parse pdf: %v", err)
}
if id != dto.ReceiptID {
t.Fatalf("parse pdf: got %q want %q", id, dto.ReceiptID)
}
}
func TestAttachArtifacts(t *testing.T) {
out := ReceiptsOut{Receipts: []ReceiptDTO{sampleDTO()}}
got, err := attachArtifacts(out, KindCSV, false)
if err != nil {
t.Fatal(err)
}
if len(got.Artifacts) != 1 || got.Artifacts[0].Format != KindCSV {
t.Fatalf("%+v", got.Artifacts)
}
}

View file

@ -2,14 +2,6 @@ package tools
import "github.com/modelcontextprotocol/go-sdk/mcp"
const instructions = "Digests-only timestamping. " +
"Public HTTP (mcp.verae.ai / mcp.veraetime.net): stamp writes need Authorization: Bearer " +
"<xVerae- key or VeraeTime JWT> — no host-login fallback. " +
"When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai (same billing as PrintIt/Zapier). " +
"New accounts: tool signup (POST /auth/signup) or portal createUser; admin create_user for operators. " +
"login is disabled on the public HTTP server. " +
"Grok Build: bearer_token_file with raw xVerae- or JWT, or -transport stdio. Do not paste tokens into chat."
// NewServer builds an MCP server with the full Verae timestamping tool set.
func NewServer(d *Deps) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{
@ -22,22 +14,32 @@ func NewServer(d *Deps) *mcp.Server {
return s
}
// Register adds stamp, stamp_batch, verify, hash_report, signup, login, session, create_user.
// Register adds welcome, stamp, stamp_batch, verify, hash_report, signup, login, session, create_user.
func Register(s *mcp.Server, d *Deps) {
if d == nil {
d = &Deps{}
}
mcp.AddTool(s, &mcp.Tool{
Name: "stamp",
Description: "Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields.",
Name: "welcome",
Description: "Show the Verae welcome and onboarding text: what timestamping is, what this MCP does, " +
"and how to signup or login. Call this when a user connects without an account or asks what Verae is.",
}, d.welcome)
mcp.AddTool(s, &mcp.Tool{
Name: "stamp",
Description: "Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. " +
"Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields. " +
"Optional receipt_format: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt. " +
"Always returns receipts[]; also returns artifacts[] in the chosen format (stdio writes ~/.verae/receipts; public MCP returns content only).",
}, d.stamp)
mcp.AddTool(s, &mcp.Tool{
Name: "stamp_batch",
Description: "Commit up to 500 SHA-256 digests at once. scope=global (default) needs your Verae API key and charges each new digest to your account (your own repeats are free); returns one receipt entry per digest, in order, with the credits charged and your balance.",
}, d.stampBatch)
mcp.AddTool(s, &mcp.Tool{
Name: "verify",
Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth).",
Name: "verify",
Description: "Check a receipt. want_scope=global asks api.veraetime.net (central source of truth). " +
"Pass receipt_id, or supply receipt / receipt_path with receipt_format (text|JSON-Receipt|MD|CSV|TSV|PDF-Receipt) to extract the id. " +
"On success, re-emits an artifact in the requested format.",
}, d.verify)
mcp.AddTool(s, &mcp.Tool{
Name: "hash_report",
@ -45,13 +47,16 @@ func Register(s *mcp.Server, d *Deps) {
}, d.hashReport)
mcp.AddTool(s, &mcp.Tool{
Name: "signup",
Description: "Self-serve create a VeraeTime user (POST /auth/signup, role=user). " +
"No Authorization required on the public server. Does not return a JWT — " +
"use a client login or portal to obtain a bearer for stamp.",
Description: "Self-serve create a VeraeTime user (POST /auth/signup, role=user), then return access_token. " +
"Username must be an email address (a welcome email is sent to that address). " +
"No Authorization required. Write access_token to your MCP bearer file (or Authorization header) before stamp. " +
"On stdio/local transport the token is also stored on this host.",
}, d.signup)
mcp.AddTool(s, &mcp.Tool{
Name: "login",
Description: "Log in to api.veraetime.net. Stores the JWT on this host and does not return it. Disabled on the public HTTP server.",
Name: "login",
Description: "Log in to api.veraetime.net and return access_token. Username is the account email address. " +
"On the public HTTP server the host credential is never replaced — only the tool result carries the token. " +
"On stdio/local transport the token is also stored on this host (auth.properties).",
}, d.login)
mcp.AddTool(s, &mcp.Tool{
Name: "session",
@ -60,6 +65,7 @@ func Register(s *mcp.Server, d *Deps) {
mcp.AddTool(s, &mcp.Tool{
Name: "create_user",
Description: "Admin create a VeraeTime user (POST /auth/users / Swagger Auth/createUser). " +
"Username must be an email address (a Welcome to Verae email is sent). " +
"Requires Authorization: Bearer of an admin. Used by operators and edge provision; " +
"self-serve MCP clients should use signup.",
}, d.createUser)

View file

@ -2,19 +2,23 @@ package tools
// StampArgs is the stamp tool input.
type StampArgs struct {
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"`
Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."`
Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"`
Message string `json:"message,omitempty" jsonschema:"optional history commit message"`
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"`
Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."`
Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"`
Message string `json:"message,omitempty" jsonschema:"optional history commit message"`
ReceiptFormat string `json:"receipt_format,omitempty" jsonschema:"optional receipt presentation: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt"`
}
// VerifyArgs is the verify tool input.
type VerifyArgs struct {
ReceiptID string `json:"receipt_id" jsonschema:"receipt or certificate id"`
WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope (local|shared|global)"`
ReceiptID string `json:"receipt_id,omitempty" jsonschema:"receipt or certificate id (optional when Receipt or ReceiptPath supplies it)"`
WantScope string `json:"want_scope,omitempty" jsonschema:"optional minimum scope (local|shared|global)"`
ReceiptFormat string `json:"receipt_format,omitempty" jsonschema:"how to interpret Receipt/ReceiptPath and/or re-emit artifact: omit=text; receipt|PDF-Receipt=PDF; JSON-Receipt=JSON; MD|CSV|TSV"`
Receipt string `json:"receipt,omitempty" jsonschema:"optional inline receipt body (text/JSON/MD/CSV/TSV or base64 PDF)"`
ReceiptPath string `json:"receipt_path,omitempty" jsonschema:"optional local path to a receipt file (stdio only; rejected on public MCP)"`
}
// HashReportArgs is the hash_report tool input.
@ -26,13 +30,13 @@ type HashReportArgs struct {
// AuthUserPass are credentials for signup/login.
type AuthUserPass struct {
Username string `json:"username" jsonschema:"VeraeTime username"`
Username string `json:"username" jsonschema:"Email address used as VeraeTime username (required; must be an email)"`
Password string `json:"password" jsonschema:"VeraeTime password"`
}
// CreateUserArgs creates a VeraeTime user (admin bearer required).
type CreateUserArgs struct {
Username string `json:"username" jsonschema:"new username"`
Username string `json:"username" jsonschema:"Email address used as the new username (required; must be an email)"`
Password string `json:"password" jsonschema:"new password"`
Role string `json:"role" jsonschema:"user or admin"`
}
@ -52,23 +56,39 @@ type ReceiptDTO struct {
RestampOf string `json:"restamp_of,omitempty"`
}
// ReceiptArtifact is a rendered receipt in the requested presentation format.
type ReceiptArtifact struct {
Format string `json:"format"` // text|pdf|json|md|csv|tsv
MediaType string `json:"media_type"` // MIME type
Filename string `json:"filename,omitempty"`
Path string `json:"path,omitempty"` // written under ~/.verae/receipts when stdio
ContentText string `json:"content_text,omitempty"` // text/JSON/MD/CSV/TSV body
ContentB64 string `json:"content_base64,omitempty"` // PDF (and large bodies if needed)
}
// ReceiptsOut wraps a receipt list.
type ReceiptsOut struct {
Receipts []ReceiptDTO `json:"receipts"`
Receipts []ReceiptDTO `json:"receipts"`
Artifacts []ReceiptArtifact `json:"artifacts,omitempty"`
}
// VerifyOut is the verify tool result.
type VerifyOut struct {
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Receipt *ReceiptDTO `json:"receipt,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Receipt *ReceiptDTO `json:"receipt,omitempty"`
Artifact *ReceiptArtifact `json:"artifact,omitempty"`
}
// LoginOut is returned by login (never includes JWT).
// LoginOut is returned by login. On the public HTTP server it includes access_token
// so the client can set Authorization / bearer_token_file. Stdio login also returns
// the token and persists it on the host.
type LoginOut struct {
Username string `json:"username"`
Role string `json:"role"`
ExpiresAt string `json:"expires_at"`
Username string `json:"username"`
Role string `json:"role"`
ExpiresAt string `json:"expires_at"`
AccessToken string `json:"access_token,omitempty"`
HowToUse string `json:"how_to_use,omitempty"`
}
// SessionOut is returned by session.
@ -80,10 +100,14 @@ type SessionOut struct {
}
// CreateUserOut is returned by signup and create_user.
// Signup includes access_token so a new account can stamp without a separate login.
type CreateUserOut struct {
ID string `json:"id,omitempty"`
Username string `json:"username"`
Role string `json:"role"`
ID string `json:"id,omitempty"`
Username string `json:"username"`
Role string `json:"role"`
AccessToken string `json:"access_token,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
HowToUse string `json:"how_to_use,omitempty"`
}
// StampBatchArgs is the stamp_batch tool input (DEC-659).

70
internal/tools/welcome.go Normal file
View file

@ -0,0 +1,70 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Welcome message shown on connect (via instructions) and by the welcome tool.
// Keep this the source of truth for onboarding copy.
const welcomeMessage = `Welcome to Verae.
Account and auth are required for use of this MCP. Before stamp, verify, hash_report, or session, you must create an account or sign in and attach your access_token.
Verae Timestamping proves that a specific digital object existed at a point in time. You hash the file (or data) locally — Verae never needs the file bytes, only the digest — then commit that digest. The result is a receipt you can verify later (and share) without revealing the original content.
This MCP server is how AI clients (Grok, Claude, Cursor, and others) and local tools talk to Verae:
• welcome — onboarding (no account required)
• signup / login — create an account or sign in; you receive an access_token (required next)
• stamp / stamp_batch — commit one or many SHA-256 digests (local ledger or global VeraeTime)
• verify / hash_report — check a receipt or list history for a digest
• session — confirm your bearer is still valid
Public HTTP (mcp.verae.ai): every tool except welcome, signup, and login needs Authorization: Bearer (VeraeTime JWT or xVerae- API key). There is no host-login fallback. Put access_token in your client's bearer file (Grok: bearer_token_file → ~/.verae/mcp-bearer) or send Authorization on each tools/call. Do not paste tokens into chat.
Important: the username must be an email address (welcome mail is sent there on signup).
Next step — create or sign in to your account:
1. New here? Call tool signup with your email as username and a password. It creates the account, emails a Welcome to Verae message, and returns access_token.
2. Already registered? Call tool login with your email and password to receive access_token.
3. Save the token in your MCP client, call session to confirm, then stamp or verify.
Global stamps may meter prepaid credits when edge billing is enabled.`
const instructions = "Welcome to Verae Timestamping MCP (mcp.verae.ai). " +
"Account and auth are required for use. Username must be an email address. " +
"On first connect, or whenever the user has no Verae account / bearer, greet them warmly: " +
"welcome them to Verae, briefly explain digests-only timestamping (hash locally; stamp digests; get a verifiable receipt), " +
"explain that this MCP requires signup or login before stamp/verify/session, " +
"tell them the username must be an email address (a Welcome to Verae email is sent on signup), " +
"then ask them to sign in (tool login) or create an account (tool signup). " +
"Prefer calling tool welcome to show the full onboarding text, then guide signup or login. " +
"Only welcome, signup, and login work without a bearer. " +
"After signup/login, save access_token to the client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) or Authorization header — never paste tokens into chat. " +
"When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai. " +
"Admin create_user is for operators only."
// WelcomeOut is returned by the welcome tool.
type WelcomeOut struct {
Title string `json:"title"`
Message string `json:"message"`
NextSteps []string `json:"next_steps"`
ToolsHint []string `json:"tools_hint"`
HasAccount string `json:"ask"`
}
func (d *Deps) welcome(_ context.Context, _ *mcp.CallToolRequest, _ EmptyArgs) (*mcp.CallToolResult, WelcomeOut, error) {
return nil, WelcomeOut{
Title: "Welcome to Verae",
Message: welcomeMessage,
NextSteps: []string{
"New user: call tool signup with your email as username and a password → receive access_token (Welcome email is sent)",
"Existing user: call tool login with your email and password → receive access_token",
"Save access_token to your MCP bearer file or Authorization header (do not paste into chat)",
"Call tool session to confirm, then stamp a SHA-256 digest",
},
ToolsHint: []string{"welcome", "signup", "login", "session", "stamp", "stamp_batch", "verify", "hash_report"},
HasAccount: "Account and auth are required for use. The username must be an email address. Do you already have a Verae account? Sign in with login, or create one with signup.",
}, nil
}

View file

@ -0,0 +1,36 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestWelcomeMessageMentionsSignupLogin(t *testing.T) {
t.Parallel()
for _, want := range []string{"Welcome to Verae", "signup", "login", "access_token", "digest"} {
if !strings.Contains(welcomeMessage, want) {
t.Fatalf("welcomeMessage missing %q", want)
}
}
for _, want := range []string{"Welcome to Verae", "signup", "login", "tool welcome"} {
if !strings.Contains(instructions, want) {
t.Fatalf("instructions missing %q", want)
}
}
}
func TestWelcomeTool(t *testing.T) {
t.Parallel()
d := &Deps{}
_, out, err := d.welcome(context.Background(), nil, EmptyArgs{})
if err != nil {
t.Fatal(err)
}
if out.Title != "Welcome to Verae" || !strings.Contains(strings.ToLower(out.Message), "timestamp") {
t.Fatalf("unexpected welcome: %+v", out)
}
if len(out.NextSteps) < 3 || out.HasAccount == "" {
t.Fatalf("next_steps/ask incomplete: %+v", out)
}
}