Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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.

Expand Down
4 changes: 3 additions & 1 deletion internal/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}
}
Expand Down
21 changes: 16 additions & 5 deletions internal/spur/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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/<x>").
Surface string
}

// userAgent builds the User-Agent header value. The CLI-default form is
// unchanged; a non-empty Surface appends " surface/<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
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions internal/spur/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion internal/spur/feeds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions internal/spur/feeds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down