zeromcp 0.1.0__tar.gz
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-0.1.0/.gitignore +10 -0
- zeromcp-0.1.0/.python-version +1 -0
- zeromcp-0.1.0/PKG-INFO +152 -0
- zeromcp-0.1.0/README.md +141 -0
- zeromcp-0.1.0/examples/mcp_example.py +99 -0
- zeromcp-0.1.0/pyproject.toml +17 -0
- zeromcp-0.1.0/src/zeromcp/__init__.py +3 -0
- zeromcp-0.1.0/src/zeromcp/jsonrpc.py +223 -0
- zeromcp-0.1.0/src/zeromcp/mcp.py +466 -0
- zeromcp-0.1.0/src/zeromcp/py.typed +0 -0
- zeromcp-0.1.0/tests/jsonrpc_test.py +484 -0
- zeromcp-0.1.0/uv.lock +8 -0
zeromcp-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
zeromcp-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
zeromcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# zeromcp
|
|
2
|
+
|
|
3
|
+
**Minimal MCP server implementation in pure Python.**
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- ✨ **Zero dependencies** - Pure Python, standard library only
|
|
10
|
+
- 🎯 **Type-safe** - Native Python type annotations for everything
|
|
11
|
+
- 🚀 **Fast** - Minimal overhead, maximum performance
|
|
12
|
+
- 🛠️ **Handcrafted** - Written by a human, verified against the spec
|
|
13
|
+
- 🌐 **HTTP/SSE transport** - Streamable responses (stdio planned)
|
|
14
|
+
- 📦 **Tiny** - Less than 1,000 lines of code
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install zeromcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or with uv:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv add zeromcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from typing import Annotated
|
|
32
|
+
from zeromcp import McpServer
|
|
33
|
+
|
|
34
|
+
mcp = McpServer("my-server")
|
|
35
|
+
|
|
36
|
+
@mcp.tool
|
|
37
|
+
def greet(
|
|
38
|
+
name: Annotated[str, "Name to greet"],
|
|
39
|
+
age: Annotated[int | None, "Age of person"] = None
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Generate a greeting message"""
|
|
42
|
+
if age:
|
|
43
|
+
return f"Hello, {name}! You are {age} years old."
|
|
44
|
+
return f"Hello, {name}!"
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
mcp.start("127.0.0.1", 8000)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then manually test your MCP server with the [inspector](https://github.com/modelcontextprotocol/inspector):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx -y @modelcontextprotocol/inspector
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Once things are working you can configure the `mcp.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"my-server": {
|
|
62
|
+
"type": "http",
|
|
63
|
+
"url": "http://127.0.0.1/mcp"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Type Annotations
|
|
70
|
+
|
|
71
|
+
zeromcp uses native Python `Annotated` types for schema generation:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from typing import Annotated, Optional, TypedDict, NotRequired
|
|
75
|
+
|
|
76
|
+
class GreetingResponse(TypedDict):
|
|
77
|
+
message: Annotated[str, "Greeting message"]
|
|
78
|
+
name: Annotated[str, "Name that was greeted"]
|
|
79
|
+
age: Annotated[NotRequired[int], "Age if provided"]
|
|
80
|
+
|
|
81
|
+
@mcp.tool
|
|
82
|
+
def greet(
|
|
83
|
+
name: Annotated[str, "Name to greet"],
|
|
84
|
+
age: Annotated[Optional[int], "Age of person"] = None
|
|
85
|
+
) -> GreetingResponse:
|
|
86
|
+
"""Generate a greeting message"""
|
|
87
|
+
if age is not None:
|
|
88
|
+
return {
|
|
89
|
+
"message": f"Hello, {name}! You are {age} years old.",
|
|
90
|
+
"name": name,
|
|
91
|
+
"age": age
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
"message": f"Hello, {name}!",
|
|
95
|
+
"name": name
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Union Types
|
|
100
|
+
|
|
101
|
+
Tools can accept multiple input types:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from typing import Annotated, TypedDict
|
|
105
|
+
|
|
106
|
+
class StructInfo(TypedDict):
|
|
107
|
+
name: Annotated[str, "Structure name"]
|
|
108
|
+
size: Annotated[int, "Structure size in bytes"]
|
|
109
|
+
fields: Annotated[list[str], "List of field names"]
|
|
110
|
+
|
|
111
|
+
@mcp.tool
|
|
112
|
+
def struct_get(
|
|
113
|
+
names: Annotated[list[str], "Array of structure names"]
|
|
114
|
+
| Annotated[str, "Single structure name"]
|
|
115
|
+
) -> list[StructInfo]:
|
|
116
|
+
"""Retrieve structure information by names"""
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
"name": name,
|
|
120
|
+
"size": 128,
|
|
121
|
+
"fields": ["field1", "field2", "field3"]
|
|
122
|
+
}
|
|
123
|
+
for name in (names if isinstance(names, list) else [names])
|
|
124
|
+
]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Error Handling
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from zeromcp import McpToolError
|
|
131
|
+
|
|
132
|
+
@mcp.tool
|
|
133
|
+
def divide(
|
|
134
|
+
numerator: Annotated[float, "Numerator"],
|
|
135
|
+
denominator: Annotated[float, "Denominator"]
|
|
136
|
+
) -> float:
|
|
137
|
+
"""Divide two numbers"""
|
|
138
|
+
if denominator == 0:
|
|
139
|
+
raise McpToolError("Division by zero")
|
|
140
|
+
return numerator / denominator
|
|
141
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Example MCP server with test tools"""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Annotated, Optional, TypedDict, NotRequired
|
|
4
|
+
from zeromcp import McpToolError, McpServer
|
|
5
|
+
|
|
6
|
+
mcp = McpServer("example")
|
|
7
|
+
|
|
8
|
+
class SystemInfo(TypedDict):
|
|
9
|
+
platform: Annotated[str, "Operating system platform"]
|
|
10
|
+
python_version: Annotated[str, "Python version"]
|
|
11
|
+
machine: Annotated[str, "Machine architecture"]
|
|
12
|
+
timestamp: Annotated[float, "Current timestamp"]
|
|
13
|
+
|
|
14
|
+
class GreetingResponse(TypedDict):
|
|
15
|
+
message: Annotated[str, "Greeting message"]
|
|
16
|
+
name: Annotated[str, "Name that was greeted"]
|
|
17
|
+
age: Annotated[NotRequired[int], "Age if provided"]
|
|
18
|
+
|
|
19
|
+
@mcp.tool
|
|
20
|
+
def divide(
|
|
21
|
+
numerator: Annotated[float, "Numerator"],
|
|
22
|
+
denominator: Annotated[float, "Denominator"]
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Divide two numbers (no zero check - tests natural exceptions)"""
|
|
25
|
+
return numerator / denominator
|
|
26
|
+
|
|
27
|
+
@mcp.tool
|
|
28
|
+
def greet(
|
|
29
|
+
name: Annotated[str, "Name to greet"],
|
|
30
|
+
age: Annotated[Optional[int], "Age of person"] = None
|
|
31
|
+
) -> GreetingResponse:
|
|
32
|
+
"""Generate a greeting message"""
|
|
33
|
+
if age is not None:
|
|
34
|
+
return {
|
|
35
|
+
"message": f"Hello, {name}! You are {age} years old.",
|
|
36
|
+
"name": name,
|
|
37
|
+
"age": age
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
"message": f"Hello, {name}!",
|
|
41
|
+
"name": name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@mcp.tool
|
|
45
|
+
def get_system_info() -> SystemInfo:
|
|
46
|
+
"""Get system information"""
|
|
47
|
+
import platform
|
|
48
|
+
return {
|
|
49
|
+
"platform": platform.system(),
|
|
50
|
+
"python_version": platform.python_version(),
|
|
51
|
+
"machine": platform.machine(),
|
|
52
|
+
"timestamp": time.time()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@mcp.tool
|
|
56
|
+
def failing_tool(message: Annotated[str, "Error message to raise"]) -> str:
|
|
57
|
+
"""Tool that always fails (for testing error handling)"""
|
|
58
|
+
raise McpToolError(message)
|
|
59
|
+
|
|
60
|
+
class StructInfo(TypedDict):
|
|
61
|
+
name: Annotated[str, "Structure name"]
|
|
62
|
+
size: Annotated[int, "Structure size in bytes"]
|
|
63
|
+
fields: Annotated[list[str], "List of field names"]
|
|
64
|
+
|
|
65
|
+
@mcp.tool
|
|
66
|
+
def struct_get(
|
|
67
|
+
names: Annotated[list[str], "Array of structure names"]
|
|
68
|
+
| Annotated[str, "Single structure name"]
|
|
69
|
+
) -> list[StructInfo]:
|
|
70
|
+
"""Retrieve structure information by names"""
|
|
71
|
+
return [
|
|
72
|
+
StructInfo({
|
|
73
|
+
"name": name,
|
|
74
|
+
"size": 128, # Dummy size
|
|
75
|
+
"fields": ["field1", "field2", "field3"] # Dummy fields
|
|
76
|
+
})
|
|
77
|
+
for name in (names if isinstance(names, list) else [names])
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
print("Starting MCP Example Server...")
|
|
82
|
+
print("\nAvailable tools:")
|
|
83
|
+
for name in mcp.tools.methods.keys():
|
|
84
|
+
func = mcp.tools.methods[name]
|
|
85
|
+
print(f" - {name}: {func.__doc__}")
|
|
86
|
+
|
|
87
|
+
mcp.start("127.0.0.1", 5001)
|
|
88
|
+
|
|
89
|
+
print("\n" + "="*60)
|
|
90
|
+
print("Server is running. Press Ctrl+C to stop.")
|
|
91
|
+
print("="*60)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
time.sleep(1)
|
|
96
|
+
except KeyboardInterrupt:
|
|
97
|
+
print("\n\nStopping server...")
|
|
98
|
+
mcp.stop()
|
|
99
|
+
print("Server stopped.")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zeromcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Zero-dependency MCP server implementation"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
license = "MIT"
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://github.com/mrexodia/zeromcp"
|
|
12
|
+
Repository = "https://github.com/mrexodia/zeromcp"
|
|
13
|
+
Issues = "https://github.com/mrexodia/zeromcp/issues"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
@@ -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
|
+
}
|