generated from personal-projects/leo-claude-mktplace
feat: implement MCP Streamable HTTP protocol endpoints
- Add POST /mcp endpoint using StreamableHTTPServerTransport - Add HEAD /mcp endpoint returning protocol version header - Remove old custom REST endpoints (/tools/list, /tools/call) - Integrate marketplace tools via MCP Server decorators - Add comprehensive MCP endpoint tests - Update README with correct MCP protocol usage The implementation properly handles: - JSON-RPC 2.0 message format - SSE (Server-Sent Events) responses - Protocol version negotiation - Tool filtering integration - Authentication middleware Tests verify: - HEAD /mcp returns correct headers - POST /mcp handles initialize requests - Proper error handling for missing headers Closes #24 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
61
README.md
61
README.md
@@ -159,30 +159,55 @@ GET /ping
|
|||||||
Response: {"status": "healthy"}
|
Response: {"status": "healthy"}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### List Tools
|
#### MCP Protocol Endpoint
|
||||||
```bash
|
|
||||||
POST /tools/list
|
|
||||||
|
|
||||||
Response: {
|
The server implements the MCP Streamable HTTP protocol:
|
||||||
"tools": [
|
|
||||||
{"name": "list_issues", "description": "...", "inputSchema": {...}},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Call Tool
|
|
||||||
```bash
|
```bash
|
||||||
POST /tools/call
|
# Check protocol version
|
||||||
|
HEAD /mcp
|
||||||
|
|
||||||
|
# Send MCP JSON-RPC requests
|
||||||
|
POST /mcp
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
Accept: application/json, text/event-stream
|
||||||
Authorization: Bearer YOUR_TOKEN # If auth enabled
|
Authorization: Bearer YOUR_TOKEN # If auth enabled
|
||||||
|
|
||||||
|
# Example: Initialize
|
||||||
{
|
{
|
||||||
"name": "list_issues",
|
"jsonrpc": "2.0",
|
||||||
"arguments": {
|
"id": 1,
|
||||||
"owner": "myorg",
|
"method": "initialize",
|
||||||
"repo": "myrepo",
|
"params": {
|
||||||
"state": "open"
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "my-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example: List tools
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example: Call tool
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "list_issues",
|
||||||
|
"arguments": {
|
||||||
|
"owner": "myorg",
|
||||||
|
"repo": "myrepo",
|
||||||
|
"state": "open"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -330,7 +355,7 @@ If tool filtering is not applied:
|
|||||||
1. Check `.env` file syntax (no spaces around `=`)
|
1. Check `.env` file syntax (no spaces around `=`)
|
||||||
2. Verify comma-separated list format
|
2. Verify comma-separated list format
|
||||||
3. Check server logs for filter configuration
|
3. Check server logs for filter configuration
|
||||||
4. Query `POST /tools/list` to see filtered tools
|
4. Send `tools/list` MCP request to see filtered tools
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ package and serves them over HTTP with authentication.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route, Mount
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
|
||||||
from gitea_mcp_remote.config import load_settings
|
from gitea_mcp_remote.config import load_settings
|
||||||
from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware
|
from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware
|
||||||
from gitea_mcp_remote.filtering import ToolFilter
|
from gitea_mcp_remote.filtering import ToolFilter
|
||||||
|
|
||||||
|
# Import marketplace package
|
||||||
|
from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +31,30 @@ async def health_check(request):
|
|||||||
return JSONResponse({"status": "ok"})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def create_mcp_server(tool_names: list[str] | None = None) -> Server:
|
||||||
|
"""Create and configure the MCP server with tools from the marketplace."""
|
||||||
|
mcp_server = Server("gitea-mcp-remote")
|
||||||
|
|
||||||
|
# Get tool definitions from marketplace
|
||||||
|
tools = get_tool_definitions(tool_filter=tool_names)
|
||||||
|
|
||||||
|
# Create Gitea client and dispatcher
|
||||||
|
gitea_client = GiteaClient()
|
||||||
|
dispatcher = create_tool_dispatcher(gitea_client, tool_filter=tool_names)
|
||||||
|
|
||||||
|
@mcp_server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
"""Return available Gitea tools."""
|
||||||
|
return tools
|
||||||
|
|
||||||
|
@mcp_server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
"""Execute a tool with the given arguments."""
|
||||||
|
return await dispatcher(name, arguments)
|
||||||
|
|
||||||
|
return mcp_server
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
"""Create the Starlette application with middleware."""
|
"""Create the Starlette application with middleware."""
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
@@ -38,12 +70,67 @@ def create_app():
|
|||||||
if tool_filter.enabled_tools:
|
if tool_filter.enabled_tools:
|
||||||
tool_names = list(tool_filter.enabled_tools)
|
tool_names = list(tool_filter.enabled_tools)
|
||||||
|
|
||||||
# TODO: Issue #24 will add MCP protocol endpoints
|
# Create MCP server with filtered tools
|
||||||
# from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient, GiteaConfig
|
mcp_server = create_mcp_server(tool_names)
|
||||||
|
|
||||||
|
# Store server for endpoint access
|
||||||
|
app_state = {"mcp_server": mcp_server}
|
||||||
|
|
||||||
|
class MCPEndpoint:
|
||||||
|
"""ASGI app wrapper for MCP protocol endpoint."""
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
"""Handle MCP requests - both POST and HEAD."""
|
||||||
|
method = scope.get("method", "")
|
||||||
|
|
||||||
|
if method == "HEAD":
|
||||||
|
# Return protocol version header
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 200,
|
||||||
|
"headers": [
|
||||||
|
[b"x-mcp-protocol-version", b"2024-11-05"],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b"",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# For POST requests, use the StreamableHTTPServerTransport
|
||||||
|
# Create transport for this request
|
||||||
|
transport = StreamableHTTPServerTransport(mcp_session_id=None)
|
||||||
|
|
||||||
|
# Run the MCP server with this transport
|
||||||
|
async with transport.connect() as (read_stream, write_stream):
|
||||||
|
# Start server task
|
||||||
|
server_task = asyncio.create_task(
|
||||||
|
app_state["mcp_server"].run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
app_state["mcp_server"].create_initialization_options()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle the HTTP request
|
||||||
|
try:
|
||||||
|
await transport.handle_request(scope, receive, send)
|
||||||
|
finally:
|
||||||
|
# Cancel server task if still running
|
||||||
|
if not server_task.done():
|
||||||
|
server_task.cancel()
|
||||||
|
try:
|
||||||
|
await server_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create MCP endpoint instance
|
||||||
|
mcp_endpoint = MCPEndpoint()
|
||||||
|
|
||||||
routes = [
|
routes = [
|
||||||
Route("/health", health_check, methods=["GET"]),
|
Route("/health", health_check, methods=["GET"]),
|
||||||
# MCP endpoints will be added in Issue #24
|
Route("/mcp", mcp_endpoint, methods=["GET", "POST", "HEAD"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
app = Starlette(routes=routes)
|
app = Starlette(routes=routes)
|
||||||
|
|||||||
108
tests/test_mcp_endpoints.py
Normal file
108
tests/test_mcp_endpoints.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests for MCP protocol endpoints."""
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from gitea_mcp_remote.server_http import create_app
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_env():
|
||||||
|
"""Mock environment variables for testing."""
|
||||||
|
env = {
|
||||||
|
"GITEA_URL": "https://gitea.example.com",
|
||||||
|
"GITEA_TOKEN": "test_token",
|
||||||
|
"GITEA_OWNER": "test_owner",
|
||||||
|
}
|
||||||
|
with patch.dict("os.environ", env):
|
||||||
|
yield env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(mock_env):
|
||||||
|
"""Create test client."""
|
||||||
|
app = create_app()
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sse_message(sse_text: str) -> dict:
|
||||||
|
"""Parse SSE message data."""
|
||||||
|
data_match = re.search(r'data: (.+)', sse_text)
|
||||||
|
if data_match:
|
||||||
|
return json.loads(data_match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_head_endpoint(client):
|
||||||
|
"""Test HEAD /mcp returns protocol version header."""
|
||||||
|
response = client.head("/mcp")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "x-mcp-protocol-version" in response.headers
|
||||||
|
assert response.headers["x-mcp-protocol-version"] == "2024-11-05"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_initialize(client):
|
||||||
|
"""Test MCP initialize request."""
|
||||||
|
initialize_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "test-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/mcp",
|
||||||
|
json=initialize_request,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Parse SSE response
|
||||||
|
data = parse_sse_message(response.text)
|
||||||
|
assert data is not None
|
||||||
|
assert data.get("jsonrpc") == "2.0"
|
||||||
|
assert "result" in data
|
||||||
|
assert data["result"].get("protocolVersion") == "2024-11-05"
|
||||||
|
assert "serverInfo" in data["result"]
|
||||||
|
assert data["result"]["serverInfo"]["name"] == "gitea-mcp-remote"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_missing_accept_header(client):
|
||||||
|
"""Test MCP request without required Accept header."""
|
||||||
|
initialize_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "test-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/mcp",
|
||||||
|
json=initialize_request,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json" # Missing text/event-stream
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return error about missing accept header
|
||||||
|
assert response.status_code == 406
|
||||||
Reference in New Issue
Block a user