diff --git a/.gitignore b/.gitignore index 59e72586..acc041ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ stats/ *cookies*txt cookies* cookies.txt +.bob .claude mutants .mutmut-cache @@ -250,4 +251,4 @@ db_path/ tmp/ .continue -plugin-catalog \ No newline at end of file +plugin-catalog diff --git a/MIGRATION_MCP_V2.md b/MIGRATION_MCP_V2.md new file mode 100644 index 00000000..8efab40f --- /dev/null +++ b/MIGRATION_MCP_V2.md @@ -0,0 +1,524 @@ +# CPEX MCP SDK v1 → v2 Migration Strategy + +**Date:** 2025-07-03 +**Target SDK:** `mcp==2.0.0b2`, `mcp-types==2.0.0b2` (already pinned in `pyproject.toml`) +**Source of truth:** [MCP Python SDK v2 Migration Guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) + +--- + +## 1. Scope Inventory + +### Source files touching MCP SDK APIs + +| File | SDK surfaces used | v2 impact | +|---|---|---| +| `cpex/framework/external/mcp/client.py` | `ClientSession`, `McpError`, `StdioServerParameters`, `stdio_client`, `streamablehttp_client`, `mcp.types.TextContent`, `mcp.server.streamable_http.MCP_SESSION_ID_HEADER` | **High** — transport removed, error renamed, types repackaged | +| `cpex/framework/external/mcp/server/runtime.py` | `FastMCP`, `TransportSecuritySettings` | **High** — class renamed+relocated, constructor signature changed | + +### Test files + +| File | SDK surfaces used | v2 impact | +|---|---|---| +| `tests/.../test_client_config.py` | `mcp.types.CallToolResult`, `mcp.types.TextContent` | **Medium** — import path change | +| `tests/.../test_client_coverage.py` | `mcp.types.TextContent`, patches on `streamablehttp_client`, `ClientSession`, `stdio_client` | **High** — transport name + mock paths | +| `tests/.../test_client_reconnect.py` | `mcp.McpError`, `mcp.types.ErrorData`, `mcp.types.CallToolResult`, `mcp.types.TextContent` | **High** — error renamed + constructor changed | +| `tests/.../test_client_stdio.py` | `mcp.ClientSession`, `mcp.StdioServerParameters`, `mcp.client.stdio.stdio_client` | **Low** — top-level re-exports preserved | + +### Files NOT affected (import CPEX internal modules only, not SDK directly) + +- `cpex/framework/external/mcp/server/__init__.py` — imports `ExternalPluginServer` from CPEX +- `cpex/framework/external/mcp/server/server.py` — CPEX's own `ExternalPluginServer` class +- `cpex/framework/external/mcp/tls_utils.py` — no MCP SDK imports +- `cpex/framework/external/grpc/server/` — imports CPEX internal `ExternalPluginServer` +- `cpex/framework/external/unix/server/server.py` — imports CPEX internal `ExternalPluginServer` + +--- + +## 2. Breaking Changes That Apply to CPEX + +Each change maps to a section in the [Migration Guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). + +### 2.1 `mcp.types` → `mcp_types` package +**Guide section:** ["mcp.types moved to the mcp-types package"](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcptypes-moved-to-the-mcp-types-package) + +`mcp.types` submodule is **removed**. Types are in the separate `mcp-types` distribution, imported as `mcp_types`. Top-level `mcp` re-exports key types but NOT via `mcp.types`. + +| v1 import | v2 import | +|---|---| +| `from mcp.types import TextContent` | `from mcp_types import TextContent` | +| `from mcp.types import CallToolResult` | `from mcp_types import CallToolResult` | +| `from mcp.types import ErrorData` | `from mcp_types import ErrorData` | + +**Impact on CPEX:** All `from mcp.types import ...` in `client.py` and every test file. + +### 2.2 `McpError` → `MCPError` (renamed + constructor changed) +**Guide section:** ["McpError renamed to MCPError"](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcperror-renamed-to-mcperror) + +- Class renamed `McpError` → `MCPError` +- Top-level export: `from mcp import MCPError` +- **Constructor changed:** takes `(code, message, data=None)` directly — no more wrapping in `ErrorData` +- **Attribute access changed:** `e.error.message` → `e.message`; `e.error.code` → `e.code` +- Instance still has `e.error: ErrorData` for backward compat + +| v1 | v2 | +|---|---| +| `from mcp import McpError` | `from mcp import MCPError` | +| `raise McpError(ErrorData(code=-1, message="..."))` | `raise MCPError(-1, "...")` | +| `except McpError as e: e.error.message` | `except MCPError as e: e.message` | + +**Impact on CPEX:** `client.py:573` catches `McpError`, `test_client_reconnect.py` constructs `McpError(ErrorData(...))`. + +### 2.3 `streamablehttp_client` removed → `streamable_http_client` +**Guide section:** ["streamablehttp_client removed"](https://py.sdk.modelcontextprotocol.io/v2/migration/#streamablehttp_client-removed) + +This is the **most impactful** change. The old `streamablehttp_client` context manager is **gone**, replaced by `streamable_http_client` with a fundamentally different API: + +| Aspect | v1 (`streamablehttp_client`) | v2 (`streamable_http_client`) | +|---|---|---| +| **Import** | `mcp.client.streamable_http.streamablehttp_client` | `mcp.client.streamable_http.streamable_http_client` | +| **HTTP client param** | `httpx_client_factory: Callable[..., httpx.AsyncClient]` | `http_client: httpx.AsyncClient \| None` (pre-built instance) | +| **Session termination** | `terminate_on_close: bool` — sends DELETE on exit | `terminate_on_close: bool` — same, built into transport | +| **Session ID** | 3-tuple `(read, write, get_session_id)` | `TransportStreams` 2-tuple `(read_stream, write_stream)`; session ID on `StreamableHTTPTransport.session_id` | +| **`get_session_id` callback** | Returned as 3rd element of yield | **Removed entirely** — use `transport.get_session_id()` or `transport.session_id` | + +**Impact on CPEX `client.py`:** +- `__connect_to_http_server`: factory pattern → pre-built client, 3-tuple unpacking → 2-tuple +- `__terminate_http_session`: can be removed if `terminate_on_close=True` is used (SDK sends DELETE) +- `_get_session_id` / `_session_id` fields: need new mechanism via transport's `session_id` property +- The `MCP_SESSION_ID_HEADER` lazy import (`mcp.server.streamable_http`) is still valid for the header constant string `"mcp-session-id"` + +### 2.4 `FastMCP` → `MCPServer` (renamed + relocated) +**Guide section:** ["FastMCP renamed to MCPServer"](https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver) + +| v1 | v2 | +|---|---| +| `from mcp.server.fastmcp import FastMCP` | `from mcp.server.mcpserver import MCPServer` | + +**Impact on CPEX:** `server/runtime.py` defines `SSLCapableFastMCP(FastMCP)`. + +### 2.5 Transport params removed from MCPServer constructor +**Guide section:** ["Transport-specific parameters moved from MCPServer constructor to run()/app methods"](https://py.sdk.modelcontextprotocol.io/v2/migration/#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) + +`host`, `port`, `transport_security`, `json_response`, `stateless_http` are **no longer accepted** by `MCPServer.__init__()`. They are passed to `run()`, `run_streamable_http_async()`, `streamable_http_app()`, etc. + +| v1 | v2 | +|---|---| +| `FastMCP("name", host="0.0.0.0", port=8000)` | `MCPServer("name")` + `await mcp.run_streamable_http_async(host="0.0.0.0", port=8000)` | + +**Impact on CPEX:** `SSLCapableFastMCP.__init__` passes `host`/`port` via `kwargs` to `super().__init__()`. Must stop doing this. The `run_streamable_http_async()` override already passes them to `uvicorn.Config` directly — the `self.settings.host` / `self.settings.port` access must be replaced with `self.server_config.host` / `self.server_config.port`. + +### 2.6 `MCPServer` constructor positional parameter order changed +**Guide section:** ["MCPServer constructor: title, description, and version added"](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters) + +New order: `name, title, description, instructions, website_url, icons, version`. + +CPEX uses keyword args (`name=...`, `instructions=...`) so this is **not a breaking issue** for us, but the strategy doc records it for awareness. + +### 2.7 `run_stdio_async()` signature unchanged +The `MCPServer.run_stdio_async()` method exists with the same signature. CPEX's `run()` function calls it directly — no change needed. + +### 2.8 `streamable_http_app()` gains `host` parameter +Used internally for transport security auto-configuration. CPEX calls `self.streamable_http_app()` without kwargs — still valid. + +### 2.9 `stdio_client` unchanged +**Guide section:** ["stdio_client shutdown reworked"](https://py.sdk.modelcontextprotocol.io/v2/migration/#stdio_client-shutdown-reworked-a-gracefully-exited-servers-children-are-left-alive-on-posix) + +The `stdio_client` context manager signature is the same: `stdio_client(server_params) → TransportStreams`. Only shutdown behavior on POSIX changed (children left alive). **No code changes needed** for CPEX. + +### 2.10 `ClientSession` constructor changed +**Guide section:** ["ClientSession now runs on JSONRPCDispatcher"](https://py.sdk.modelcontextprotocol.io/v2/migration/#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed) + +`ClientSession` now takes `(read_stream, write_stream, ...)` as the first two positional args. This matches the v1 `ClientSession(read, write)` usage — **no change needed** for the constructor call, only the stream types are internally different. + +The session is still used as an async context manager: `async with ClientSession(read, write) as session:`. + +### 2.11 `TransportSecuritySettings` still at same path +`from mcp.server.transport_security import TransportSecuritySettings` — **unchanged**. + +### 2.12 Field names camelCase → snake_case +**Guide section:** ["Field names changed from camelCase to snake_case"](https://py.sdk.modelcontextprotocol.io/v2/migration/#field-names-changed-from-camelcase-to-snake_case) + +CPEX does not access `isError`, `nextCursor`, `inputSchema` etc. on MCP types. **No impact.** + +### 2.13 `Client` defaults to `mode='auto'` +**Guide section:** ["Client defaults to mode='auto'"](https://py.sdk.modelcontextprotocol.io/v2/migration/#client-defaults-to-modeauto) + +CPEX does not use the high-level `Client` class (uses `ClientSession` directly). **No impact.** + +--- + +## 3. Migration Order + +Following the [Suggested migration order](https://py.sdk.modelcontextprotocol.io/v2/migration/#suggested-migration-order) from the guide, adapted to CPEX: + +### Phase 1 — Mechanical import renames (low risk, all files) +1. `from mcp.types import X` → `from mcp_types import X` (client.py, all tests) +2. `from mcp import McpError` → `from mcp import MCPError` (client.py, test_client_reconnect.py) +3. `from mcp.server.fastmcp import FastMCP` → `from mcp.server.mcpserver import MCPServer` (runtime.py) + +### Phase 2 — Server surface (runtime.py) +4. Rename `SSLCapableFastMCP` → `SSLCapableMCPServer` (or keep name, change base class) +5. Remove `host`/`port` from `super().__init__()` kwargs +6. Replace `self.settings.host` / `self.settings.port` with `self.server_config.host` / `self.server_config.port` in `run_streamable_http_async()` and `_start_health_check_server()` +7. Update docstrings referencing "FastMCP" + +### Phase 3 — Client transport (client.py) — highest risk +8. Replace `streamablehttp_client` import with `streamable_http_client` (and optionally `StreamableHTTPTransport`) +9. Replace `httpx_client_factory` pattern with pre-built `httpx.AsyncClient` instance +10. Update 3-tuple unpacking `(read, write, get_session_id)` → 2-tuple `(read_stream, write_stream)` +11. Replace `_get_session_id` callback with `StreamableHTTPTransport.session_id` access +12. Remove `__terminate_http_session()` — use `terminate_on_close=True` +13. Remove `MCP_SESSION_ID_HEADER` lazy import (no longer needed for termination) +14. Update `McpError` → `MCPError` in catch block + +### Phase 4 — Tests +15. `test_client_reconnect.py`: `McpError(ErrorData(...))` → `MCPError(code, message)` +16. `test_client_config.py`: `mcp.types` → `mcp_types` +17. `test_client_coverage.py`: `mcp.types` → `mcp_types`, update mock paths for `streamablehttp_client` → `streamable_http_client` +18. `test_client_stdio.py`: verify top-level `mcp` re-exports still work (they should) + +### Phase 5 — Server tests +19. `test_runtime.py`, `test_runtime_coverage.py`, `test_server.py`: update `FastMCP` → `MCPServer` references + +### Phase 6 — Verification +20. Run full test suite +21. Smoke test stdio + streamable HTTP external plugin flows + +--- + +## 4. Detailed Change Specifications + +### 4.1 `cpex/framework/external/mcp/client.py` + +#### 4.1.1 Import block (lines 24-27) + +```python +# BEFORE (v1): +from mcp import ClientSession, McpError, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client +from mcp.types import TextContent + +# AFTER (v2): +from mcp import ClientSession, MCPError, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamable_http_client, StreamableHTTPTransport +from mcp_types import TextContent +``` + +#### 4.1.2 `__connect_to_http_server` — factory → pre-built client + +The current pattern builds a factory function, then calls `streamablehttp_client(uri, httpx_client_factory=factory, terminate_on_close=False)`. The v2 `streamable_http_client` takes `http_client: httpx.AsyncClient | None` — a **pre-built** client instance. + +```python +# BEFORE (v1) — lines ~380-398: +streamable_client = streamablehttp_client( + uri, httpx_client_factory=client_factory, terminate_on_close=False +) +http_transport = await self._exit_stack.enter_async_context(streamable_client) +self._http, self._write, get_session_id = http_transport # 3-tuple +self._get_session_id = get_session_id +self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) + +# AFTER (v2): +http_client_instance = _tls_httpx_client_factory() +streamable_client = streamable_http_client( + uri, http_client=http_client_instance, terminate_on_close=True +) +http_transport = await self._exit_stack.enter_async_context(streamable_client) +self._http, self._write = http_transport # 2-tuple (TransportStreams) +self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) +``` + +Key decisions: +- **`terminate_on_close=True`**: Let the SDK send the DELETE on session close. This replaces the manual `__terminate_http_session()` entirely. +- **Session ID retrieval**: Store a reference to the `StreamableHTTPTransport` (accessible via the transport's `.session_id` property). Option A: create the transport explicitly and wrap it. Option B: access the transport through the context manager. The simplest approach: store the transport as an instance attribute and read `.session_id` from it after initialize(). + +#### 4.1.3 Session ID tracking + +The v1 code stored `get_session_id` callback and called `self._get_session_id()` after initialize. In v2, the `StreamableHTTPTransport` instance has a `.session_id` property populated by the transport during the initialize POST response. + +Approach: Create the transport explicitly, keep a reference, use `terminate_on_close=True`: + +```python +transport = StreamableHTTPTransport(uri) +# ... but streamable_http_client creates its own transport internally +``` + +Alternative: Use `streamable_http_client` as a context manager and access the transport from the exit stack. The cleanest approach is to build the client, create the transport manually, and use the transport directly with `ClientSession`: + +```python +transport = StreamableHTTPTransport(uri) +async with streamable_http_client(uri, http_client=http_client_instance, terminate_on_close=True) as (read_stream, write_stream): + self._session = ClientSession(read_stream, write_stream) + await self._session.initialize() + self._session_id = transport.session_id # populated after initialize +``` + +However, `streamable_http_client` creates its own internal `StreamableHTTPTransport`. To access `session_id`, we'd need to either: +1. **Use the high-level `Client` class** which exposes session info +2. **Create the transport manually** and use it as a transport (it's an async context manager itself) +3. **Store the session ID from the response headers** in a custom way + +**Recommended approach:** Since `StreamableHTTPTransport` is itself an async context manager yielding `TransportStreams`, and the `streamable_http_client` is a convenience wrapper, we can construct the transport directly for full control. However, this is complex (the transport needs a task group, httpx client, etc.). + +**Pragmatic approach for CPEX:** The `streamable_http_client` yields `(read_stream, write_stream)`. The internal transport's `session_id` is extracted from the POST response headers during `initialize()`. After `session.initialize()`, we can get the session ID by making a lightweight HTTP GET and reading the `mcp-session-id` header, OR we can simply not track session ID at all since `terminate_on_close=True` handles cleanup. + +**Best pragmatic approach:** Keep `_session_id` tracking by using the `MCP_SESSION_ID_HEADER` constant and reading it from a diagnostic request, OR accept that `terminate_on_close=True` eliminates the need for manual session tracking. Given that `_session_id` is only used in `__terminate_http_session` (which we're removing), **we can drop session ID tracking entirely** and remove `_get_session_id`, `_session_id`, and `__terminate_http_session`. + +#### 4.1.4 Remove `__terminate_http_session` and related state + +Remove: +- `__terminate_http_session()` method (lines 646-663) +- `self._get_session_id` instance attribute +- `self._session_id` instance attribute +- `self._http_client_factory` instance attribute (no longer needed — client is built inline) +- The `MCP_SESSION_ID_HEADER` lazy import (line 651) +- `shutdown()` calls to `__terminate_http_session` (line 641) +- Cleanup of `_get_session_id`, `_session_id`, `_http_client_factory` in `_cleanup_session` and `shutdown` + +#### 4.1.5 `McpError` → `MCPError` (line 573) + +```python +# BEFORE: +except McpError as e: + logger.warning("McpError for plugin %s: %s", self.name, e) + +# AFTER: +except MCPError as e: + logger.warning("MCPError for plugin %s: %s", self.name, e) +``` + +#### 4.1.6 Stdio transport — no changes needed + +`stdio_client` signature and return type are compatible. `StdioServerParameters` is still exported from `mcp` top-level. + +### 4.2 `cpex/framework/external/mcp/server/runtime.py` + +#### 4.2.1 Import block (lines 69-70) + +```python +# BEFORE: +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings + +# AFTER: +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings +``` + +#### 4.2.2 Class rename and base class + +```python +# BEFORE: +class SSLCapableFastMCP(FastMCP): + +# AFTER: +class SSLCapableFastMCP(MCPServer): +``` + +(Keep the class name `SSLCapableFastMCP` to minimize ripple effects in tests/docstrings, or rename to `SSLCapableMCPServer` — either is fine. Recommend renaming for clarity.) + +#### 4.2.3 Constructor — remove host/port from super() kwargs + +```python +# BEFORE (lines ~224-248): +if "host" not in kwargs: + kwargs["host"] = self.server_config.host +if "port" not in kwargs: + kwargs["port"] = self.server_config.port +# ... transport_security setup ... +super().__init__(*args, **kwargs) + +# AFTER: +if self.server_config.uds and kwargs.get("transport_security") is None: + kwargs["transport_security"] = TransportSecuritySettings(...) + +# Remove host/port from kwargs before passing to MCPServer +kwargs.pop("host", None) +kwargs.pop("port", None) +super().__init__(*args, **kwargs) +``` + +Actually, the cleaner approach: just don't inject them at all: + +```python +def __init__(self, server_config: MCPServerConfig, *args, **kwargs): + self.server_config = server_config + + if self.server_config.uds and kwargs.get("transport_security") is None: + kwargs["transport_security"] = TransportSecuritySettings(...) + + # MCPServer v2 does not accept host/port in constructor + super().__init__(*args, **kwargs) +``` + +#### 4.2.4 `self.settings.host` / `self.settings.port` → `self.server_config.*` + +The `MCPServer.Settings` class no longer has `host`/`port` fields. CPEX's `run_streamable_http_async()` override uses `self.settings.host` and `self.settings.port` extensively: + +- Line 364: `host=self.settings.host` (health check uvicorn) +- Line 365: `port=health_port` (health check port) +- Line 366: `logger.info(f"Starting HTTP health check server on {self.settings.host}:{health_port}")` +- Line 439-443: `host=self.settings.host`, `port=self.settings.port` (main uvicorn) +- Line 451: `logger.info(f"Starting plugin server on {self.settings.host}:{self.settings.port}")` +- Line 459: `health_port = self.settings.port + 1000` + +Replace all `self.settings.host` → `self.server_config.host` +Replace all `self.settings.port` → `self.server_config.port` + +Also: `self.settings.log_level` (line 444) — the `MCPServer.Settings` still has `log_level`. This is fine, no change. + +#### 4.2.5 `run()` function — FastMCP instantiation (line 528-531) + +```python +# BEFORE: +mcp = FastMCP( + name=MCP_SERVER_NAME, + instructions=MCP_SERVER_INSTRUCTIONS, +) + +# AFTER: +mcp = MCPServer( + name=MCP_SERVER_NAME, + instructions=MCP_SERVER_INSTRUCTIONS, +) +``` + +#### 4.2.6 Doctest strings + +Update all docstring references to "FastMCP" → "MCPServer" throughout the file. The docstrings at lines 8-55 reference `FastMCP` by name and in example code blocks. + +#### 4.2.7 `run_stdio_async()` call (line 542) + +`MCPServer.run_stdio_async()` exists with the same signature. No change needed. + +#### 4.2.8 `streamable_http_app()` call (line 388) + +`MCPServer.streamable_http_app()` exists. CPEX calls it without args: `self.streamable_http_app()`. In v2 this method accepts keyword args but has defaults. **No change needed.** + +### 4.3 Test files + +#### 4.3.1 `test_client_reconnect.py` (lines 216-227, 295-296) + +```python +# BEFORE: +from mcp import McpError +from mcp.types import ErrorData +raise McpError(ErrorData(code=-1, message="Connection lost")) + +# AFTER: +from mcp import MCPError +raise MCPError(-1, "Connection lost") +``` + +Also update `from mcp.types import CallToolResult, TextContent` → `from mcp_types import CallToolResult, TextContent` (lines 226, 253). + +#### 4.3.2 `test_client_config.py` (lines 19-20) + +```python +# BEFORE: +from mcp.types import CallToolResult +from mcp.types import TextContent as MCPTextContent + +# AFTER: +from mcp_types import CallToolResult +from mcp_types import TextContent as MCPTextContent +``` + +#### 4.3.3 `test_client_coverage.py` (line 13) + +```python +# BEFORE: +from mcp.types import TextContent + +# AFTER: +from mcp_types import TextContent +``` + +Update mock paths: +- `patch("cpex.framework.external.mcp.client.streamablehttp_client", ...)` → `patch("cpex.framework.external.mcp.client.streamable_http_client", ...)` +- Mock return values change from 3-tuple to 2-tuple `(read_stream, write_stream)` + +#### 4.3.4 `test_client_stdio.py` (lines 21-22) + +```python +# These are fine — ClientSession, StdioServerParameters, stdio_client are all still +# exported from the same top-level paths in v2: +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +``` + +**No changes needed** — the top-level `mcp` package re-exports `ClientSession`, `StdioServerParameters`, and `stdio_client`. + +--- + +## 5. Risk Assessment + +| Risk | Severity | Mitigation | +|---|---|---| +| `streamable_http_client` internal behavior differs (task groups, SSE handling) | **High** | The transport is a black box; integration tests are mandatory after migration | +| Session ID tracking removal — TLS session termination may fail silently | **Medium** | `terminate_on_close=True` handles this; the SDK sends DELETE with session ID from the transport's internal state | +| `MCPServer` `Settings` class differs — `self.settings.host` returns undefined | **High** | Replace with `self.server_config.host` — this is a deterministic find-and-replace | +| Test mocks for `streamablehttp_client` break | **Medium** | Update mock paths and return value shapes | +| `stdio_client` POSIX shutdown behavior change | **Low** | Only affects child process cleanup on graceful exit; doesn't affect CPEX functionality | +| `MCPError` attribute access (`e.message` vs `e.error.message`) | **Low** | CPEX only logs `e` (the exception repr), doesn't access `.error.message` directly | + +--- + +## 6. Dependencies + +The `pyproject.toml` already pins: +```toml + "mcp==2.0.0b2", + "mcp-types==2.0.0b2", +``` + +Per the [Dependency floors section](https://py.sdk.modelcontextprotocol.io/v2/migration/#dependency-floors-raised-and-new-required-dependencies), these new floors apply: +- `anyio>=4.9` (Python <3.14) — check if CPEX pins below this +- `pydantic>=2.12` — CPEX pins `pydantic>=2.12.5` ✅ +- `sse-starlette>=3.0.0` — CPEX does not directly depend on this; it's a transitive dep of `mcp` +- `typing-extensions>=4.13.0` — transitive +- `opentelemetry-api>=1.28.0` — **new required dep**, transitive from `mcp` + +No action needed on `pyproject.toml` — the pins are already correct. + +--- + +## 7. Execution Plan + +| Step | File | Action | Estimated complexity | +|---|---|---|---| +| 1 | `client.py` | Import renames | Trivial | +| 2 | `client.py` | HTTP transport rewrite (factory→instance, 3-tuple→2-tuple, remove termination) | **Complex** | +| 3 | `client.py` | Remove session ID tracking | Moderate | +| 4 | `runtime.py` | Import + class rename | Trivial | +| 5 | `runtime.py` | Constructor host/port removal | Moderate | +| 6 | `runtime.py` | `self.settings.host/port` → `self.server_config.host/port` | Moderate | +| 7 | `runtime.py` | Doctest updates | Trivial | +| 8 | `test_client_reconnect.py` | MCPError + mcp_types | Moderate | +| 9 | `test_client_config.py` | mcp_types | Trivial | +| 10 | `test_client_coverage.py` | mcp_types + mock paths | Moderate | +| 11 | All | Run tests, fix failures | Variable | + +**Recommended dispatch:** Steps 1-3 (client.py) and steps 4-7 (runtime.py) are independent and can be done in parallel by two `@fixer` agents. Steps 8-10 (tests) depend on the source files being correct and should follow. Step 11 is the orchestrator's responsibility. + +--- + +## 8. Migration Guide References + +All changes reference these sections of the [MCP Python SDK v2 Migration Guide](https://py.sdk.modelcontextprotocol.io/v2/migration/): + +| CPEX change | Guide anchor | +|---|---| +| `mcp.types` → `mcp_types` | [`#mcptypes-moved-to-the-mcp-types-package`](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcptypes-moved-to-the-mcp-types-package) | +| `McpError` → `MCPError` | [`#mcperror-renamed-to-mcperror`](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcperror-renamed-to-mcperror) | +| `streamablehttp_client` → `streamable_http_client` | [`#streamablehttp_client-removed`](https://py.sdk.modelcontextprotocol.io/v2/migration/#streamablehttp_client-removed) | +| `get_session_id` callback removed | [`#get_session_id-callback-removed-from-streamable_http_client`](https://py.sdk.modelcontextprotocol.io/v2/migration/#get_session_id-callback-removed-from-streamable_http_client) | +| `FastMCP` → `MCPServer` | [`#fastmcp-renamed-to-mcpserver`](https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver) | +| host/port moved from constructor | [`#transport-specific-parameters-moved-from-mcpserver-constructor-to-run-app-methods`](https://py.sdk.modelcontextprotocol.io/v2/migration/#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) | +| Constructor param order | [`#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters`](https://py.sdk.modelcontextprotocol.io/v2/migration/#mcpserver-constructor-title-description-and-version-added-to-the-positional-parameters) | +| Dependency floors | [`#dependency-floors-raised-and-new-required-dependencies`](https://py.sdk.modelcontextprotocol.io/v2/migration/#dependency-floors-raised-and-new-required-dependencies) | +| camelCase → snake_case fields | [`#field-names-changed-from-camelcase-to-snake_case`](https://py.sdk.modelcontextprotocol.io/v2/migration/#field-names-changed-from-camelcase-to-snake_case) | +| stdio_client shutdown | [`#stdio_client-shutdown-reworked`](https://py.sdk.modelcontextprotocol.io/v2/migration/#stdio_client-shutdown-reworked-a-gracefully-exited-servers-children-are-left-alive-on-posix) | +| ClientSession on JSONRPCDispatcher | [`#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed`](https://py.sdk.modelcontextprotocol.io/v2/migration/#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed) | diff --git a/cpex/framework/external/mcp/client.py b/cpex/framework/external/mcp/client.py index 9b410792..8e4c7a1b 100644 --- a/cpex/framework/external/mcp/client.py +++ b/cpex/framework/external/mcp/client.py @@ -19,12 +19,12 @@ from typing import Any, Awaitable, Callable, Optional # Third-Party -import httpx +import httpx2 import orjson -from mcp import ClientSession, McpError, StdioServerParameters +from mcp import ClientSession, MCPError, StdioServerParameters from mcp.client.stdio import stdio_client -from mcp.client.streamable_http import streamablehttp_client -from mcp.types import TextContent +from mcp.client.streamable_http import streamable_http_client +from mcp_types import TextContent # First-Party from cpex.framework.base import HookRef, Plugin, PluginRef @@ -82,9 +82,6 @@ def __init__(self, config: PluginConfig) -> None: self._stdio_ready: Optional[asyncio.Event] = None self._stdio_stop: Optional[asyncio.Event] = None self._stdio_error: Optional[BaseException] = None - self._get_session_id: Optional[Callable[[], str | None]] = None - self._session_id: Optional[str] = None - self._http_client_factory: Optional[Callable[..., httpx.AsyncClient]] = None self._reconnect_attempts: int = 3 self._reconnect_delay: float = 0.1 self._reconnect_lock: asyncio.Lock = asyncio.Lock() @@ -318,10 +315,10 @@ async def __connect_to_http_server(self, uri: str) -> None: def _tls_httpx_client_factory( headers: Optional[dict[str, str]] = None, - timeout: Optional[httpx.Timeout] = None, - auth: Optional[httpx.Auth] = None, - ) -> httpx.AsyncClient: - """Build an httpx client with TLS configuration for external MCP servers. + timeout: Optional[httpx2.Timeout] = None, + auth: Optional[httpx2.Auth] = None, + ) -> httpx2.AsyncClient: + """Build an httpx2 client with TLS configuration for external MCP servers. Args: headers: Optional HTTP headers to include in requests. @@ -337,14 +334,14 @@ def _tls_httpx_client_factory( kwargs: dict[str, Any] = {"follow_redirects": True} if uds_path: - kwargs["transport"] = httpx.AsyncHTTPTransport(uds=uds_path) + kwargs["transport"] = httpx2.AsyncHTTPTransport(uds=uds_path) if headers: kwargs["headers"] = headers http_settings = get_http_client_settings() kwargs["timeout"] = ( timeout if timeout - else httpx.Timeout( + else httpx2.Timeout( connect=http_settings.httpx_connect_timeout, read=http_settings.httpx_read_timeout, write=http_settings.httpx_write_timeout, @@ -355,7 +352,7 @@ def _tls_httpx_client_factory( kwargs["auth"] = auth # Add connection pool limits - kwargs["limits"] = httpx.Limits( + kwargs["limits"] = httpx2.Limits( max_connections=http_settings.httpx_max_connections, max_keepalive_connections=http_settings.httpx_max_keepalive_connections, keepalive_expiry=http_settings.httpx_keepalive_expiry, @@ -364,32 +361,34 @@ def _tls_httpx_client_factory( if not tls_config: # Use skip_ssl_verify setting when no custom TLS config kwargs["verify"] = not http_settings.skip_ssl_verify - return httpx.AsyncClient(**kwargs) + return httpx2.AsyncClient(**kwargs) # Create SSL context using the utility function # This implements certificate validation per test_client_certificate_validation.py ssl_context = create_ssl_context(tls_config, self.name) kwargs["verify"] = ssl_context - return httpx.AsyncClient(**kwargs) + return httpx2.AsyncClient(**kwargs) - self._http_client_factory = _tls_httpx_client_factory max_retries = 3 base_delay = 1.0 for attempt in range(max_retries): try: - client_factory = _tls_httpx_client_factory - streamable_client = streamablehttp_client( - uri, httpx_client_factory=client_factory, terminate_on_close=False + http_client = _tls_httpx_client_factory() + # The v2 SDK does not close a caller-provided http_client, so we own its + # lifecycle. Enter it into the exit stack first (before the transport) so + # LIFO teardown closes the transport — firing the terminate_on_close DELETE + # while the client is still open — and then closes the client itself. + await self._exit_stack.enter_async_context(http_client) + streamable_client = streamable_http_client( + uri, http_client=http_client, terminate_on_close=True ) http_transport = await self._exit_stack.enter_async_context(streamable_client) - self._http, self._write, get_session_id = http_transport - self._get_session_id = get_session_id + self._http, self._write = http_transport self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) await self._session.initialize() - self._session_id = self._get_session_id() if self._get_session_id else None response = await self._session.list_tools() tools = response.tools logger.info( @@ -446,8 +445,6 @@ async def _cleanup_session(self) -> None: self._http = None self._write = None self._stdio = None - self._get_session_id = None - self._session_id = None async def _reconnect_session(self) -> None: """Tear down old session and reconnect to MCP server with linear backoff. @@ -570,8 +567,8 @@ async def _execute_call() -> PluginResult: ) from reconn_err logger.exception(pe) raise - except McpError as e: - logger.warning("McpError for plugin %s: %s", self.name, e) + except MCPError as e: + logger.warning("MCPError for plugin %s: %s", self.name, e) try: async with self._reconnect_lock: await self._reconnect_session() @@ -637,30 +634,6 @@ async def shutdown(self) -> None: if self._exit_stack: await self._exit_stack.aclose() - if self._config and self._config.mcp and self._config.mcp.proto == TransportType.STREAMABLEHTTP: - await self.__terminate_http_session() - self._get_session_id = None - self._session_id = None - self._http_client_factory = None - - async def __terminate_http_session(self) -> None: - """Terminate streamable HTTP session explicitly to avoid lingering server state.""" - if not self._session_id or not self._config or not self._config.mcp or not self._config.mcp.url: - return - # Third-Party - from mcp.server.streamable_http import MCP_SESSION_ID_HEADER # pylint: disable=import-outside-toplevel - - client_factory = self._http_client_factory - try: - if client_factory: - client = client_factory() - else: - client = httpx.AsyncClient(follow_redirects=True) - async with client: - headers = {MCP_SESSION_ID_HEADER: self._session_id} - await client.delete(self._config.mcp.url, headers=headers) - except Exception as exc: - logger.debug("Failed to terminate streamable HTTP session: %s", exc) class ExternalHookRef(HookRef): diff --git a/cpex/framework/external/mcp/server/runtime.py b/cpex/framework/external/mcp/server/runtime.py index 5bd592fc..97fc5341 100755 --- a/cpex/framework/external/mcp/server/runtime.py +++ b/cpex/framework/external/mcp/server/runtime.py @@ -5,31 +5,31 @@ SPDX-License-Identifier: Apache-2.0 Authors: Fred Araujo, Teryl Taylor -MCP Plugin Runtime using FastMCP with SSL/TLS support. +MCP Plugin Runtime using MCPServer with SSL/TLS support. This runtime does the following: -- Uses FastMCP from the MCP Python SDK +- Uses MCPServer from the MCP Python SDK - Supports both mTLS and non-mTLS configurations - Reads configuration from PLUGINS_SERVER_* environment variables or uses configurations the plugin config.yaml - Implements all plugin hook tools (get_plugin_configs, tool_pre_invoke, etc.) Examples: - Create an SSL-capable FastMCP server: + Create an SSL-capable MCPServer: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="localhost", port=8000) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") - >>> server.settings.host + >>> server = SSLCapableMCPServer(server_config=config, name="TestServer") + >>> server.server_config.host 'localhost' - >>> server.settings.port + >>> server.server_config.port 8000 Check SSL configuration returns empty dict when TLS is not configured: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="127.0.0.1", port=8000, tls=None) - >>> server = SSLCapableFastMCP(server_config=config, name="NoTLSServer") + >>> server = SSLCapableMCPServer(server_config=config, name="NoTLSServer") >>> ssl_config = server._get_ssl_config() >>> ssl_config {} @@ -38,20 +38,20 @@ >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="localhost", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="ConfigTest") + >>> server = SSLCapableMCPServer(server_config=config, name="ConfigTest") >>> server.server_config.host 'localhost' >>> server.server_config.port 9000 - Settings are properly passed to FastMCP: + Settings are properly passed to MCPServer: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="0.0.0.0", port=8080) - >>> server = SSLCapableFastMCP(server_config=config, name="SettingsTest") - >>> server.settings.host + >>> server = SSLCapableMCPServer(server_config=config, name="SettingsTest") + >>> server.server_config.host '0.0.0.0' - >>> server.settings.port + >>> server.server_config.port 8080 """ @@ -66,7 +66,7 @@ # Third-Party from fastapi import Response, status -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.server.transport_security import TransportSecuritySettings from prometheus_client import REGISTRY, Gauge, generate_latest @@ -185,33 +185,33 @@ async def invoke_hook(hook_type: str, plugin_name: str, payload: Dict[str, Any], return await SERVER.invoke_hook(hook_type, plugin_name, payload, context) -class SSLCapableFastMCP(FastMCP): - """FastMCP server with SSL/TLS support using MCPServerConfig. +class SSLCapableMCPServer(MCPServer): + """MCPServer with SSL/TLS support using MCPServerConfig. Examples: - Create an SSL-capable FastMCP server: + Create an SSL-capable MCPServer: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="127.0.0.1", port=8000) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") - >>> server.settings.host + >>> server = SSLCapableMCPServer(server_config=config, name="TestServer") + >>> server.server_config.host '127.0.0.1' - >>> server.settings.port + >>> server.server_config.port 8000 """ def __init__(self, server_config: MCPServerConfig, *args, **kwargs): - """Initialize an SSL capable Fast MCP server. + """Initialize an SSL capable MCPServer. Args: server_config: the MCP server configuration including mTLS information. - *args: Additional positional arguments passed to FastMCP. - **kwargs: Additional keyword arguments passed to FastMCP. + *args: Additional positional arguments passed to MCPServer. + **kwargs: Additional keyword arguments passed to MCPServer. Examples: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="0.0.0.0", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="PluginServer") + >>> server = SSLCapableMCPServer(server_config=config, name="PluginServer") >>> server.server_config.host '0.0.0.0' >>> server.server_config.port @@ -220,13 +220,14 @@ def __init__(self, server_config: MCPServerConfig, *args, **kwargs): # Load server config from environment self.server_config = server_config - # Override FastMCP settings with our server config - if "host" not in kwargs: - kwargs["host"] = self.server_config.host - if "port" not in kwargs: - kwargs["port"] = self.server_config.port - if self.server_config.uds and kwargs.get("transport_security") is None: - kwargs["transport_security"] = TransportSecuritySettings( + # MCPServer v2 does not accept host/port/transport_security in __init__; + # transport_security is passed to streamable_http_app(), host/port to run methods. + kwargs.pop("host", None) + kwargs.pop("port", None) + + transport_security = kwargs.pop("transport_security", None) + if self.server_config.uds and transport_security is None: + transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=[ "127.0.0.1", @@ -245,6 +246,7 @@ def __init__(self, server_config: MCPServerConfig, *args, **kwargs): "http://[::1]:*", ], ) + self._transport_security = transport_security super().__init__(*args, **kwargs) @@ -257,7 +259,7 @@ def _get_ssl_config(self) -> dict: Examples: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="127.0.0.1", port=8000, tls=None) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") + >>> server = SSLCapableMCPServer(server_config=config, name="TestServer") >>> ssl_config = server._get_ssl_config() >>> ssl_config {} @@ -361,10 +363,10 @@ async def metrics_disabled(): # Create a minimal Starlette app with only the health endpoint health_app = Starlette(routes=routes) - logger.info(f"Starting HTTP health check server on {self.settings.host}:{health_port}") + logger.info(f"Starting HTTP health check server on {self.server_config.host}:{health_port}") config = uvicorn.Config( app=health_app, - host=self.settings.host, + host=self.server_config.host, port=health_port, log_level="warning", # Reduce noise from health checks ) @@ -379,13 +381,16 @@ async def run_streamable_http_async(self) -> None: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="0.0.0.0", port=9000) - >>> server = SSLCapableFastMCP(server_config=config, name="HTTPServer") - >>> server.settings.host + >>> server = SSLCapableMCPServer(server_config=config, name="HTTPServer") + >>> server.server_config.host '0.0.0.0' - >>> server.settings.port + >>> server.server_config.port 9000 """ - starlette_app = self.streamable_http_app() + starlette_app = self.streamable_http_app( + transport_security=self._transport_security, + host=self.server_config.host, + ) # Add health check endpoint to main app # Third-Party @@ -438,8 +443,8 @@ async def metrics_disabled(): ssl_config = self._get_ssl_config() config_kwargs = { "app": starlette_app, - "host": self.settings.host, - "port": self.settings.port, + "host": self.server_config.host, + "port": self.server_config.port, "log_level": self.settings.log_level.lower(), } config_kwargs.update(ssl_config) @@ -450,13 +455,13 @@ async def metrics_disabled(): config_kwargs["uds"] = self.server_config.uds logger.info(f"Starting plugin server on unix socket {self.server_config.uds}") else: - logger.info(f"Starting plugin server on {self.settings.host}:{self.settings.port}") + logger.info(f"Starting plugin server on {self.server_config.host}:{self.server_config.port}") config = uvicorn.Config(**config_kwargs) # type: ignore[arg-type] server = uvicorn.Server(config) # If SSL is enabled, start a separate HTTP health check server if ssl_config and not self.server_config.uds: - health_port = self.settings.port + 1000 # Use port+1000 for health checks + health_port = self.server_config.port + 1000 # Use port+1000 for health checks logger.info(f"SSL enabled - starting separate HTTP health check on port {health_port}") # Run both servers concurrently await asyncio.gather(server.serve(), self._start_health_check_server(health_port)) @@ -466,7 +471,7 @@ async def metrics_disabled(): async def run() -> None: - """Run the external plugin server with FastMCP. + """Run the external plugin server with MCPServer. Supports both stdio and HTTP transports. Auto-detects transport based on stdin (if stdin is not a TTY, uses stdio mode), or you can explicitly set PLUGINS_TRANSPORT. @@ -491,7 +496,7 @@ async def run() -> None: >>> SERVER is None True - FastMCP server names are defined as constants: + MCPServer names are defined as constants: >>> from cpex.framework.constants import MCP_SERVER_NAME >>> isinstance(MCP_SERVER_NAME, str) @@ -524,13 +529,13 @@ async def run() -> None: try: if transport == "stdio": - # Create basic FastMCP server for stdio (no SSL support needed for stdio) - mcp = FastMCP( + # Create basic MCPServer for stdio (no SSL support needed for stdio) + mcp = MCPServer( name=MCP_SERVER_NAME, instructions=MCP_SERVER_INSTRUCTIONS, ) - # Register module-level tool functions with FastMCP + # Register module-level tool functions with MCPServer mcp.tool(name=GET_PLUGIN_CONFIGS)(get_plugin_configs) mcp.tool(name=GET_PLUGIN_CONFIG)(get_plugin_config) mcp.tool(name=INVOKE_HOOK)(invoke_hook) @@ -538,19 +543,19 @@ async def run() -> None: PLUGIN_INFO.labels(server_name=MCP_SERVER_NAME, transport="stdio", ssl_enabled="false").set(1) # Run with stdio transport - logger.info("Starting MCP plugin server with FastMCP (stdio transport)") + logger.info("Starting MCP plugin server with MCPServer (stdio transport)") await mcp.run_stdio_async() else: # http or streamablehttp server_config: MCPServerConfig = SERVER.get_server_config() - # Create FastMCP server with SSL support - mcp = SSLCapableFastMCP( + # Create MCPServer with SSL support + mcp = SSLCapableMCPServer( server_config, name=MCP_SERVER_NAME, instructions=MCP_SERVER_INSTRUCTIONS, ) - # Register module-level tool functions with FastMCP + # Register module-level tool functions with MCPServer mcp.tool(name=GET_PLUGIN_CONFIGS)(get_plugin_configs) mcp.tool(name=GET_PLUGIN_CONFIG)(get_plugin_config) mcp.tool(name=INVOKE_HOOK)(invoke_hook) @@ -564,7 +569,7 @@ async def run() -> None: f"Prometheus metrics available at http://{server_config.host}:{server_config.port}/metrics/prometheus" ) # Run with streamable-http transport - logger.info("Starting MCP plugin server with FastMCP (HTTP transport)") + logger.info("Starting MCP plugin server with MCPServer (HTTP transport)") await mcp.run_streamable_http_async() except Exception: diff --git a/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml b/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml index 87928ea8..2ebc0ac7 100644 --- a/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml +++ b/cpex/templates/external/{{cookiecutter.plugin_slug}}/pyproject.toml @@ -44,7 +44,8 @@ authors = [ ] dependencies = [ - "mcp>=1.16.0", + "mcp==2.0.0b1", + "mcp-types==2.0.0b1", "mcp-contextforge-gateway", ] diff --git a/pyproject.toml b/pyproject.toml index fe1c324a..50df58a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ dependencies = [ "httpx>=0.28.1", "httpx[http2]>=0.28.1", "jinja2>=3.1.6", - "mcp>=1.26.0", + "mcp==2.0.0b2", + "mcp-types==2.0.0b2", "orjson>=3.11.7", "prometheus-fastapi-instrumentator>=7.1.0", "prometheus_client>=0.24.1", diff --git a/tests/unit/cpex/framework/external/mcp/server/test_runtime.py b/tests/unit/cpex/framework/external/mcp/server/test_runtime.py index 6173ccd6..bf1a17ff 100644 --- a/tests/unit/cpex/framework/external/mcp/server/test_runtime.py +++ b/tests/unit/cpex/framework/external/mcp/server/test_runtime.py @@ -198,9 +198,9 @@ def test_ssl_config_with_tls(tmp_path): ), ) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config - ssl_config = runtime.SSLCapableFastMCP._get_ssl_config(server) + ssl_config = runtime.SSLCapableMCPServer._get_ssl_config(server) assert ssl_config["ssl_keyfile"] == str(key_path) assert ssl_config["ssl_certfile"] == str(cert_path) @@ -210,7 +210,8 @@ def test_ssl_config_with_tls(tmp_path): @pytest.mark.asyncio async def test_start_health_check_server(monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) + server.server_config = SimpleNamespace(host="127.0.0.1", port=8000, uds=None, tls=None) server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") served = MagicMock() @@ -225,7 +226,7 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) + await runtime.SSLCapableMCPServer._start_health_check_server(server, 9000) served.assert_called_once() @@ -233,12 +234,13 @@ async def serve(self): async def test_run_streamable_http_async_with_ssl(monkeypatch): from cpex.framework.models import MCPServerConfig - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = MCPServerConfig(host="127.0.0.1", port=8000) + server._transport_security = None server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) + server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {"ssl_keyfile": "/tmp/key.pem"}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {"ssl_keyfile": "/tmp/key.pem"}) monkeypatch.setattr(server, "_start_health_check_server", AsyncMock()) served = MagicMock() @@ -253,7 +255,7 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) assert server._start_health_check_server.await_count == 1 served.assert_called_once() @@ -287,7 +289,7 @@ async def run_stdio_async(self): created["ran_stdio"] = True monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) + monkeypatch.setattr(runtime, "MCPServer", DummyFastMCP) monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") try: @@ -331,7 +333,7 @@ async def run_stdio_async(self): settings.cache_clear() monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) + monkeypatch.setattr(runtime, "MCPServer", DummyFastMCP) monkeypatch.setenv("PLUGINS_SERVER_PORT", "abc") monkeypatch.setenv("PLUGINS_TRANSPORT", "stdio") @@ -374,7 +376,7 @@ async def run_streamable_http_async(self): created["ran_http"] = True monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) + monkeypatch.setattr(runtime, "SSLCapableMCPServer", DummyMCP) monkeypatch.setenv("PLUGINS_TRANSPORT", "http") try: diff --git a/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py b/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py index b600a321..731ea2ad 100644 --- a/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py +++ b/tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py @@ -27,27 +27,70 @@ async def test_get_plugin_config_requires_server(self, monkeypatch): # =========================================================================== -# SSLCapableFastMCP __init__ +# SSLCapableMCPServer __init__ # =========================================================================== -class TestSSLCapableFastMCPInit: +class TestSSLCapableMCPServerInit: def test_kwargs_override_host_port(self): config = MCPServerConfig(host="0.0.0.0", port=9000) - server = runtime.SSLCapableFastMCP( + server = runtime.SSLCapableMCPServer( server_config=config, name="Test", host="custom_host", port=1234, ) - assert server.settings.host == "custom_host" - assert server.settings.port == 1234 + # MCPServer v2 ignores host/port kwargs; values come from server_config + assert server.server_config.host == "0.0.0.0" + assert server.server_config.port == 9000 def test_uds_sets_transport_security(self, tmp_path): uds_path = str(tmp_path / "plugin.sock") config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) - server = runtime.SSLCapableFastMCP(server_config=config, name="UDSTest") + server = runtime.SSLCapableMCPServer(server_config=config, name="UDSTest") assert server.server_config.uds == uds_path + # UDS servers get DNS rebinding protection auto-configured in __init__. + assert server._transport_security is not None + assert server._transport_security.enable_dns_rebinding_protection is True + + def test_non_uds_leaves_transport_security_unset(self): + config = MCPServerConfig(host="0.0.0.0", port=8000) + server = runtime.SSLCapableMCPServer(server_config=config, name="NonUDSTest") + # Non-UDS servers rely on the SDK/host handling, not the UDS auto-config. + assert server._transport_security is None + + @pytest.mark.asyncio + async def test_run_streamable_http_async_forwards_transport_security(self, tmp_path, monkeypatch): + """run_streamable_http_async must forward _transport_security to streamable_http_app().""" + uds_path = str(tmp_path / "plugin.sock") + config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) + server = runtime.SSLCapableMCPServer(server_config=config, name="ForwardTest") + + captured = {} + + def capture_app(**kwargs): + captured.update(kwargs) + return SimpleNamespace(routes=[]) + + server.streamable_http_app = capture_app + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) + + class DummyServer: + def __init__(self, config): + self.config = config + + async def serve(self): + pass + + monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) + monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) + + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) + + assert captured["transport_security"] is server._transport_security + assert captured["transport_security"].enable_dns_rebinding_protection is True + # Real bind host is forwarded so the SDK does not force a localhost-only allowlist. + assert captured["host"] == "127.0.0.1" def test_ssl_config_partial_tls_warns(self, tmp_path, caplog): """TLS present but no keyfile/certfile returns empty dict + warning.""" @@ -57,7 +100,7 @@ def test_ssl_config_partial_tls_warns(self, tmp_path, caplog): # Create a config object then patch tls to have certfile but no keyfile config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config # Manually set tls with no keyfile and no certfile @@ -92,10 +135,10 @@ def test_ssl_config_tls_without_ca_or_password(self, tmp_path): ), ) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config - ssl_config = runtime.SSLCapableFastMCP._get_ssl_config(server) + ssl_config = runtime.SSLCapableMCPServer._get_ssl_config(server) assert ssl_config["ssl_keyfile"] == str(key_path) assert ssl_config["ssl_certfile"] == str(cert_path) assert "ssl_ca_certs" not in ssl_config @@ -113,12 +156,13 @@ async def test_with_uds(self, tmp_path, monkeypatch): uds_path = str(tmp_path / "plugin.sock") config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) + server._transport_security = None + server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) served = MagicMock() @@ -139,7 +183,7 @@ def capture_config(**kwargs): monkeypatch.setattr(runtime.uvicorn, "Config", capture_config) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) served.assert_called_once() assert configs_seen[0].get("uds") == uds_path @@ -150,12 +194,13 @@ def capture_config(**kwargs): async def test_no_ssl(self, monkeypatch): config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) + server._transport_security = None + server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) served = MagicMock() @@ -169,22 +214,23 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) served.assert_called_once() @pytest.mark.asyncio async def test_metrics_disabled(self, monkeypatch): config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") routes_added = [] app = SimpleNamespace(routes=routes_added) - server.streamable_http_app = lambda: app + server._transport_security = None + server.streamable_http_app = lambda **kwargs: app - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) monkeypatch.setenv("ENABLE_METRICS", "false") served = MagicMock() @@ -199,7 +245,7 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) served.assert_called_once() # Verify routes were added (health + metrics_disabled) assert len(routes_added) >= 2 @@ -213,7 +259,8 @@ async def serve(self): class TestStartHealthCheckServerEndpoints: @pytest.mark.asyncio async def test_metrics_enabled_executes_health_and_metrics_endpoints(self, monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) + server.server_config = SimpleNamespace(host="127.0.0.1", port=8000, uds=None, tls=None) server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") monkeypatch.setenv("ENABLE_METRICS", "true") @@ -236,13 +283,14 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) + await runtime.SSLCapableMCPServer._start_health_check_server(server, 9000) assert called["health"] is True assert called["metrics"] is True @pytest.mark.asyncio async def test_metrics_disabled_executes_metrics_disabled_endpoint(self, monkeypatch): - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) + server.server_config = SimpleNamespace(host="127.0.0.1", port=8000, uds=None, tls=None) server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") monkeypatch.setenv("ENABLE_METRICS", "false") @@ -265,7 +313,7 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) + await runtime.SSLCapableMCPServer._start_health_check_server(server, 9000) assert called["disabled"] is True @@ -278,13 +326,14 @@ class TestRunStreamableHTTPAsyncEndpoints: @pytest.mark.asyncio async def test_metrics_enabled_executes_routes(self, monkeypatch): config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) + server._transport_security = None + server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setenv("ENABLE_METRICS", "true") - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) called = {"health": False, "metrics": False} @@ -302,20 +351,21 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) assert called["health"] is True assert called["metrics"] is True @pytest.mark.asyncio async def test_metrics_disabled_executes_route(self, monkeypatch): config = MCPServerConfig(host="127.0.0.1", port=8000) - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") - server.streamable_http_app = lambda: SimpleNamespace(routes=[]) + server._transport_security = None + server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setenv("ENABLE_METRICS", "false") - monkeypatch.setattr(runtime.SSLCapableFastMCP, "_get_ssl_config", lambda self: {}) + monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) called = {"disabled": False} @@ -335,7 +385,7 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP.run_streamable_http_async(server) + await runtime.SSLCapableMCPServer.run_streamable_http_async(server) assert called["disabled"] is True @@ -385,7 +435,7 @@ async def run_stdio_async(self): created["ran_stdio"] = True monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "FastMCP", DummyFastMCP) + monkeypatch.setattr(runtime, "MCPServer", DummyFastMCP) monkeypatch.delenv("PLUGINS_TRANSPORT", raising=False) monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: False)) @@ -423,7 +473,7 @@ async def run_streamable_http_async(self): created["ran_http"] = True monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) + monkeypatch.setattr(runtime, "SSLCapableMCPServer", DummyMCP) monkeypatch.delenv("PLUGINS_TRANSPORT", raising=False) monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True)) @@ -463,7 +513,7 @@ async def run_streamable_http_async(self): raise RuntimeError("server crashed") monkeypatch.setattr(runtime, "ExternalPluginServer", lambda: DummyServer()) - monkeypatch.setattr(runtime, "SSLCapableFastMCP", DummyMCP) + monkeypatch.setattr(runtime, "SSLCapableMCPServer", DummyMCP) monkeypatch.setenv("PLUGINS_TRANSPORT", "http") with pytest.raises(RuntimeError, match="server crashed"): @@ -474,7 +524,8 @@ async def run_streamable_http_async(self): @pytest.mark.asyncio async def test_health_check_metrics_disabled(self, monkeypatch): """Test _start_health_check_server with ENABLE_METRICS=false.""" - server = object.__new__(runtime.SSLCapableFastMCP) + server = object.__new__(runtime.SSLCapableMCPServer) + server.server_config = SimpleNamespace(host="127.0.0.1", port=8000, uds=None, tls=None) server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") monkeypatch.setenv("ENABLE_METRICS", "false") @@ -491,5 +542,5 @@ async def serve(self): monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config)) - await runtime.SSLCapableFastMCP._start_health_check_server(server, 9000) + await runtime.SSLCapableMCPServer._start_health_check_server(server, 9000) served.assert_called_once() diff --git a/tests/unit/cpex/framework/external/mcp/test_client_config.py b/tests/unit/cpex/framework/external/mcp/test_client_config.py index 5b05a353..18e63458 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_config.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_config.py @@ -16,8 +16,8 @@ # Third-Party import pytest -from mcp.types import CallToolResult -from mcp.types import TextContent as MCPTextContent +from mcp_types import CallToolResult +from mcp_types import TextContent as MCPTextContent # First-Party from cpex.framework import ( diff --git a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py index d3b43f1d..ab0beb49 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py @@ -7,10 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch # Third-Party -import httpx import orjson import pytest -from mcp.types import TextContent +from mcp_types import TextContent # First-Party from cpex.framework.base import PluginRef @@ -193,8 +192,7 @@ async def __aenter__(self): raise ConnectionError("refused") read = AsyncMock() write = AsyncMock() - get_session_id = MagicMock(return_value="sid") - return read, write, get_session_id + return read, write async def __aexit__(self, *args): pass @@ -209,7 +207,7 @@ def mock_streamable(*args, **kwargs): mock_session.list_tools = AsyncMock(return_value=list_tools_result) with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", side_effect=mock_streamable), + patch("cpex.framework.external.mcp.client.streamable_http_client", side_effect=mock_streamable), patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), ): @@ -231,13 +229,48 @@ def mock_streamable(*args, **kwargs): return MockCtx() with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", side_effect=mock_streamable), + patch("cpex.framework.external.mcp.client.streamable_http_client", side_effect=mock_streamable), patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), ): plugin._exit_stack = AsyncExitStack() with pytest.raises(PluginError, match="connection failed after 3 attempts"): await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") + @pytest.mark.asyncio + async def test_streamable_client_called_with_prebuilt_client_and_terminate_on_close(self): + """v2 SDK contract: streamable_http_client receives a pre-built httpx client and + terminate_on_close=True (replacing the removed manual __terminate_http_session).""" + plugin = _make_plugin() + + class OkCtx: + async def __aenter__(self): + return AsyncMock(), AsyncMock() + + async def __aexit__(self, *args): + return False + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + list_tools_result = MagicMock() + list_tools_result.tools = [] + mock_session.list_tools = AsyncMock(return_value=list_tools_result) + + prebuilt_client = AsyncMock(spec=httpx.AsyncClient) + + with ( + patch("cpex.framework.external.mcp.client.streamable_http_client", return_value=OkCtx()) as mock_streamable, + patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), + patch("cpex.framework.external.mcp.client.httpx.AsyncClient", return_value=prebuilt_client), + ): + plugin._exit_stack = AsyncExitStack() + await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") + + mock_streamable.assert_called_once() + _, kwargs = mock_streamable.call_args + assert kwargs["terminate_on_close"] is True + # A pre-built AsyncClient instance is passed, not a factory callable (v2 API change). + assert kwargs["http_client"] is prebuilt_client + # =========================================================================== # Shutdown @@ -288,47 +321,6 @@ async def raise_error(): assert plugin._stdio_task is None -# =========================================================================== -# Terminate HTTP session -# =========================================================================== - - -class TestTerminateHTTPSession: - @pytest.mark.asyncio - async def test_no_session_id_returns(self): - plugin = _make_plugin() - plugin._session_id = None - # Should return early without error - await plugin._ExternalPlugin__terminate_http_session() - - @pytest.mark.asyncio - async def test_with_factory(self): - plugin = _make_plugin() - plugin._session_id = "test-session" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - plugin._http_client_factory = MagicMock(return_value=mock_client) - - await plugin._ExternalPlugin__terminate_http_session() - mock_client.delete.assert_called_once() - - @pytest.mark.asyncio - async def test_no_factory(self): - plugin = _make_plugin() - plugin._session_id = "test-session" - plugin._http_client_factory = None - - with patch("cpex.framework.external.mcp.client.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_client - - await plugin._ExternalPlugin__terminate_http_session() - mock_client.delete.assert_called_once() - - # =========================================================================== # Command Resolution # =========================================================================== @@ -444,7 +436,7 @@ async def __aexit__(self, *args): # Mock the connection to fail immediately so we can check the warning with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", return_value=FailCtx()), + patch("cpex.framework.external.mcp.client.streamable_http_client", return_value=FailCtx()), patch("cpex.framework.external.mcp.client.asyncio.sleep", new_callable=AsyncMock), pytest.raises(PluginError), ): @@ -570,8 +562,7 @@ class OkCtx: async def __aenter__(self): read = AsyncMock() write = AsyncMock() - get_session_id = MagicMock(return_value="sid") - return read, write, get_session_id + return read, write async def __aexit__(self, *args): return False @@ -592,23 +583,18 @@ async def __aexit__(self, *args): mock_http_settings.skip_ssl_verify = False with ( - patch("cpex.framework.external.mcp.client.streamablehttp_client", return_value=OkCtx()), + patch("cpex.framework.external.mcp.client.streamable_http_client", return_value=OkCtx()), patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session), patch("cpex.framework.external.mcp.client.create_ssl_context", return_value="sslctx"), - patch("cpex.framework.external.mcp.client.httpx.AsyncClient") as mock_httpx, + patch("cpex.framework.external.mcp.client.httpx2.AsyncClient") as mock_httpx, patch("cpex.framework.external.mcp.client.get_http_client_settings", return_value=mock_http_settings), ): plugin._exit_stack = AsyncExitStack() await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") - assert plugin._http_client_factory is not None - plugin._http_client_factory(headers={"x-test": "1"}, auth=httpx.BasicAuth("u", "p")) - assert mock_httpx.call_count >= 1 _, kwargs = mock_httpx.call_args - assert kwargs["headers"]["x-test"] == "1" - assert kwargs["auth"] is not None - assert kwargs["verify"] == "sslctx" + assert kwargs.get("verify") == "sslctx" class TestGetPluginConfig: @@ -730,7 +716,6 @@ async def test_shutdown_cleans_stdio_state_even_when_proto_is_http(self): plugin._stdio_stop.set = MagicMock() plugin._stdio_ready = MagicMock() plugin._exit_stack = None - plugin._session_id = None await plugin.shutdown() assert plugin._stdio_task is None @@ -884,19 +869,3 @@ async def test_connect_http_range_empty_exits_loop(self): await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp") -class TestTerminateHTTPSessionErrors: - @pytest.mark.asyncio - async def test_terminate_http_session_delete_failure_is_swallowed(self, caplog): - plugin = _make_plugin() - plugin._session_id = "sid" - - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.delete = AsyncMock(side_effect=RuntimeError("delete failed")) - - plugin._http_client_factory = MagicMock(return_value=mock_client) - with caplog.at_level("DEBUG", logger="cpex.framework.external.mcp.client"): - await plugin._ExternalPlugin__terminate_http_session() - - assert any("Failed to terminate streamable HTTP session" in r.message for r in caplog.records) diff --git a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py b/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py index 5de38f28..7ad27072 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py @@ -99,8 +99,6 @@ async def test_cleanup_session_resets_all_state(self, mock_http_plugin_config): plugin._http = MagicMock() plugin._write = MagicMock() plugin._stdio = MagicMock() - plugin._get_session_id = MagicMock() - plugin._session_id = "test-session-id" plugin._exit_stack = AsyncMock() plugin._stdio_exit_stack = AsyncMock() @@ -110,8 +108,6 @@ async def test_cleanup_session_resets_all_state(self, mock_http_plugin_config): assert plugin._http is None assert plugin._write is None assert plugin._stdio is None - assert plugin._get_session_id is None - assert plugin._session_id is None @pytest.mark.asyncio async def test_cleanup_session_closes_exit_stacks(self, mock_http_plugin_config): @@ -213,8 +209,7 @@ async def test_invoke_hook_reconnects_on_mcp_error(self, mock_http_plugin_config mock_session = AsyncMock() plugin._session = mock_session - from mcp import McpError - from mcp.types import ErrorData + from mcp import MCPError call_count = 0 @@ -222,8 +217,8 @@ async def mock_call_tool(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: - raise McpError(ErrorData(code=-1, message="Connection lost")) - from mcp.types import CallToolResult, TextContent + raise MCPError(-1, "Connection lost") + from mcp_types import CallToolResult, TextContent return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')]) @@ -250,7 +245,7 @@ async def mock_call_tool(*args, **kwargs): call_count += 1 if call_count == 1: raise PluginError(error=PluginErrorModel(message="Session terminated", plugin_name="TestHTTPPlugin")) - from mcp.types import CallToolResult, TextContent + from mcp_types import CallToolResult, TextContent return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')]) @@ -263,6 +258,8 @@ async def mock_call_tool(*args, **kwargs): result = await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) mock_reconnect.assert_called_once() assert result is not None + # The call is retried after reconnect: first raises, second succeeds. + assert call_count == 2 @pytest.mark.asyncio async def test_invoke_hook_no_reconnect_on_other_plugin_errors(self, mock_http_plugin_config, mock_plugin_context): @@ -292,11 +289,10 @@ async def test_invoke_hook_reconnect_failure_raises_original_error( mock_session = AsyncMock() plugin._session = mock_session - from mcp import McpError - from mcp.types import ErrorData + from mcp import MCPError async def mock_call_tool(*args, **kwargs): - raise McpError(ErrorData(code=-1, message="Connection lost")) + raise MCPError(-1, "Connection lost") mock_session.call_tool = mock_call_tool diff --git a/uv.lock b/uv.lock index 9140fb10..3a337d10 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", ] [[package]] @@ -472,6 +473,7 @@ dependencies = [ { name = "inquirer" }, { name = "jinja2" }, { name = "mcp" }, + { name = "mcp-types" }, { name = "orjson" }, { name = "packaging" }, { name = "prometheus-client" }, @@ -535,7 +537,8 @@ requires-dist = [ { name = "inquirer", specifier = ">=3.4.1" }, { name = "interrogate", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "jinja2", specifier = ">=3.1.6" }, - { name = "mcp", specifier = ">=1.26.0" }, + { name = "mcp", specifier = "==2.0.0b2" }, + { name = "mcp-types", specifier = "==2.0.0b2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18.2" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "packaging", specifier = ">=26.0" }, @@ -816,6 +819,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -837,12 +853,19 @@ http2 = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, ] [[package]] @@ -868,11 +891,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -880,7 +903,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.15'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1213,13 +1236,14 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -1231,9 +1255,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, ] [[package]] @@ -1347,6 +1384,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/7c/19cd0671d1ba2762fb388fc149697d20d0568ccfeef833b11280a619e526/nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b", size = 611069, upload-time = "2026-04-25T10:44:14.934Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -2323,6 +2372,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/bb/fbdc4e57731efb86b4209ffdd2519520782bf27b3c961beac3e5c20d2b87/trove_classifiers-2026.4.28.13-py3-none-any.whl", hash = "sha256:8f4b1eb4e16296b57d612965444f87a83861cc989a0451ac97fe4265ddef03b8", size = 14216, upload-time = "2026-04-28T13:45:39.943Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "twine" version = "6.2.0"