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
19 changes: 18 additions & 1 deletion docs/docs/reference/configuration/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ For most users with standard JupyterLab setups:
|----------|-------------|---------|---------|----------|
| `JUPYTER_URL` | URL of your Jupyter server | `http://localhost:8888` | `http://localhost:8888` | No |
| `JUPYTER_TOKEN` | Authentication token for Jupyter server | `my-secret-token` | `None` | No* |
| `JUPYTER_PASSWORD` | Password for Jupyter server authentication (alternative to token) | `my-password` | `None` | No* |
| `DOCUMENT_ID` | Default notebook path (relative to Jupyter root) | `notebook.ipynb` | `None` | No |
| `ALLOWED_JUPYTER_MCP_TOOLS` | Comma-separated list of jupyter-mcp-tools to enable | `notebook_run-all-cells,notebook_get-selected-cell` | `notebook_run-all-cells,notebook_get-selected-cell` | No |
| `ALLOW_IMG_OUTPUT` | Enable multimodal image support | `true` / `false` | `true` | No |

*Required for authentication when connecting to secured Jupyter servers
*At least one of `JUPYTER_TOKEN` or `JUPYTER_PASSWORD` is required when connecting to a secured Jupyter server. They are independent authentication mechanisms — a server may have both configured, and supplying either one is sufficient to connect. When both are provided to the MCP server, password authentication takes precedence. See [Security](../security) for details on when to use each.

### Advanced Configuration (Complex Deployments)

Expand All @@ -67,13 +68,15 @@ For deployments requiring granular control over document storage and runtime exe
|----------|-------------|---------|---------|
| `DOCUMENT_URL` | URL for notebook file operations | `http://notebook-storage:8888` | `http://localhost:8888` |
| `DOCUMENT_TOKEN` | Authentication for document operations | `storage-access-token` | `None` |
| `DOCUMENT_PASSWORD` | Password for document server authentication (alternative to token) | `storage-password` | `None` |
| `DOCUMENT_ID` | Notebook path/ID | `shared/analysis.ipynb` | `None` |

#### Runtime Execution Variables
| Variable | Description | Example | Default |
|----------|-------------|---------|---------|
| `RUNTIME_URL` | URL for kernel/execution operations | `http://compute-cluster:8888` | `http://localhost:8888` |
| `RUNTIME_TOKEN` | Authentication for runtime operations | `compute-access-token` | `None` |
| `RUNTIME_PASSWORD` | Password for runtime server authentication (alternative to token) | `compute-password` | `None` |
| `RUNTIME_ID` | Specific kernel ID to use | `kernel-abc123` | `None` |

#### MCP Client Authentication
Expand Down Expand Up @@ -173,6 +176,10 @@ Variables are resolved in this order:
2. **Simplified variables** (`JUPYTER_*`) - fallback
3. **Default values** - when no variables are set

This applies to tokens, passwords, and URLs alike. For example, `RUNTIME_PASSWORD` takes precedence over `JUPYTER_PASSWORD` for runtime server authentication.

When both a password and a token are configured for the same server, password authentication takes precedence and the token is ignored.

## Configuration Examples

### Standard JupyterLab Setup
Expand Down Expand Up @@ -202,6 +209,13 @@ RUNTIME_TOKEN=compute-access-token
ALLOW_IMG_OUTPUT=false
```

### Password-Protected Jupyter Server
```bash
JUPYTER_URL=http://localhost:8888
JUPYTER_PASSWORD=my-jupyter-password
DOCUMENT_ID=my-notebook.ipynb
```

### Development Setup
```bash
JUPYTER_URL=http://localhost:8888
Expand All @@ -224,15 +238,18 @@ Options:
--jupyterlab BOOLEAN Enable JupyterLab mode (default: true)
--runtime-url TEXT Runtime URL for kernel operations (default: None)
--runtime-token TEXT Runtime authentication token (default: None)
--runtime-password TEXT Password for runtime Jupyter server authentication (default: None)
--mcp-token TEXT Token for authenticating MCP clients (required unless --insecure-mcp-noauth)
--insecure-mcp-noauth Allow streamable-http without MCP client auth (not recommended)
--runtime-id TEXT Specific kernel ID to use (default: None)
--start-new-runtime BOOLEAN Create new runtime vs use existing (default: true)
--document-url TEXT Document URL for notebook operations (default: None)
--document-id TEXT Notebook path/ID (default: None)
--document-token TEXT Document authentication token (default: None)
--document-password TEXT Password for document Jupyter server authentication (default: None)
--jupyter-url TEXT Jupyter URL as default for both document and runtime URLs (default: None)
--jupyter-token TEXT Jupyter token as default for both document and runtime tokens (default: None)
--jupyter-password TEXT Shared password for both runtime and document servers (default: None)
--allowed-jupyter-mcp-tools TEXT Comma-separated list of jupyter-mcp-tools to enable (default: notebook_run-all-cells,notebook_get-selected-cell)
--reconnect-interval INTEGER Seconds before retrying a dropped kernel WebSocket connection (default: 0)
--execution-timeout INTEGER RANGE Default timeout in seconds for code execution when a tool call passes no timeout (default: 120)
Expand Down
107 changes: 98 additions & 9 deletions docs/docs/reference/security/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,28 +161,117 @@ Cross-Site Request Forgery (XSRF/CSRF) protection prevents unauthorized commands
As described in [issue #183](https://github.com/datalayer/jupyter-mcp-server/issues/183), some Jupyter deployments don't use Bearer tokens but rely on XSRF cookies for authentication:

**Affected Environments:**
- Jupyter servers started without a token: `jupyter lab --IdentityProvider.token ''`
- Jupyter servers that run without a Bearer token (for example, password-protected servers)
- Enterprise deployments with SSO/OAuth/IAM
- Managed environments (AWS SageMaker Studio, Google Colab Enterprise, Azure ML)
- JupyterHub where authentication is handled by the Hub

### Current Limitations
## Password Authentication

:::info Current Status
For Jupyter servers configured with password-based login (instead of or in addition to tokens), the MCP server can authenticate by performing the standard Jupyter `/login` flow and using the resulting session cookies for all subsequent requests.

**Bearer Token Required**: Currently, Jupyter MCP Server requires a Bearer token (`JUPYTER_TOKEN`) to authenticate with the Jupyter collaboration API.
This is useful when:
- Your Jupyter server uses a password set via `jupyter server password` or `--ServerApp.password`
- You don't have a Bearer token available
- Your deployment relies on XSRF cookie protection

**Issue**: The server fails with `403 Forbidden` when connecting to Jupyter deployments that use XSRF protection without Bearer tokens.
### How It Works

**Tracking**: We're working on automatic XSRF cookie handling - see [issue #183](https://github.com/datalayer/jupyter-mcp-server/issues/183) for details.
1. The MCP server POSTs to `/login` on the Jupyter server with the configured password
2. Jupyter returns session cookies (including the `_xsrf` token)
3. These cookies are injected into all HTTP requests (API calls, kernel operations) and WebSocket connections (notebook collaboration)
4. The `X-XSRFToken` header is automatically included in requests that require XSRF protection

### Configuration

#### Simplified (Same Password for Both Servers)

When document storage and runtime execution use the same Jupyter server:

```json
{
"env": {
"JUPYTER_URL": "http://localhost:8888",
"JUPYTER_PASSWORD": "my-jupyter-password"
}
}
```

Or via CLI:

```bash
jupyter-mcp-server start \
--jupyter-url http://localhost:8888 \
--jupyter-password my-jupyter-password
```

#### Advanced (Separate Passwords)

For deployments where document and runtime servers have different passwords:

```json
{
"env": {
"DOCUMENT_URL": "http://storage-server:8888",
"DOCUMENT_PASSWORD": "storage-password",
"RUNTIME_URL": "http://compute-server:8888",
"RUNTIME_PASSWORD": "compute-password"
}
}
```

### Password vs Token

| | Token Auth | Password Auth |
|---|---|---|
| **Mechanism** | Bearer token in `Authorization` header | Session cookies + XSRF token |
| **Setup** | `--IdentityProvider.token MY_TOKEN` | `jupyter server password` |
| **Best for** | API access, automation, JupyterHub | Local servers, password-protected deployments |
| **XSRF handling** | Not needed (token bypasses XSRF) | Automatic (cookies include XSRF) |

:::info Priority

When both a password and a token are configured for the same server, **password authentication takes precedence**. The token is ignored and a warning is logged. This avoids ambiguity about which authentication method is active.

:::

### Setting a Jupyter Server Password

If your Jupyter server doesn't have a password configured yet:

```bash
# Interactive prompt to set a password
jupyter server password
```

This stores a hashed password in `~/.jupyter/jupyter_server_config.json`. Then start Jupyter normally — the password is active immediately, so the MCP server can authenticate against it:

```bash
jupyter lab
```

:::warning Keep the token configured

Do **not** blank out the server token (e.g. `--IdentityProvider.token ''`) just to enable password
auth. Token and password are independent mechanisms — password auth works whether or not a token is
set. Leaving a token in place keeps a working fallback: if password authentication ever fails, the
server still requires *some* credential rather than being left open with no authentication at all.

:::

### Limitations

:::info Partial Coverage of Issue #183

Password authentication addresses the XSRF-protected scenario described in [issue #183](https://github.com/datalayer/jupyter-mcp-server/issues/183) for password-protected Jupyter servers. Other scenarios mentioned in that issue — SSO/OAuth, IAM-based auth in managed environments — are not yet supported.

:::

### Workarounds for XSRF-only Environments
### Alternatives to Password Authentication

Until automatic XSRF handling is implemented, you have these options:
If password auth doesn't fit your deployment, you can authenticate with a token instead (or, for development only, disable XSRF):

#### Option 1: Start Jupyter with a Token (Recommended)
#### Option 1: Token-based Authentication

```bash
jupyter lab --IdentityProvider.token YOUR_SECURE_TOKEN
Expand Down
197 changes: 197 additions & 0 deletions jupyter_mcp_server/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Copyright (c) 2024- Datalayer, Inc.
#
# BSD 3-Clause License

"""Authentication utilities for Jupyter server password login."""

from typing import Optional

import requests

from jupyter_mcp_server.log import logger


_BODY_PREVIEW_CHARS = 200


def _truncate(text: str) -> str:
"""Trim a response body for inclusion in an error message."""
if not text:
return "<empty>"
snippet = text.strip().replace("\n", " ")
if len(snippet) <= _BODY_PREVIEW_CHARS:
return repr(snippet)
return repr(snippet[:_BODY_PREVIEW_CHARS] + "…")


class JupyterPasswordAuth:
"""Handles password-based authentication with a Jupyter server.

Performs POST /login with the password, then keeps a `requests.Session`
alive so cookies refreshed by the server during subsequent requests are
visible via `live_headers()`. Use `inject_into_session()` to copy cookies
into another `Session` (e.g. the one inside `JupyterServerClient`).
"""

def __init__(self, server_url: str, password: str):
self.server_url = server_url.rstrip("/")
self.password = password
self._session: Optional[requests.Session] = None
self._xsrf_token: Optional[str] = None
self._authenticated = False

def _do_request(self, method: str, url: str, stage: str, timeout: float, **kwargs):
"""Wrap session.request with consistent error translation per stage."""
assert self._session is not None # set by login() before calling
try:
return self._session.request(method, url, timeout=timeout, **kwargs)
except requests.exceptions.Timeout as error:
raise RuntimeError(
f"Timed out during {stage} ({method} {url}) after {timeout}s: {error}"
) from error
except requests.exceptions.ConnectionError as error:
raise RuntimeError(
f"Connection error during {stage} ({method} {url}): {error}"
) from error

def login(self, timeout: float = 10.0) -> None:
"""Perform the /login POST to obtain session cookies.

Args:
timeout: Per-request timeout in seconds (applied to GET /login,
POST /login, and the verification GET /api/status).

Raises:
RuntimeError: If login fails (connection, timeout, bad credentials,
server error, or missing XSRF cookie).
"""
# Build the session and only retain it on success — on failure, close
# it so we don't leak connections.
session = requests.Session()
self._session = session
try:
# GET /login to obtain the initial _xsrf cookie. Disable redirects
# so we don't silently follow to a different origin (e.g. JupyterHub).
self._do_request(
"GET", f"{self.server_url}/login",
stage="initial XSRF fetch", timeout=timeout,
allow_redirects=False,
)
xsrf = session.cookies.get("_xsrf", "")

# POST /login with password (and _xsrf if present).
post_data = {"password": self.password}
if xsrf:
post_data["_xsrf"] = xsrf
response = self._do_request(
"POST", f"{self.server_url}/login",
stage="login POST", timeout=timeout,
data=post_data, allow_redirects=False,
)
if response.status_code >= 500:
raise RuntimeError(
f"Jupyter server returned {response.status_code} on /login — "
f"the server is failing, not an auth problem. "
f"Body: {_truncate(response.text)}"
)
if response.status_code not in (200, 302):
raise RuntimeError(
f"Password login failed with status {response.status_code}. "
f"Check that the Jupyter server is configured for password auth. "
f"Body: {_truncate(response.text)}"
)

# Verify we actually got authenticated by testing the API.
# Distinguish 401/403 (bad credentials) from other failure modes.
verify = self._do_request(
"GET", f"{self.server_url}/api/status",
stage="session verification", timeout=timeout,
)
if verify.status_code in (401, 403):
raise RuntimeError(
f"Password login did not produce a valid session "
f"(GET /api/status returned {verify.status_code}). "
f"The password may be incorrect. Body: {_truncate(verify.text)}"
)
if verify.status_code >= 500:
raise RuntimeError(
f"Jupyter server returned {verify.status_code} on /api/status — "
f"the server is failing, not an auth problem. "
f"Body: {_truncate(verify.text)}"
)
if verify.status_code != 200:
raise RuntimeError(
f"Unexpected status {verify.status_code} from /api/status "
f"while verifying the login session. Body: {_truncate(verify.text)}"
)

self._xsrf_token = session.cookies.get("_xsrf", "")
if not self._xsrf_token:
raise RuntimeError(
"Login succeeded but no _xsrf cookie was set. "
"XSRF-protected POST requests would deterministically fail; "
"refusing to proceed. Check the Jupyter server's XSRF configuration."
)
self._authenticated = True
logger.info(f"Password authentication successful for {self.server_url}")
except BaseException:
session.close()
self._session = None
raise

def close(self) -> None:
"""Close the underlying session. Safe to call multiple times."""
if self._session is not None:
self._session.close()
self._session = None
self._authenticated = False

def _live_cookies(self) -> dict[str, str]:
"""Snapshot the current cookie jar (post-login, may include refreshes)."""
if self._session is None:
return {}
return dict(self._session.cookies)

@property
def cookie_header(self) -> str:
"""Cookie header value built from the current (live) cookie jar."""
return "; ".join(f"{name}={value}" for name, value in self._live_cookies().items())

def get_headers(self) -> dict[str, str]:
"""Return headers dict with Cookie and X-XSRFToken for injection.

Reads the live cookie jar so a refreshed `_xsrf` is picked up.
Returns an empty dict if `login()` has not been called or has failed.
"""
if not self._authenticated:
return {}
cookies = self._live_cookies()
if not cookies:
return {}
headers = {"Cookie": "; ".join(f"{name}={value}" for name, value in cookies.items())}
xsrf = cookies.get("_xsrf", "")
if xsrf:
headers["X-XSRFToken"] = xsrf
return headers

def relogin(self, timeout: float = 10.0) -> None:
"""Re-authenticate after session expiry. Closes the current session and logs in again.

Raises the same errors as `login()` if the new login fails.
"""
self.close()
self.login(timeout=timeout)

def inject_into_session(self, session: requests.Session) -> None:
"""Copy current cookies into another session.

Deliberately does NOT set `X-XSRFToken` as a default header — if the
server ever rotates the `_xsrf` cookie, a frozen header value would go
stale. Callers that need the XSRF token per-request must read it from
the live cookie jar (see `ServerContext.runtime_auth_headers`) or call
`get_headers()` on this auth.
"""
if not self._authenticated:
return
for name, value in self._live_cookies().items():
session.cookies.set(name, value)
Loading
Loading