Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"structlog>=24.0",
"mcp>=1.0",
"pyyaml>=6.0",
"httpx>=0.27",
]

[project.scripts]
Expand Down
8 changes: 7 additions & 1 deletion src/nene2/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
105 changes: 105 additions & 0 deletions src/nene2/mcp/http_client.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file added tests/nene2/mcp/__init__.py
Empty file.
91 changes: 91 additions & 0 deletions tests/nene2/mcp/test_http_client.py
Original file line number Diff line number Diff line change
@@ -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"
Loading