Merge feat/24: Implement MCP Streamable HTTP protocol endpoints

This commit is contained in:
2026-02-03 18:16:51 -05:00
3 changed files with 243 additions and 23 deletions

View File

@@ -159,25 +159,49 @@ GET /ping
Response: {"status": "healthy"}
```
#### List Tools
```bash
POST /tools/list
#### MCP Protocol Endpoint
Response: {
"tools": [
{"name": "list_issues", "description": "...", "inputSchema": {...}},
...
]
}
```
The server implements the MCP Streamable HTTP protocol:
#### Call Tool
```bash
POST /tools/call
# Check protocol version
HEAD /mcp
# Send MCP JSON-RPC requests
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer YOUR_TOKEN # If auth enabled
# Example: Initialize
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"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",
@@ -185,6 +209,7 @@ Authorization: Bearer YOUR_TOKEN # If auth enabled
"state": "open"
}
}
}
```
### With Claude Desktop
@@ -330,7 +355,7 @@ If tool filtering is not applied:
1. Check `.env` file syntax (no spaces around `=`)
2. Verify comma-separated list format
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

View File

@@ -6,15 +6,23 @@ package and serves them over HTTP with authentication.
"""
import logging
import asyncio
from typing import Any
import uvicorn
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.responses import JSONResponse, Response
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.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware
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__)
@@ -23,6 +31,30 @@ async def health_check(request):
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():
"""Create the Starlette application with middleware."""
settings = load_settings()
@@ -38,12 +70,67 @@ def create_app():
if tool_filter.enabled_tools:
tool_names = list(tool_filter.enabled_tools)
# TODO: Issue #24 will add MCP protocol endpoints
# from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient, GiteaConfig
# Create MCP server with filtered tools
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 = [
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)

108
tests/test_mcp_endpoints.py Normal file
View 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