Skip to content

Commit 9730f35

Browse files
committed
[minimcp] Add MiniMCP orchestrator
- Implement MiniMCP class as main entry point for building MCP servers - Integrate ToolManager, PromptManager, ResourceManager, and ContextManager - Add message processing pipeline (parsing, validation, dispatch, error handling) - Add concurrency control with configurable idle timeout and max concurrency - Support protocol handshake with initialize request/response handling - Add comprehensive unit test suite - Added RESOURCE_NOT_FOUND in mcp.types
1 parent 1209d96 commit 9730f35

File tree

3 files changed

+1241
-1
lines changed

3 files changed

+1241
-1
lines changed

src/mcp/server/minimcp/minimcp.py

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
import logging
2+
import uuid
3+
from typing import Any, Generic
4+
5+
import anyio
6+
from pydantic import ValidationError
7+
8+
import mcp.server.minimcp.utils.json_rpc as json_rpc
9+
import mcp.shared.version as version
10+
import mcp.types as types
11+
from mcp.server.lowlevel.server import NotificationOptions, Server
12+
from mcp.server.minimcp.exceptions import (
13+
ContextError,
14+
InternalMCPError,
15+
InvalidArgumentsError,
16+
InvalidJSONError,
17+
InvalidJSONRPCMessageError,
18+
InvalidMCPMessageError,
19+
InvalidMessageError,
20+
MCPRuntimeError,
21+
PrimitiveError,
22+
RequestHandlerNotFoundError,
23+
ResourceNotFoundError,
24+
ToolInvalidArgumentsError,
25+
ToolMCPRuntimeError,
26+
ToolPrimitiveError,
27+
UnsupportedMessageTypeError,
28+
)
29+
from mcp.server.minimcp.limiter import Limiter
30+
from mcp.server.minimcp.managers.context_manager import Context, ContextManager, ScopeT
31+
from mcp.server.minimcp.managers.prompt_manager import PromptManager
32+
from mcp.server.minimcp.managers.resource_manager import ResourceManager
33+
from mcp.server.minimcp.managers.tool_manager import ToolManager
34+
from mcp.server.minimcp.responder import Responder
35+
from mcp.server.minimcp.types import Message, NoMessage, Send
36+
37+
logger = logging.getLogger(__name__)
38+
39+
40+
class MiniMCP(Generic[ScopeT]):
41+
"""
42+
MiniMCP is the key orchestrator for building Model Context Protocol (MCP) servers.
43+
44+
MiniMCP provides a high-level, stateless API for implementing MCP servers that expose tools,
45+
prompts, and resources to language models. It handles the complete message lifecycle from
46+
parsing and validation through handler execution and error formatting, while managing
47+
concurrency limits and idle timeouts.
48+
49+
Architecture:
50+
MiniMCP integrates several specialized managers to provide its functionality:
51+
- tool: ToolManager for registering and executing tool handlers
52+
- prompt: PromptManager for exposing prompt templates to clients
53+
- resource: ResourceManager for providing contextual data to language models
54+
- context: ContextManager for accessing request metadata within handlers
55+
56+
Key Features:
57+
- Message Processing: Parses JSON-RPC messages, validates against MCP protocol specification,
58+
and dispatches to appropriate handlers based on request type
59+
- Concurrency Control: Enforces configurable limits on concurrent message handlers and
60+
idle timeouts to prevent resource exhaustion
61+
- Context Management: Maintains thread-safe, async-safe context isolation using contextvars,
62+
providing handlers with access to message metadata, scope objects, and responder for
63+
bidirectional communication
64+
- Error Handling: Centralizes error processing with automatic conversion to JSON-RPC error
65+
responses, mapping MiniMCP exceptions to appropriate MCP error codes
66+
- Protocol Compliance: Implements MCP handshake (initialize request/response) with protocol
67+
version negotiation and capability advertisement
68+
69+
Generic Parameter:
70+
ScopeT: Optional type for custom scope objects passed to handlers. Use this to provide
71+
authentication details, user info, session data, database handles, or any other
72+
per-request context to your handlers. Set to None if not needed.
73+
74+
Example:
75+
```python
76+
from mcp.server.minimcp import MiniMCP
77+
78+
# Create server instance
79+
mcp = MiniMCP("my-server", version="1.0.0")
80+
81+
# Register a tool
82+
@mcp.tool()
83+
def calculate(expression: str) -> float:
84+
'''Evaluate a mathematical expression'''
85+
return eval(expression)
86+
87+
# Register a prompt
88+
@mcp.prompt()
89+
def code_review(code: str) -> str:
90+
'''Request a code review'''
91+
return f"Please review this code:\n{code}"
92+
93+
# Register a resource
94+
@mcp.resource("config://app.json")
95+
def get_config() -> dict:
96+
return {"version": "1.0", "environment": "production"}
97+
98+
# Handle incoming messages
99+
response = await mcp.handle(message, send=send_callback, scope=user_context)
100+
```
101+
102+
For transport integration, use with HTTPTransport, StreamableHTTPTransport, or StdioTransport.
103+
See https://modelcontextprotocol.io/specification/2025-06-18 for protocol details.
104+
"""
105+
106+
_core: Server
107+
_notification_options: NotificationOptions | None = None
108+
_limiter: Limiter
109+
_include_stack_trace: bool
110+
111+
tool: ToolManager
112+
prompt: PromptManager
113+
resource: ResourceManager
114+
context: ContextManager[ScopeT]
115+
116+
def __init__(
117+
self,
118+
name: str,
119+
version: str | None = None,
120+
instructions: str | None = None,
121+
idle_timeout: int = 30,
122+
max_concurrency: int = 100,
123+
include_stack_trace: bool = False,
124+
) -> None:
125+
"""
126+
Initialize the MCP server.
127+
128+
Args:
129+
name: The name of the MCP server.
130+
version: The version of the MCP server.
131+
instructions: The instructions for the MCP server.
132+
133+
idle_timeout: Time in seconds after which a message handler will be considered idle and
134+
timed out. Default is 30 seconds.
135+
max_concurrency: The maximum number of message handlers that could be run at the same time,
136+
beyond which the handle() calls will be blocked. Default is 100.
137+
include_stack_trace: Whether to include the stack trace in the error response. Default is False.
138+
"""
139+
self._limiter = Limiter(idle_timeout, max_concurrency)
140+
self._include_stack_trace = include_stack_trace
141+
142+
# TODO: Add support for server-to-client notifications
143+
self._notification_options = NotificationOptions(
144+
prompts_changed=False,
145+
resources_changed=False,
146+
tools_changed=False,
147+
)
148+
149+
# Setup core
150+
self._core = Server[Any, Any](name=name, version=version, instructions=instructions)
151+
self._core.request_handlers[types.InitializeRequest] = self._initialize_handler
152+
# MiniMCP handles InitializeRequest but not InitializedNotification as it is stateless
153+
154+
# Setup managers
155+
self.tool = ToolManager(self._core)
156+
self.prompt = PromptManager(self._core)
157+
self.resource = ResourceManager(self._core)
158+
159+
self.context = ContextManager[ScopeT]()
160+
161+
# --- Properties ---
162+
@property
163+
def name(self) -> str:
164+
"""The name of the MCP server."""
165+
return self._core.name
166+
167+
@property
168+
def instructions(self) -> str | None:
169+
"""The instructions for the MCP server."""
170+
return self._core.instructions
171+
172+
@property
173+
def version(self) -> str | None:
174+
"""The version of the MCP server."""
175+
return self._core.version
176+
177+
# --- Handlers ---
178+
async def handle(
179+
self, message: Message, send: Send | None = None, scope: ScopeT | None = None
180+
) -> Message | NoMessage:
181+
"""
182+
Handle an incoming MCP message from the client. It is the entry point for all MCP messages and runs
183+
on the current event loop task.
184+
185+
It is responsible for parsing the message, validating the message, enforcing limiters, activating the
186+
context, and dispatching the message to the appropriate handler. It also provides a centralized error
187+
handling mechanism for all MCP errors.
188+
189+
Args:
190+
message: The incoming MCP message from the client.
191+
send: The send function for transmitting messages to the client. Optional.
192+
scope: The scope object for the client. Optional.
193+
194+
Returns:
195+
The response MCP message to the client. If the message is a notification, NoMessage.NOTIFICATION
196+
is returned.
197+
198+
Raises:
199+
InvalidMessageError: If the message is not a valid JSON-RPC message.
200+
cancelled_exception_class: If the task is cancelled.
201+
"""
202+
try:
203+
rpc_msg = self._parse_message(message)
204+
205+
async with self._limiter() as time_limiter:
206+
responder = Responder(message, send, time_limiter) if send else None
207+
context = Context[ScopeT](message=rpc_msg, time_limiter=time_limiter, scope=scope, responder=responder)
208+
with self.context.active(context):
209+
return await self._handle_rpc_msg(rpc_msg)
210+
211+
# --- Centralized MCP error handling - Handles all internal MCP errors ---
212+
# - Exception raised - InvalidMessageFormatError from ParseError or InvalidJSONRPCMessageError
213+
# - Other exceptions will be formatted and returned as JSON-RPC response.
214+
# - Errors inside each tool call will be handled by the core and returned as part of CallToolResult.
215+
except InvalidJSONError as e:
216+
response = self._process_error(e, message, types.PARSE_ERROR)
217+
raise InvalidMessageError(str(e), response) from e
218+
except InvalidJSONRPCMessageError as e:
219+
response = self._process_error(e, message, types.INVALID_REQUEST)
220+
raise InvalidMessageError(str(e), response) from e
221+
except UnsupportedMessageTypeError as e:
222+
return self._process_error(e, message, types.INVALID_REQUEST)
223+
except (
224+
InvalidMCPMessageError,
225+
InvalidArgumentsError,
226+
PrimitiveError,
227+
ToolInvalidArgumentsError,
228+
ToolPrimitiveError,
229+
) as e:
230+
return self._process_error(e, message, types.INVALID_PARAMS)
231+
except RequestHandlerNotFoundError as e:
232+
return self._process_error(e, message, types.METHOD_NOT_FOUND)
233+
except ResourceNotFoundError as e:
234+
return self._process_error(e, message, types.RESOURCE_NOT_FOUND)
235+
except (MCPRuntimeError, ContextError, TimeoutError, ToolMCPRuntimeError) as e:
236+
return self._process_error(e, message, types.INTERNAL_ERROR)
237+
except InternalMCPError as e:
238+
return self._process_error(e, message, types.INTERNAL_ERROR)
239+
except Exception as e:
240+
return self._process_error(e, message, types.INTERNAL_ERROR)
241+
except anyio.get_cancelled_exc_class() as e:
242+
logger.debug("Task cancelled: %s. Message: %s", e, message)
243+
raise # Cancel must be re-raised
244+
245+
def _parse_message(self, message: Message) -> types.JSONRPCMessage:
246+
"""
247+
Parse the incoming MCP message from the client into a JSON-RPC message.
248+
249+
Args:
250+
message: The incoming MCP message from the client.
251+
252+
Returns:
253+
The parsed JSON-RPC message.
254+
255+
Raises:
256+
InvalidJSONError: If the message is not a valid JSON string.
257+
InvalidJSONRPCMessageError: If the message is not a valid JSON-RPC object.
258+
InvalidMCPMessageError: If the message is not a valid MCP message.
259+
"""
260+
try:
261+
return types.JSONRPCMessage.model_validate_json(message)
262+
except ValidationError as e:
263+
for error in e.errors():
264+
error_type = error.get("type", "")
265+
error_message = error.get("message", "")
266+
267+
if error_type in ("json_type", "json_invalid"):
268+
# message cannot be parsed as JSON string
269+
# json_type - message passed is not a string
270+
# json_invalid - message cannot be parsed as JSON
271+
raise InvalidJSONError(error_message) from e
272+
elif error_type == "model_type":
273+
# message is not a valid JSON-RPC object
274+
raise InvalidJSONRPCMessageError(error_message) from e
275+
elif error_type in ("missing", "literal_error") and not json_rpc.check_jsonrpc_version(message):
276+
# jsonrpc field is missing or not valid JSON-RPC version
277+
raise InvalidJSONRPCMessageError(error_message) from e
278+
279+
# Validation errors - Datatype mismatch, missing required fields, etc.
280+
raise InvalidMCPMessageError(str(e)) from e
281+
282+
async def _handle_rpc_msg(self, rpc_msg: types.JSONRPCMessage) -> Message | NoMessage:
283+
"""
284+
Handle a JSON-RPC MCP message. The message must be a request or notification.
285+
286+
Args:
287+
rpc_msg: The JSON-RPC MCP message to handle.
288+
289+
Returns:
290+
The response MCP message to the client. If the message is a notification,
291+
NoMessage.NOTIFICATION is returned.
292+
"""
293+
msg_root = rpc_msg.root
294+
295+
# --- Handle request ---
296+
if isinstance(msg_root, types.JSONRPCRequest):
297+
client_request = types.ClientRequest.model_validate(json_rpc.to_dict(msg_root))
298+
299+
logger.debug("Handling request %s - %s", msg_root.id, client_request)
300+
response = await self._handle_client_request(client_request)
301+
logger.debug("Successfully handled request %s - Response: %s", msg_root.id, response)
302+
303+
return json_rpc.build_response_message(msg_root.id, response)
304+
305+
# --- Handle notification ---
306+
elif isinstance(msg_root, types.JSONRPCNotification):
307+
# TODO: Add full support for client notification - This just implements the handler.
308+
client_notification = types.ClientNotification.model_validate(json_rpc.to_dict(msg_root))
309+
notification_id = uuid.uuid4() # Creating an id for debugging
310+
311+
logger.debug("Handling notification %s - %s", notification_id, client_notification)
312+
response = await self._handle_client_notification(client_notification)
313+
logger.debug("Successfully handled notification %s", notification_id)
314+
315+
return response
316+
else:
317+
raise UnsupportedMessageTypeError("Message to MCP server must be a request or notification")
318+
319+
async def _handle_client_request(self, request: types.ClientRequest) -> types.ServerResult:
320+
request_type = type(request.root)
321+
if handler := self._core.request_handlers.get(request_type):
322+
logger.debug("Dispatching request of type %s", request_type.__name__)
323+
return await handler(request.root)
324+
else:
325+
raise RequestHandlerNotFoundError(f"Method not found for request type {request_type.__name__}")
326+
327+
async def _handle_client_notification(self, notification: types.ClientNotification) -> NoMessage:
328+
notification_type = type(notification.root)
329+
if handler := self._core.notification_handlers.get(notification_type):
330+
logger.debug("Dispatching notification of type %s", notification_type.__name__)
331+
332+
try:
333+
# Avoiding the "fire-and-forget" pattern for notifications at the server layer.
334+
# This behavior should be handled at the transport layer.
335+
# This ensures all handlers are explicitly controlled and have a defined time to live.
336+
await handler(notification.root)
337+
except Exception:
338+
logger.exception("Uncaught exception in notification handler")
339+
340+
else:
341+
logger.debug("No handler found for notification type %s", notification_type.__name__)
342+
343+
return NoMessage.NOTIFICATION
344+
345+
async def _initialize_handler(self, req: types.InitializeRequest) -> types.ServerResult:
346+
"""
347+
Custom handler for the MCP InitializeRequest.
348+
It is directly hooked on initializing MiniMCP, and is an integral part of MCP client-server handshake.
349+
350+
Args:
351+
req: The InitializeRequest to handle.
352+
353+
Returns:
354+
The InitializeResult.
355+
"""
356+
client_protocol_version = req.params.protocolVersion
357+
server_protocol_version = (
358+
client_protocol_version
359+
if client_protocol_version in version.SUPPORTED_PROTOCOL_VERSIONS
360+
else types.LATEST_PROTOCOL_VERSION
361+
)
362+
# TODO: Error handling on protocol version mismatch. Handled in HTTP transport.
363+
# https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#error-handling
364+
365+
init_options = self._core.create_initialization_options(
366+
notification_options=self._notification_options,
367+
)
368+
369+
init_result = types.InitializeResult(
370+
protocolVersion=server_protocol_version,
371+
capabilities=init_options.capabilities,
372+
serverInfo=types.Implementation(
373+
name=init_options.server_name,
374+
version=init_options.server_version,
375+
),
376+
instructions=init_options.instructions,
377+
)
378+
379+
return types.ServerResult(init_result)
380+
381+
def _process_error(
382+
self,
383+
error: BaseException,
384+
request_message: Message,
385+
error_code: int,
386+
) -> Message:
387+
data = error.data if isinstance(error, InternalMCPError) else None
388+
389+
json_rpc_message, error_message = json_rpc.build_error_message(
390+
error,
391+
request_message,
392+
error_code,
393+
data=data,
394+
include_stack_trace=self._include_stack_trace,
395+
)
396+
397+
logger.error(error_message, exc_info=(type(error), error, error.__traceback__))
398+
399+
return json_rpc_message

0 commit comments

Comments
 (0)