diff --git a/pyproject.toml b/pyproject.toml index b1fe379..98705b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "structlog>=24.0", "mcp>=1.0", "pyyaml>=6.0", + "httpx>=0.27", ] [project.scripts] diff --git a/src/nene2/mcp/__init__.py b/src/nene2/mcp/__init__.py index a0c47ed..56c1a04 100644 --- a/src/nene2/mcp/__init__.py +++ b/src/nene2/mcp/__init__.py @@ -1,5 +1,11 @@ """NENE2 MCP integration — expose UseCases as MCP tools.""" +from .http_client import HttpxMcpClient, McpHttpClientProtocol, McpHttpResponse from .server import LocalMcpServer -__all__ = ["LocalMcpServer"] +__all__ = [ + "HttpxMcpClient", + "LocalMcpServer", + "McpHttpClientProtocol", + "McpHttpResponse", +] diff --git a/src/nene2/mcp/http_client.py b/src/nene2/mcp/http_client.py new file mode 100644 index 0000000..77ef338 --- /dev/null +++ b/src/nene2/mcp/http_client.py @@ -0,0 +1,105 @@ +"""MCP HTTP client — equivalent to PHP LocalMcpHttpClientInterface. + +Provides a lightweight HTTP transport for calling a nene2 API from MCP tool handlers. +The default implementation uses httpx; inject a custom McpHttpClientProtocol for tests. +""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +import httpx +from httpx import BaseTransport + + +@dataclass(frozen=True, slots=True) +class McpHttpResponse: + """HTTP response value object returned by McpHttpClientProtocol.""" + + status_code: int + headers: dict[str, str] + body: str + + def is_successful(self) -> bool: + return 200 <= self.status_code < 300 + + def request_id(self) -> str | None: + return self.headers.get("x-request-id") + + +@runtime_checkable +class McpHttpClientProtocol(Protocol): + """Structural contract for MCP HTTP clients.""" + + def get(self, base_url: str, path: str) -> McpHttpResponse: ... + + def post( + self, base_url: str, path: str, body: dict[str, object] + ) -> McpHttpResponse: ... + + def put( + self, base_url: str, path: str, body: dict[str, object] + ) -> McpHttpResponse: ... + + def delete(self, base_url: str, path: str) -> McpHttpResponse: ... + + def has_authentication(self) -> bool: ... + + +class HttpxMcpClient: + """httpx-backed MCP HTTP client with optional Bearer token authentication. + + Pass a custom transport (e.g. httpx.MockTransport or httpx.WSGITransport) + for testing without making real network calls. + """ + + def __init__( + self, + bearer_token: str | None = None, + *, + transport: BaseTransport | None = None, + ) -> None: + self._bearer_token = bearer_token + self._transport = transport + + def get(self, base_url: str, path: str) -> McpHttpResponse: + return self._request("GET", base_url, path, None) + + def post( + self, base_url: str, path: str, body: dict[str, object] + ) -> McpHttpResponse: + return self._request("POST", base_url, path, body) + + def put( + self, base_url: str, path: str, body: dict[str, object] + ) -> McpHttpResponse: + return self._request("PUT", base_url, path, body) + + def delete(self, base_url: str, path: str) -> McpHttpResponse: + return self._request("DELETE", base_url, path, None) + + def has_authentication(self) -> bool: + return self._bearer_token is not None + + def _request( + self, + method: str, + base_url: str, + path: str, + body: dict[str, object] | None, + ) -> McpHttpResponse: + headers: dict[str, str] = {"Accept": "application/json"} + if self._bearer_token is not None: + headers["Authorization"] = f"Bearer {self._bearer_token}" + + with httpx.Client(transport=self._transport) as client: + response = client.request( + method, + base_url.rstrip("/") + path, + json=body, + headers=headers, + ) + return McpHttpResponse( + status_code=response.status_code, + headers=dict(response.headers), + body=response.text, + ) diff --git a/tests/nene2/mcp/__init__.py b/tests/nene2/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nene2/mcp/test_http_client.py b/tests/nene2/mcp/test_http_client.py new file mode 100644 index 0000000..fc982b0 --- /dev/null +++ b/tests/nene2/mcp/test_http_client.py @@ -0,0 +1,91 @@ +"""Tests for McpHttpResponse, McpHttpClientProtocol, and HttpxMcpClient.""" + +import json + +import httpx +import pytest + +from nene2.mcp import HttpxMcpClient, McpHttpClientProtocol, McpHttpResponse + + +def _mock_transport(status: int, body: dict[str, object]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, json=body) + + return httpx.MockTransport(handler) + + +def test_mcp_http_response_is_successful() -> None: + assert McpHttpResponse(200, {}, "ok").is_successful() is True + assert McpHttpResponse(201, {}, "").is_successful() is True + assert McpHttpResponse(299, {}, "").is_successful() is True + + +def test_mcp_http_response_is_not_successful() -> None: + assert McpHttpResponse(400, {}, "").is_successful() is False + assert McpHttpResponse(404, {}, "").is_successful() is False + assert McpHttpResponse(500, {}, "").is_successful() is False + + +def test_mcp_http_response_request_id() -> None: + assert McpHttpResponse(200, {"x-request-id": "abc"}, "").request_id() == "abc" + assert McpHttpResponse(200, {}, "").request_id() is None + + +def test_httpx_mcp_client_satisfies_protocol() -> None: + assert isinstance(HttpxMcpClient(), McpHttpClientProtocol) + + +def test_has_authentication_without_token() -> None: + assert HttpxMcpClient().has_authentication() is False + + +def test_has_authentication_with_token() -> None: + assert HttpxMcpClient("my-token").has_authentication() is True + + +def test_get_request() -> None: + transport = _mock_transport(200, {"id": 1, "title": "note"}) + client = HttpxMcpClient(transport=transport) + response = client.get("http://test", "/notes/1") + assert response.is_successful() + assert json.loads(response.body)["id"] == 1 + + +def test_post_request_sends_body() -> None: + received: list[bytes] = [] + + def handler(request: httpx.Request) -> httpx.Response: + received.append(request.content) + return httpx.Response(201, json={"id": 2}) + + client = HttpxMcpClient(transport=httpx.MockTransport(handler)) + response = client.post("http://test", "/notes", {"title": "t", "body": "b"}) + assert response.status_code == 201 + assert b"title" in received[0] + + +def test_put_request() -> None: + transport = _mock_transport(200, {"id": 1, "title": "updated"}) + client = HttpxMcpClient(transport=transport) + response = client.put("http://test", "/notes/1", {"title": "updated", "body": "b"}) + assert response.is_successful() + + +def test_delete_request() -> None: + transport = _mock_transport(204, {}) + client = HttpxMcpClient(transport=transport) + response = client.delete("http://test", "/notes/1") + assert response.status_code == 204 + + +def test_bearer_token_added_to_header() -> None: + received_headers: list[dict[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + received_headers.append(dict(request.headers)) + return httpx.Response(200, json={}) + + client = HttpxMcpClient("secret-token", transport=httpx.MockTransport(handler)) + client.get("http://test", "/notes") + assert received_headers[0].get("authorization") == "Bearer secret-token"