Skip to content

Commit e719d94

Browse files
authored
feat(mcp): browser OAuth2 login on first run when no token is cached (#80)
When the bridge has no cached token it now runs the interactive browser login (using CLIENT_ID/CLIENT_SECRET from its environment) and holds the handshake until it completes, instead of failing fast. This lets the npx-launched bridge authenticate with no prior xurl install or setup, then caches and auto-refreshes the token. Login output stays on stderr so the stdout JSON-RPC channel is unaffected; set a generous startup_timeout_sec on the server. Headless hosts authenticate out-of-band first with `xurl auth oauth2 --headless`.
1 parent a312d5d commit e719d94

5 files changed

Lines changed: 65 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All user-visible bugs and enhancements should be recorded here.
44

5+
## v1.2.1 - 2026-06-29
6+
7+
### Changed
8+
9+
- [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`.
10+
511
## v1.2.0 - 2026-06-29
612

713
### Fixed

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,7 @@ If no token is available (and none can be refreshed), it exits non-zero with a h
277277

278278
`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.
279279

280-
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:
281-
282-
```bash
283-
xurl auth oauth2 --app my-app # local machine with a browser
284-
xurl auth oauth2 --app my-app --headless # remote/headless machine
285-
```
280+
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`.
286281

287282
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:
288283

@@ -292,12 +287,15 @@ Use it directly from any MCP client (Claude Desktop, Cursor, etc.) with a standa
292287
"xapi": {
293288
"command": "npx",
294289
"args": ["-y", "@xdevplatform/xurl", "mcp", "https://api.x.com/mcp"],
295-
"env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." }
290+
"env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." },
291+
"startup_timeout_sec": 300
296292
}
297293
}
298294
}
299295
```
300296

297+
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).
298+
301299
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:
302300

303301
```bash

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,4 +414,4 @@ xurl --app staging /2/users/me # one-off request against staging
414414
- **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.
415415
- **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context.
416416
- **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.
417-
- **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":"..."}}`.
417+
- **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}`.

cli/mcp.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ type mcpBridge struct {
6060
username string
6161
httpClient *http.Client
6262

63+
// oauth2Flow obtains a token via the interactive browser login when none is
64+
// cached. It is a field rather than a direct call so tests can substitute a
65+
// stub for the real browser flow.
66+
oauth2Flow func(username string) (string, error)
67+
6368
// tokenMu serialises all access to the (mutex-less) token store, so the
6469
// message loop and the server->client listener never refresh/persist
6570
// 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 {
8287

8388
func newMCPBridgeWithIO(url string, a *auth.Auth, username string, in io.Reader, out io.Writer) *mcpBridge {
8489
return &mcpBridge{
85-
url: url,
86-
auth: a,
87-
username: username,
90+
url: url,
91+
auth: a,
92+
username: username,
93+
oauth2Flow: a.OAuth2Flow,
8894
// No client timeout: SSE responses and the server->client stream are
8995
// long-lived; cancellation is driven by the request context instead.
9096
httpClient: &http.Client{},
@@ -110,19 +116,22 @@ func (b *mcpBridge) forceRefreshToken() (string, error) {
110116
return b.auth.ForceRefreshOAuth2Token(b.username)
111117
}
112118

113-
// bootstrap ensures a usable token exists before bridging. It will silently
114-
// refresh an expired token, but it never launches a browser: the bridge's stdio
115-
// is the MCP channel (owned by the client) and a login prompt mid-startup would
116-
// hang the client's handshake and corrupt stdout. If no token is available it
117-
// fails fast with instructions to authenticate out-of-band first.
119+
// bootstrap ensures a usable token exists before bridging. It refreshes an
120+
// expired token; if none is cached it runs the interactive browser login and
121+
// blocks until that completes, so the bridge never starts serving without
122+
// credentials. The login writes only to stderr, keeping the stdout JSON-RPC
123+
// channel clean.
118124
func (b *mcpBridge) bootstrap() error {
119125
if _, err := b.accessToken(); err == nil {
120126
return nil
121127
}
122-
hint := appFlagHint(b.auth.AppName())
123-
return fmt.Errorf("no valid OAuth2 token for this app. Authenticate first, then start the MCP server:\n"+
124-
" xurl auth oauth2%s # local machine with a browser\n"+
125-
" xurl auth oauth2%s --headless # remote/headless machine (paste a code)", hint, hint)
128+
b.logf("no valid OAuth2 token; opening the browser to sign in -- complete the login to start the bridge...")
129+
if _, err := b.oauth2Flow(b.username); err != nil {
130+
hint := appFlagHint(b.auth.AppName())
131+
return fmt.Errorf("authentication failed: %w\n(on a headless machine, run `xurl auth oauth2%s --headless` first)", err, hint)
132+
}
133+
b.logf("authentication complete; starting bridge")
134+
return nil
126135
}
127136

128137
// run reads JSON-RPC messages from stdin and bridges them until stdin closes or

cli/mcp_test.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -568,22 +568,47 @@ func TestMCPBridgeNotificationNotHeadOfLineBlocked(t *testing.T) {
568568
assertStdoutIsJSONLines(t, out.String())
569569
}
570570

571-
// TestMCPBridgeBootstrapFailsFastWithoutToken verifies the bridge does NOT try
572-
// to launch a browser when no token exists; it fails fast with guidance to
573-
// authenticate out-of-band (including the --headless option).
574-
func TestMCPBridgeBootstrapFailsFastWithoutToken(t *testing.T) {
575-
tempDir := t.TempDir()
571+
// newTokenlessBridge builds a bridge whose store holds no OAuth2 token, with the
572+
// browser login stubbed so bootstrap never opens a real browser.
573+
func newTokenlessBridge(t *testing.T, flow func(string) (string, error)) *mcpBridge {
574+
t.Helper()
576575
ts := &store.TokenStore{
577576
Apps: map[string]*store.App{"default": {OAuth2Tokens: map[string]store.Token{}}},
578577
DefaultApp: "default",
579-
FilePath: filepath.Join(tempDir, ".xurl"),
578+
FilePath: filepath.Join(t.TempDir(), ".xurl"),
580579
}
581580
a := auth.NewAuth(&config.Config{}).WithTokenStore(ts)
582581
b := newMCPBridgeWithIO("http://127.0.0.1:0", a, "", strings.NewReader(""), &bytes.Buffer{})
582+
b.oauth2Flow = flow
583+
return b
584+
}
585+
586+
// TestMCPBridgeBootstrapSkipsLoginWithToken verifies a cached token short-circuits
587+
// bootstrap without invoking the browser login.
588+
func TestMCPBridgeBootstrapSkipsLoginWithToken(t *testing.T) {
589+
a := mcpTestAuth(t, "tok")
590+
b := newMCPBridgeWithIO("http://127.0.0.1:0", a, "", strings.NewReader(""), &bytes.Buffer{})
591+
called := false
592+
b.oauth2Flow = func(string) (string, error) { called = true; return "", nil }
593+
require.NoError(t, b.bootstrap())
594+
assert.False(t, called)
595+
}
596+
597+
// TestMCPBridgeBootstrapRunsLoginWithoutToken verifies bootstrap runs the browser
598+
// login when no token is cached and proceeds once it succeeds.
599+
func TestMCPBridgeBootstrapRunsLoginWithoutToken(t *testing.T) {
600+
called := false
601+
b := newTokenlessBridge(t, func(string) (string, error) { called = true; return "new-token", nil })
602+
require.NoError(t, b.bootstrap())
603+
assert.True(t, called)
604+
}
583605

606+
// TestMCPBridgeBootstrapSurfacesLoginFailure verifies a failed login is reported
607+
// with guidance to authenticate out-of-band.
608+
func TestMCPBridgeBootstrapSurfacesLoginFailure(t *testing.T) {
609+
b := newTokenlessBridge(t, func(string) (string, error) { return "", io.ErrUnexpectedEOF })
584610
err := b.bootstrap()
585611
require.Error(t, err)
586-
assert.Contains(t, err.Error(), "xurl auth oauth2")
587612
assert.Contains(t, err.Error(), "--headless")
588613
}
589614

0 commit comments

Comments
 (0)