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. 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) }, } } 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) {