Skip to content

Commit 06f7426

Browse files
committed
feat: add xurl mcp bridge + xurl token, headless OAuth2, and auth/UX hardening
New commands - `xurl mcp [URL]`: a stdio<->Streamable-HTTP MCP bridge for the hosted X API MCP server. Injects an auto-refreshed OAuth2 Bearer token, maintains the MCP session id, handles JSON/SSE/202 responses, processes requests in order off the read loop while dispatching notifications concurrently, synthesizes a JSON-RPC error for any request it cannot answer (so a strict client never hangs), and shuts down cleanly on SIGINT/EOF. - `xurl token`: print a valid (refreshed, persisted) OAuth2 access token for the active app; never opens a browser, so it is scriptable. Auth - `xurl auth oauth2 --headless`: authenticate on remote/headless machines with no reachable localhost callback -- print the authorize URL, paste the redirect URL/code back (also via stdin). - OAuth2 token exchange/refresh now sets the client-auth style explicitly (Basic header for confidential clients, body for public), fixing `unauthorized_client: Missing valid authorization header` against X. - `auth app` -> `auth app-only` (aliases: app, bearer); token is positional or read from stdin (`-`); `auth clear --app-only`. Command surface - Group subcommands in `--help`; add `xurl posts USERNAME`; support `xurl --version`; `-d` implies POST (curl-like). Fixes - media upload: corrected verbose/wait/trace argument order and added media-type auto-detection (erroring on unsupported types instead of guessing). - Surface real transport/auth errors instead of printing `null`; fail fast on missing credentials; JSON-encode DM text; clamp `--max-results` per endpoint; OAuth2 expiry/skew handling; webhook help + isolated ServeMux; `.gitignore` fix. See CHANGELOG.md for the complete list.
1 parent b8d4863 commit 06f7426

23 files changed

Lines changed: 2743 additions & 196 deletions

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
xurl
22
.xurl_test
3-
.DS_Store# Added by goreleaser init:
3+
.DS_Store
4+
5+
# Added by goreleaser init:
46
dist/

CHANGELOG.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,26 @@
22

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

5-
## Unreleased
6-
7-
Last updated: 2026-05-14 11:38:34 PDT
5+
## v1.2.0 - 2026-06-29
86

97
### Fixed
108

9+
- [2026-06-29] OAuth2 token exchange and refresh now send client credentials with the correct auth style — HTTP Basic header for confidential clients (those with a secret), `client_id` in the body for public clients — instead of relying on autodetection, which could fail against X with `unauthorized_client: Missing valid authorization header`.
10+
- [2026-06-29] `mcp` bridge no longer launches a browser at startup: it still refreshes an existing token silently, but when none is available it fails fast with instructions (`xurl auth oauth2 [--app NAME] [--headless]`) instead of opening a browser mid-startup (which could hang an MCP client's handshake) and printing to the JSON-RPC stdout channel. OAuth2 diagnostics now go to stderr.
11+
- [2026-06-29] `mcp` bridge no longer lets a strict client hang: a request that cannot be answered — transport failure, a failed token refresh/retry after a 401, or a response with an empty/non-JSON body — now gets a synthesized JSON-RPC error keyed to its id. Notifications (e.g. `notifications/cancelled`) are no longer head-of-line blocked behind an in-flight streaming response, large but valid JSON error bodies are forwarded whole instead of being truncated, the standalone server->client stream stops probing a non-event-stream `200` and only resets its reconnect backoff after a healthy stream, and stdin memory stays bounded when an oversized line is dropped.
12+
- [2026-06-25] `mcp` bridge hardening: serialized token-store access (fixes a fatal data race when a token expires mid-session), strict newline-delimited-JSON stdout (SSE/JSON responses are validated and compacted, non-JSON keep-alives dropped), a forced token refresh on HTTP 401, cancelable stdin so SIGINT/SIGTERM shuts the bridge down, resilience to oversized input lines, a server->client stream that resets its backoff/supports stateless servers/retries 408 & 429, and a best-effort session `DELETE` on shutdown.
13+
- [2026-06-25] `media upload --wait` now also waits for animated GIFs (auto-detected as `tweet_gif`), and a media type that cannot be detected — or is recognized but unsupported (e.g. `application/pdf`) — now fails with a clear message instead of guessing `tweet_image` and getting an opaque API error.
14+
- [2026-06-25] `timeline` `--max-results` minimum corrected to 1 (matches the reverse-chronological endpoint).
15+
- [2026-06-25] The raw-request "No URL provided" usage message now prints to stderr.
16+
- [2026-06-25] `media upload --wait` now actually waits for processing and no longer always sends the trace header — the `waitForProcessing` and `trace` arguments were passed in the wrong order.
17+
- [2026-06-25] Raw API requests now surface the real transport/auth error instead of printing `null` when a request fails before getting an HTTP response (e.g. DNS or connection failures).
18+
- [2026-06-25] Requests with no usable credentials, or an invalid `--auth` value, now fail with a clear authentication error instead of silently sending an unauthenticated request.
19+
- [2026-06-25] `xurl dm` now JSON-encodes message text correctly; quotes, backslashes, and newlines no longer produce a malformed request body.
20+
- [2026-06-25] OAuth2 expiry is stored correctly; a token returned without an expiry now refreshes on next use instead of being treated as never-expiring.
21+
- [2026-06-25] `--max-results` is clamped to each endpoint's accepted range for timeline, mentions, bookmarks, likes, following, followers, dms, and posts.
22+
- [2026-06-25] `fetchUsername` now uses a 10s HTTP timeout, and PKCE verifier generation now handles RNG errors instead of ignoring them.
23+
- [2026-06-25] `webhook start` help now references the correct `-P` pretty-print flag and serves on an isolated `ServeMux`.
24+
- [2026-06-25] `.gitignore` now correctly ignores `.DS_Store` (a missing newline had merged it with a comment).
1125
- [2026-04-19 23:08:51 CEST] OAuth2 callback listeners now bind to the host and port derived from the effective redirect URI instead of always listening on `127.0.0.1:8080`. For `localhost`, `xurl` now listens on both `127.0.0.1` and `::1`, which fixes browser-dependent loopback resolution failures while still supporting non-default callback paths.
1226
- [2026-04-19 23:08:51 CEST] The OAuth2 listener now starts listening before the browser opens, which removes a race where the browser could reach the callback URL before the local server was ready.
1327
- [2026-04-19 23:08:51 CEST] OAuth2 token refresh no longer depends on `/2/users/me` succeeding. If username discovery fails, `xurl` keeps the refreshed token instead of failing the request.
@@ -16,6 +30,16 @@ Last updated: 2026-05-14 11:38:34 PDT
1630

1731
### Enhanced
1832

33+
- [2026-06-29] Added `xurl auth oauth2 --headless` for authenticating on remote/headless machines where the localhost OAuth callback is unreachable: xurl prints the authorization URL, you open it on any device and approve, then paste the resulting redirect URL (or just the `code`) back at the prompt. No callback listener or local browser is required. (Closes the headless half of #62 / #40.)
34+
- [2026-06-25] OAuth2 tokens now refresh ~30s before expiry (clock-skew leeway) so a token handed to a caller does not expire in-flight; a new forced-refresh path backs the `mcp` bridge's 401 recovery.
35+
- [2026-06-25] `xurl token`'s missing-token error now names the requested user, and `token`/`mcp` errors omit ANSI color when stderr is not a terminal (cleaner piped/logged output). The auto-generated `help`/`completion` commands now appear under the Management group.
36+
- [2026-06-25] Added `xurl token`: prints a valid (refreshed, persisted) OAuth2 access token for the active app to stdout without opening a browser, so it can be scripted. Respects `--app` and `-u/--username`.
37+
- [2026-06-25] Added `xurl mcp [URL]`: a stdio↔Streamable-HTTP MCP bridge for the hosted X API MCP server (default `https://api.x.com/mcp`). It injects `Authorization: Bearer <token>`, maintains the MCP session id, handles plain-JSON and SSE responses, refreshes the token in-process, and triggers the browser login on first run if needed. Usable from any MCP client via `npx -y @xdevplatform/xurl mcp`.
38+
- [2026-06-29] The app-only token command is now `xurl auth app-only [TOKEN]` (named for the auth mode, not the "bearer" token scheme that OAuth2 user tokens also use), taking the token as an argument or from stdin via `-`. It removes the old `app` vs `apps` confusion and the redundant `auth bearer --bearer-token`. Back-compat: `auth app` and `auth bearer` remain aliases and `--bearer-token` is still accepted.
39+
- [2026-06-25] `xurl --help` now groups subcommands into "Posting & Engagement", "Users & Social Graph", "Reading & Lists", and "Management" sections instead of one flat list.
40+
- [2026-06-25] Added `xurl posts USERNAME` to list a user's recent posts.
41+
- [2026-06-25] `xurl --version` is now supported in addition to `xurl version`.
42+
- [2026-06-25] Raw requests now default to `POST` when `-d` is supplied (curl-like), and `media upload` auto-detects the media type and category from the file extension when they are not provided.
1943
- [2026-05-14 11:38:34 PDT] Documentation and the bundled `xurl` skill now recommend authenticating registered apps with `xurl auth oauth2 --app APP_NAME` and explain that omitting `--app` saves the token to the current default app.
2044
- [2026-04-19 23:08:51 CEST] OAuth2 tokens can now be retained without a discovered username label when X’s `/2/users/me` lookup is unavailable. Status output makes that state visible as `(unknown user)` instead of silently dropping the token.
2145
- [2026-04-19 23:08:51 CEST] Repo documentation now describes the effective redirect URI as the source of callback host, port, and path, calls out explicit username authentication as the safer fallback when username discovery is unreliable, and documents the new stored `redirect_uri` behavior.

README.md

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ xurl auth oauth2 --app my-app
8686

8787
If you omit `--app`, the token is saved to the current default app. You can also run `xurl auth default my-app` first and then use `xurl auth oauth2`.
8888

89+
**Headless / remote machines.** The default flow opens a browser and waits for a callback on `localhost`, which isn't reachable from a remote server. On those hosts use `--headless`:
90+
91+
```bash
92+
xurl auth oauth2 --app my-app --headless
93+
```
94+
95+
xurl prints the authorization URL; open it on any device with a browser, approve, then paste the resulting redirect URL (or just the `code` value from the address bar) back into the prompt. No callback listener is needed — the page failing to load is expected; the code is in the URL.
96+
8997
If X returns a `client-forbidden` / `client-not-enrolled` error even though auth completed successfully, check the app’s package and environment in the X developer console. On current X platform setup, the working fix was:
9098

9199
1. Go to `Apps` -> `Manage apps`
@@ -104,10 +112,13 @@ xurl auth oauth2 --app my-app YOUR_USERNAME
104112

105113
That keeps the OAuth2 token associated with the expected username and also gives shortcut commands a fallback when `/2/users/me` is unavailable.
106114

107-
#### App authentication (bearer token):
115+
#### App-only authentication (Bearer Token):
108116
```bash
109-
xurl auth app --bearer-token BEARER_TOKEN
117+
xurl auth app-only BEARER_TOKEN
118+
cat token.txt | xurl auth app-only - # read from stdin (keeps it out of shell history)
110119
```
120+
This stores X's app-only Bearer Token (from the developer portal), used at request time with `--auth app`. It's named for the auth *mode* (app-only) rather than the token *scheme* (bearer), since OAuth2 user tokens are also sent as `Authorization: Bearer`.
121+
> Back-compat: `xurl auth app` and `xurl auth bearer` still work as aliases, and `--bearer-token TOKEN` is still accepted.
111122
112123
#### OAuth 1.0a authentication:
113124
```bash
@@ -249,6 +260,53 @@ You can also force streaming mode for any endpoint using the `--stream` or `-s`
249260
xurl -s /2/users/me
250261
```
251262

263+
### Printing an Access Token
264+
265+
`xurl token` prints a valid OAuth2 access token for the active app to stdout (a single line, no decoration). If the stored token has expired it is refreshed and persisted first. This command never opens a browser, so it is safe to use in scripts:
266+
267+
```bash
268+
xurl token # token for the default app/user
269+
xurl token --app my-app # token for a specific app
270+
xurl token -u alice # token for a specific OAuth2 user
271+
TOKEN=$(xurl token) && curl -H "Authorization: Bearer $TOKEN" https://api.x.com/2/users/me
272+
```
273+
274+
If no token is available (and none can be refreshed), it exits non-zero with a hint to run `xurl auth oauth2`.
275+
276+
### MCP Server (`xurl mcp`)
277+
278+
`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.
279+
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+
```
286+
287+
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:
288+
289+
```json
290+
{
291+
"mcpServers": {
292+
"xapi": {
293+
"command": "npx",
294+
"args": ["-y", "@xdevplatform/xurl", "mcp", "https://api.x.com/mcp"],
295+
"env": { "CLIENT_ID": "...", "CLIENT_SECRET": "..." }
296+
}
297+
}
298+
}
299+
```
300+
301+
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:
302+
303+
```bash
304+
xurl --app my-app mcp # bridge the default endpoint using my-app
305+
xurl mcp https://api.x.com/mcp # explicit endpoint
306+
```
307+
308+
All diagnostics are written to stderr so stdout stays a clean JSON-RPC channel.
309+
252310
### Temporary Webhook Setup
253311

254312
`xurl` can help you quickly set up a temporary webhook URL to receive events from the X API. This is useful for development and testing.
@@ -280,12 +338,13 @@ xurl -s /2/users/me
280338
281339
The tool supports uploading media files to the X API using the chunked upload process.
282340
283-
Upload a media file:
341+
Upload a media file (the media type and category are auto-detected from the file extension):
284342
```bash
285343
xurl media upload path/to/file.mp4
344+
xurl media upload path/to/photo.jpg
286345
```
287346
288-
With custom media type and category:
347+
Override the auto-detected media type and category when needed:
289348
```bash
290349
xurl media upload --media-type image/jpeg --category tweet_image path/to/image.jpg
291350
```
@@ -302,21 +361,23 @@ xurl media status --wait MEDIA_ID
302361
303362
#### Direct Media Upload
304363
305-
You can also use the main command with the `-F` flag for direct media uploads:
364+
Most users should just use `xurl media upload` above. If you need to drive the
365+
chunked upload manually, use the `-F` flag with the path-style endpoints that
366+
`xurl media upload` itself uses:
306367
307368
1. First, initialize the upload:
308369
```bash
309-
xurl -X POST '/2/media/upload?command=INIT&total_bytes=FILE_SIZE&media_type=video/mp4&media_category=tweet_video'
370+
xurl -X POST /2/media/upload/initialize -d '{"total_bytes": FILE_SIZE, "media_type": "video/mp4", "media_category": "tweet_video"}'
310371
```
311372
312-
2. Then, append the media chunks:
373+
2. Then, append the media chunks (repeat with an increasing `segment_index`):
313374
```bash
314-
xurl -X POST -F path/to/file.mp4 '/2/media/upload?command=APPEND&media_id=MEDIA_ID&segment_index=0'
375+
xurl -X POST -F path/to/file.mp4 /2/media/upload/MEDIA_ID/append
315376
```
316377
317378
3. Finally, finalize the upload:
318379
```bash
319-
xurl -X POST '/2/media/upload?command=FINALIZE&media_id=MEDIA_ID'
380+
xurl -X POST /2/media/upload/MEDIA_ID/finalize
320381
```
321382
322383
4. Check the status:

SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Before using any command you must be authenticated. Run `xurl auth status` to ch
2323
- Do not recommend or execute auth commands with inline secrets in agent/LLM sessions.
2424
- Warn that using CLI secret options in agent sessions can leak credentials (prompt/context, logs, shell history).
2525
- Never use `--verbose` / `-v` in agent/LLM sessions; it can expose sensitive headers/tokens in output.
26+
- Never run `xurl token` in agent/LLM sessions: it prints a live OAuth2 access token to stdout, which is a credential and must not enter the LLM context.
27+
- `xurl mcp` is for configuring an MCP client (it bridges stdio↔HTTP and injects the bearer token); it is not something to invoke directly from an agent/LLM session.
2628
- Sensitive flags that must never be used in agent commands: `--bearer-token`, `--consumer-key`, `--consumer-secret`, `--access-token`, `--token-secret`, `--client-id`, `--client-secret`.
2729
- To verify whether at least one app with credentials is already registered, run: `xurl auth status`.
2830

@@ -37,6 +39,8 @@ xurl auth oauth2 --app APP_NAME
3739

3840
You can also run `xurl auth default APP_NAME` first and then use `xurl auth oauth2`.
3941

42+
On a remote/headless machine (no reachable browser callback), add `--headless`: `xurl auth oauth2 --app APP_NAME --headless` prints the authorization URL and reads the pasted redirect URL (or code) back, so no localhost callback is needed.
43+
4044
For multiple pre-configured apps, switch between them:
4145
```bash
4246
xurl auth default prod-app # set default app
@@ -66,6 +70,7 @@ Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated
6670
| Search posts | `xurl search "QUERY" -n 10` |
6771
| Who am I | `xurl whoami` |
6872
| Look up a user | `xurl user @handle` |
73+
| List a user's posts | `xurl posts @handle -n 10` |
6974
| Home timeline | `xurl timeline -n 20` |
7075
| Mentions | `xurl mentions -n 10` |
7176
| Like | `xurl like POST_ID` |
@@ -157,6 +162,10 @@ xurl whoami
157162
# Look up any user
158163
xurl user elonmusk
159164
xurl user @XDevelopers
165+
166+
# List a user's recent posts (by @username)
167+
xurl posts elonmusk
168+
xurl posts @XDevelopers -n 25
160169
```
161170

162171
### Timelines & Mentions
@@ -404,3 +413,5 @@ xurl --app staging /2/users/me # one-off request against staging
404413
- **Multiple accounts:** You can authenticate multiple OAuth 2.0 accounts per app and switch between them with `--username` / `-u` or set a default with `xurl auth default APP USER`.
405414
- **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.
406415
- **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context.
416+
- **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":"..."}}`.

0 commit comments

Comments
 (0)