Skip to content
Open
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
39 changes: 37 additions & 2 deletions app/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from typing import Any

from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response

from app.ledger.service import LedgerError

MCPToolHandler = Callable[[str, str, dict[str, Any]], str | dict[str, Any]]
DEFAULT_PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_PROTOCOL_VERSIONS = {DEFAULT_PROTOCOL_VERSION}
SERVER_INFO = {"name": "MergeWork MCP", "version": "0.1.0"}

MCP_TOOLS: list[dict[str, Any]] = [
{
Expand Down Expand Up @@ -100,7 +103,7 @@ def _tool_result_response(response_id: Any, tool_result: str | dict[str, Any]) -

async def handle_mcp_request(
request: Request, database_url: str, call_tool: MCPToolHandler
) -> dict[str, Any] | JSONResponse:
) -> dict[str, Any] | JSONResponse | Response:
try:
payload = await request.json()
except ValueError:
Expand All @@ -111,6 +114,38 @@ async def handle_mcp_request(

response_id = payload.get("id")
method = payload.get("method")
if method == "initialize":
params = payload.get("params", {})
if not isinstance(params, dict):
return _jsonrpc_error(response_id, -32602, "invalid params")
requested_version = params.get("protocolVersion")
if (
isinstance(requested_version, str)
and requested_version not in SUPPORTED_PROTOCOL_VERSIONS
):
error = _jsonrpc_error(response_id, -32602, "Unsupported protocol version")
error["error"]["data"] = {
"supported": sorted(SUPPORTED_PROTOCOL_VERSIONS),
"requested": requested_version,
}
return error
protocol_version = DEFAULT_PROTOCOL_VERSION
return {
"jsonrpc": "2.0",
"id": response_id,
"result": {
"protocolVersion": protocol_version,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
},
}
if method == "notifications/initialized":
if response_id is not None:
return _jsonrpc_error(response_id, -32600, "invalid request")
return Response(status_code=202)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if method == "ping":
return {"jsonrpc": "2.0", "id": response_id, "result": {}}

if method == "tools/list":
return {"jsonrpc": "2.0", "id": response_id, "result": {"tools": MCP_TOOLS}}

Expand Down
148 changes: 148 additions & 0 deletions tests/test_api_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,154 @@ def test_mcp_tools_list_and_call(sqlite_url: str) -> None:
assert "100000000" in balance["result"]["content"][0]["text"]


def test_mcp_initialize_advertises_tool_capability(sqlite_url: str) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 42,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0"},
},
},
)

assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 42,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "MergeWork MCP", "version": "0.1.0"},
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@pytest.mark.parametrize(
("payload", "expected_version"),
[
({"jsonrpc": "2.0", "id": 42, "method": "initialize"}, "2025-06-18"),
(
{
"jsonrpc": "2.0",
"id": 42,
"method": "initialize",
"params": {"capabilities": {}},
},
"2025-06-18",
),
(
{
"jsonrpc": "2.0",
"id": 42,
"method": "initialize",
"params": {"protocolVersion": 20250618},
},
"2025-06-18",
),
],
)
def test_mcp_initialize_defaults_protocol_version(
sqlite_url: str, payload: dict[str, object], expected_version: str
) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post("/mcp", json=payload)

assert response.status_code == 200
assert response.json()["result"]["protocolVersion"] == expected_version


@pytest.mark.parametrize("protocol_version", ["1900-01-01", "not-a-version"])
def test_mcp_initialize_rejects_unsupported_protocol_version(
sqlite_url: str, protocol_version: str
) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 42,
"method": "initialize",
"params": {"protocolVersion": protocol_version},
},
)

assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32602,
"message": "Unsupported protocol version",
"data": {
"supported": ["2025-06-18"],
"requested": protocol_version,
},
},
}


def test_mcp_initialize_rejects_invalid_params(sqlite_url: str) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 42, "method": "initialize", "params": []},
)

assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 42,
"error": {"code": -32602, "message": "invalid params"},
}


def test_mcp_initialized_notification_returns_accepted(sqlite_url: str) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)

assert response.status_code == 202
assert response.content == b""


def test_mcp_initialized_request_returns_jsonrpc_error(sqlite_url: str) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 44, "method": "notifications/initialized"},
)

assert response.status_code == 200
assert response.json() == {
"jsonrpc": "2.0",
"id": 44,
"error": {"code": -32600, "message": "invalid request"},
}


def test_mcp_ping_returns_empty_result(sqlite_url: str) -> None:
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.post("/mcp", json={"jsonrpc": "2.0", "id": 43, "method": "ping"})

assert response.status_code == 200
assert response.json() == {"jsonrpc": "2.0", "id": 43, "result": {}}


def test_mcp_list_bounty_attempts_reports_active_and_expired(sqlite_url: str) -> None:
create_schema(sqlite_url)
now = datetime.now(UTC)
Expand Down
Loading