zeromcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zeromcp/__init__.py +3 -0
- zeromcp/jsonrpc.py +223 -0
- zeromcp/mcp.py +466 -0
- zeromcp/py.typed +0 -0
- zeromcp-0.1.0.dist-info/METADATA +152 -0
- zeromcp-0.1.0.dist-info/RECORD +7 -0
- zeromcp-0.1.0.dist-info/WHEEL +4 -0
zeromcp/__init__.py
ADDED
zeromcp/jsonrpc.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import types
|
|
3
|
+
import inspect
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import Any, Callable, get_type_hints, get_origin, get_args, Union, TypedDict, TypeAlias, NotRequired
|
|
6
|
+
|
|
7
|
+
JsonRpcId: TypeAlias = str | int | float | None
|
|
8
|
+
JsonRpcParams: TypeAlias = dict[str, Any] | list[Any] | None
|
|
9
|
+
|
|
10
|
+
class JsonRpcRequest(TypedDict):
|
|
11
|
+
jsonrpc: str
|
|
12
|
+
method: str
|
|
13
|
+
params: NotRequired[JsonRpcParams]
|
|
14
|
+
id: NotRequired[JsonRpcId]
|
|
15
|
+
|
|
16
|
+
class JsonRpcError(TypedDict):
|
|
17
|
+
code: int
|
|
18
|
+
message: str
|
|
19
|
+
data: NotRequired[Any]
|
|
20
|
+
|
|
21
|
+
class JsonRpcResponse(TypedDict):
|
|
22
|
+
jsonrpc: str
|
|
23
|
+
result: NotRequired[Any]
|
|
24
|
+
error: NotRequired[JsonRpcError]
|
|
25
|
+
id: JsonRpcId
|
|
26
|
+
|
|
27
|
+
class JsonRpcException(Exception):
|
|
28
|
+
def __init__(self, code: int, message: str, data: Any = None):
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
self.data = data
|
|
32
|
+
|
|
33
|
+
class JsonRpcRegistry:
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.methods: dict[str, Callable] = {}
|
|
36
|
+
|
|
37
|
+
def method(self, func: Callable, name: str | None = None) -> Callable:
|
|
38
|
+
self.methods[name or func.__name__] = func # type: ignore
|
|
39
|
+
return func
|
|
40
|
+
|
|
41
|
+
def dispatch(self, request: dict | str | bytes | bytearray) -> JsonRpcResponse | None:
|
|
42
|
+
try:
|
|
43
|
+
if not isinstance(request, dict):
|
|
44
|
+
request = json.loads(request)
|
|
45
|
+
if not isinstance(request, dict):
|
|
46
|
+
return self._error(None, -32600, "Invalid request: must be a JSON object")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return self._error(None, -32700, "JSON parse error", str(e))
|
|
49
|
+
|
|
50
|
+
if request.get("jsonrpc") != "2.0":
|
|
51
|
+
return self._error(None, -32600, "Invalid request: 'jsonrpc' must be '2.0'")
|
|
52
|
+
|
|
53
|
+
method = request.get("method")
|
|
54
|
+
if method is None:
|
|
55
|
+
return self._error(None, -32600, "Invalid request: 'method' is required")
|
|
56
|
+
if not isinstance(method, str):
|
|
57
|
+
return self._error(None, -32600, "Invalid request: 'method' must be a string")
|
|
58
|
+
|
|
59
|
+
request_id: JsonRpcId = request.get("id")
|
|
60
|
+
is_notification = "id" not in request
|
|
61
|
+
params: JsonRpcParams = request.get("params")
|
|
62
|
+
try:
|
|
63
|
+
result = self._call(method, params)
|
|
64
|
+
if is_notification:
|
|
65
|
+
return None
|
|
66
|
+
return {
|
|
67
|
+
"jsonrpc": "2.0",
|
|
68
|
+
"result": result,
|
|
69
|
+
"id": request_id,
|
|
70
|
+
}
|
|
71
|
+
except JsonRpcException as e:
|
|
72
|
+
if is_notification:
|
|
73
|
+
return None
|
|
74
|
+
return self._error(request_id, e.code, e.message, e.data)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
if is_notification:
|
|
77
|
+
return None
|
|
78
|
+
error = self.map_exception(e)
|
|
79
|
+
return self._error(request_id, error["code"], error["message"], error.get("data"))
|
|
80
|
+
|
|
81
|
+
def map_exception(self, e: Exception) -> JsonRpcError:
|
|
82
|
+
return {
|
|
83
|
+
"code": -32603,
|
|
84
|
+
"message": "\n".join(traceback.format_exception(e)).strip() + "\n\nPlease report a bug!",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _call(self, method: str, params: Any) -> Any:
|
|
88
|
+
if method not in self.methods:
|
|
89
|
+
raise JsonRpcException(-32601, f"Method '{method}' not found")
|
|
90
|
+
|
|
91
|
+
func = self.methods[method]
|
|
92
|
+
sig = inspect.signature(func)
|
|
93
|
+
hints = get_type_hints(func)
|
|
94
|
+
hints.pop("return", None)
|
|
95
|
+
|
|
96
|
+
# Determine required vs optional parameters
|
|
97
|
+
required_params = []
|
|
98
|
+
for param_name, param in sig.parameters.items():
|
|
99
|
+
if param.default is inspect.Parameter.empty:
|
|
100
|
+
required_params.append(param_name)
|
|
101
|
+
|
|
102
|
+
# Handle None params
|
|
103
|
+
if params is None:
|
|
104
|
+
if len(required_params) == 0:
|
|
105
|
+
return func()
|
|
106
|
+
else:
|
|
107
|
+
raise JsonRpcException(-32602, "Missing required params")
|
|
108
|
+
|
|
109
|
+
# Convert list params to dict by parameter names
|
|
110
|
+
if isinstance(params, list):
|
|
111
|
+
if len(params) < len(required_params):
|
|
112
|
+
raise JsonRpcException(
|
|
113
|
+
-32602,
|
|
114
|
+
f"Invalid params: expected at least {len(required_params)} arguments, got {len(params)}"
|
|
115
|
+
)
|
|
116
|
+
if len(params) > len(hints):
|
|
117
|
+
raise JsonRpcException(
|
|
118
|
+
-32602,
|
|
119
|
+
f"Invalid params: expected at most {len(hints)} arguments, got {len(params)}"
|
|
120
|
+
)
|
|
121
|
+
params = dict(zip(hints.keys(), params))
|
|
122
|
+
|
|
123
|
+
# Validate dict params
|
|
124
|
+
if isinstance(params, dict):
|
|
125
|
+
# Check all required params are present
|
|
126
|
+
missing = set(required_params) - set(params.keys())
|
|
127
|
+
if missing:
|
|
128
|
+
raise JsonRpcException(
|
|
129
|
+
-32602,
|
|
130
|
+
f"Invalid params: missing required parameters: {list(missing)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Check no extra params
|
|
134
|
+
extra = set(params.keys()) - set(hints.keys())
|
|
135
|
+
if extra:
|
|
136
|
+
raise JsonRpcException(
|
|
137
|
+
-32602,
|
|
138
|
+
f"Invalid params: unexpected parameters: {list(extra)}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
validated_params = {}
|
|
142
|
+
for param_name, expected_type in hints.items():
|
|
143
|
+
if param_name not in params:
|
|
144
|
+
continue # Skip optional params not provided
|
|
145
|
+
|
|
146
|
+
value = params[param_name]
|
|
147
|
+
|
|
148
|
+
# Inline type validation
|
|
149
|
+
origin = get_origin(expected_type)
|
|
150
|
+
args = get_args(expected_type)
|
|
151
|
+
|
|
152
|
+
# Handle None/null
|
|
153
|
+
if value is None:
|
|
154
|
+
if expected_type is not type(None):
|
|
155
|
+
# Check if None is allowed in a Union
|
|
156
|
+
if not (origin is Union and type(None) in args):
|
|
157
|
+
raise JsonRpcException(-32602, f"Invalid params: {param_name} cannot be null")
|
|
158
|
+
validated_params[param_name] = None
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# Handle Union types (int | str, Optional[int], etc.)
|
|
162
|
+
if origin is Union or (hasattr(types, 'UnionType') and origin is types.UnionType):
|
|
163
|
+
type_matched = False
|
|
164
|
+
for arg_type in args:
|
|
165
|
+
if arg_type is type(None):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
arg_origin = get_origin(arg_type)
|
|
169
|
+
check_type = arg_origin if arg_origin is not None else arg_type
|
|
170
|
+
|
|
171
|
+
if isinstance(value, check_type):
|
|
172
|
+
type_matched = True
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if not type_matched:
|
|
176
|
+
raise JsonRpcException(-32602, f"Invalid params: {param_name} has invalid type")
|
|
177
|
+
validated_params[param_name] = value
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Handle generic types (list[X], dict[K,V])
|
|
181
|
+
if origin is not None:
|
|
182
|
+
if not isinstance(value, origin):
|
|
183
|
+
raise JsonRpcException(
|
|
184
|
+
-32602,
|
|
185
|
+
f"Invalid params: {param_name} expected {origin.__name__}, got {type(value).__name__}"
|
|
186
|
+
)
|
|
187
|
+
validated_params[param_name] = value
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Handle basic types
|
|
191
|
+
if isinstance(expected_type, type):
|
|
192
|
+
# Allow int -> float conversion
|
|
193
|
+
if expected_type is float and isinstance(value, int):
|
|
194
|
+
validated_params[param_name] = float(value)
|
|
195
|
+
continue
|
|
196
|
+
if not isinstance(value, expected_type):
|
|
197
|
+
raise JsonRpcException(
|
|
198
|
+
-32602,
|
|
199
|
+
f"Invalid params: {param_name} expected {expected_type.__name__}, got {type(value).__name__}"
|
|
200
|
+
)
|
|
201
|
+
validated_params[param_name] = value
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Fallback for Any or unknown
|
|
205
|
+
validated_params[param_name] = value
|
|
206
|
+
|
|
207
|
+
return func(**validated_params)
|
|
208
|
+
|
|
209
|
+
else:
|
|
210
|
+
raise JsonRpcException(-32602, "Invalid params: must be array or object")
|
|
211
|
+
|
|
212
|
+
def _error(self, request_id: JsonRpcId, code: int, message: str, data: Any = None) -> JsonRpcResponse | None:
|
|
213
|
+
error: JsonRpcError = {
|
|
214
|
+
"code": code,
|
|
215
|
+
"message": message,
|
|
216
|
+
}
|
|
217
|
+
if data is not None:
|
|
218
|
+
error["data"] = data
|
|
219
|
+
return {
|
|
220
|
+
"jsonrpc": "2.0",
|
|
221
|
+
"error": error,
|
|
222
|
+
"id": request_id,
|
|
223
|
+
}
|
zeromcp/mcp.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import traceback
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from typing import Any, Callable, get_type_hints, Annotated
|
|
8
|
+
from urllib.parse import urlparse, parse_qs
|
|
9
|
+
from io import BufferedIOBase
|
|
10
|
+
|
|
11
|
+
from zeromcp.jsonrpc import JsonRpcRegistry, JsonRpcError
|
|
12
|
+
|
|
13
|
+
class McpToolError(Exception):
|
|
14
|
+
def __init__(self, message: str):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
|
|
17
|
+
class McpToolRegistry(JsonRpcRegistry):
|
|
18
|
+
"""JSON-RPC registry with custom error handling for MCP tools"""
|
|
19
|
+
def map_exception(self, e: Exception) -> JsonRpcError:
|
|
20
|
+
if isinstance(e, McpToolError):
|
|
21
|
+
return {
|
|
22
|
+
"code": -32000,
|
|
23
|
+
"message": e.args[0] or "MCP Tool Error",
|
|
24
|
+
}
|
|
25
|
+
return super().map_exception(e)
|
|
26
|
+
|
|
27
|
+
class _McpSseConnection:
|
|
28
|
+
"""Manages a single SSE client connection"""
|
|
29
|
+
def __init__(self, wfile):
|
|
30
|
+
self.wfile: BufferedIOBase = wfile
|
|
31
|
+
self.session_id = str(uuid.uuid4())
|
|
32
|
+
self.alive = True
|
|
33
|
+
|
|
34
|
+
def send_event(self, event_type: str, data):
|
|
35
|
+
"""Send an SSE event to the client
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
event_type: Type of event (e.g., "endpoint", "message", "ping")
|
|
39
|
+
data: Event data - can be string (sent as-is) or dict (JSON-encoded)
|
|
40
|
+
"""
|
|
41
|
+
if not self.alive:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# SSE format: "event: type\ndata: content\n\n"
|
|
46
|
+
if isinstance(data, str):
|
|
47
|
+
data_str = f"data: {data}\n\n"
|
|
48
|
+
else:
|
|
49
|
+
data_str = f"data: {json.dumps(data)}\n\n"
|
|
50
|
+
message = f"event: {event_type}\n{data_str}".encode("utf-8")
|
|
51
|
+
self.wfile.write(message)
|
|
52
|
+
self.wfile.flush() # Ensure data is sent immediately
|
|
53
|
+
return True
|
|
54
|
+
except (BrokenPipeError, OSError):
|
|
55
|
+
self.alive = False
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
class _McpHttpRequestHandler(BaseHTTPRequestHandler):
|
|
59
|
+
mcp_server: "McpServer"
|
|
60
|
+
|
|
61
|
+
def log_message(self, format, *args):
|
|
62
|
+
"""Override to suppress default logging or customize"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def handle(self):
|
|
66
|
+
"""Override to add error handling for connection errors"""
|
|
67
|
+
try:
|
|
68
|
+
super().handle()
|
|
69
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
70
|
+
# Client disconnected - normal, suppress traceback
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def do_GET(self):
|
|
74
|
+
match urlparse(self.path).path:
|
|
75
|
+
case "/mcp":
|
|
76
|
+
self.send_error(405, "Method Not Allowed")
|
|
77
|
+
case "/sse":
|
|
78
|
+
self._handle_sse_get()
|
|
79
|
+
case _:
|
|
80
|
+
self.send_error(404, "Not Found")
|
|
81
|
+
|
|
82
|
+
def do_POST(self):
|
|
83
|
+
# Read request body (TODO: do we need to handle chunked encoding and what about no Content-Length?)
|
|
84
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
85
|
+
body = self.rfile.read(content_length) if content_length > 0 else b""
|
|
86
|
+
|
|
87
|
+
match urlparse(self.path).path:
|
|
88
|
+
case "/mcp":
|
|
89
|
+
self._handle_mcp_post(body)
|
|
90
|
+
case "/sse":
|
|
91
|
+
self._handle_sse_post(body)
|
|
92
|
+
case _:
|
|
93
|
+
self.send_error(404, "Not Found")
|
|
94
|
+
|
|
95
|
+
def do_OPTIONS(self):
|
|
96
|
+
"""Handle CORS preflight requests"""
|
|
97
|
+
self.send_response(200)
|
|
98
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
99
|
+
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
100
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, Mcp-Session-Id, Mcp-Protocol-Version")
|
|
101
|
+
self.send_header("Access-Control-Max-Age", "86400")
|
|
102
|
+
self.end_headers()
|
|
103
|
+
|
|
104
|
+
def _handle_mcp_post(self, body: bytes):
|
|
105
|
+
# Dispatch to MCP registry
|
|
106
|
+
response = self.mcp_server.mcp_registry.dispatch(body)
|
|
107
|
+
|
|
108
|
+
def send_response(status: int, body: bytes):
|
|
109
|
+
self.send_response(status)
|
|
110
|
+
self.send_header("Content-Type", "application/json")
|
|
111
|
+
self.send_header("Content-Length", str(len(body)))
|
|
112
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
113
|
+
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
114
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version")
|
|
115
|
+
self.end_headers()
|
|
116
|
+
self.wfile.write(body)
|
|
117
|
+
|
|
118
|
+
# Check if notification (returns None)
|
|
119
|
+
if response is None:
|
|
120
|
+
send_response(202, b"Accepted")
|
|
121
|
+
else:
|
|
122
|
+
send_response(200, json.dumps(response).encode("utf-8"))
|
|
123
|
+
|
|
124
|
+
def _handle_sse_get(self):
|
|
125
|
+
# Create SSE connection wrapper
|
|
126
|
+
conn = _McpSseConnection(self.wfile)
|
|
127
|
+
self.mcp_server.sse_connections[conn.session_id] = conn
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Send SSE headers
|
|
131
|
+
self.send_response(200)
|
|
132
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
133
|
+
self.send_header("Cache-Control", "no-cache")
|
|
134
|
+
self.send_header("Connection", "keep-alive")
|
|
135
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
136
|
+
self.end_headers()
|
|
137
|
+
|
|
138
|
+
# Send endpoint event with session ID for routing
|
|
139
|
+
conn.send_event("endpoint", f"/sse?session={conn.session_id}")
|
|
140
|
+
|
|
141
|
+
# Keep connection alive with periodic pings
|
|
142
|
+
last_ping = time.time()
|
|
143
|
+
while conn.alive and self.mcp_server.running:
|
|
144
|
+
now = time.time()
|
|
145
|
+
if now - last_ping > 30: # Ping every 30 seconds
|
|
146
|
+
if not conn.send_event("ping", {}):
|
|
147
|
+
break
|
|
148
|
+
last_ping = now
|
|
149
|
+
time.sleep(1)
|
|
150
|
+
|
|
151
|
+
finally:
|
|
152
|
+
conn.alive = False
|
|
153
|
+
if conn.session_id in self.mcp_server.sse_connections:
|
|
154
|
+
del self.mcp_server.sse_connections[conn.session_id]
|
|
155
|
+
|
|
156
|
+
def _handle_sse_post(self, body: bytes):
|
|
157
|
+
query_params = parse_qs(urlparse(self.path).query)
|
|
158
|
+
session_id = query_params.get("session", [None])[0]
|
|
159
|
+
if session_id is None:
|
|
160
|
+
self.send_error(400, "Missing ?session for SSE POST")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Dispatch to MCP registry
|
|
164
|
+
response = self.mcp_server.mcp_registry.dispatch(body)
|
|
165
|
+
|
|
166
|
+
# Send SSE response if necessary
|
|
167
|
+
if response is not None:
|
|
168
|
+
sse_conn = self.mcp_server.sse_connections.get(session_id)
|
|
169
|
+
if sse_conn is None or not sse_conn.alive:
|
|
170
|
+
# No SSE connection found
|
|
171
|
+
error_msg = f"No active SSE connection found for session {session_id}"
|
|
172
|
+
print(f"[MCP SSE ERROR] {error_msg}")
|
|
173
|
+
self.send_error(400, error_msg)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Send response via SSE event stream
|
|
177
|
+
sse_conn.send_event("message", response)
|
|
178
|
+
|
|
179
|
+
# Return 202 Accepted to acknowledge POST
|
|
180
|
+
self.send_response(202)
|
|
181
|
+
self.send_header("Content-Type", "application/json")
|
|
182
|
+
self.send_header("Content-Length", str(len(body)))
|
|
183
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
184
|
+
self.end_headers()
|
|
185
|
+
self.wfile.write(body)
|
|
186
|
+
|
|
187
|
+
class McpServer:
|
|
188
|
+
def __init__(self, name: str):
|
|
189
|
+
self.name = name
|
|
190
|
+
self.tools = McpToolRegistry()
|
|
191
|
+
|
|
192
|
+
self.http_server = None
|
|
193
|
+
self.server_thread = None
|
|
194
|
+
self.running = False
|
|
195
|
+
self.sse_connections: dict[str, _McpSseConnection] = {}
|
|
196
|
+
|
|
197
|
+
# Register MCP protocol methods with correct names
|
|
198
|
+
self.mcp_registry = JsonRpcRegistry()
|
|
199
|
+
self.mcp_registry.methods["ping"] = self._mcp_ping
|
|
200
|
+
self.mcp_registry.methods["initialize"] = self._mcp_initialize
|
|
201
|
+
self.mcp_registry.methods["tools/list"] = self._mcp_tools_list
|
|
202
|
+
self.mcp_registry.methods["tools/call"] = self._mcp_tools_call
|
|
203
|
+
|
|
204
|
+
def tool(self, func: Callable) -> Callable:
|
|
205
|
+
return self.tools.method(func)
|
|
206
|
+
|
|
207
|
+
def start(self, host: str, port: int):
|
|
208
|
+
if self.running:
|
|
209
|
+
print("[MCP] Server is already running")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
self.server_thread = threading.Thread(target=self._run_server, daemon=True, args=(host, port))
|
|
213
|
+
self.running = True
|
|
214
|
+
self.server_thread.start()
|
|
215
|
+
|
|
216
|
+
def stop(self):
|
|
217
|
+
if not self.running:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
self.running = False
|
|
221
|
+
|
|
222
|
+
# Close all SSE connections
|
|
223
|
+
for conn in self.sse_connections.values():
|
|
224
|
+
conn.alive = False
|
|
225
|
+
self.sse_connections.clear()
|
|
226
|
+
|
|
227
|
+
# Shutdown the HTTP server
|
|
228
|
+
if self.http_server:
|
|
229
|
+
# shutdown() must be called from a different thread
|
|
230
|
+
# than the one running serve_forever()
|
|
231
|
+
self.http_server.shutdown()
|
|
232
|
+
self.http_server.server_close()
|
|
233
|
+
self.http_server = None
|
|
234
|
+
|
|
235
|
+
if self.server_thread:
|
|
236
|
+
self.server_thread.join(timeout=2)
|
|
237
|
+
|
|
238
|
+
print("[MCP] Server stopped")
|
|
239
|
+
|
|
240
|
+
def _run_server(self, host: str, port: int):
|
|
241
|
+
"""Run the HTTP server main loop using ThreadingHTTPServer"""
|
|
242
|
+
# Set the MCPServer instance on the handler class
|
|
243
|
+
_McpHttpRequestHandler.mcp_server = self
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Create HTTP server with threading support and exclusive binding
|
|
247
|
+
self.http_server = ThreadingHTTPServer(
|
|
248
|
+
(host, port),
|
|
249
|
+
_McpHttpRequestHandler
|
|
250
|
+
)
|
|
251
|
+
self.http_server.allow_reuse_address = False
|
|
252
|
+
|
|
253
|
+
print("[MCP] Server started:")
|
|
254
|
+
print(f" Streamable HTTP: http://{host}:{port}/mcp")
|
|
255
|
+
print(f" SSE: http://{host}:{port}/sse")
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Serve until shutdown() is called
|
|
259
|
+
self.http_server.serve_forever()
|
|
260
|
+
except Exception as e:
|
|
261
|
+
print(f"[MCP] Server error: {e}")
|
|
262
|
+
traceback.print_exc()
|
|
263
|
+
finally:
|
|
264
|
+
self.running = False
|
|
265
|
+
|
|
266
|
+
def _mcp_ping(self, _meta: dict | None = None) -> dict:
|
|
267
|
+
"""MCP ping method"""
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
def _mcp_initialize(self, protocolVersion: str, capabilities: dict, clientInfo: dict, _meta: dict | None = None) -> dict:
|
|
271
|
+
"""MCP initialize method"""
|
|
272
|
+
return {
|
|
273
|
+
"protocolVersion": protocolVersion,
|
|
274
|
+
"capabilities": {
|
|
275
|
+
"tools": {}
|
|
276
|
+
},
|
|
277
|
+
"serverInfo": {
|
|
278
|
+
"name": self.name,
|
|
279
|
+
"version": "1.0.0"
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def _mcp_tools_list(self, _meta: dict | None = None) -> dict:
|
|
284
|
+
"""MCP tools/list method"""
|
|
285
|
+
return {
|
|
286
|
+
"tools": [
|
|
287
|
+
self._generate_tool_schema(func_name, func)
|
|
288
|
+
for func_name, func in self.tools.methods.items()
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def _mcp_tools_call(self, name: str, arguments: dict | None = None, _meta: dict | None = None) -> dict:
|
|
293
|
+
"""MCP tools/call method"""
|
|
294
|
+
# Wrap tool call in JSON-RPC request
|
|
295
|
+
tool_response = self.tools.dispatch({
|
|
296
|
+
"jsonrpc": "2.0",
|
|
297
|
+
"method": name,
|
|
298
|
+
"params": arguments,
|
|
299
|
+
"id": None,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
# Check for error response
|
|
303
|
+
if tool_response and "error" in tool_response:
|
|
304
|
+
error = tool_response["error"]
|
|
305
|
+
return {
|
|
306
|
+
"content": [{"type": "text", "text": error.get("message", "Unknown error")}],
|
|
307
|
+
"isError": True
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
result = tool_response.get("result") if tool_response else None
|
|
311
|
+
return {
|
|
312
|
+
"content": [{"type": "text", "text": json.dumps(result, indent=2)}],
|
|
313
|
+
"structuredContent": result if isinstance(result, dict) else {"value": result},
|
|
314
|
+
"isError": False
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
def _type_to_json_schema(self, py_type: Any) -> dict:
|
|
318
|
+
"""Convert Python type hint to JSON schema object"""
|
|
319
|
+
from typing import get_origin, get_args, Union
|
|
320
|
+
|
|
321
|
+
# Handle Annotated[Type, "description"]
|
|
322
|
+
if get_origin(py_type) is Annotated:
|
|
323
|
+
args = get_args(py_type)
|
|
324
|
+
actual_type = args[0]
|
|
325
|
+
description = args[1] if len(args) > 1 else None
|
|
326
|
+
schema = self._type_to_json_schema(actual_type)
|
|
327
|
+
if description:
|
|
328
|
+
schema["description"] = description
|
|
329
|
+
return schema
|
|
330
|
+
|
|
331
|
+
# Handle Union/Optional types
|
|
332
|
+
if get_origin(py_type) is Union:
|
|
333
|
+
union_args = get_args(py_type)
|
|
334
|
+
non_none = [t for t in union_args if t is not type(None)]
|
|
335
|
+
if len(non_none) == 1:
|
|
336
|
+
return self._type_to_json_schema(non_none[0])
|
|
337
|
+
# Multiple types -> anyOf
|
|
338
|
+
return {"anyOf": [self._type_to_json_schema(t) for t in non_none]}
|
|
339
|
+
|
|
340
|
+
# Primitives
|
|
341
|
+
if py_type == int:
|
|
342
|
+
return {"type": "integer"}
|
|
343
|
+
if py_type == float:
|
|
344
|
+
return {"type": "number"}
|
|
345
|
+
if py_type == str:
|
|
346
|
+
return {"type": "string"}
|
|
347
|
+
if py_type == bool:
|
|
348
|
+
return {"type": "boolean"}
|
|
349
|
+
|
|
350
|
+
# Handle list types
|
|
351
|
+
if py_type == list or get_origin(py_type) is list:
|
|
352
|
+
args = get_args(py_type)
|
|
353
|
+
schema: dict[str, Any] = {"type": "array"}
|
|
354
|
+
if args:
|
|
355
|
+
schema["items"] = self._type_to_json_schema(args[0])
|
|
356
|
+
return schema
|
|
357
|
+
|
|
358
|
+
# Handle dict types
|
|
359
|
+
if py_type == dict or get_origin(py_type) is dict:
|
|
360
|
+
return {"type": "object"}
|
|
361
|
+
|
|
362
|
+
# TypedDict detection
|
|
363
|
+
if hasattr(py_type, "__annotations__"):
|
|
364
|
+
if hasattr(py_type, "__required_keys__") or hasattr(py_type, "__optional_keys__"):
|
|
365
|
+
return self._typed_dict_to_schema(py_type)
|
|
366
|
+
|
|
367
|
+
# Fallback
|
|
368
|
+
return {"type": "object"}
|
|
369
|
+
|
|
370
|
+
def _typed_dict_to_schema(self, typed_dict_class) -> dict:
|
|
371
|
+
"""Convert TypedDict to JSON schema"""
|
|
372
|
+
try:
|
|
373
|
+
from typing_extensions import NotRequired
|
|
374
|
+
except ImportError:
|
|
375
|
+
from typing import NotRequired
|
|
376
|
+
|
|
377
|
+
from typing import get_origin, get_args
|
|
378
|
+
|
|
379
|
+
hints = get_type_hints(typed_dict_class, include_extras=True)
|
|
380
|
+
properties = {}
|
|
381
|
+
required = []
|
|
382
|
+
|
|
383
|
+
for field_name, field_type in hints.items():
|
|
384
|
+
# Check if field is NotRequired
|
|
385
|
+
is_not_required = get_origin(field_type) is NotRequired
|
|
386
|
+
if is_not_required:
|
|
387
|
+
field_type = get_args(field_type)[0]
|
|
388
|
+
|
|
389
|
+
properties[field_name] = self._type_to_json_schema(field_type)
|
|
390
|
+
|
|
391
|
+
# Add to required if not NotRequired
|
|
392
|
+
if not is_not_required:
|
|
393
|
+
# Also check __required_keys__ if available
|
|
394
|
+
if hasattr(typed_dict_class, "__required_keys__"):
|
|
395
|
+
if field_name in typed_dict_class.__required_keys__:
|
|
396
|
+
if field_name not in required:
|
|
397
|
+
required.append(field_name)
|
|
398
|
+
else:
|
|
399
|
+
# Default to required if no __required_keys__
|
|
400
|
+
required.append(field_name)
|
|
401
|
+
|
|
402
|
+
schema = {
|
|
403
|
+
"type": "object",
|
|
404
|
+
"properties": properties
|
|
405
|
+
}
|
|
406
|
+
if required:
|
|
407
|
+
schema["required"] = required
|
|
408
|
+
|
|
409
|
+
return schema
|
|
410
|
+
|
|
411
|
+
def _generate_tool_schema(self, func_name: str, func: Callable) -> dict:
|
|
412
|
+
"""Generate MCP tool schema from a function"""
|
|
413
|
+
import inspect
|
|
414
|
+
|
|
415
|
+
hints = get_type_hints(func, include_extras=True)
|
|
416
|
+
return_type = hints.pop("return", None)
|
|
417
|
+
sig = inspect.signature(func)
|
|
418
|
+
|
|
419
|
+
# Build parameter schema
|
|
420
|
+
properties = {}
|
|
421
|
+
required = []
|
|
422
|
+
|
|
423
|
+
for param_name, param_type in hints.items():
|
|
424
|
+
# Check if parameter has default value
|
|
425
|
+
param = sig.parameters.get(param_name)
|
|
426
|
+
has_default = param and param.default is not inspect.Parameter.empty
|
|
427
|
+
|
|
428
|
+
# Use _type_to_json_schema to handle all type conversions including Union
|
|
429
|
+
properties[param_name] = self._type_to_json_schema(param_type)
|
|
430
|
+
|
|
431
|
+
# Only add to required if no default value
|
|
432
|
+
if not has_default:
|
|
433
|
+
required.append(param_name)
|
|
434
|
+
|
|
435
|
+
# Get docstring as description
|
|
436
|
+
description = func.__doc__ or f"Call {func_name}"
|
|
437
|
+
if description:
|
|
438
|
+
description = description.strip()
|
|
439
|
+
|
|
440
|
+
schema: dict[str, Any] = {
|
|
441
|
+
"name": func_name,
|
|
442
|
+
"description": description,
|
|
443
|
+
"inputSchema": {
|
|
444
|
+
"type": "object",
|
|
445
|
+
"properties": properties,
|
|
446
|
+
"required": required
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Add outputSchema if return type exists and is not None
|
|
451
|
+
if return_type and return_type is not type(None):
|
|
452
|
+
return_schema = self._type_to_json_schema(return_type)
|
|
453
|
+
# MCP spec requires outputSchema to always be type: object
|
|
454
|
+
# Wrap primitives in an object with a "value" property
|
|
455
|
+
if return_schema.get("type") != "object":
|
|
456
|
+
schema["outputSchema"] = {
|
|
457
|
+
"type": "object",
|
|
458
|
+
"properties": {
|
|
459
|
+
"value": return_schema
|
|
460
|
+
},
|
|
461
|
+
"required": ["value"]
|
|
462
|
+
}
|
|
463
|
+
else:
|
|
464
|
+
schema["outputSchema"] = return_schema
|
|
465
|
+
|
|
466
|
+
return schema
|
zeromcp/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zeromcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency MCP server implementation
|
|
5
|
+
Project-URL: Homepage, https://github.com/mrexodia/zeromcp
|
|
6
|
+
Project-URL: Repository, https://github.com/mrexodia/zeromcp
|
|
7
|
+
Project-URL: Issues, https://github.com/mrexodia/zeromcp/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# zeromcp
|
|
13
|
+
|
|
14
|
+
**Minimal MCP server implementation in pure Python.**
|
|
15
|
+
|
|
16
|
+
A lightweight, handcrafted implementation of the [Model Context Protocol](https://modelcontextprotocol.io/) focused on what most users actually need: exposing tools with clean Python type annotations.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- ✨ **Zero dependencies** - Pure Python, standard library only
|
|
21
|
+
- 🎯 **Type-safe** - Native Python type annotations for everything
|
|
22
|
+
- 🚀 **Fast** - Minimal overhead, maximum performance
|
|
23
|
+
- 🛠️ **Handcrafted** - Written by a human, verified against the spec
|
|
24
|
+
- 🌐 **HTTP/SSE transport** - Streamable responses (stdio planned)
|
|
25
|
+
- 📦 **Tiny** - Less than 1,000 lines of code
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install zeromcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or with uv:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add zeromcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from typing import Annotated
|
|
43
|
+
from zeromcp import McpServer
|
|
44
|
+
|
|
45
|
+
mcp = McpServer("my-server")
|
|
46
|
+
|
|
47
|
+
@mcp.tool
|
|
48
|
+
def greet(
|
|
49
|
+
name: Annotated[str, "Name to greet"],
|
|
50
|
+
age: Annotated[int | None, "Age of person"] = None
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Generate a greeting message"""
|
|
53
|
+
if age:
|
|
54
|
+
return f"Hello, {name}! You are {age} years old."
|
|
55
|
+
return f"Hello, {name}!"
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
mcp.start("127.0.0.1", 8000)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then manually test your MCP server with the [inspector](https://github.com/modelcontextprotocol/inspector):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx -y @modelcontextprotocol/inspector
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Once things are working you can configure the `mcp.json`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"my-server": {
|
|
73
|
+
"type": "http",
|
|
74
|
+
"url": "http://127.0.0.1/mcp"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Type Annotations
|
|
81
|
+
|
|
82
|
+
zeromcp uses native Python `Annotated` types for schema generation:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from typing import Annotated, Optional, TypedDict, NotRequired
|
|
86
|
+
|
|
87
|
+
class GreetingResponse(TypedDict):
|
|
88
|
+
message: Annotated[str, "Greeting message"]
|
|
89
|
+
name: Annotated[str, "Name that was greeted"]
|
|
90
|
+
age: Annotated[NotRequired[int], "Age if provided"]
|
|
91
|
+
|
|
92
|
+
@mcp.tool
|
|
93
|
+
def greet(
|
|
94
|
+
name: Annotated[str, "Name to greet"],
|
|
95
|
+
age: Annotated[Optional[int], "Age of person"] = None
|
|
96
|
+
) -> GreetingResponse:
|
|
97
|
+
"""Generate a greeting message"""
|
|
98
|
+
if age is not None:
|
|
99
|
+
return {
|
|
100
|
+
"message": f"Hello, {name}! You are {age} years old.",
|
|
101
|
+
"name": name,
|
|
102
|
+
"age": age
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
"message": f"Hello, {name}!",
|
|
106
|
+
"name": name
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Union Types
|
|
111
|
+
|
|
112
|
+
Tools can accept multiple input types:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from typing import Annotated, TypedDict
|
|
116
|
+
|
|
117
|
+
class StructInfo(TypedDict):
|
|
118
|
+
name: Annotated[str, "Structure name"]
|
|
119
|
+
size: Annotated[int, "Structure size in bytes"]
|
|
120
|
+
fields: Annotated[list[str], "List of field names"]
|
|
121
|
+
|
|
122
|
+
@mcp.tool
|
|
123
|
+
def struct_get(
|
|
124
|
+
names: Annotated[list[str], "Array of structure names"]
|
|
125
|
+
| Annotated[str, "Single structure name"]
|
|
126
|
+
) -> list[StructInfo]:
|
|
127
|
+
"""Retrieve structure information by names"""
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
"name": name,
|
|
131
|
+
"size": 128,
|
|
132
|
+
"fields": ["field1", "field2", "field3"]
|
|
133
|
+
}
|
|
134
|
+
for name in (names if isinstance(names, list) else [names])
|
|
135
|
+
]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Error Handling
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from zeromcp import McpToolError
|
|
142
|
+
|
|
143
|
+
@mcp.tool
|
|
144
|
+
def divide(
|
|
145
|
+
numerator: Annotated[float, "Numerator"],
|
|
146
|
+
denominator: Annotated[float, "Denominator"]
|
|
147
|
+
) -> float:
|
|
148
|
+
"""Divide two numbers"""
|
|
149
|
+
if denominator == 0:
|
|
150
|
+
raise McpToolError("Division by zero")
|
|
151
|
+
return numerator / denominator
|
|
152
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
zeromcp/__init__.py,sha256=ukyvFUDieMQ7RxpBJg_Bvk8vIQV9k4s8WJwBOTthr6Y,82
|
|
2
|
+
zeromcp/jsonrpc.py,sha256=B-W7BjTBaFC78e3tOudlBDYVkC1EJDAOM8KKkMJAkCg,8750
|
|
3
|
+
zeromcp/mcp.py,sha256=9xttMgav4wrHFbey9sPRAiEGx7U8oguKhVDYtjO4r0Y,17462
|
|
4
|
+
zeromcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
zeromcp-0.1.0.dist-info/METADATA,sha256=mG7zwAbOqm89freH8XMnjzrVBJsLDZWls1mljoOc6hI,3752
|
|
6
|
+
zeromcp-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
zeromcp-0.1.0.dist-info/RECORD,,
|