[BUG] claude mcp login aborts when stdin isn't a TTY — the same command under a pty completes with zero input, so headless re-auth is impossible
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [ ] I am using the latest version of Claude Code — reproduced on 2.1.233; latest is 2.1.251. No CHANGELOG entry between 2.1.234 and 2.1.251 touches MCP OAuth or TTY detection, and the responsible code path is present verbatim in the 2.1.233 binary (quoted below). Happy to re-verify on 2.1.251 if that blocks triage.
What's Wrong?
claude mcp login <name> aborts immediately when stdin isn't a TTY:
Starting authentication for "mysrv"…
Couldn't complete authentication for "mysrv": stdin isn't a terminal, so authentication can't be completed here. Re-run in an interactive terminal — e.g. `ssh -t` — and paste the redirect URL when prompted.
No browser opens and no authorization URL is printed.
The decisive part: the exact same command, wrapped in a pty, completes on its own with zero human input. script forwards EOF on stdin immediately (the ^D at the top of the output below), so nothing was ever typed or pasted — the browser round-tripped against a live SSO session and the localhost callback finished the flow:
$ script -q /dev/null claude mcp login mysrv
^DStarting authentication for "mysrv"…
If the browser didn't open, visit:
https://idp.example.com/auth?response_type=code&client_id=…&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&…
Waiting for authorization… (^C to cancel)
Or paste the redirect URL here:
Authenticated with "mysrv". Its tools are now available in Claude Code.
So the gate is the presence of a TTY, not the presence of a human — and stdin contributes nothing to the successful path, since it was at EOF throughout.
Why this matters: it means there is no way to re-authenticate a remote MCP server from any non-interactive context — a launchd/systemd unit, cron, CI, or a headless Claude Code session that finds a server unauthenticated. Every automated path has to fake a pty to work around it. --no-browser does not look like an escape hatch either — its own help text ends with "paste the redirect URL back when prompted", i.e. the same stdin dependency — though I did not run it myself, see the note at the end.
Looking at the 2.1.233 binary, the mechanism is consistent with the 2.1.191 change "headless environments skip the browser popup and go straight to the paste-the-URL prompt" — in a headless environment the flow is routed to a prompt that, by definition, cannot be answered there. The readline is created, immediately hits EOF, and its abort rejects the whole flow:
d = new Promise((p, f) => {
a.signal.addEventListener("abort", () => f(new (i8r()).AuthenticationCancelledError), { once: true })
})
if (c) return await pd("cli_mcp_login", "no_tty_stdin"),
rg(`Couldn't complete authentication for "${e}": stdin isn't a terminal, so authentication can't be completed here. `
+ "Re-run in an interactive terminal — e.g. `ssh -t` — and paste the redirect URL when prompted.")
The named telemetry code no_tty_stdin shows the case is anticipated; the reaction to it is what looks wrong — "cannot prompt" is treated as "cannot authenticate".
What Should Happen?
With no TTY on stdin, claude mcp login <name> should run the browser + localhost-callback flow and wait for the callback, and only skip the paste-the-URL prompt (or offer it solely under --no-browser). It should fail only if the callback never arrives before the timeout.
Concretely: gate the prompt on process.stdin.isTTY, rather than letting the prompt's EOF abort the flow. The pty run above shows the rest of the flow already works with stdin at EOF.
Error Messages/Logs
$ claude mcp login mysrv # stdin not a TTY (backgrounded / launchd / CI)
Starting authentication for "mysrv"…
Couldn't complete authentication for "mysrv": stdin isn't a terminal, so authentication can't be completed here. Re-run in an interactive terminal — e.g. `ssh -t` — and paste the redirect URL when prompted.
EXIT=1
$ script -q /dev/null claude mcp login mysrv # same command, pty, no input given
^DStarting authentication for "mysrv"…
[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ensure your provider round-trips the issuer field.
If the browser didn't open, visit:
https://idp.example.com/auth?response_type=code&client_id=…&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&scope=openid+offline_access&prompt=consent&…
Waiting for authorization… (^C to cancel)
Or paste the redirect URL here:
Authenticated with "mysrv". Its tools are now available in Claude Code.
$ claude mcp get mysrv
Status: ✔ Connected
Steps to Reproduce
- Register any OAuth-protected HTTP MCP server whose IdP session is already valid in the default browser (so the authorization round-trip needs no interaction):
``bash``
claude mcp add --transport http mysrv https://mcp.example.com/mcp
- Run the login with stdin not attached to a terminal — any of these reproduce it:
``bash`
claude mcp login mysrv < /dev/null
echo | claude mcp login mysrv
( claude mcp login mysrv & wait ) # backgrounded, no controlling terminal
stdin isn't a terminal` failure, no browser, no URL printed.
→ immediate
- Run the identical command under a pty, supplying no input at all:
``bash`
script -q /dev/null claude mcp login mysrv # macOS/BSD
script -qec "claude mcp login mysrv" /dev/null # GNU/Linux
Authenticated with "mysrv"
→ browser opens, callback arrives, , claude mcp get mysrv reports ✔ Connected`.
The difference between 2 and 3 is only the presence of a pty; in 3 stdin is at EOF from the first moment.
Not verified by me: --no-browser in step 2. Once step 3 had produced a working credential I stopped re-running the flow, because it revokes the stored one on each attempt (see the note below). #82894 reports --no-browser failing on the same check before printing a URL, and its help text implies the same stdin dependency, but treat that as unconfirmed here.
Claude Model
Opus (not relevant — this is the claude mcp login subcommand, no model involved)
Is this a regression?
I don't know
Last Working Version
n/a — the claude mcp login subcommand was added in 2.1.186, and 2.1.191 introduced the headless → paste-prompt routing this depends on
Claude Code Version
2.1.233 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment
Additional Information
Related but distinct: #82894 reports the same error string, but the opposite failure — TTY detection wrongly reporting no terminal inside a genuinely interactive Windows Terminal + PowerShell 7 session. That is a detection bug on Windows; this report is about a genuinely non-TTY environment, where detection is correct and the reaction to it removes the only path that would have worked. The two would need different fixes, so I filed separately rather than commenting there.
Workaround for anyone hitting this in automation: wrap the command in script as shown above. A guard is advisable — if the IdP session itself has expired, the flow parks on Waiting for authorization… indefinitely, so e.g. perl -e 'alarm 90; exec @ARGV' script -q /dev/null claude mcp login mysrv.
Note for whoever tests this: the flow appears to revoke the stored credential before starting, so a failed attempt costs you a working token. That makes the current behavior worse than a no-op in an automated retry loop.
Adjacent, possibly worth a look while in this code: Claude Code already implements a stdin-free variant of this flow as the in-session tool pair <server>__authenticate / <server>__complete_authentication, whose success message is literally "Authentication completed silently for …". The CLI subcommand doesn't use it.
_(Server name and IdP host in the pasted output are redacted; everything else is verbatim.)_