mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 19:02:16 +00:00
CI's lint job ran ruff check but never ruff format --check, letting format drift land silently (79 files had drifted from the pinned ruff 0.15.1). Add the ruff format --check step to ci.yml, reformat the 79 drifted files with the pinned ruff (mechanical only — verified AST-identical to before across all files, no logic changes), and add a Makefile whose test target mirrors the actual CI lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
8.2 KiB
Python
205 lines
8.2 KiB
Python
"""Tests for the StreamableHTTPTransport class."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from openjarvis.mcp.protocol import MCPRequest
|
|
|
|
|
|
@pytest.fixture
|
|
def _mock_httpx_client():
|
|
"""Patch httpx.Client so no real HTTP connections are made."""
|
|
with patch("httpx.Client") as mock_cls, patch("httpx.Timeout"):
|
|
mock_instance = MagicMock()
|
|
mock_cls.return_value = mock_instance
|
|
yield mock_instance
|
|
|
|
|
|
def _make_http_response(result, *, session_id=None):
|
|
"""Build a mock httpx.Response with the given JSON-RPC result."""
|
|
resp = MagicMock()
|
|
resp.text = json.dumps({"jsonrpc": "2.0", "id": 1, "result": result})
|
|
resp.raise_for_status = MagicMock()
|
|
headers = {}
|
|
if session_id is not None:
|
|
headers["mcp-session-id"] = session_id
|
|
resp.headers = headers
|
|
return resp
|
|
|
|
|
|
class TestStreamableHTTPTransport:
|
|
def test_send_request(self, _mock_httpx_client):
|
|
"""Verify correct URL, headers, JSON body, and MCPResponse parsing."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({"tools": []})
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
req = MCPRequest(method="tools/list", id=1)
|
|
resp = transport.send(req)
|
|
|
|
# Verify the POST call
|
|
mock_client.post.assert_called_once()
|
|
call_kwargs = mock_client.post.call_args
|
|
assert call_kwargs[0][0] == "http://localhost:9583/mcp"
|
|
headers = call_kwargs[1]["headers"]
|
|
assert headers["Content-Type"] == "application/json"
|
|
assert "application/json" in headers["Accept"]
|
|
assert "text/event-stream" in headers["Accept"]
|
|
|
|
# Verify JSON body matches the request
|
|
sent_json = call_kwargs[1]["json"]
|
|
assert sent_json["method"] == "tools/list"
|
|
assert sent_json["id"] == 1
|
|
assert sent_json["jsonrpc"] == "2.0"
|
|
|
|
# Verify response parsing
|
|
assert resp.error is None
|
|
assert resp.result == {"tools": []}
|
|
|
|
def test_session_id_tracking(self, _mock_httpx_client):
|
|
"""First response sets Mcp-Session-Id, subsequent requests include it."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
# First response sets the session id
|
|
mock_client.post.return_value = _make_http_response(
|
|
{"capabilities": {}}, session_id="sess-abc-123"
|
|
)
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
req1 = MCPRequest(method="initialize", id=1)
|
|
transport.send(req1)
|
|
|
|
assert transport._session_id == "sess-abc-123"
|
|
|
|
# Second request — prepare new response without session header
|
|
mock_client.post.return_value = _make_http_response({"tools": []})
|
|
req2 = MCPRequest(method="tools/list", id=2)
|
|
transport.send(req2)
|
|
|
|
# Verify the second call included the session id header
|
|
second_call_headers = mock_client.post.call_args[1]["headers"]
|
|
assert second_call_headers["Mcp-Session-Id"] == "sess-abc-123"
|
|
|
|
def test_first_request_has_no_session_id(self, _mock_httpx_client):
|
|
"""First request should not include Mcp-Session-Id header."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({})
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
transport.send(MCPRequest(method="initialize", id=1))
|
|
|
|
first_call_headers = mock_client.post.call_args[1]["headers"]
|
|
assert "Mcp-Session-Id" not in first_call_headers
|
|
|
|
def test_authorization_header_with_token(self, _mock_httpx_client):
|
|
"""Regression for #461 — token kwarg → Authorization: Bearer header."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({})
|
|
|
|
transport = StreamableHTTPTransport(
|
|
"http://homeassistant.local:8123/mcp",
|
|
token="ha-long-lived-token-xyz",
|
|
)
|
|
transport.send(MCPRequest(method="tools/list", id=1))
|
|
|
|
headers = mock_client.post.call_args[1]["headers"]
|
|
assert headers["Authorization"] == "Bearer ha-long-lived-token-xyz"
|
|
|
|
def test_no_authorization_header_without_token(self, _mock_httpx_client):
|
|
"""Backward compat — no token kwarg → no Authorization header."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({})
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
transport.send(MCPRequest(method="tools/list", id=1))
|
|
|
|
headers = mock_client.post.call_args[1]["headers"]
|
|
assert "Authorization" not in headers
|
|
|
|
def test_empty_token_does_not_send_header(self, _mock_httpx_client):
|
|
"""token='' → no Authorization (empty/falsy tokens skip the header).
|
|
|
|
Matters because `cfg.get('token')` returns `''` if the user wrote
|
|
`token = ""` in config.toml. We don't want to send `Authorization:
|
|
Bearer ` (with a trailing space) — that's a malformed header that
|
|
most servers reject with a confusing 400 rather than 401.
|
|
"""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({})
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp", token="")
|
|
transport.send(MCPRequest(method="tools/list", id=1))
|
|
|
|
headers = mock_client.post.call_args[1]["headers"]
|
|
assert "Authorization" not in headers
|
|
|
|
def test_authorization_persists_across_requests(self, _mock_httpx_client):
|
|
"""Authorization header must accompany every request, not just the first."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.return_value = _make_http_response({})
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp", token="abc123")
|
|
for i in range(3):
|
|
transport.send(MCPRequest(method="tools/list", id=i))
|
|
|
|
for call in mock_client.post.call_args_list:
|
|
assert call[1]["headers"]["Authorization"] == "Bearer abc123"
|
|
|
|
def test_connect_error_handling(self, _mock_httpx_client):
|
|
"""httpx.ConnectError should be wrapped in RuntimeError."""
|
|
import httpx
|
|
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.side_effect = httpx.ConnectError("Connection refused")
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
with pytest.raises(RuntimeError, match="Failed to connect"):
|
|
transport.send(MCPRequest(method="initialize", id=1))
|
|
|
|
def test_timeout_error_handling(self, _mock_httpx_client):
|
|
"""httpx.TimeoutException should be wrapped in RuntimeError."""
|
|
import httpx
|
|
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
mock_client.post.side_effect = httpx.TimeoutException("Read timed out")
|
|
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
with pytest.raises(RuntimeError, match="Timeout communicating"):
|
|
transport.send(MCPRequest(method="initialize", id=1))
|
|
|
|
def test_close(self, _mock_httpx_client):
|
|
"""close() should close the underlying httpx client."""
|
|
from openjarvis.mcp.transport import StreamableHTTPTransport
|
|
|
|
mock_client = _mock_httpx_client
|
|
transport = StreamableHTTPTransport("http://localhost:9583/mcp")
|
|
transport.close()
|
|
mock_client.close.assert_called_once()
|
|
|
|
def test_backward_compat_alias(self):
|
|
"""SSETransport should be the same class as StreamableHTTPTransport."""
|
|
from openjarvis.mcp.transport import SSETransport, StreamableHTTPTransport
|
|
|
|
assert SSETransport is StreamableHTTPTransport
|