From a8fa6db8f7d3ec54e291fad0d16de00370d35cf8 Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sat, 4 Jul 2026 00:39:44 +0100 Subject: [PATCH 1/5] refactor(mcp): migrate from MCP SDK v1 to v2.0.0b1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes applied: - streamablehttp_client → streamable_http_client (renamed import) - McpError → MCPError (exception renamed) - mcp.types → mcp_types (split into separate package) - FastMCP → MCPServer (base class renamed) - SSLCapableFastMCP → SSLCapableMCPServer - MCPServer.__init__ no longer accepts host/port/transport_security; these are passed to run methods and streamable_http_app() instead - streamable_http_client returns 2-tuple (read, write) instead of 3-tuple; session ID tracking and manual termination removed (terminate_on_close=True) - Remove _http_client_factory; use pre-built httpx.AsyncClient directly - Replace self.settings.host/port with self.server_config.host/port Deprecations removed: - __terminate_http_session(), _session_id, _get_session_id, _http_client_factory Tests: 190 passing --- .gitignore | 3 +- MIGRATION_MCP_V2.md | 524 ++++++++++++++++++ cpex/framework/external/mcp/client.py | 50 +- cpex/framework/external/mcp/server/runtime.py | 84 +-- pyproject.toml | 3 +- .../external/mcp/server/test_runtime.py | 24 +- .../mcp/server/test_runtime_coverage.py | 80 +-- .../external/mcp/test_client_config.py | 4 +- .../external/mcp/test_client_coverage.py | 80 +-- .../external/mcp/test_client_reconnect.py | 32 +- uv.lock | 42 +- 11 files changed, 688 insertions(+), 238 deletions(-) create mode 100644 MIGRATION_MCP_V2.md 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..5030cdc0 --- /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.0b1`, `mcp-types==2.0.0b1` (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.0b1", +"mcp-types==2.0.0b1", +``` + +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..2fd82945 100644 --- a/cpex/framework/external/mcp/client.py +++ b/cpex/framework/external/mcp/client.py @@ -21,10 +21,10 @@ # Third-Party import httpx 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() @@ -373,23 +370,20 @@ def _tls_httpx_client_factory( return httpx.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_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, 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 +440,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 +562,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 +629,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..755febae 100755 --- a/cpex/framework/external/mcp/server/runtime.py +++ b/cpex/framework/external/mcp/server/runtime.py @@ -5,10 +5,10 @@ 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 @@ -19,7 +19,7 @@ >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="localhost", port=8000) - >>> server = SSLCapableFastMCP(server_config=config, name="TestServer") + >>> server = SSLCapableMCPServer(server_config=config, name="TestServer") >>> server.settings.host 'localhost' >>> server.settings.port @@ -29,7 +29,7 @@ >>> 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,17 +38,17 @@ >>> 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 = SSLCapableMCPServer(server_config=config, name="SettingsTest") >>> server.settings.host '0.0.0.0' >>> server.settings.port @@ -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,15 +185,15 @@ 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 = SSLCapableMCPServer(server_config=config, name="TestServer") >>> server.settings.host '127.0.0.1' >>> server.settings.port @@ -205,13 +205,13 @@ def __init__(self, server_config: MCPServerConfig, *args, **kwargs): 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,13 @@ 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 = SSLCapableMCPServer(server_config=config, name="HTTPServer") >>> server.settings.host '0.0.0.0' >>> server.settings.port 9000 """ - starlette_app = self.streamable_http_app() + starlette_app = self.streamable_http_app(transport_security=getattr(self, '_transport_security', None)) # Add health check endpoint to main app # Third-Party @@ -438,8 +440,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 +452,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 +468,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 +493,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 +526,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 +540,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 +566,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/pyproject.toml b/pyproject.toml index fe1c324a..f0c006fb 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.0b1", + "mcp-types==2.0.0b1", "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..f88a8a65 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,26 +27,27 @@ 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 def test_ssl_config_partial_tls_warns(self, tmp_path, caplog): @@ -57,7 +58,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 +93,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 +114,12 @@ 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.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 +140,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 +151,12 @@ 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.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 +170,22 @@ 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.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 +200,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 +214,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 +238,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 +268,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 +281,13 @@ 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.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 +305,20 @@ 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.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 +338,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 +388,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 +426,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 +466,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 +477,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 +495,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..0c89ebbc 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py @@ -10,7 +10,7 @@ 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 +193,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 +208,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,7 +230,7 @@ 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() @@ -288,47 +287,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 +402,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 +528,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,7 +549,7 @@ 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, @@ -601,14 +558,9 @@ async def __aexit__(self, *args): 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: @@ -884,19 +836,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..15df6f7c 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,8 @@ 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 + from mcp_types import ErrorData call_count = 0 @@ -222,8 +218,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,22 +246,9 @@ 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": {}}}')]) - - mock_session.call_tool = mock_call_tool - - with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: - mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload - with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect: - payload = ToolPreInvokePayload(name="test", args={}) - result = await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context) - mock_reconnect.assert_called_once() - assert result is not None - - @pytest.mark.asyncio - async def test_invoke_hook_no_reconnect_on_other_plugin_errors(self, mock_http_plugin_config, mock_plugin_context): plugin = ExternalPlugin(mock_http_plugin_config) mock_session = AsyncMock() plugin._session = mock_session @@ -292,11 +275,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..d5aaf6d1 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.0b1" }, + { name = "mcp-types", specifier = "==2.0.0b1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18.2" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "packaging", specifier = ">=26.0" }, @@ -880,7 +883,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 +1216,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "2.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -1231,9 +1236,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/d6/78/9f540a2f8f673973c9dee7977d020a0c10b97be3a6417a833bc3af166811/mcp-2.0.0b1.tar.gz", hash = "sha256:f0bb4543507117f872613fc76bd7b73cc2a8f6ddd9426f0402f268447f21b260", size = 1478434, upload-time = "2026-06-30T23:24:38.363Z" } 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/ac/bb/9c5c4e428e63d40b5542cfc9c61a0438943ccb260bf9929dc02c47527235/mcp-2.0.0b1-py3-none-any.whl", hash = "sha256:7e169929da99487b1998f8f53548bb93ff5157165f2f5a10fb45202c02a91fce", size = 320414, upload-time = "2026-06-30T23:24:34.788Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/88/f3e4322a5bfc382f8851e584f2e2c87354467dc17fb602dd4b13dee65b7c/mcp_types-2.0.0b1.tar.gz", hash = "sha256:6a26910a737c4cd4de36c7d5629febe7d33d4556a9d196166b5c7187a6516944", size = 65785, upload-time = "2026-06-30T23:24:39.796Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/05/2da79c73dd07d028163c2b24d4cc6e988da11dff83f7902a07d69a87df80/mcp_types-2.0.0b1-py3-none-any.whl", hash = "sha256:c1b22b56b0ba7b1d51c84dc99afdd603ae225c55e9e8f07477774552bf34c8cd", size = 68945, upload-time = "2026-06-30T23:24:36.671Z" }, ] [[package]] @@ -1347,6 +1365,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" From 7a72951cc7f29787d04240670b1151a7022b5fea Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 13 Jul 2026 18:37:13 -0400 Subject: [PATCH 2/5] chore(mcp): apply review fixes from PR #112 - Fix spurious whitespace in runtime.py f-strings and dict values - Replace broken server.settings.host/port doctests with server.server_config.* - Simplify getattr(_transport_security) to direct attribute access - Update stale "Fast MCP server" docstring and class Examples indentation - Update scaffold template to mcp==2.0.0b1 and mcp-types==2.0.0b1 - Remove unused ErrorData import in test_client_reconnect.py - Remove dead plugin._session_id = None test setup - Rename http_client_instance to http_client Signed-off-by: Frederico Araujo --- cpex/framework/external/mcp/client.py | 4 +-- cpex/framework/external/mcp/server/runtime.py | 36 +++++++++---------- .../pyproject.toml | 3 +- .../external/mcp/test_client_coverage.py | 1 - .../external/mcp/test_client_reconnect.py | 1 - 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/cpex/framework/external/mcp/client.py b/cpex/framework/external/mcp/client.py index 2fd82945..57baa95c 100644 --- a/cpex/framework/external/mcp/client.py +++ b/cpex/framework/external/mcp/client.py @@ -375,9 +375,9 @@ def _tls_httpx_client_factory( for attempt in range(max_retries): try: - http_client_instance = _tls_httpx_client_factory() + http_client = _tls_httpx_client_factory() streamable_client = streamable_http_client( - uri, http_client=http_client_instance, terminate_on_close=True + uri, http_client=http_client, terminate_on_close=True ) http_transport = await self._exit_stack.enter_async_context(streamable_client) self._http, self._write = http_transport diff --git a/cpex/framework/external/mcp/server/runtime.py b/cpex/framework/external/mcp/server/runtime.py index 755febae..94650e27 100755 --- a/cpex/framework/external/mcp/server/runtime.py +++ b/cpex/framework/external/mcp/server/runtime.py @@ -15,14 +15,14 @@ - 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 = SSLCapableMCPServer(server_config=config, name="TestServer") - >>> server.settings.host + >>> server.server_config.host 'localhost' - >>> server.settings.port + >>> server.server_config.port 8000 Check SSL configuration returns empty dict when TLS is not configured: @@ -49,9 +49,9 @@ >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="0.0.0.0", port=8080) >>> server = SSLCapableMCPServer(server_config=config, name="SettingsTest") - >>> server.settings.host + >>> server.server_config.host '0.0.0.0' - >>> server.settings.port + >>> server.server_config.port 8080 """ @@ -189,19 +189,19 @@ class SSLCapableMCPServer(MCPServer): """MCPServer with SSL/TLS support using MCPServerConfig. Examples: - Create an SSL-capable MCPServer: + Create an SSL-capable MCPServer: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="127.0.0.1", port=8000) >>> server = SSLCapableMCPServer(server_config=config, name="TestServer") - >>> server.settings.host + >>> 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. @@ -363,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.server_config.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.server_config.host, + host=self.server_config.host, port=health_port, log_level="warning", # Reduce noise from health checks ) @@ -382,12 +382,12 @@ async def run_streamable_http_async(self) -> None: >>> from cpex.framework.models import MCPServerConfig >>> config = MCPServerConfig(host="0.0.0.0", port=9000) >>> server = SSLCapableMCPServer(server_config=config, name="HTTPServer") - >>> server.settings.host + >>> server.server_config.host '0.0.0.0' - >>> server.settings.port + >>> server.server_config.port 9000 """ - starlette_app = self.streamable_http_app(transport_security=getattr(self, '_transport_security', None)) + starlette_app = self.streamable_http_app(transport_security=self._transport_security) # Add health check endpoint to main app # Third-Party @@ -440,8 +440,8 @@ async def metrics_disabled(): ssl_config = self._get_ssl_config() config_kwargs = { "app": starlette_app, - "host": self.server_config.host, - "port": self.server_config.port, + "host": self.server_config.host, + "port": self.server_config.port, "log_level": self.settings.log_level.lower(), } config_kwargs.update(ssl_config) @@ -452,13 +452,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.server_config.host}:{ self.server_config.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.server_config.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)) 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/tests/unit/cpex/framework/external/mcp/test_client_coverage.py b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py index 0c89ebbc..5a08bc92 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py @@ -682,7 +682,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 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 15df6f7c..de4f0bca 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py @@ -210,7 +210,6 @@ async def test_invoke_hook_reconnects_on_mcp_error(self, mock_http_plugin_config plugin._session = mock_session from mcp import MCPError - from mcp_types import ErrorData call_count = 0 From 58acdbb188320275f36848d3b92389e7ae736fa8 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 13 Jul 2026 18:44:30 -0400 Subject: [PATCH 3/5] fix(mcp): pass bind host to streamable_http_app and close caller-owned http client - runtime.py: pass host=server_config.host to streamable_http_app(). Without it the v2 SDK defaults host to 127.0.0.1 and auto-applies a localhost-only DNS rebinding allowlist when transport_security is None, causing HTTP 421 on every non-localhost request for servers bound to 0.0.0.0. - client.py: enter the caller-provided httpx.AsyncClient into the exit stack. The v2 SDK does not close a caller-provided client, so it leaked on every connection attempt. Entered before the transport so LIFO teardown fires the terminate_on_close DELETE while the client is still open. - test_runtime_coverage.py: set _transport_security on object.__new__ servers that bypass __init__, now required after dropping the getattr fallback. Signed-off-by: Frederico Araujo --- cpex/framework/external/mcp/client.py | 5 +++++ cpex/framework/external/mcp/server/runtime.py | 5 ++++- .../framework/external/mcp/server/test_runtime_coverage.py | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cpex/framework/external/mcp/client.py b/cpex/framework/external/mcp/client.py index 57baa95c..98408dd3 100644 --- a/cpex/framework/external/mcp/client.py +++ b/cpex/framework/external/mcp/client.py @@ -376,6 +376,11 @@ def _tls_httpx_client_factory( for attempt in range(max_retries): try: 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 ) diff --git a/cpex/framework/external/mcp/server/runtime.py b/cpex/framework/external/mcp/server/runtime.py index 94650e27..97fc5341 100755 --- a/cpex/framework/external/mcp/server/runtime.py +++ b/cpex/framework/external/mcp/server/runtime.py @@ -387,7 +387,10 @@ async def run_streamable_http_async(self) -> None: >>> server.server_config.port 9000 """ - starlette_app = self.streamable_http_app(transport_security=self._transport_security) + 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 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 f88a8a65..938c0720 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 @@ -117,6 +117,7 @@ async def test_with_uds(self, tmp_path, monkeypatch): server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") + server._transport_security = None server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) @@ -154,6 +155,7 @@ async def test_no_ssl(self, monkeypatch): server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="info") + server._transport_security = None server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) @@ -183,6 +185,7 @@ async def test_metrics_disabled(self, monkeypatch): routes_added = [] app = SimpleNamespace(routes=routes_added) + server._transport_security = None server.streamable_http_app = lambda **kwargs: app monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {}) @@ -284,6 +287,7 @@ async def test_metrics_enabled_executes_routes(self, monkeypatch): server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") + server._transport_security = None server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setenv("ENABLE_METRICS", "true") @@ -315,6 +319,7 @@ async def test_metrics_disabled_executes_route(self, monkeypatch): server = object.__new__(runtime.SSLCapableMCPServer) server.server_config = config server.settings = SimpleNamespace(host="127.0.0.1", port=8000, log_level="INFO") + server._transport_security = None server.streamable_http_app = lambda **kwargs: SimpleNamespace(routes=[]) monkeypatch.setenv("ENABLE_METRICS", "false") From eac9437188734a72a2277177de87bca62d2badac Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 13 Jul 2026 18:50:26 -0400 Subject: [PATCH 4/5] test(mcp): cover v2 reconnect, transport wiring, and session security - Split test_invoke_hook_reconnects_on_session_terminated: the session-terminated success path (reconnect then retry succeeds) was dead code shadowed by a second plugin setup. Restore it as a real assertion and re-add the separate no-reconnect-on-other-errors test that had been merged away. - Assert streamable_http_client is called with a pre-built http_client instance and terminate_on_close=True (the v2 API change and DELETE-on-close behavior that replaced the removed __terminate_http_session). - Assert UDS servers populate _transport_security with DNS rebinding protection, non-UDS servers leave it unset, and run_streamable_http_async forwards both transport_security and the real bind host to streamable_http_app(). Signed-off-by: Frederico Araujo --- .../mcp/server/test_runtime_coverage.py | 42 +++++++++++++++++++ .../external/mcp/test_client_coverage.py | 35 ++++++++++++++++ .../external/mcp/test_client_reconnect.py | 15 +++++++ 3 files changed, 92 insertions(+) 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 938c0720..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 @@ -49,6 +49,48 @@ def test_uds_sets_transport_security(self, tmp_path): config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path) 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.""" 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 5a08bc92..956276fd 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_coverage.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py @@ -237,6 +237,41 @@ def mock_streamable(*args, **kwargs): 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 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 de4f0bca..7ad27072 100644 --- a/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py +++ b/tests/unit/cpex/framework/external/mcp/test_client_reconnect.py @@ -248,6 +248,21 @@ async def mock_call_tool(*args, **kwargs): from mcp_types import CallToolResult, TextContent return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')]) + + mock_session.call_tool = mock_call_tool + + with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry: + mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload + with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect: + payload = ToolPreInvokePayload(name="test", args={}) + 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): plugin = ExternalPlugin(mock_http_plugin_config) mock_session = AsyncMock() plugin._session = mock_session From 8078691f02325315ace661a5225cd4b8714d4c7c Mon Sep 17 00:00:00 2001 From: Jonathan Springer Date: Sun, 19 Jul 2026 16:10:35 +0100 Subject: [PATCH 5/5] Upgrade mcp SDK from 2.0.0b1 to 2.0.0b2 The b2 release replaces httpx with httpx2 internally. Update the MCP transport client factory in client.py to use httpx2.AsyncClient and related types. Adjust the corresponding test mock path accordingly. --- MIGRATION_MCP_V2.md | 6 +- cpex/framework/external/mcp/client.py | 20 +++--- pyproject.toml | 4 +- .../external/mcp/test_client_coverage.py | 3 +- uv.lock | 62 ++++++++++++++----- 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/MIGRATION_MCP_V2.md b/MIGRATION_MCP_V2.md index 5030cdc0..8efab40f 100644 --- a/MIGRATION_MCP_V2.md +++ b/MIGRATION_MCP_V2.md @@ -1,7 +1,7 @@ # CPEX MCP SDK v1 → v2 Migration Strategy **Date:** 2025-07-03 -**Target SDK:** `mcp==2.0.0b1`, `mcp-types==2.0.0b1` (already pinned in `pyproject.toml`) +**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/) --- @@ -470,8 +470,8 @@ from mcp.client.stdio import stdio_client The `pyproject.toml` already pins: ```toml -"mcp==2.0.0b1", -"mcp-types==2.0.0b1", + "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: diff --git a/cpex/framework/external/mcp/client.py b/cpex/framework/external/mcp/client.py index 98408dd3..8e4c7a1b 100644 --- a/cpex/framework/external/mcp/client.py +++ b/cpex/framework/external/mcp/client.py @@ -19,7 +19,7 @@ from typing import Any, Awaitable, Callable, Optional # Third-Party -import httpx +import httpx2 import orjson from mcp import ClientSession, MCPError, StdioServerParameters from mcp.client.stdio import stdio_client @@ -315,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. @@ -334,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, @@ -352,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, @@ -361,14 +361,14 @@ 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) max_retries = 3 base_delay = 1.0 diff --git a/pyproject.toml b/pyproject.toml index f0c006fb..50df58a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,8 @@ dependencies = [ "httpx>=0.28.1", "httpx[http2]>=0.28.1", "jinja2>=3.1.6", - "mcp==2.0.0b1", - "mcp-types==2.0.0b1", + "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/test_client_coverage.py b/tests/unit/cpex/framework/external/mcp/test_client_coverage.py index 956276fd..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,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch # Third-Party -import httpx import orjson import pytest from mcp_types import TextContent @@ -587,7 +586,7 @@ async def __aexit__(self, *args): 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() diff --git a/uv.lock b/uv.lock index d5aaf6d1..3a337d10 100644 --- a/uv.lock +++ b/uv.lock @@ -537,8 +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 = "==2.0.0b1" }, - { name = "mcp-types", specifier = "==2.0.0b1" }, + { 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" }, @@ -819,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" @@ -840,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]] @@ -871,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]] @@ -1216,12 +1236,11 @@ wheels = [ [[package]] name = "mcp" -version = "2.0.0b1" +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" }, @@ -1236,22 +1255,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/78/9f540a2f8f673973c9dee7977d020a0c10b97be3a6417a833bc3af166811/mcp-2.0.0b1.tar.gz", hash = "sha256:f0bb4543507117f872613fc76bd7b73cc2a8f6ddd9426f0402f268447f21b260", size = 1478434, upload-time = "2026-06-30T23:24:38.363Z" } +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/ac/bb/9c5c4e428e63d40b5542cfc9c61a0438943ccb260bf9929dc02c47527235/mcp-2.0.0b1-py3-none-any.whl", hash = "sha256:7e169929da99487b1998f8f53548bb93ff5157165f2f5a10fb45202c02a91fce", size = 320414, upload-time = "2026-06-30T23:24:34.788Z" }, + { 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.0b1" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/88/f3e4322a5bfc382f8851e584f2e2c87354467dc17fb602dd4b13dee65b7c/mcp_types-2.0.0b1.tar.gz", hash = "sha256:6a26910a737c4cd4de36c7d5629febe7d33d4556a9d196166b5c7187a6516944", size = 65785, upload-time = "2026-06-30T23:24:39.796Z" } +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/3f/05/2da79c73dd07d028163c2b24d4cc6e988da11dff83f7902a07d69a87df80/mcp_types-2.0.0b1-py3-none-any.whl", hash = "sha256:c1b22b56b0ba7b1d51c84dc99afdd603ae225c55e9e8f07477774552bf34c8cd", size = 68945, upload-time = "2026-06-30T23:24:36.671Z" }, + { 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]] @@ -2353,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"