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 ADDED
@@ -0,0 +1,3 @@
1
+ from .mcp import McpServer, McpToolError
2
+
3
+ __all__ = ["McpServer", "McpToolError"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any