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" },