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
3 changes: 3 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ mcp-stdio [OPTIONS] URL
--oauth-device OAuth 2.1 Device Authorization Grant(RFC 8628)— ヘッドレス環境向け
--client-id ID 事前登録済み OAuth クライアント ID(MCP_OAUTH_CLIENT_ID 環境変数でも指定可)
--oauth-scope SCOPE 要求する OAuth スコープ
--oauth-refresh-leeway SECONDS
アクセストークンを expire の何秒前に proactive refresh
するか(デフォルト: 60、または MCP_OAUTH_REFRESH_LEEWAY 環境変数)
-H, --header 'Key: Value' カスタムヘッダー(複数指定可)
--transport {streamable-http,sse}
トランスポート種別(デフォルト: streamable-http)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ Options:
--oauth-device Enable OAuth 2.1 Device Authorization Grant (RFC 8628, headless)
--client-id ID Pre-registered OAuth client ID (or set MCP_OAUTH_CLIENT_ID)
--oauth-scope SCOPE OAuth scope to request
--oauth-refresh-leeway SECONDS
Proactively refresh tokens this many seconds before
expiry (default: 60, or MCP_OAUTH_REFRESH_LEEWAY)
-H, --header 'Key: Value' Custom header (can be repeated)
--transport {streamable-http,sse}
Transport type (default: streamable-http)
Expand Down
1 change: 1 addition & 0 deletions WORKAROUNDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Works around known issues in Claude Code's HTTP transport:
- **OAuth discovery does not fall back when 401 carries a non-`Bearer` `WWW-Authenticate`** — the MCP TypeScript SDK's `StreamableHTTPClientTransport` only falls back to well-known PRM discovery when the 401 response has no `WWW-Authenticate` header at all; a `Negotiate` or other non-Bearer challenge is bubbled up as a hard failure, so servers that expose both SPNEGO and OAuth (common in Windows-integrated enterprise deployments) cannot initiate the OAuth flow even though valid metadata is hosted at the spec-defined well-known URI ([typescript-sdk#1946](https://github.com/modelcontextprotocol/typescript-sdk/issues/1946)). mcp-stdio performs discovery pre-flight rather than on 401: it probes `/.well-known/oauth-protected-resource` (path-aware, then host-root) and `/.well-known/oauth-authorization-server` directly, so the fallback is not gated on the `WWW-Authenticate` scheme and the OAuth flow proceeds regardless of what authentication schemes the server additionally advertises.
- **Cannot connect to servers that only support static bearer tokens** — mcp-remote always initiates an OAuth discovery handshake before any tool call; servers that authenticate via static bearer tokens (e.g. [Zabbix MCP Server](https://github.com/initMAX/zabbix-mcp-server)) respond with 404 on `/.well-known/oauth-authorization-server` and the proxy gives up before the bearer-auth path is ever reached ([zabbix-mcp-server#36](https://github.com/initMAX/zabbix-mcp-server/issues/36)); mcp-stdio connects directly with `--bearer-token YOUR_TOKEN http://your-server:8080/mcp`, skipping OAuth entirely.
- **OAuth token exchange fails with URL-encoded responses** — TypeScript SDK's token exchange assumes the response is always JSON; servers that return `application/x-www-form-urlencoded` (e.g. GitHub OAuth) cause a JSON parse error, blocking authentication ([typescript-sdk#759](https://github.com/modelcontextprotocol/typescript-sdk/issues/759)); mcp-stdio's `_parse_token_response()` checks the `Content-Type` header and parses `application/x-www-form-urlencoded` via `urllib.parse.parse_qs`, so GitHub MCP and similar servers work without extra configuration.
- **No proactive token refresh window** — mcp-remote (and `adaptOAuthProvider` in TypeScript SDK) only refreshes the access token after a 401 has already fired, with no early-refresh leeway. ASes that issue refresh tokens whose lifetime is barely longer than the access token's leave no margin for clock skew, so a refresh attempt can race the token expiry and fail ([mcp-remote#252](https://github.com/geelen/mcp-remote/issues/252), [typescript-sdk#1954](https://github.com/modelcontextprotocol/typescript-sdk/issues/1954)). mcp-stdio's `ensure_token()` performs proactive refresh: a cached access token is treated as expired when its expiry is within `--oauth-refresh-leeway` seconds (default 60, configurable via flag or `MCP_OAUTH_REFRESH_LEEWAY`), so the refresh hits well before the AS revokes the token.
- **No OAuth support in headless/SSH environments** — mcp-remote's OAuth flow requires opening a browser window, making it unusable in SSH sessions, CI/CD pipelines, or other browserless environments; there is no Device Authorization Grant support ([mcp-remote#228](https://github.com/geelen/mcp-remote/issues/228)). mcp-stdio supports RFC 8628 Device Authorization Grant via `--oauth-device`: it displays a short user code and verification URI on stderr so the user can authenticate from any browser, while the device polls the token endpoint in the background.
- **`resource` indicator gets a trailing slash appended** — TypeScript SDK normalises the resource URL via `new URL(...).href`, converting `https://api.example.com` to `https://api.example.com/`; Atlassian authv2 and similar servers reject this with `InvalidTargetError: Incorrect resource parameters` ([typescript-sdk#1968](https://github.com/modelcontextprotocol/typescript-sdk/issues/1968), [mcp-remote#261](https://github.com/geelen/mcp-remote/issues/261)); mcp-stdio passes `resource=server_url` verbatim in both code exchange and refresh requests — Python's URL handling does not add trailing slashes.

Expand Down
28 changes: 28 additions & 0 deletions src/mcp_stdio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
from . import __version__
from .relay import check_connection, log, run, run_sse

def _non_negative_float(value: str) -> float:
"""argparse type for non-negative floats (rejects negative leeway)."""
try:
f = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
if f < 0:
raise argparse.ArgumentTypeError(f"value must be >= 0 (got {f})")
return f


# RFC 7230 §3.2.6 field-name = token = 1*tchar. tchar covers
# "!#$%&'*+-.^_`|~" plus DIGIT and ALPHA. Used to reject header names
# that could be misinterpreted by downstream HTTP parsers.
Expand Down Expand Up @@ -166,6 +177,22 @@ def main() -> None:
default="",
help="OAuth scope to request",
)
parser.add_argument(
"--oauth-refresh-leeway",
type=_non_negative_float,
# Pass as string so argparse re-applies _non_negative_float to the
# default — invalid env var values (negative, non-numeric) surface
# as argparse errors instead of a raw Python ValueError on startup.
default=os.environ.get("MCP_OAUTH_REFRESH_LEEWAY", "60"),
metavar="SECONDS",
help=(
"Proactively refresh access tokens this many seconds before they "
"expire (default: 60, or MCP_OAUTH_REFRESH_LEEWAY env var). "
"Increase for ASes with significant clock skew; decrease for "
"deployments where short-lived tokens make a 60 s window "
"exceed token TTL"
),
)
parser.add_argument(
"-H",
"--header",
Expand Down Expand Up @@ -290,6 +317,7 @@ def main() -> None:
client_id=args.client_id or None,
scope=args.oauth_scope or None,
device_flow=args.oauth_device,
refresh_leeway=args.oauth_refresh_leeway,
)
headers["Authorization"] = f"Bearer {token_data.access_token}"
token_refresher = _build_token_refresher(
Expand Down
11 changes: 9 additions & 2 deletions src/mcp_stdio/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,20 +978,27 @@ def ensure_token(
scope: str | None = None,
timeout: float = 120,
device_flow: bool = False,
refresh_leeway: float = 60.0,
) -> TokenData:
"""Ensure a valid access token is available.

1. Check cached token — use if not expired
1. Check cached token — use if not expired (with ``refresh_leeway`` margin)
2. If expired, try refresh
3. If no token or refresh fails, run OAuth flow:
- ``device_flow=True``: Device Authorization Grant (RFC 8628)
- ``device_flow=False``: Authorization Code flow with PKCE (default)

``refresh_leeway`` is the proactive-refresh window in seconds: a cached
token is considered expired when its actual expiry is within this many
seconds from now. Default 60 s absorbs typical clock skew. Tune via
``--oauth-refresh-leeway`` for ASes that issue extremely short-lived
access tokens or for deployments with larger clock skew.

Returns TokenData with a valid access_token.
"""
cached = load_token(server_url)
if cached and cached.access_token:
if cached.expires_at is None or cached.expires_at > time.time() + 60:
if cached.expires_at is None or cached.expires_at > time.time() + refresh_leeway:
log("using cached OAuth token")
return cached

Expand Down
69 changes: 69 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,75 @@ def test_custom_timeouts(self):
assert kwargs.kwargs["timeout_connect"] == 5.0
assert kwargs.kwargs["timeout_read"] == 60.0

def test_oauth_refresh_leeway_default(self, monkeypatch):
"""#56: --oauth-refresh-leeway defaults to 60 s when env var unset and flag absent."""
monkeypatch.delenv("MCP_OAUTH_REFRESH_LEEWAY", raising=False)
with (
patch("sys.argv", ["mcp-stdio", "--oauth", "https://example.com/mcp"]),
patch("mcp_stdio.oauth.ensure_token") as mock_ensure,
patch("mcp_stdio.cli.run"),
):
mock_ensure.return_value.access_token = "tok"
main()
assert mock_ensure.call_args.kwargs["refresh_leeway"] == 60.0

def test_oauth_refresh_leeway_custom_flag(self):
"""#56: --oauth-refresh-leeway flag is propagated to ensure_token."""
with (
patch(
"sys.argv",
[
"mcp-stdio",
"--oauth",
"--oauth-refresh-leeway",
"300",
"https://example.com/mcp",
],
),
patch("mcp_stdio.oauth.ensure_token") as mock_ensure,
patch("mcp_stdio.cli.run"),
):
mock_ensure.return_value.access_token = "tok"
main()
assert mock_ensure.call_args.kwargs["refresh_leeway"] == 300.0

def test_oauth_refresh_leeway_env_var(self, monkeypatch):
"""#56: MCP_OAUTH_REFRESH_LEEWAY env var is respected when flag absent."""
monkeypatch.setenv("MCP_OAUTH_REFRESH_LEEWAY", "120")
with (
patch("sys.argv", ["mcp-stdio", "--oauth", "https://example.com/mcp"]),
patch("mcp_stdio.oauth.ensure_token") as mock_ensure,
patch("mcp_stdio.cli.run"),
):
mock_ensure.return_value.access_token = "tok"
main()
assert mock_ensure.call_args.kwargs["refresh_leeway"] == 120.0

def test_oauth_refresh_leeway_negative_rejected(self, capsys):
"""#56: negative leeway values are rejected at parse time."""
with patch(
"sys.argv",
[
"mcp-stdio",
"--oauth-refresh-leeway",
"-1",
"https://example.com/mcp",
],
):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 2 # argparse error
assert "must be >= 0" in capsys.readouterr().err

def test_oauth_refresh_leeway_invalid_env_var_rejected(self, monkeypatch, capsys):
"""#56: invalid env var values surface as argparse errors, not ValueError."""
monkeypatch.setenv("MCP_OAUTH_REFRESH_LEEWAY", "not-a-number")
with patch("sys.argv", ["mcp-stdio", "https://example.com/mcp"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 2
assert "invalid float value" in capsys.readouterr().err

def test_oauth_and_bearer_token_mutually_exclusive(self):
with patch(
"sys.argv",
Expand Down
90 changes: 90 additions & 0 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,96 @@ def test_uses_cached_valid_token(self, tmp_path, monkeypatch):
data = ensure_token("https://example.com/mcp", client)
assert data.access_token == "cached_at"

def test_refresh_leeway_zero_uses_actual_expiry(self, tmp_path, monkeypatch):
"""#56: refresh_leeway=0 disables proactive refresh — token valid until literal expiry.

Default leeway (60 s) would treat a token expiring in 30 s as expired
and trigger refresh. With leeway=0, the cached token is used until the
actual expires_at moment.
"""
store_file = tmp_path / "tokens.json"
monkeypatch.setattr("mcp_stdio.token_store._STORE_DIR", tmp_path)
monkeypatch.setattr("mcp_stdio.token_store._STORE_FILE", store_file)

from mcp_stdio.token_store import save_token

save_token(
"https://example.com/mcp",
TokenData(
access_token="short_lived_at",
expires_at=time.time() + 30, # within default 60 s leeway
refresh_token="rt",
client_id="cid",
token_endpoint="https://example.com/token",
authorization_endpoint="https://example.com/authorize",
),
)

client = httpx.Client()
data = ensure_token("https://example.com/mcp", client, refresh_leeway=0)
assert data.access_token == "short_lived_at" # used as-is, no refresh

def test_refresh_leeway_large_triggers_proactive_refresh(
self, tmp_path, monkeypatch, httpx_mock
):
"""#56: large refresh_leeway proactively refreshes even when token has time left."""
store_file = tmp_path / "tokens.json"
monkeypatch.setattr("mcp_stdio.token_store._STORE_DIR", tmp_path)
monkeypatch.setattr("mcp_stdio.token_store._STORE_FILE", store_file)

from mcp_stdio.token_store import save_token

save_token(
"https://example.com/mcp",
TokenData(
access_token="cached_at",
expires_at=time.time() + 200, # 200 s left — exceeds default leeway
refresh_token="valid_rt",
client_id="cid",
token_endpoint="https://example.com/token",
authorization_endpoint="https://example.com/authorize",
),
)

# leeway=300 — 200 < 300, treated as near-expiry → refresh
httpx_mock.add_response(
url="https://example.com/token",
json={"access_token": "refreshed_at", "expires_in": 3600},
)

client = httpx.Client()
data = ensure_token("https://example.com/mcp", client, refresh_leeway=300)
assert data.access_token == "refreshed_at"

def test_refresh_leeway_default_60s(self, tmp_path, monkeypatch, httpx_mock):
"""#56: default leeway of 60 s — token expiring in 30 s is refreshed."""
store_file = tmp_path / "tokens.json"
monkeypatch.setattr("mcp_stdio.token_store._STORE_DIR", tmp_path)
monkeypatch.setattr("mcp_stdio.token_store._STORE_FILE", store_file)

from mcp_stdio.token_store import save_token

save_token(
"https://example.com/mcp",
TokenData(
access_token="near_expiry_at",
expires_at=time.time() + 30, # within default leeway
refresh_token="valid_rt",
client_id="cid",
token_endpoint="https://example.com/token",
authorization_endpoint="https://example.com/authorize",
),
)

httpx_mock.add_response(
url="https://example.com/token",
json={"access_token": "refreshed_at", "expires_in": 3600},
)

client = httpx.Client()
data = ensure_token("https://example.com/mcp", client) # default leeway
assert data.access_token == "refreshed_at"

def test_refreshes_expired_token(self, tmp_path, monkeypatch, httpx_mock):
"""Token expired but refresh_token available."""
store_file = tmp_path / "tokens.json"
Expand Down
Loading