fix(auth): clear public write errors and Grok client path
Public DEC-654 still requires a caller bearer; anonymous session now returns that guidance as a tool error instead of silent valid:false. Error text names JWT and API key, Grok bearer_token_file, and local stdio. BearerFromRequest also accepts X-Api-Key / X-Verae-Key. Document Grok stdio vs public URL; add scripts/sync-mcp-bearer.sh.
This commit is contained in:
parent
dc39095694
commit
369f9210bd
8 changed files with 160 additions and 22 deletions
37
README.md
37
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 <JWT or API key>` 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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <Verae API key> " +
|
||||
"(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 {
|
||||
|
|
|
|||
|
|
@ -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 " +
|
||||
"<VeraeTime JWT or Verae API key> 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 {
|
||||
|
|
|
|||
30
scripts/sync-mcp-bearer.sh
Executable file
30
scripts/sync-mcp-bearer.sh
Executable file
|
|
@ -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
|
||||
Loading…
Reference in a new issue