Skip to content

Commit 7bf431c

Browse files
committed
[minimcp][transport] Add HTTP transport (BaseHTTPTransport and HTTPTransport)
- Implement BaseHTTPTransport as abstract base for HTTP transports - Add HTTPTransport for standard HTTP POST request/response - Add dispatch() method for handling HTTP requests to MiniMCP - Add protocol version validation via MCP-Protocol-Version header - Add request validation (method, headers, media type) - Add Starlette support - Add comprehensive unit test suites
1 parent 3e864a3 commit 7bf431c

File tree

4 files changed

+1177
-0
lines changed

4 files changed

+1177
-0
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import json
2+
import logging
3+
from abc import abstractmethod
4+
from collections.abc import Mapping
5+
from http import HTTPStatus
6+
from typing import Generic, NamedTuple
7+
8+
from starlette.applications import Starlette
9+
from starlette.requests import Request
10+
from starlette.responses import Response
11+
12+
import mcp.types as types
13+
from mcp.server.minimcp.exceptions import InvalidMessageError
14+
from mcp.server.minimcp.managers.context_manager import ScopeT
15+
from mcp.server.minimcp.minimcp import MiniMCP
16+
from mcp.server.minimcp.types import NoMessage, Send
17+
from mcp.server.minimcp.utils import json_rpc
18+
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
24+
25+
MEDIA_TYPE_JSON = "application/json"
26+
27+
28+
class MCPHTTPResponse(NamedTuple):
29+
"""
30+
Represents the response from a MiniMCP server to a client HTTP request.
31+
32+
Attributes:
33+
status_code: The HTTP status code to return to the client.
34+
content: The response content as a string or None. The content must be utf-8 encoded whenever required.
35+
media_type: The MIME type of the response content (e.g., "application/json").
36+
headers: Additional HTTP headers to include in the response.
37+
"""
38+
39+
status_code: HTTPStatus
40+
content: str | None = None
41+
headers: Mapping[str, str] | None = None
42+
media_type: str = MEDIA_TYPE_JSON
43+
44+
45+
class RequestValidationError(Exception):
46+
"""
47+
Exception raised when an error occurs in the HTTP transport.
48+
"""
49+
50+
status_code: HTTPStatus
51+
52+
def __init__(self, message: str, status_code: HTTPStatus):
53+
"""
54+
Args:
55+
message: The error message to return to the client.
56+
status_code: The HTTP status code to return to the client.
57+
"""
58+
super().__init__(message)
59+
self.status_code = status_code
60+
61+
62+
class BaseHTTPTransport(Generic[ScopeT]):
63+
"""
64+
HTTP transport implementations for MiniMCP.
65+
66+
Provides handling of HTTP requests by the MiniMCP server, including header validation,
67+
media type checking, protocol version validation, and error response generation.
68+
"""
69+
70+
minimcp: MiniMCP[ScopeT]
71+
72+
RESPONSE_MEDIA_TYPES: frozenset[str]
73+
SUPPORTED_HTTP_METHODS: frozenset[str]
74+
75+
def __init__(self, minimcp: MiniMCP[ScopeT]) -> None:
76+
"""
77+
Args:
78+
minimcp: The MiniMCP instance to use.
79+
"""
80+
self.minimcp = minimcp
81+
82+
@abstractmethod
83+
async def dispatch(
84+
self, method: str, headers: Mapping[str, str], body: str, scope: ScopeT | None = None
85+
) -> NamedTuple:
86+
"""
87+
Dispatch an HTTP request to the MiniMCP server.
88+
89+
Args:
90+
method: The HTTP method of the request.
91+
headers: HTTP request headers.
92+
body: HTTP request body as a string.
93+
scope: Optional message scope passed to the MiniMCP server.
94+
"""
95+
raise NotImplementedError("Subclasses must implement this method")
96+
97+
@abstractmethod
98+
async def starlette_dispatch(self, request: Request, scope: ScopeT | None = None) -> Response:
99+
"""
100+
Dispatch a Starlette request to the MiniMCP server and return the response as a Starlette response object.
101+
102+
Args:
103+
request: Starlette request object.
104+
scope: Optional message scope passed to the MiniMCP server.
105+
106+
Returns:
107+
Starlette response object.
108+
"""
109+
raise NotImplementedError("Subclasses must implement this method")
110+
111+
@abstractmethod
112+
def as_starlette(self, path: str = "/", debug: bool = False) -> Starlette:
113+
"""
114+
Provide the HTTP transport as a Starlette application.
115+
116+
Args:
117+
path: The path to the MCP application endpoint.
118+
debug: Whether to enable debug mode.
119+
120+
Returns:
121+
Starlette application object.
122+
"""
123+
raise NotImplementedError("Subclasses must implement this method")
124+
125+
async def _handle_post_request(
126+
self, headers: Mapping[str, str], body: str, scope: ScopeT | None, send_callback: Send | None = None
127+
) -> MCPHTTPResponse:
128+
"""
129+
Handle a POST HTTP request.
130+
It validates the request headers and body, and then passes on the message to the MiniMCP for handling.
131+
132+
Args:
133+
headers: HTTP request headers.
134+
body: HTTP request body.
135+
scope: Optional message scope passed to the MiniMCP server.
136+
send_callback: Optional send function for transmitting messages to the client.
137+
138+
Returns:
139+
MCPHTTPResponse with the response from the MiniMCP server.
140+
"""
141+
142+
try:
143+
# Validate the request headers and body
144+
self._validate_accept_headers(headers)
145+
self._validate_content_type(headers)
146+
self._validate_protocol_version(headers, body)
147+
148+
# Handle the request
149+
response = await self.minimcp.handle(body, send_callback, scope)
150+
151+
# Process the response
152+
if response == NoMessage.NOTIFICATION:
153+
return MCPHTTPResponse(HTTPStatus.ACCEPTED)
154+
else:
155+
return MCPHTTPResponse(HTTPStatus.OK, response)
156+
except RequestValidationError as e:
157+
return self._build_error_response(e, body, types.INVALID_REQUEST, e.status_code)
158+
except InvalidMessageError as e:
159+
return MCPHTTPResponse(HTTPStatus.BAD_REQUEST, e.response)
160+
except Exception as e:
161+
# Handler shouldn't raise any exceptions other than InvalidMessageError
162+
# Ideally we should not get here
163+
logger.exception("Unexpected error in %s: %s", self.__class__.__name__, e)
164+
return self._build_error_response(e, body)
165+
166+
def _build_error_response(
167+
self,
168+
error: Exception,
169+
body: str,
170+
json_rpc_error_code: int = types.INTERNAL_ERROR,
171+
http_status_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
172+
) -> MCPHTTPResponse:
173+
response, error_message = json_rpc.build_error_message(
174+
error,
175+
body,
176+
json_rpc_error_code,
177+
include_stack_trace=True,
178+
)
179+
logger.error("Error in %s - %s", self.__class__.__name__, error_message, exc_info=error)
180+
181+
return MCPHTTPResponse(http_status_code, response)
182+
183+
def _handle_unsupported_request(self) -> MCPHTTPResponse:
184+
"""
185+
Handle an HTTP request with an unsupported method.
186+
187+
Returns:
188+
MCPHTTPResponse with 405 METHOD_NOT_ALLOWED status and an Allow header
189+
listing the supported methods.
190+
"""
191+
content = json.dumps({"message": "Method Not Allowed"})
192+
headers = {
193+
"Allow": ", ".join(self.SUPPORTED_HTTP_METHODS),
194+
}
195+
196+
return MCPHTTPResponse(HTTPStatus.METHOD_NOT_ALLOWED, content, headers)
197+
198+
def _validate_accept_headers(self, headers: Mapping[str, str]) -> MCPHTTPResponse | None:
199+
"""
200+
Validate that the client accepts the required media types.
201+
202+
Parses the Accept header and checks if all needed media types are present.
203+
204+
Args:
205+
headers: HTTP request headers containing the Accept header.
206+
207+
Raises:
208+
RequestValidationError: If the client doesn't accept all supported types.
209+
"""
210+
accept_header = headers.get("Accept", "")
211+
accepted_types = [t.split(";")[0].strip().lower() for t in accept_header.split(",")]
212+
213+
if not self.RESPONSE_MEDIA_TYPES.issubset(accepted_types):
214+
response_content_types_str = " and ".join(self.RESPONSE_MEDIA_TYPES)
215+
raise RequestValidationError(
216+
f"Not Acceptable: Client must accept {response_content_types_str}",
217+
HTTPStatus.NOT_ACCEPTABLE,
218+
)
219+
220+
def _validate_content_type(self, headers: Mapping[str, str]) -> MCPHTTPResponse | None:
221+
"""
222+
Validate that the request Content-Type is application/json.
223+
224+
Extracts and validates the Content-Type header, ignoring any charset
225+
or other parameters.
226+
227+
Args:
228+
headers: HTTP request headers containing the Content-Type header.
229+
230+
Raises:
231+
RequestValidationError: If the type is not application/json.
232+
"""
233+
content_type = headers.get("Content-Type", "")
234+
content_type = content_type.split(";")[0].strip().lower()
235+
236+
if content_type != MEDIA_TYPE_JSON:
237+
raise RequestValidationError(
238+
"Unsupported Media Type: Content-Type must be " + MEDIA_TYPE_JSON,
239+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
240+
)
241+
242+
def _validate_protocol_version(self, headers: Mapping[str, str], body: str) -> MCPHTTPResponse | None:
243+
"""
244+
Validate the MCP protocol version from the request headers.
245+
246+
The protocol version is checked via the MCP-Protocol-Version header.
247+
If not provided, a default version is assumed per the MCP specification.
248+
Protocol version validation is skipped for the initialize request, as
249+
version negotiation happens during initialization.
250+
251+
Args:
252+
headers: HTTP request headers containing the protocol version header.
253+
body: The request body, checked to determine if this is an initialize request.
254+
255+
Raises:
256+
RequestValidationError: If the protocol version is unsupported.
257+
258+
See Also:
259+
https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header
260+
"""
261+
262+
if json_rpc.is_initialize_request(body):
263+
# Ignore protocol version validation for initialize request
264+
return None
265+
266+
# If no protocol version provided, assume default version as per the specification
267+
# https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header
268+
protocol_version = headers.get(MCP_PROTOCOL_VERSION_HEADER, types.DEFAULT_NEGOTIATED_VERSION)
269+
270+
# Check if the protocol version is supported
271+
if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS:
272+
supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS)
273+
raise RequestValidationError(
274+
(
275+
f"Bad Request: Unsupported protocol version: {protocol_version}. "
276+
f"Supported versions: {supported_versions}"
277+
),
278+
HTTPStatus.BAD_REQUEST,
279+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import logging
2+
from collections.abc import Mapping
3+
4+
from starlette.applications import Starlette
5+
from starlette.requests import Request
6+
from starlette.responses import Response
7+
from starlette.routing import Route
8+
from typing_extensions import override
9+
10+
from mcp.server.minimcp.managers.context_manager import ScopeT
11+
from mcp.server.minimcp.minimcp import MiniMCP
12+
from mcp.server.minimcp.transports.base_http import MEDIA_TYPE_JSON, BaseHTTPTransport, MCPHTTPResponse
13+
from mcp.server.minimcp.types import MESSAGE_ENCODING
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class HTTPTransport(BaseHTTPTransport[ScopeT]):
19+
"""
20+
HTTP transport implementation for MiniMCP.
21+
"""
22+
23+
RESPONSE_MEDIA_TYPES: frozenset[str] = frozenset[str]([MEDIA_TYPE_JSON])
24+
SUPPORTED_HTTP_METHODS: frozenset[str] = frozenset[str](["POST"])
25+
26+
def __init__(self, minimcp: MiniMCP[ScopeT]) -> None:
27+
super().__init__(minimcp)
28+
29+
@override
30+
async def dispatch(
31+
self, method: str, headers: Mapping[str, str], body: str, scope: ScopeT | None = None
32+
) -> MCPHTTPResponse:
33+
"""
34+
Dispatch an HTTP request to the MiniMCP server.
35+
36+
Args:
37+
method: The HTTP method of the request.
38+
headers: HTTP request headers.
39+
body: HTTP request body as a string.
40+
scope: Optional message scope passed to the MiniMCP server.
41+
42+
Returns:
43+
MCPHTTPResponse object with the response from the MiniMCP server.
44+
"""
45+
46+
logger.debug("Handling HTTP request. Method: %s, Headers: %s", method, headers)
47+
48+
if method == "POST":
49+
return await self._handle_post_request(headers, body, scope)
50+
else:
51+
return self._handle_unsupported_request()
52+
53+
@override
54+
async def starlette_dispatch(self, request: Request, scope: ScopeT | None = None) -> Response:
55+
"""
56+
Dispatch a Starlette request to the MiniMCP server and return the response as a Starlette response object.
57+
58+
Args:
59+
request: Starlette request object.
60+
scope: Optional message scope passed to the MiniMCP server.
61+
62+
Returns:
63+
MiniMCP server response formatted as a Starlette Response object.
64+
"""
65+
body = await request.body()
66+
body_str = body.decode(MESSAGE_ENCODING)
67+
68+
result = await self.dispatch(request.method, request.headers, body_str, scope)
69+
70+
return Response(result.content, result.status_code, result.headers, result.media_type)
71+
72+
@override
73+
def as_starlette(self, path: str = "/", debug: bool = False) -> Starlette:
74+
"""
75+
Provide the HTTP transport as a Starlette application.
76+
77+
Args:
78+
path: The path to the MCP application endpoint.
79+
debug: Whether to enable debug mode.
80+
81+
Returns:
82+
Starlette application.
83+
"""
84+
85+
route = Route(path, endpoint=self.starlette_dispatch, methods=self.SUPPORTED_HTTP_METHODS)
86+
87+
logger.info("Creating MCP application at path: %s", path)
88+
return Starlette(routes=[route], debug=debug)

0 commit comments

Comments
 (0)