diff --git a/README.md b/README.md index cf46585..6bc5edb 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,43 @@ Point MCP clients at `http://127.0.0.1:8001/mcp`. For stdio: `-transport stdio` Global stamps use VeraeTime with auth from `auth.properties` (or caller Bearer). Embedded NATS serves `verae.ts.*`; job-complete is core `verae.ts.completed` only. +### Public HTTP vs local stdio (DEC-654) + +| Transport | Writes | +| --- | --- | +| Public HTTP (`mcp.verae.ai`, default streamable-http) | Caller must send `Authorization: Bearer ` on every tools/call. No host-login fallback. | +| Local stdio (`-transport stdio`) | Host login from `~/.verae/auth.properties` is used when no caller bearer is sent. | + +### Grok Build CLI + +**Option A — local stdio (recommended for laptop agents):** host auth, no public lockdown. + +```toml +# ~/.grok/config.toml +[mcp_servers.verae-timestamping] +command = "/absolute/path/to/bin/verae-mcp-timestamping-server" +args = ["-transport", "stdio"] +enabled = true + +[mcp_servers.verae-timestamping.env] +VERAE_NATS_LISTEN = "" +``` + +**Option B — public URL with caller bearer:** + +```sh +./scripts/sync-mcp-bearer.sh # writes ~/.verae/mcp-bearer from auth.properties +``` + +```toml +[mcp_servers.verae-timestamping] +url = "https://mcp.veraetime.net/mcp" +enabled = true +bearer_token_file = "~/.verae/mcp-bearer" +``` + +Reconnect the MCP server after config changes (new Grok session or `/mcps` toggle). + Parity smoke when Python `:8000` and Go `:8001` are both up: ```sh diff --git a/internal/server/public_test.go b/internal/server/public_test.go index a3c7d6e..17c52c4 100644 --- a/internal/server/public_test.go +++ b/internal/server/public_test.go @@ -147,8 +147,9 @@ func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) { } } ok, out := call(t, s, "session", map[string]any{}) - if !ok || strings.Contains(out, `"valid":true`) { - t.Fatalf("anonymous session must not report the host login: %v %s", ok, out) + // Anonymous session is a tool error (not a silent valid:false) so agents see how to attach Bearer. + if ok || strings.Contains(out, `"valid":true`) || !strings.Contains(out, "needs your own Verae credential") { + t.Fatalf("anonymous session must error with credential guidance: %v %s", ok, out) } if vt.stamps.Load() != 0 || vt.users.Load() != 0 || vt.hostUse.Load() != 0 { t.Fatalf("upstream writes or host-token use: stamps=%d users=%d host=%d", vt.stamps.Load(), vt.users.Load(), vt.hostUse.Load()) diff --git a/internal/tools/bearer.go b/internal/tools/bearer.go index f8a28f8..623abf0 100644 --- a/internal/tools/bearer.go +++ b/internal/tools/bearer.go @@ -8,12 +8,27 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// BearerFromRequest extracts Authorization: Bearer from the HTTP request headers. +// BearerFromRequest extracts the caller credential from the MCP HTTP request. +// Order: Authorization: Bearer, then X-Api-Key / X-Verae-Key (raw token). func BearerFromRequest(req *mcp.CallToolRequest) string { if req == nil || req.Extra == nil || req.Extra.Header == nil { return "" } - auth := strings.TrimSpace(req.Extra.Header.Get("Authorization")) + h := req.Extra.Header + if tok := bearerFromAuthHeader(h.Get("Authorization")); tok != "" { + return tok + } + // Some gateways forward the key under these names instead of Authorization. + for _, name := range []string{"X-Api-Key", "X-Verae-Key", "X-Verae-Token"} { + if tok := strings.TrimSpace(h.Get(name)); tok != "" { + return tok + } + } + return "" +} + +func bearerFromAuthHeader(auth string) string { + auth = strings.TrimSpace(auth) if auth == "" { return "" } diff --git a/internal/tools/bearer_test.go b/internal/tools/bearer_test.go index 62deea2..006b6c8 100644 --- a/internal/tools/bearer_test.go +++ b/internal/tools/bearer_test.go @@ -1,6 +1,12 @@ package tools -import "testing" +import ( + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) func TestLooksLikeCID(t *testing.T) { t.Parallel() @@ -20,3 +26,35 @@ func TestLooksLikeCID(t *testing.T) { } } } + +func TestBearerFromRequest(t *testing.T) { + t.Parallel() + req := &mcp.CallToolRequest{Extra: &mcp.RequestExtra{Header: http.Header{}}} + if got := BearerFromRequest(req); got != "" { + t.Fatalf("empty headers: %q", got) + } + req.Extra.Header.Set("Authorization", "Bearer jwt-one") + if got := BearerFromRequest(req); got != "jwt-one" { + t.Fatalf("Authorization: %q", got) + } + req.Extra.Header = http.Header{} + req.Extra.Header.Set("X-Api-Key", "key-two") + if got := BearerFromRequest(req); got != "key-two" { + t.Fatalf("X-Api-Key: %q", got) + } + req.Extra.Header = http.Header{} + req.Extra.Header.Set("Authorization", "bearer spaced ") + if got := BearerFromRequest(req); got != "spaced" { + t.Fatalf("case/space: %q", got) + } +} + +func TestErrWriteNeedsKeyMentionsJWTAndGrok(t *testing.T) { + t.Parallel() + msg := ErrWriteNeedsKey.Error() + for _, want := range []string{"Authorization: Bearer", "JWT", "bearer_token_file", "stdio"} { + if !strings.Contains(msg, want) { + t.Fatalf("ErrWriteNeedsKey missing %q: %s", want, msg) + } + } +} diff --git a/internal/tools/handlers.go b/internal/tools/handlers.go index 6933a9e..a567f70 100644 --- a/internal/tools/handlers.go +++ b/internal/tools/handlers.go @@ -293,10 +293,12 @@ func (d *Deps) session(ctx context.Context, req *mcp.CallToolRequest, _ EmptyArg if d.Global == nil { return nil, SessionOut{}, fmt.Errorf("global VeraeTime client not configured") } - tok, err := d.resolveBearer(ctx, req) + // 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) == "" { - err = ErrWriteNeedsKey // never report the host's own login to an anonymous caller + return nil, SessionOut{Valid: false}, ErrWriteNeedsKey } + tok, err := d.resolveBearer(ctx, req) if err != nil { return nil, SessionOut{Valid: false}, nil } diff --git a/internal/tools/public.go b/internal/tools/public.go index c332ff7..e0f23dd 100644 --- a/internal/tools/public.go +++ b/internal/tools/public.go @@ -1,11 +1,15 @@ package tools -// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a Verae API -// key or a VeraeTime login/OAuth bearer, checked at api.veraetime.net - and never falls back to this host's stored -// service login. Writes: stamp (any scope; global stamps are billed to the key's customer), signup, create_user, -// session, stamp_batch; login (it would replace the host credential) is refused. Read-only verify and hash_report -// stay anonymous for the public pages (receipt links, explorer); only those use the host login, and they are never -// rate limited (DEC-660, George 2026-10-10: "never limit"). +// 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: stamp (any scope; global stamps are billed to the caller's account), signup, +// create_user, session, stamp_batch; login (it would replace the host credential) is refused. Read-only verify and +// hash_report stay anonymous for the public pages (receipt links, explorer); only those use the host login, and they +// are never rate limited (DEC-660, George 2026-10-10: "never limit"). +// +// AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call. 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. import ( "context" @@ -19,12 +23,22 @@ import ( ) // ErrWriteNeedsKey is returned for an anonymous write on the public server. -var ErrWriteNeedsKey = errors.New("this tool needs your own Verae credential: send Authorization: Bearer " + - "(create one at https://zap.verae.ai/portal/ or run `verae login`)") +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", +) // ErrLoginDisabled is returned by the login tool on the public server. -var ErrLoginDisabled = errors.New("login is not available on the public server; sign in at https://zap.verae.ai/portal/ " + - "(or `verae login`) and send the Verae API key as a bearer token") +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", +) // CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential). type CredentialChecker interface { diff --git a/internal/tools/register.go b/internal/tools/register.go index c062187..b5b6439 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -2,11 +2,12 @@ package tools import "github.com/modelcontextprotocol/go-sdk/mcp" -const instructions = "Global stamps need a VeraeTime login on the machine that runs this server, " + - "or a bearer token from the browser signup on the public server. " + - "If session is not valid, ask the user to run `uvx verae-mcp-timestamping-server setup`. " + - "Do not ask them to paste a token into the chat or into mcp.json. " + - "The token file is ~/.verae/auth.properties, mode 600." +const instructions = "Digests-only timestamping. " + + "Public HTTP (mcp.verae.ai / mcp.veraetime.net): every write needs Authorization: Bearer " + + " on each tools/call — no host-login fallback. " + + "Grok Build: set bearer_token_file to a file that holds only the raw token (e.g. ~/.verae/mcp-bearer from access_token), " + + "or run this binary with -transport stdio so the laptop ~/.verae/auth.properties host login is used. " + + "Do not paste tokens into chat. Stdio/trusted-local HTTP may use the host login when no caller bearer is sent." // NewServer builds an MCP server with the full Verae timestamping tool set. func NewServer(d *Deps) *mcp.Server { diff --git a/scripts/sync-mcp-bearer.sh b/scripts/sync-mcp-bearer.sh new file mode 100755 index 0000000..ce478cd --- /dev/null +++ b/scripts/sync-mcp-bearer.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Write ~/.verae/mcp-bearer (raw JWT only) from ~/.verae/auth.properties for Grok bearer_token_file. +set -euo pipefail +AUTH="${VERAE_AUTH_PROPERTIES:-$HOME/.verae/auth.properties}" +OUT="${VERAE_MCP_BEARER_FILE:-$HOME/.verae/mcp-bearer}" +if [[ ! -f "$AUTH" ]]; then + echo "missing $AUTH" >&2 + exit 1 +fi +python3 - "$AUTH" "$OUT" <<'PY' +import sys +from pathlib import Path +auth, out = Path(sys.argv[1]), Path(sys.argv[2]) +vals = {} +for line in auth.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + vals[k.strip()] = v.strip() +tok = vals.get("access_token") or vals.get("auth_token") or vals.get("id_token") +if not tok: + raise SystemExit("no access_token/auth_token/id_token in auth.properties") +tmp = out.with_suffix(".tmp") +tmp.write_text(tok + "\n") +tmp.chmod(0o600) +tmp.replace(out) +out.chmod(0o600) +print(f"wrote {out} ({len(tok)} chars)") +PY