feat(mcpclient): fail-fast on empty bearer token
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s

mcpclient.New previously accepted an empty token and silently omitted
the Authorization header at request time. When the env var sourcing
the token was missing from a Kubernetes Secret (envFrom doesn't warn
on missing keys), this surfaced as an opaque 401 from the upstream
MCP server with no log trail — see hyperguild #13 and brain entry
"mcpclient-empty-token-silent-401-envfrom-missing-key".

mcpclient.New now returns ErrTokenRequired when token is empty.
The routing pod's project_create init checks the error and exits
with a clear message pointing at routing-secrets, turning a runtime
401 storm into a startup crashloop the operator can fix immediately.

Tests pass a dummy "test" token (httptest servers don't enforce
bearer auth, so any non-empty value works). Added a regression
test asserting empty-token construction returns ErrTokenRequired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathias
2026-05-18 16:28:09 +02:00
parent a220fcaf2b
commit 5950ef5f0f
4 changed files with 54 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -20,13 +21,27 @@ type Client struct {
http *http.Client
}
// New returns a Client. token may be empty for unauthenticated servers.
func New(url, token string) *Client {
// ErrTokenRequired is returned by New when token is empty. Empty token
// causes mcpclient to omit the Authorization header at request time,
// which is silently misread as 401 by bearer-auth servers — see
// hyperguild #13 and the brain entry on the failure mode.
var ErrTokenRequired = errors.New("mcpclient: token required")
// New returns a Client. Returns ErrTokenRequired when token is empty:
// every MCP server we talk to today is bearer-protected, and an empty
// token is always a configuration bug (typically a Kubernetes Secret
// missing the expected key, see hyperguild #13). Callers that genuinely
// need an unauthenticated client should construct &Client{} directly in
// tests, not call New.
func New(url, token string) (*Client, error) {
if token == "" {
return nil, ErrTokenRequired
}
return &Client{
url: url,
token: token,
http: &http.Client{Timeout: 60 * time.Second},
}
}, nil
}
// WithHTTPClient overrides the underlying HTTP client (test injection).

View File

@@ -14,6 +14,13 @@ import (
"github.com/stretchr/testify/require"
)
func TestNew_EmptyTokenFailsFast(t *testing.T) {
c, err := mcpclient.New("http://example.invalid", "")
require.Error(t, err)
require.Nil(t, c)
require.ErrorIs(t, err, mcpclient.ErrTokenRequired)
}
func TestCallTool_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
@@ -30,13 +37,13 @@ func TestCallTool_Success(t *testing.T) {
}))
defer srv.Close()
c := mcpclient.New(srv.URL, "tok")
c, err := mcpclient.New(srv.URL, "tok")
require.NoError(t, err)
var out struct {
OK bool `json:"ok"`
N int `json:"n"`
}
err := c.CallTool(context.Background(), "x_y", map[string]any{"a": 1}, &out)
require.NoError(t, err)
require.NoError(t, c.CallTool(context.Background(), "x_y", map[string]any{"a": 1}, &out))
assert.True(t, out.OK)
assert.Equal(t, 7, out.N)
}
@@ -48,8 +55,9 @@ func TestCallTool_RPCError(t *testing.T) {
}))
defer srv.Close()
c := mcpclient.New(srv.URL, "")
err := c.CallTool(context.Background(), "x", nil, nil)
c, err := mcpclient.New(srv.URL, "test")
require.NoError(t, err)
err = c.CallTool(context.Background(), "x", nil, nil)
require.Error(t, err)
var me *mcpclient.Error
require.True(t, errors.As(err, &me))
@@ -64,8 +72,9 @@ func TestCallTool_HTTPError(t *testing.T) {
}))
defer srv.Close()
c := mcpclient.New(srv.URL, "")
err := c.CallTool(context.Background(), "x", nil, nil)
c, err := mcpclient.New(srv.URL, "test")
require.NoError(t, err)
err = c.CallTool(context.Background(), "x", nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "401")
}
@@ -77,6 +86,7 @@ func TestCallTool_NilResult(t *testing.T) {
}))
defer srv.Close()
c := mcpclient.New(srv.URL, "")
c, err := mcpclient.New(srv.URL, "test")
require.NoError(t, err)
require.NoError(t, c.CallTool(context.Background(), "x", nil, nil))
}

View File

@@ -125,7 +125,7 @@ func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) {
t.Cleanup(ghSrv.Close)
return project.New(project.Config{
Client: mcpclient.New(srv.URL, ""),
Client: mustClient(t, srv.URL),
GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL),
GiteaOwner: "mathias",
GitHubOwner: "mathiasb",
@@ -141,13 +141,23 @@ func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill {
srv := httptest.NewServer(f.handler())
t.Cleanup(srv.Close)
return project.New(project.Config{
Client: mcpclient.New(srv.URL, ""),
Client: mustClient(t, srv.URL),
GiteaOwner: "mathias",
GitHubOwner: "mathiasb",
InfraRepo: "infra",
})
}
// mustClient builds an mcpclient against an httptest server. Uses a
// non-empty dummy token because httptest servers don't enforce bearer
// auth, but mcpclient.New now requires non-empty token (see #13).
func mustClient(t *testing.T, url string) *mcpclient.Client {
t.Helper()
c, err := mcpclient.New(url, "test-token")
require.NoError(t, err)
return c
}
func happyArgs() json.RawMessage {
return json.RawMessage(`{
"name":"my-experiment",