From 06def021888b25446b5103cb645ddcdf8503c1ec Mon Sep 17 00:00:00 2001 From: Joshua Junqueira Date: Wed, 22 Jul 2026 11:48:23 -0500 Subject: [PATCH 1/3] feat(DEV-757): tag MCP requests with surface User-Agent token Add a Surface field to spur.Client and make userAgent() a method that appends ' surface/' when set, so API logs can attribute MCP-originated requests. CLI User-Agent is unchanged. --- internal/spur/client.go | 21 +++++++++++---- internal/spur/client_test.go | 50 ++++++++++++++++++++++++++++++++++++ internal/spur/feeds.go | 2 +- internal/spur/feeds_test.go | 31 ++++++++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/internal/spur/client.go b/internal/spur/client.go index 72ddb61..d723488 100644 --- a/internal/spur/client.go +++ b/internal/spur/client.go @@ -19,10 +19,6 @@ const BaseURL = "https://api.spur.us" const maxResponseBodyBytes int64 = 1 << 20 -func userAgent() string { - return fmt.Sprintf("spur/%s (+https://spur.us)", buildinfo.Version) -} - // Client is a stateless-ish wrapper around net/http. Its only mutable state is // the embedded *http.Client; each API method is safe for concurrent use. type Client struct { @@ -37,6 +33,21 @@ type Client struct { // distinct host from Base and FeedsBase, overridable independently via // config, and serves the dynamically filtered feed subsets. ExportsBase string + // Surface identifies the calling surface for API-log attribution. Empty + // means the CLI surface; set to a short token like "mcp" to tag requests + // from a different caller (appended to the User-Agent as " surface/"). + Surface string +} + +// userAgent builds the User-Agent header value. The CLI-default form is +// unchanged; a non-empty Surface appends " surface/" so API logs can +// attribute requests to a specific caller. +func (c *Client) userAgent() string { + ua := fmt.Sprintf("spur/%s (+https://spur.us)", buildinfo.Version) + if c.Surface != "" { + ua += " surface/" + c.Surface + } + return ua } // NewClient constructs a Client with a sensible default timeout. The redirect @@ -123,7 +134,7 @@ func (c *Client) doJSON(ctx context.Context, endpoint string, dst any) (Meta, er } req.Header.Set("Token", c.Token) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", c.userAgent()) resp, err := c.HTTP.Do(req) if err != nil { diff --git a/internal/spur/client_test.go b/internal/spur/client_test.go index 43d7bf1..9985e1a 100644 --- a/internal/spur/client_test.go +++ b/internal/spur/client_test.go @@ -4,12 +4,62 @@ import ( "bytes" "context" "errors" + "fmt" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/spurintel/cli/internal/buildinfo" ) +func TestUserAgentDefaultSurface(t *testing.T) { + t.Parallel() + + c := &Client{} + want := fmt.Sprintf("spur/%s (+https://spur.us)", buildinfo.Version) + if got := c.userAgent(); got != want { + t.Fatalf("userAgent() = %q, want %q", got, want) + } +} + +func TestUserAgentWithSurface(t *testing.T) { + t.Parallel() + + c := &Client{Surface: "mcp"} + def := fmt.Sprintf("spur/%s (+https://spur.us)", buildinfo.Version) + got := c.userAgent() + if !strings.HasPrefix(got, def) { + t.Fatalf("userAgent() = %q, want prefix %q", got, def) + } + if !strings.HasSuffix(got, " surface/mcp") { + t.Fatalf("userAgent() = %q, want suffix %q", got, " surface/mcp") + } +} + +func TestDoJSONSendsSurfaceInUserAgent(t *testing.T) { + t.Parallel() + + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + client := NewClient("token") + client.Base = srv.URL + client.Surface = "mcp" + + if _, err := client.GetStatus(context.Background()); err != nil { + t.Fatalf("GetStatus: %v", err) + } + if !strings.Contains(gotUA, "surface/mcp") { + t.Fatalf("User-Agent = %q, want to contain %q", gotUA, "surface/mcp") + } +} + func TestReadBodyRejectsOversizeResponses(t *testing.T) { t.Parallel() diff --git a/internal/spur/feeds.go b/internal/spur/feeds.go index 3a89ae0..a542a06 100644 --- a/internal/spur/feeds.go +++ b/internal/spur/feeds.go @@ -163,7 +163,7 @@ func (c *Client) doStream(ctx context.Context, endpoint string) (io.ReadCloser, return nil, 0, err } req.Header.Set("Token", c.Token) - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", c.userAgent()) resp, err := c.downloadHTTPClient().Do(req) if err != nil { diff --git a/internal/spur/feeds_test.go b/internal/spur/feeds_test.go index 479a124..31dba45 100644 --- a/internal/spur/feeds_test.go +++ b/internal/spur/feeds_test.go @@ -207,6 +207,37 @@ func TestDownloadFeedStreamsBody(t *testing.T) { } } +func TestDownloadFeedSendsSurfaceInUserAgent(t *testing.T) { + t.Parallel() + + payload := gzipBytes(t, `{"ip":"1.2.3.4"}`+"\n") + + var gotUA string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(payload) + })) + defer srv.Close() + + client := NewClient("secret") + client.FeedsBase = srv.URL + client.Surface = "mcp" + + body, _, err := client.DownloadFeed(context.Background(), "anonymous", "", FeedJSONGzip) + if err != nil { + t.Fatalf("DownloadFeed: %v", err) + } + defer func() { _ = body.Close() }() + if _, err := io.ReadAll(body); err != nil { + t.Fatalf("read body: %v", err) + } + + if !strings.Contains(gotUA, "surface/mcp") { + t.Fatalf("User-Agent = %q, want to contain %q", gotUA, "surface/mcp") + } +} + // The date and format together select the artifact path: latest vs historical, // json.gz vs mmdb; a nested slug keeps its slash. func TestDownloadFeedPathSelection(t *testing.T) { From 6a177341ed849bc4ddcc698d9d169e0564241628 Mon Sep 17 00:00:00 2001 From: Joshua Junqueira Date: Wed, 22 Jul 2026 11:49:55 -0500 Subject: [PATCH 2/3] feat(DEV-757): set surface=mcp on the MCP-backed client The spur mcp command tags its Spur API client with Surface="mcp" so its requests are attributable in API logs. Other commands are unaffected. --- internal/app/mcp.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/app/mcp.go b/internal/app/mcp.go index b1cb388..c3ef379 100644 --- a/internal/app/mcp.go +++ b/internal/app/mcp.go @@ -68,7 +68,9 @@ func newMCPCmd(cf *configFlags) *cobra.Command { if err != nil { return err } - return runMCPServer(cmd, cf.newClient(token)) + client := cf.newClient(token) + client.Surface = "mcp" + return runMCPServer(cmd, client) }, } } From 2b090ef8cb8402c83efd7a1ebbdf96f90b59a6d1 Mon Sep 17 00:00:00 2001 From: Joshua Junqueira Date: Wed, 22 Jul 2026 11:50:09 -0500 Subject: [PATCH 3/3] docs(DEV-757): note surface User-Agent token in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5270064..43f9aba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ surrounding code before making structural changes. - `internal/tools/`, `internal/resources/`, `internal/prompts/` — the MCP surface behind `spur mcp`: five tools, schema/doc resources, and the `assess_ip` prompt, all wired to the shared `internal/spur` client. -- `internal/buildinfo/` — ldflags-injected version, consumed by `spur version` and the API User-Agent. +- `internal/buildinfo/` — ldflags-injected version, consumed by `spur version` and the API User-Agent. The User-Agent carries a `surface/` token identifying the request's origin (set via `Client.Surface`); `spur mcp` sets `mcp`, plain CLI commands leave it empty. - `scripts/mcp-smoke.sh` — end-to-end stdio smoke test for `spur mcp`; run against a built binary. `SPUR_LIVE=1` adds authenticated calls.