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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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:

Expand All @@ -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 `<url>` 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
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 <token>` 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}`.
33 changes: 21 additions & 12 deletions cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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{},
Expand All @@ -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
Expand Down
39 changes: 32 additions & 7 deletions cli/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Loading