diff --git a/CHANGELOG.md b/CHANGELOG.md index 74bba51..b63df8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All user-visible bugs and enhancements should be recorded here. +## v1.2.1 - 2026-06-29 + +### Changed + +- [2026-06-29] `mcp` bridge now runs the interactive browser OAuth2 login on first run when no token is cached (using `CLIENT_ID`/`CLIENT_SECRET` from its environment), instead of failing fast. This lets the bridge authenticate with no prior xurl setup — e.g. straight from `npx … mcp` — and then caches/auto-refreshes the token. The MCP handshake is held until the login completes (set a generous `startup_timeout_sec` on the server), and login diagnostics stay on stderr so the stdout JSON-RPC channel is unaffected. On a headless host, authenticate out-of-band first with `xurl auth oauth2 --headless`. + ## v1.2.0 - 2026-06-29 ### Fixed diff --git a/README.md b/README.md index 727ef1d..7a62366 100644 --- a/README.md +++ b/README.md @@ -277,12 +277,7 @@ If no token is available (and none can be refreshed), it exits non-zero with a h `xurl mcp` turns xurl into a [Model Context Protocol](https://modelcontextprotocol.io) bridge for the hosted X API MCP server. It reads newline-delimited JSON-RPC from stdin, relays each message to a remote Streamable HTTP MCP endpoint with an `Authorization: Bearer ` header, and writes the server's responses (plain JSON or `text/event-stream`) back to stdout as newline-delimited JSON. The MCP session id is maintained automatically and the token is refreshed in-process as it expires. -Because X's OAuth requires your own app (there is no dynamic client registration), xurl holds the app identity and mints/refreshes the token. **Authenticate once before starting the bridge** — `xurl mcp` will refresh an expired token automatically but never opens a browser itself (its stdio is the MCP channel), so it fails fast with instructions if no token exists: - -```bash -xurl auth oauth2 --app my-app # local machine with a browser -xurl auth oauth2 --app my-app --headless # remote/headless machine -``` +Because X's OAuth requires your own app (there is no dynamic client registration), xurl holds the app identity and mints/refreshes the token. On first run with no cached token, the bridge opens the browser for a one-time OAuth2 login using the `CLIENT_ID`/`CLIENT_SECRET` from its environment, then caches and auto-refreshes the token for subsequent runs. The MCP handshake is held until that login completes, so give the server a generous `startup_timeout_sec`. Use it directly from any MCP client (Claude Desktop, Cursor, etc.) with a standard MCP server config — no separate install step is needed thanks to the npm launcher: @@ -292,12 +287,15 @@ Use it directly from any MCP client (Claude Desktop, Cursor, etc.) with a standa "xapi": { "command": "npx", "args": ["-y", "@xdevplatform/xurl", "mcp", "https://api.x.com/mcp"], - "env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." } + "env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." }, + "startup_timeout_sec": 300 } } } ``` +Requirements for the first-run browser login: a browser on the machine running the client, and your X app must have the OAuth2 redirect URI `http://localhost:8080/callback` registered (or set `REDIRECT_URI` to one that is). On a headless host with no reachable browser, authenticate out-of-band first with `xurl auth oauth2 --headless` (the bridge then just reuses the cached token). + The `` positional is optional and defaults to `https://api.x.com/mcp`. `--app` is honored, so you can point a client at a specific registered app: ```bash diff --git a/SKILL.md b/SKILL.md index c040b78..78b06e7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -414,4 +414,4 @@ xurl --app staging /2/users/me # one-off request against staging - **Default user:** When no `-u` flag is given, xurl uses the default user for the active app (set via `xurl auth default`). If no default user is set, it uses the first available token. - **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context. - **Access tokens:** `xurl token` prints a valid (refreshed) OAuth2 access token for the active app to stdout, refreshing and persisting it if expired. It never opens a browser. The output is a secret — use it only in the user's own scripts, never in agent/LLM sessions. -- **MCP bridge:** `xurl mcp [URL]` bridges a stdio MCP client to a remote Streamable HTTP MCP server (default `https://api.x.com/mcp`), injecting `Authorization: Bearer ` and refreshing the token automatically. Authenticate once first (`xurl auth oauth2 --app APP_NAME`, or `--headless` on a remote host) — the bridge refreshes an existing token but never opens a browser itself, so it fails fast with guidance if none exists. Configure it in an MCP client via the npm launcher: `{"command":"npx","args":["-y","@xdevplatform/xurl","mcp","https://api.x.com/mcp"],"env":{"CLIENT_ID":"...","CLIENT_SECRET":"..."}}`. +- **MCP bridge:** `xurl mcp [URL]` bridges a stdio MCP client to a remote Streamable HTTP MCP server (default `https://api.x.com/mcp`), injecting `Authorization: Bearer ` and refreshing the token automatically. On first run with no cached token it opens the browser for a one-time OAuth2 login using the `CLIENT_ID`/`CLIENT_SECRET` from its environment (the handshake waits for it, so set a generous `startup_timeout_sec`); on a headless host, authenticate out-of-band first with `xurl auth oauth2 --headless`. Configure it in an MCP client via the npm launcher: `{"command":"npx","args":["-y","@xdevplatform/xurl","mcp","https://api.x.com/mcp"],"env":{"CLIENT_ID":"...","CLIENT_SECRET":"..."},"startup_timeout_sec":300}`. diff --git a/cli/mcp.go b/cli/mcp.go index a5d9561..58d227c 100644 --- a/cli/mcp.go +++ b/cli/mcp.go @@ -60,6 +60,11 @@ type mcpBridge struct { username string httpClient *http.Client + // oauth2Flow obtains a token via the interactive browser login when none is + // cached. It is a field rather than a direct call so tests can substitute a + // stub for the real browser flow. + oauth2Flow func(username string) (string, error) + // tokenMu serialises all access to the (mutex-less) token store, so the // message loop and the server->client listener never refresh/persist // concurrently (which would be a fatal map race and could corrupt ~/.xurl). @@ -82,9 +87,10 @@ func newMCPBridge(url string, a *auth.Auth, username string) *mcpBridge { func newMCPBridgeWithIO(url string, a *auth.Auth, username string, in io.Reader, out io.Writer) *mcpBridge { return &mcpBridge{ - url: url, - auth: a, - username: username, + url: url, + auth: a, + username: username, + oauth2Flow: a.OAuth2Flow, // No client timeout: SSE responses and the server->client stream are // long-lived; cancellation is driven by the request context instead. httpClient: &http.Client{}, @@ -110,19 +116,22 @@ func (b *mcpBridge) forceRefreshToken() (string, error) { return b.auth.ForceRefreshOAuth2Token(b.username) } -// bootstrap ensures a usable token exists before bridging. It will silently -// refresh an expired token, but it never launches a browser: the bridge's stdio -// is the MCP channel (owned by the client) and a login prompt mid-startup would -// hang the client's handshake and corrupt stdout. If no token is available it -// fails fast with instructions to authenticate out-of-band first. +// bootstrap ensures a usable token exists before bridging. It refreshes an +// expired token; if none is cached it runs the interactive browser login and +// blocks until that completes, so the bridge never starts serving without +// credentials. The login writes only to stderr, keeping the stdout JSON-RPC +// channel clean. func (b *mcpBridge) bootstrap() error { if _, err := b.accessToken(); err == nil { return nil } - hint := appFlagHint(b.auth.AppName()) - return fmt.Errorf("no valid OAuth2 token for this app. Authenticate first, then start the MCP server:\n"+ - " xurl auth oauth2%s # local machine with a browser\n"+ - " xurl auth oauth2%s --headless # remote/headless machine (paste a code)", hint, hint) + b.logf("no valid OAuth2 token; opening the browser to sign in -- complete the login to start the bridge...") + if _, err := b.oauth2Flow(b.username); err != nil { + hint := appFlagHint(b.auth.AppName()) + return fmt.Errorf("authentication failed: %w\n(on a headless machine, run `xurl auth oauth2%s --headless` first)", err, hint) + } + b.logf("authentication complete; starting bridge") + return nil } // run reads JSON-RPC messages from stdin and bridges them until stdin closes or diff --git a/cli/mcp_test.go b/cli/mcp_test.go index b58d1aa..eed662d 100644 --- a/cli/mcp_test.go +++ b/cli/mcp_test.go @@ -568,22 +568,47 @@ func TestMCPBridgeNotificationNotHeadOfLineBlocked(t *testing.T) { assertStdoutIsJSONLines(t, out.String()) } -// TestMCPBridgeBootstrapFailsFastWithoutToken verifies the bridge does NOT try -// to launch a browser when no token exists; it fails fast with guidance to -// authenticate out-of-band (including the --headless option). -func TestMCPBridgeBootstrapFailsFastWithoutToken(t *testing.T) { - tempDir := t.TempDir() +// newTokenlessBridge builds a bridge whose store holds no OAuth2 token, with the +// browser login stubbed so bootstrap never opens a real browser. +func newTokenlessBridge(t *testing.T, flow func(string) (string, error)) *mcpBridge { + t.Helper() ts := &store.TokenStore{ Apps: map[string]*store.App{"default": {OAuth2Tokens: map[string]store.Token{}}}, DefaultApp: "default", - FilePath: filepath.Join(tempDir, ".xurl"), + FilePath: filepath.Join(t.TempDir(), ".xurl"), } a := auth.NewAuth(&config.Config{}).WithTokenStore(ts) b := newMCPBridgeWithIO("http://127.0.0.1:0", a, "", strings.NewReader(""), &bytes.Buffer{}) + b.oauth2Flow = flow + return b +} + +// TestMCPBridgeBootstrapSkipsLoginWithToken verifies a cached token short-circuits +// bootstrap without invoking the browser login. +func TestMCPBridgeBootstrapSkipsLoginWithToken(t *testing.T) { + a := mcpTestAuth(t, "tok") + b := newMCPBridgeWithIO("http://127.0.0.1:0", a, "", strings.NewReader(""), &bytes.Buffer{}) + called := false + b.oauth2Flow = func(string) (string, error) { called = true; return "", nil } + require.NoError(t, b.bootstrap()) + assert.False(t, called) +} + +// TestMCPBridgeBootstrapRunsLoginWithoutToken verifies bootstrap runs the browser +// login when no token is cached and proceeds once it succeeds. +func TestMCPBridgeBootstrapRunsLoginWithoutToken(t *testing.T) { + called := false + b := newTokenlessBridge(t, func(string) (string, error) { called = true; return "new-token", nil }) + require.NoError(t, b.bootstrap()) + assert.True(t, called) +} +// TestMCPBridgeBootstrapSurfacesLoginFailure verifies a failed login is reported +// with guidance to authenticate out-of-band. +func TestMCPBridgeBootstrapSurfacesLoginFailure(t *testing.T) { + b := newTokenlessBridge(t, func(string) (string, error) { return "", io.ErrUnexpectedEOF }) err := b.bootstrap() require.Error(t, err) - assert.Contains(t, err.Error(), "xurl auth oauth2") assert.Contains(t, err.Error(), "--headless") }