From bee2b836ce2f58c64627bc74534dcdf24dca5d4e Mon Sep 17 00:00:00 2001 From: Marc Kelechava Date: Wed, 8 Jul 2026 10:41:16 -0700 Subject: [PATCH] fix(mcp): route empty-API-key 401s to the Configure dialog instead of OAuth (#7203) --- docs/snippets/mcp-content.mdx | 13 ++--- pyproject.toml | 2 +- skyvern/cli/core/mcp_http_auth.py | 33 ++++++++++-- skyvern/cli/mcpb/claude_desktop/manifest.json | 2 +- skyvern/forge/sdk/api/files.py | 9 ++-- tests/unit/test_mcp_http_auth.py | 51 ++++++++++++++++++- uv.lock | 2 +- 7 files changed, 90 insertions(+), 22 deletions(-) diff --git a/docs/snippets/mcp-content.mdx b/docs/snippets/mcp-content.mdx index debd52f1b..d1fc196f3 100644 --- a/docs/snippets/mcp-content.mdx +++ b/docs/snippets/mcp-content.mdx @@ -140,18 +140,19 @@ claude mcp add-json skyvern '{"type":"http","url":"https://api.skyvern.com/mcp/" 3. When Claude Desktop opens the Skyvern installer preview, click **Install**. 4. In the **Configure Skyvern** dialog, paste your Skyvern API key. 5. Leave **Skyvern MCP URL** set to `https://api.skyvern.com/mcp/` unless you are connecting to a different deployment, then click **Save**. +6. In Claude, ask: "Create a Skyvern browser session". A working setup returns a Skyvern browser session id. If double-clicking is blocked by your system, you can instead open **Claude Desktop > Settings > Extensions > Install Extension** and select the downloaded file manually. The bundle defaults to `https://api.skyvern.com/mcp/` and removes the separate Node.js requirement. -**Linux, self-hosted, or advanced fallback (requires Node.js >= 20)** + +If Claude says Skyvern "needs authorization" or mentions OAuth, the API key is not configured. Open Claude Desktop **Settings -> Extensions -> Skyvern -> Configure**, paste your Skyvern API key, and save. + -Add this manual bridge to your Claude Desktop config file: +**Linux manual config (requires Node.js >= 20)** -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Linux**: `~/.config/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +On Linux, add this manual bridge to `~/.config/Claude/claude_desktop_config.json`: ```json { @@ -169,7 +170,7 @@ Add this manual bridge to your Claude Desktop config file: } ``` -Use this fallback for Linux or any Claude Desktop setup where the one-click bundle is not available. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20. +Use this Linux path when the one-click bundle is not available on your platform. It uses [mcp-remote](https://www.npmjs.com/package/mcp-remote) to bridge the remote MCP server to stdio and requires Node.js >= 20. diff --git a/pyproject.toml b/pyproject.toml index b2b7de8bd..8bc7fce49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "skyvern" -version = "1.0.46" +version = "1.0.45" description = "" authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }] requires-python = ">=3.11,<3.14" diff --git a/skyvern/cli/core/mcp_http_auth.py b/skyvern/cli/core/mcp_http_auth.py index b403bfc5b..a5e9ca4a2 100644 --- a/skyvern/cli/core/mcp_http_auth.py +++ b/skyvern/cli/core/mcp_http_auth.py @@ -564,6 +564,25 @@ def _unauthorized_response(message: str, *, include_oauth_challenge: bool = True ) +_EMPTY_API_KEY_MESSAGE = ( + "Empty Skyvern API key. Paste your API key from https://app.skyvern.com Settings into your client's Skyvern " + "config (Claude Desktop: Settings -> Extensions -> Skyvern -> Configure)." +) +_EMPTY_BEARER_MESSAGE = ( + "Empty Bearer token. Send a valid OAuth token or use an API key from https://app.skyvern.com Settings " + "(Claude Desktop: Settings -> Extensions -> Skyvern -> Configure)." +) +_MISSING_CREDENTIALS_MESSAGE = ( + "Missing credentials. Use an API key from https://app.skyvern.com Settings (Claude Desktop: Settings -> " + "Extensions -> Skyvern -> Configure) or connect with an OAuth-capable client. Docs: " + "https://www.skyvern.com/docs/developers/getting-started/mcp" +) +_INVALID_API_KEY_MESSAGE = ( + "Invalid Skyvern API key. Check the key from https://app.skyvern.com Settings in your client's Skyvern config " + "(Claude Desktop: Settings -> Extensions -> Skyvern -> Configure)." +) + + def _internal_error_response() -> JSONResponse: return JSONResponse( {"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}}, @@ -604,16 +623,20 @@ class MCPAPIKeyMiddleware: auth_header = request.headers.get(AUTHORIZATION_HEADER, "") if auth_header.startswith(BEARER_PREFIX): bearer_token = auth_header[len(BEARER_PREFIX) :] - if not bearer_token: - response = _unauthorized_response("Empty Bearer token") + if not bearer_token.strip(): + response = _unauthorized_response(_EMPTY_BEARER_MESSAGE) await response(scope, receive, send) return await self._handle_bearer(scope, receive, send, bearer_token) return api_key = request.headers.get(API_KEY_HEADER) - if not api_key: - response = _unauthorized_response("Missing x-api-key or Authorization: Bearer header") + if api_key is None: + response = _unauthorized_response(_MISSING_CREDENTIALS_MESSAGE) + await response(scope, receive, send) + return + if not api_key.strip(): + response = _unauthorized_response(_EMPTY_API_KEY_MESSAGE, include_oauth_challenge=False) await response(scope, receive, send) return @@ -696,7 +719,7 @@ class MCPAPIKeyMiddleware: scope["state"]["organization_id"] = validation.organization_id except HTTPException as e: if e.status_code in {401, 403}: - response = _unauthorized_response("Invalid API key", include_oauth_challenge=False) + response = _unauthorized_response(_INVALID_API_KEY_MESSAGE, include_oauth_challenge=False) elif e.status_code == 503: response = _service_unavailable_response(e.detail or _VALIDATION_RETRY_EXHAUSTED_MESSAGE) else: diff --git a/skyvern/cli/mcpb/claude_desktop/manifest.json b/skyvern/cli/mcpb/claude_desktop/manifest.json index 249209c42..bd0204b69 100644 --- a/skyvern/cli/mcpb/claude_desktop/manifest.json +++ b/skyvern/cli/mcpb/claude_desktop/manifest.json @@ -56,7 +56,7 @@ "api_key": { "type": "string", "title": "Skyvern API Key", - "description": "Create an API key in Skyvern Settings, then paste it here.", + "description": "Paste your API key from app.skyvern.com -> Settings. Claude cannot use Skyvern until this is set.", "sensitive": true, "required": true }, diff --git a/skyvern/forge/sdk/api/files.py b/skyvern/forge/sdk/api/files.py index 9ec5be281..343862b7c 100644 --- a/skyvern/forge/sdk/api/files.py +++ b/skyvern/forge/sdk/api/files.py @@ -259,18 +259,15 @@ async def download_file( # response.headers stays a CIMultiDictProxy: dict() would make header # lookups case-sensitive and miss lowercase wire headers. file_name = _determine_download_filename(filename, response.headers, url) - allowed_dir = os.path.realpath(download_dir_resolved) - resolved_final_path = os.path.realpath(os.path.join(allowed_dir, file_name)) + final_path = (download_dir_resolved / file_name).resolve() # sanitize_filename strips separators but keeps dots, so a dots-only name can # still resolve outside the download dir; require a direct child. Checked # before streaming so a rejected name downloads zero bytes. if ( - resolved_final_path == allowed_dir - or not resolved_final_path.startswith(allowed_dir + os.sep) - or os.path.dirname(resolved_final_path) != allowed_dir + not final_path.is_relative_to(download_dir_resolved) + or final_path.parent != download_dir_resolved ): raise ValueError(f"Unsafe filename derived from download: {file_name!r}") - final_path = Path(resolved_final_path) temp_file = tempfile.NamedTemporaryFile(mode="wb", dir=download_dir_resolved, delete=False) file_path = Path(temp_file.name).resolve() diff --git a/tests/unit/test_mcp_http_auth.py b/tests/unit/test_mcp_http_auth.py index 8e2e557c7..d1d789720 100644 --- a/tests/unit/test_mcp_http_auth.py +++ b/tests/unit/test_mcp_http_auth.py @@ -108,11 +108,57 @@ async def test_mcp_http_auth_rejects_missing_api_key(monkeypatch: pytest.MonkeyP assert response.status_code == 401 assert response.json()["error"]["code"] == "UNAUTHORIZED" - assert "x-api-key" in response.json()["error"]["message"] + assert response.json()["error"]["message"] == mcp_http_auth._MISSING_CREDENTIALS_MESSAGE + assert "Configure" in response.json()["error"]["message"] assert response.headers["www-authenticate"] == expected_challenge assert response.headers["access-control-expose-headers"] == "WWW-Authenticate" +@pytest.mark.parametrize("api_key", ["", " "]) +@pytest.mark.asyncio +async def test_mcp_http_auth_rejects_empty_api_key_without_oauth_challenge( + monkeypatch: pytest.MonkeyPatch, + api_key: str, +) -> None: + validate_api_key = AsyncMock(side_effect=AssertionError("empty API key should not be validated")) + monkeypatch.setattr(mcp_http_auth, "validate_mcp_api_key", validate_api_key) + app = _build_test_app() + + response = await _request(app, "POST", "/mcp", headers={"x-api-key": api_key}, json={}) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "UNAUTHORIZED" + assert response.json()["error"]["message"] == mcp_http_auth._EMPTY_API_KEY_MESSAGE + assert "Configure" in response.json()["error"]["message"] + assert "www-authenticate" not in response.headers + validate_api_key.assert_not_awaited() + + +@pytest.mark.parametrize("bearer_token", ["", " "]) +@pytest.mark.asyncio +async def test_mcp_http_auth_rejects_empty_bearer_with_oauth_challenge( + monkeypatch: pytest.MonkeyPatch, + bearer_token: str, +) -> None: + validate_oauth_token = AsyncMock(side_effect=AssertionError("empty Bearer token should not be validated")) + validate_api_key = AsyncMock(side_effect=AssertionError("empty Bearer token should not be validated")) + monkeypatch.setattr(mcp_http_auth, "validate_mcp_oauth_token", validate_oauth_token) + monkeypatch.setattr(mcp_http_auth, "validate_mcp_api_key", validate_api_key) + app = _build_test_app() + expected_challenge = _expected_oauth_challenge(monkeypatch) + + response = await _request(app, "POST", "/mcp", headers={"authorization": f"Bearer {bearer_token}"}, json={}) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "UNAUTHORIZED" + assert response.json()["error"]["message"] == mcp_http_auth._EMPTY_BEARER_MESSAGE + assert "Configure" in response.json()["error"]["message"] + assert response.headers["www-authenticate"] == expected_challenge + assert response.headers["access-control-expose-headers"] == "WWW-Authenticate" + validate_oauth_token.assert_not_awaited() + validate_api_key.assert_not_awaited() + + @pytest.mark.asyncio async def test_mcp_http_auth_head_request_exposes_oauth_challenge(monkeypatch: pytest.MonkeyPatch) -> None: app = _build_test_app() @@ -148,7 +194,8 @@ async def test_mcp_http_auth_rejects_invalid_api_key(monkeypatch: pytest.MonkeyP assert response.status_code == 401 assert response.json()["error"]["code"] == "UNAUTHORIZED" - assert response.json()["error"]["message"] == "Invalid API key" + assert response.json()["error"]["message"] == mcp_http_auth._INVALID_API_KEY_MESSAGE + assert "Configure" in response.json()["error"]["message"] assert "www-authenticate" not in response.headers diff --git a/uv.lock b/uv.lock index d08567f63..55c7beff4 100644 --- a/uv.lock +++ b/uv.lock @@ -5776,7 +5776,7 @@ wheels = [ [[package]] name = "skyvern" -version = "1.0.46" +version = "1.0.45" source = { editable = "." } dependencies = [ { name = "cachetools" },