diff --git a/CLAUDE.md b/CLAUDE.md index c0e0c80..66eb510 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,10 @@ Token resolution order: 1. `PIPEBOARD_API_TOKEN` env var 2. `~/.pipeboard/config.json` token field +`VERCEL_AUTOMATION_BYPASS_SECRET` (optional) is sent as `x-vercel-protection-bypass` +on every request so `PIPEBOARD_API_URL` can target SSO-protected Vercel preview +deployments. See `internal/client/headers.go`. + ### Build-time variables Version and commit are injected via ldflags (see Makefile and .goreleaser.yml): diff --git a/README.md b/README.md index 9c1d81f..39b53b1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,24 @@ Get your API token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens) pipeboard login ``` +### Targeting a Vercel preview deployment + +`PIPEBOARD_API_URL` can point at any Pipeboard deployment, including a pull +request's Vercel preview. Previews are protected by Vercel SSO, so set the +project's [Protection Bypass for Automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) +secret and the CLI sends it as `x-vercel-protection-bypass` on every request: + +```bash +export VERCEL_AUTOMATION_BYPASS_SECRET=... # from the Vercel project settings +export PIPEBOARD_API_URL=https://.vercel.app +pipeboard mcp --server meta-ads-mcp tools-list +``` + +Hosts without deployment protection ignore the header, so it is safe to leave +set. Without it, a protected preview answers +`HTTP 401 {"error":{"message":"Protected deployment"}}` before the app sees the +request. + ## Quick Start ```bash diff --git a/internal/client/headers.go b/internal/client/headers.go new file mode 100644 index 0000000..ba46531 --- /dev/null +++ b/internal/client/headers.go @@ -0,0 +1,31 @@ +package client + +import ( + "net/http" + "os" +) + +// BypassSecretEnv is the environment variable holding a Vercel "Protection +// Bypass for Automation" secret. Pipeboard preview deployments +// (PIPEBOARD_API_URL=https://.vercel.app) sit behind Vercel SSO +// and answer HTTP 401 "Protected deployment" to any request that does not +// present the secret. When the variable is set, every request carries it as +// the x-vercel-protection-bypass header; hosts without deployment protection +// ignore the header, so leaving it set is harmless. +const BypassSecretEnv = "VERCEL_AUTOMATION_BYPASS_SECRET" + +// bypassHeader is the header Vercel's edge reads the secret from. +const bypassHeader = "x-vercel-protection-bypass" + +// setCommonHeaders applies the headers every Pipeboard request needs: bearer +// auth (skipped when token is empty, e.g. the unauthenticated tools-hash +// endpoint), User-Agent, and the Vercel protection bypass when configured. +func setCommonHeaders(req *http.Request, token, userAgent string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("User-Agent", userAgent) + if secret := os.Getenv(BypassSecretEnv); secret != "" { + req.Header.Set(bypassHeader, secret) + } +} diff --git a/internal/client/headers_test.go b/internal/client/headers_test.go new file mode 100644 index 0000000..1e79a19 --- /dev/null +++ b/internal/client/headers_test.go @@ -0,0 +1,114 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// recordingServer answers every request with a minimal valid body and keeps +// the headers of the most recent request for assertions. +type recordingServer struct { + *httptest.Server + last http.Header +} + +func newRecordingServer(t *testing.T) *recordingServer { + t.Helper() + rs := &recordingServer{} + rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rs.last = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"hash":"abc"}`)) + return + } + var req struct { + ID int64 `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": map[string]interface{}{"tools": []interface{}{}}, + }) + })) + t.Cleanup(rs.Close) + return rs +} + +func (rs *recordingServer) bypass() (string, bool) { + v, ok := rs.last[http.CanonicalHeaderKey(bypassHeader)] + if !ok || len(v) == 0 { + return "", false + } + return v[0], true +} + +func TestBypassHeader_SentOnEveryRequestKindWhenConfigured(t *testing.T) { + t.Setenv(BypassSecretEnv, "s3cret") + srv := newRecordingServer(t) + + c := New(srv.URL, "tok", "test") + if _, err := c.ListTools("meta-ads-mcp"); err != nil { + t.Fatalf("ListTools: %v", err) + } + if got, ok := srv.bypass(); !ok || got != "s3cret" { + t.Errorf("tools/list: bypass header = %q, %v; want s3cret", got, ok) + } + if got := srv.last.Get("Authorization"); got != "Bearer tok" { + t.Errorf("tools/list: Authorization = %q; want Bearer tok", got) + } + if got := srv.last.Get("User-Agent"); got != "pipeboard-cli/test" { + t.Errorf("tools/list: User-Agent = %q", got) + } + + if err := c.notify("meta-ads-mcp", "notifications/initialized", nil); err != nil { + t.Fatalf("notify: %v", err) + } + if got, ok := srv.bypass(); !ok || got != "s3cret" { + t.Errorf("notify: bypass header = %q, %v; want s3cret", got, ok) + } + + rest := NewREST(srv.URL, "tok", "test") + if _, err := rest.Post("/api/debug/x", map[string]string{"a": "b"}); err != nil { + t.Fatalf("REST Post: %v", err) + } + if got, ok := srv.bypass(); !ok || got != "s3cret" { + t.Errorf("REST: bypass header = %q, %v; want s3cret", got, ok) + } + + if _, err := FetchToolsHash(srv.URL, "test"); err != nil { + t.Fatalf("FetchToolsHash: %v", err) + } + if got, ok := srv.bypass(); !ok || got != "s3cret" { + t.Errorf("tools-hash: bypass header = %q, %v; want s3cret", got, ok) + } + if got := srv.last.Get("Authorization"); got != "" { + t.Errorf("tools-hash must stay unauthenticated, got Authorization=%q", got) + } +} + +func TestBypassHeader_OmittedWhenUnset(t *testing.T) { + t.Setenv(BypassSecretEnv, "") + srv := newRecordingServer(t) + + c := New(srv.URL, "tok", "test") + if _, err := c.ListTools("meta-ads-mcp"); err != nil { + t.Fatalf("ListTools: %v", err) + } + if got, ok := srv.bypass(); ok { + t.Errorf("bypass header should be absent when env is unset, got %q", got) + } + if got := srv.last.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q; want Bearer tok", got) + } + + if _, err := FetchToolsHash(srv.URL, "test"); err != nil { + t.Fatalf("FetchToolsHash: %v", err) + } + if got, ok := srv.bypass(); ok { + t.Errorf("tools-hash: bypass header should be absent, got %q", got) + } +} diff --git a/internal/client/mcp.go b/internal/client/mcp.go index 4a8027d..dbd6b36 100644 --- a/internal/client/mcp.go +++ b/internal/client/mcp.go @@ -178,8 +178,7 @@ func (c *Client) call(serverPath, method string, params interface{}) (json.RawMe } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+c.token) - httpReq.Header.Set("User-Agent", c.userAgent) + setCommonHeaders(httpReq, c.token, c.userAgent) resp, err := c.httpClient.Do(httpReq) if err != nil { @@ -215,13 +214,14 @@ type ToolsHashResult struct { // FetchToolsHash retrieves the combined tools hash from the server. // baseURL is the MCP base URL (e.g. https://mcp.pipeboard.co). -// This endpoint requires no authentication. +// This endpoint requires no authentication, but it still needs the Vercel +// protection bypass when baseURL is a preview deployment. func FetchToolsHash(baseURL, version string) (*ToolsHashResult, error) { req, err := http.NewRequest("GET", baseURL+"/api/tools-hash", nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } - req.Header.Set("User-Agent", "pipeboard-cli/"+version) + setCommonHeaders(req, "", "pipeboard-cli/"+version) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -266,8 +266,7 @@ func (c *Client) notify(serverPath, method string, params interface{}) error { } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+c.token) - httpReq.Header.Set("User-Agent", c.userAgent) + setCommonHeaders(httpReq, c.token, c.userAgent) resp, err := c.httpClient.Do(httpReq) if err != nil { diff --git a/internal/client/rest.go b/internal/client/rest.go index b23d108..7583cb0 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -40,8 +40,7 @@ func (c *RESTClient) Post(path string, body interface{}) (map[string]interface{} } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("User-Agent", c.userAgent) + setCommonHeaders(req, c.token, c.userAgent) resp, err := c.httpClient.Do(req) if err != nil {