Skip to content
Open
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
21 changes: 19 additions & 2 deletions apps/cli/mcp_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import json
import logging
import os
from pathlib import Path

Expand Down Expand Up @@ -37,6 +38,8 @@
"import_claude_code_servers",
]

logger = logging.getLogger(__name__)


def mcp_config_path() -> Path:
"""Path to the per-project MCP config file."""
Expand All @@ -58,17 +61,31 @@ def mcp_oauth_storage() -> object | None:
Tokens (e.g. for the hosted Figma server) are cached on disk under
``~/.pydantic-deep/mcp-oauth`` and keyed by server URL, so authorizing once
(via ``/mcp`` test) works for the agent too and survives restarts. Returns
``None`` if the disk store backend isn't installed (falls back to in-memory).
``None`` — with a logged warning, since the resulting in-memory fallback
means a browser re-auth on every restart — if the disk store backend isn't
installed or the store directory can't be created.
"""
try:
from key_value.aio.stores.disk import DiskStore
except Exception:
logger.warning(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right instinct — the silent fallback was the confusing part — but logging.getLogger(__name__) is the wrong channel here, and it can make things worse than staying quiet.

load_mcp_registry() is called from inside running Textual modals (apps/cli/modals/mcp_view.py:178, apps/cli/modals/info_view.py:97). apps.cli.mcp_store isn't in _NOISY_CONSOLE_LOGGERS, so it propagates to root; root has no handler under the TUI, so logging.lastResort writes the record straight to stderr — over the live screen. That's exactly what quiet_console_logging() and the stdio log-file redirect exist to prevent. And the user who needs this message (repeated re-auth) still doesn't get anything actionable in the UI.

The house pattern for CLI-side warnings is the session logger — propagate=False, file handler, no screen corruption:

from apps.cli.debug_log import get_logger

get_logger().warning(
    "MCP OAuth tokens will not persist: the disk store backend is not "
    "installed (pip install 'py-key-value-aio[disk]'); using in-memory "
    "storage, so hosted servers re-authorize on every restart."
)

Same for the second logger.warning below. If you want the user to actually see it, the /mcp view already has a place to surface it — but the file log is the minimum and the safe default.

"MCP OAuth tokens will not persist: the disk store backend is not "
"installed (pip install 'py-key-value-aio[disk]'); using in-memory "
"storage, so hosted servers re-authorize on every restart."
)
return None
path = Path.home() / ".pydantic-deep" / "mcp-oauth"
try:
path.mkdir(parents=True, exist_ok=True)
return DiskStore(directory=str(path))
except Exception:
except Exception as exc:
logger.warning(
"MCP OAuth tokens will not persist: cannot initialise the disk store "
"at %s (%s); using in-memory storage, so hosted servers re-authorize "
"on every restart.",
path,
exc,
)
return None


Expand Down
6 changes: 6 additions & 0 deletions docs/api/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ them to an agent via the `mcp_servers` parameter of
options:
show_source: false

## HttpClientFactory

::: pydantic_deep.mcp.registry.HttpClientFactory
options:
show_source: false

## probe_mcp_server

::: pydantic_deep.mcp.probe_mcp_server
Expand Down
72 changes: 72 additions & 0 deletions docs/learn/web-and-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,78 @@ you can keep a whole shelf of servers defined and switch them on as needed.
keystore). See [MCP Servers](../learn/web-and-mcp.md) for OAuth, stdio subprocesses,
and importing servers from Claude Code.

### Hosted OAuth servers

Some hosted servers (Figma, Atlassian) authenticate with an interactive OAuth
flow instead of a token: set `MCPAuth(kind="oauth")` and the browser opens on
first connect. Servers that are strict about registration take two more fields —
explicit `scopes` and a fixed `callback_port` for a pre-registered redirect URI:

```python
from pydantic_deep import MCPAuth, MCPServerConfig, build_mcp_server

atlassian = MCPServerConfig(
name="atlassian",
transport="http",
url="https://mcp.atlassian.com/v1/mcp/authv2",
auth=MCPAuth(
kind="oauth",
scopes=["read:jira-work", "read:confluence-content.all"],
callback_port=8123,
),
init_timeout=180, # leave time for the browser round-trip
)
server = build_mcp_server(atlassian)
```

Pass `oauth_token_storage=` (any `AsyncKeyValue`, e.g. a disk store) to make the
token survive restarts. Tokens are cached per scope set: change `scopes` and the
next connect re-authorizes instead of silently reusing a token minted under the
old permissions.

### Corporate proxies and custom CAs

Behind a TLS-inspecting proxy, both the MCP transport and the OAuth flow
(discovery, token exchange, refresh) must trust your network. Try the standard
environment variables first — httpx honours them for every connection:

```bash
export HTTPS_PROXY="http://proxy.corp.example:8080"
export SSL_CERT_FILE="/etc/ssl/corp-bundle.pem"
```

(For `stdio` servers the subprocess doesn't inherit your environment — pass
what it needs, e.g. `NODE_EXTRA_CA_CERTS`, explicitly via `config.env`.)

When env vars can't express your setup — the CA lives in the OS trust store, the
proxy wants authentication, or you need mTLS — pass an
[`HttpClientFactory`][pydantic_deep.mcp.registry.HttpClientFactory]. One factory
applies to every HTTP-based server in a registry and reaches both the transport
and the OAuth flow:

```python
import httpx
import ssl
import truststore

from pydantic_deep import MCPRegistry, MCPServerConfig


def corporate_client(config: MCPServerConfig) -> httpx.AsyncClient:
# Called once per connection — always return a fresh client.
return httpx.AsyncClient(
proxy="http://proxy.corp.example:8080",
verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT),
)


registry = MCPRegistry(configs, http_client_factory=corporate_client)
```

The factory must return a **new client on every call**: each connection (and
each OAuth step) closes the client it used, so a shared instance would be dead
after the first request.

## Recap

You gave your agent the world:
Expand Down
2 changes: 2 additions & 0 deletions pydantic_deep/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pydantic_deep.mcp.loader import expand_env_vars, parse_mcp_servers
from pydantic_deep.mcp.registry import (
MCP_INSTALL_HINT,
HttpClientFactory,
MCPNotInstalledError,
MCPProbeResult,
MCPRegistry,
Expand Down Expand Up @@ -46,6 +47,7 @@
"MCPNotInstalledError",
"MCP_INSTALL_HINT",
"SecretResolver",
"HttpClientFactory",
"auth_satisfied",
"build_mcp_server",
"make_resilient",
Expand Down
14 changes: 14 additions & 0 deletions pydantic_deep/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ class MCPAuth:
client_name: OAuth client name advertised during dynamic client
registration (``oauth`` only). Some servers (e.g. Figma's hosted MCP
during beta) allowlist this; leave ``None`` to use the default.
scopes: OAuth scopes to request up front (``oauth`` only). Some hosted
servers (e.g. Atlassian's) require explicit scopes at registration
instead of granting a default set. Cached tokens are namespaced per
scope set, so changing this forces a fresh authorization rather
than silently reusing a token minted under the old scopes.
callback_port: Fixed localhost port for the OAuth redirect URI
(``oauth`` only). Needed by servers that validate a pre-registered
redirect URI; ``None`` lets the client pick a free port.
"""

secret_key: str = ""
Expand All @@ -69,10 +77,14 @@ class MCPAuth:
value_template: str = "Bearer {token}"
instructions: str = ""
client_name: str | None = None
scopes: list[str] = field(default_factory=list)
callback_port: int | None = None

def __post_init__(self) -> None:
if self.kind in _SECRET_AUTH_KINDS and not self.secret_key:
raise MCPConfigError(f"{self.kind} MCP auth requires a non-empty secret_key")
if self.callback_port is not None and self.callback_port <= 0:
raise MCPConfigError(f"callback_port must be positive, got {self.callback_port}")
Comment on lines +86 to +87

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: while you're validating, the upper bound is worth the same line. callback_port=70000 passes here and then fails much later with a socket error, in the middle of a browser flow, where it's hard to trace back to the config.

Suggested change
if self.callback_port is not None and self.callback_port <= 0:
raise MCPConfigError(f"callback_port must be positive, got {self.callback_port}")
if self.callback_port is not None and not 0 < self.callback_port <= 65535:
raise MCPConfigError(
f"callback_port must be between 1 and 65535, got {self.callback_port}"
)

# Fail fast on a template that would raise at `render_value` time —
# extra placeholders (`{foo}`) or unbalanced literal braces (B13).
try:
Expand All @@ -97,6 +109,8 @@ def from_dict(cls, data: dict[str, Any]) -> MCPAuth:
value_template=data.get("value_template", "Bearer {token}"),
instructions=data.get("instructions", ""),
client_name=data.get("client_name"),
scopes=list(data.get("scopes", [])),
callback_port=data.get("callback_port"),
)


Expand Down
Loading
Loading