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"