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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<deployment>.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
Expand Down
31 changes: 31 additions & 0 deletions internal/client/headers.go
Original file line number Diff line number Diff line change
@@ -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://<deployment>.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)
}
}
114 changes: 114 additions & 0 deletions internal/client/headers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 5 additions & 6 deletions internal/client/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading