214 lines
7.4 KiB
Python
214 lines
7.4 KiB
Python
"""Integration tests for the MCP proxy endpoint ``POST /v1/sessions/{id}/mcp``.
|
|
|
|
Covers JSON-RPC validation, method routing, and error paths that do
|
|
not require a live runner connection. The MCP endpoint proxies
|
|
JSON-RPC requests to the runner's MCP servers; these tests exercise
|
|
the validation layer and error handling on the AP server side.
|
|
|
|
Uses the shared ``client`` fixture from ``tests/server/conftest.py``
|
|
which wires a real ``RunnerRouter`` with an empty tunnel registry,
|
|
so the endpoint is reachable and runner-dependent methods return the
|
|
"no runner bound" application error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests.server.helpers import create_test_session
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────
|
|
|
|
|
|
def _jsonrpc(
|
|
method: str,
|
|
rpc_id: int = 1,
|
|
params: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a JSON-RPC 2.0 request envelope.
|
|
|
|
:param method: JSON-RPC method name, e.g. ``"tools/list"``.
|
|
:param rpc_id: Request id echoed in the response, e.g. ``1``.
|
|
:param params: Optional method parameters.
|
|
:returns: A dict ready to POST as JSON.
|
|
"""
|
|
body: dict[str, Any] = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
|
|
if params is not None:
|
|
body["params"] = params
|
|
return body
|
|
|
|
|
|
async def _post_mcp(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
body: Any,
|
|
) -> httpx.Response:
|
|
"""POST to the MCP proxy endpoint with a JSON body.
|
|
|
|
:param client: The test HTTP client.
|
|
:param session_id: Target session id.
|
|
:param body: JSON-serialisable request body.
|
|
:returns: The HTTP response.
|
|
"""
|
|
return await client.post(f"/v1/sessions/{session_id}/mcp", json=body)
|
|
|
|
|
|
async def _post_mcp_raw(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
raw: str | bytes,
|
|
content_type: str = "application/json",
|
|
) -> httpx.Response:
|
|
"""POST raw bytes to the MCP proxy endpoint.
|
|
|
|
:param client: The test HTTP client.
|
|
:param session_id: Target session id.
|
|
:param raw: Raw body content.
|
|
:param content_type: Content-Type header value.
|
|
:returns: The HTTP response.
|
|
"""
|
|
data = raw if isinstance(raw, bytes) else raw.encode()
|
|
return await client.post(
|
|
f"/v1/sessions/{session_id}/mcp",
|
|
content=data,
|
|
headers={"Content-Type": content_type},
|
|
)
|
|
|
|
|
|
# ── Tests: JSON-RPC validation layer ─────────────────────
|
|
|
|
|
|
async def test_mcp_invalid_json_returns_parse_error(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""Malformed JSON body returns a JSON-RPC parse error (-32700)."""
|
|
session = await create_test_session(client, name="mcp-bad-json")
|
|
resp = await _post_mcp_raw(client, session["id"], "not valid json{{{")
|
|
assert resp.status_code == 200 # JSON-RPC errors travel in the body
|
|
payload = resp.json()
|
|
assert payload["error"]["code"] == -32700
|
|
assert "Parse error" in payload["error"]["message"]
|
|
|
|
|
|
async def test_mcp_non_object_body_returns_invalid_request(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A JSON array body returns a JSON-RPC invalid request error (-32600)."""
|
|
session = await create_test_session(client, name="mcp-non-object")
|
|
resp = await _post_mcp(client, session["id"], [1, 2, 3])
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert payload["error"]["code"] == -32600
|
|
assert "expected JSON object" in payload["error"]["message"]
|
|
|
|
|
|
# ── Tests: method dispatch ────────────────────────────────
|
|
|
|
|
|
async def test_mcp_initialize_returns_capabilities(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""The ``initialize`` method returns protocol version and capabilities."""
|
|
session = await create_test_session(client, name="mcp-init")
|
|
resp = await _post_mcp(client, session["id"], _jsonrpc("initialize"))
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert "result" in payload
|
|
result = payload["result"]
|
|
assert result["protocolVersion"] == "2024-11-05"
|
|
assert "tools" in result["capabilities"]
|
|
assert result["serverInfo"]["name"] == "omnigent-mcp-proxy"
|
|
# Echoes the request id.
|
|
assert payload["id"] == 1
|
|
|
|
|
|
async def test_mcp_initialize_echoes_rpc_id(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""The response echoes the caller's JSON-RPC id."""
|
|
session = await create_test_session(client, name="mcp-init-id")
|
|
resp = await _post_mcp(client, session["id"], _jsonrpc("initialize", rpc_id=99))
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == 99
|
|
|
|
|
|
async def test_mcp_unknown_method_returns_method_not_found(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""An unknown JSON-RPC method returns -32601 (method not found)."""
|
|
session = await create_test_session(client, name="mcp-unknown")
|
|
resp = await _post_mcp(client, session["id"], _jsonrpc("resources/list", rpc_id=42))
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert payload["id"] == 42
|
|
assert payload["error"]["code"] == -32601
|
|
assert "Method not found" in payload["error"]["message"]
|
|
assert "resources/list" in payload["error"]["message"]
|
|
|
|
|
|
# ── Tests: tools/list without runner ──────────────────────
|
|
|
|
|
|
async def test_mcp_tools_list_no_runner_returns_error(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""``tools/list`` with no runner bound returns a -32000 application error."""
|
|
session = await create_test_session(client, name="mcp-list-no-runner")
|
|
resp = await _post_mcp(client, session["id"], _jsonrpc("tools/list", rpc_id=5))
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert payload["id"] == 5
|
|
assert payload["error"]["code"] == -32000
|
|
assert "No runner bound" in payload["error"]["message"]
|
|
|
|
|
|
# ── Tests: tools/call validation ──────────────────────────
|
|
|
|
|
|
async def test_mcp_tools_call_missing_name_returns_error(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""``tools/call`` without a ``name`` param returns an application error."""
|
|
session = await create_test_session(client, name="mcp-call-no-name")
|
|
resp = await _post_mcp(
|
|
client,
|
|
session["id"],
|
|
_jsonrpc("tools/call", rpc_id=9, params={}),
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert payload["error"]["code"] == -32000
|
|
assert "Missing tool name" in payload["error"]["message"]
|
|
|
|
|
|
# ── Tests: nonexistent session ────────────────────────────
|
|
|
|
|
|
async def test_mcp_nonexistent_session_returns_error(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""MCP on a nonexistent session returns an error.
|
|
|
|
The session lookup (``_require_access``) runs before the JSON-RPC
|
|
dispatch. Without a permission store the access check is a no-op,
|
|
so the request reaches the JSON-RPC layer. ``tools/list`` then
|
|
fails with "No runner bound" because the tunnel registry has no
|
|
binding for the nonexistent session.
|
|
"""
|
|
resp = await _post_mcp(
|
|
client,
|
|
"conv_does_not_exist_mcp",
|
|
_jsonrpc("tools/list", rpc_id=10),
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.json()
|
|
assert payload["id"] == 10
|
|
assert payload["error"]["code"] == -32000
|
|
assert "No runner bound" in payload["error"]["message"]
|