Skip to content

Commit 51fd2fc

Browse files
authored
Merge pull request #4 from mrexodia/gh-actions
Extensive testing and CI
2 parents e5c963e + 144397e commit 51fd2fc

File tree

10 files changed

+1323
-157
lines changed

10 files changed

+1323
-157
lines changed

.github/workflows/ci.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: CI
2+
3+
on: [push, pull_request]
4+
5+
# Automatically cancel previous runs of this workflow on the same branch
6+
concurrency:
7+
group: ${{ github.workflow }}-${{ github.ref }}
8+
cancel-in-progress: true
9+
10+
jobs:
11+
linux:
12+
# Skip building pull requests from the same repository
13+
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
14+
runs-on: ubuntu-latest
15+
permissions:
16+
id-token: write
17+
contents: read
18+
env:
19+
PYTHONUNBUFFERED: 1
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v5
23+
24+
- name: Update pyproject.toml version
25+
if: ${{ startsWith(github.ref, 'refs/tags/') }}
26+
shell: bash
27+
run: |
28+
# Extract version from tag (strip 'v' prefix if present)
29+
VERSION=${GITHUB_REF#refs/tags/}
30+
VERSION=${VERSION#v}
31+
echo "Extracted version: $VERSION"
32+
33+
# Update version in pyproject.toml (works on both GNU and BSD sed)
34+
sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
35+
rm pyproject.toml.bak
36+
37+
- name: Install uv
38+
uses: astral-sh/setup-uv@v6
39+
with:
40+
version: "0.9.3"
41+
42+
- name: Create venv
43+
run: uv sync
44+
45+
- name: Type check
46+
run: uvx ty check
47+
48+
- name: Test JSON-RPC
49+
run: uv run coverage run --data-file=.coverage.jsonrpc tests/jsonrpc_test.py
50+
51+
- name: Test MCP
52+
run: uv run coverage run --data-file=.coverage.mcp tests/mcp_test.py
53+
54+
- name: Generate coverage report
55+
run: |
56+
uv run coverage combine
57+
uv run coverage report
58+
59+
- name: Publish
60+
if: ${{ startsWith(github.ref, 'refs/tags/') }}
61+
run: uv build && uv publish

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ wheels/
88

99
# Virtual environments
1010
.venv
11+
12+
# Coverage
13+
htmlcov/
14+
.coverage*

CONTRIBUTING.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Development notes
2+
3+
Run the tests with coverage:
4+
5+
```sh
6+
uv run coverage run --data-file=.coverage.mcp tests/mcp_test.py
7+
uv run coverage run --data-file=.coverage.jsonrpc tests/jsonrpc_test.py
8+
```
9+
10+
Combine coverage and generate report:
11+
12+
```sh
13+
uv run coverage combine
14+
uv run coverage report
15+
uv run coverage html
16+
```
17+
18+
Generate report for just `jsonrpc_test.py:
19+
20+
```sh
21+
uv run coverage html --data-file=.coverage.jsonrpc
22+
```

examples/mcp_example.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
11
"""Example MCP server with test tools"""
22
import time
33
import argparse
4+
from urllib.parse import urlparse
45
from typing import Annotated, Optional, TypedDict, NotRequired
56
from zeromcp import McpToolError, McpServer
67

78
mcp = McpServer("example")
89

9-
class SystemInfo(TypedDict):
10-
platform: Annotated[str, "Operating system platform"]
11-
python_version: Annotated[str, "Python version"]
12-
machine: Annotated[str, "Machine architecture"]
13-
timestamp: Annotated[float, "Current timestamp"]
14-
15-
class GreetingResponse(TypedDict):
16-
message: Annotated[str, "Greeting message"]
17-
name: Annotated[str, "Name that was greeted"]
18-
age: Annotated[NotRequired[int], "Age if provided"]
19-
2010
@mcp.tool
2111
def divide(
2212
numerator: Annotated[float, "Numerator"],
@@ -25,6 +15,11 @@ def divide(
2515
"""Divide two numbers (no zero check - tests natural exceptions)"""
2616
return numerator / denominator
2717

18+
class GreetingResponse(TypedDict):
19+
message: Annotated[str, "Greeting message"]
20+
name: Annotated[str, "Name that was greeted"]
21+
age: Annotated[NotRequired[int], "Age if provided"]
22+
2823
@mcp.tool
2924
def greet(
3025
name: Annotated[str, "Name to greet"],
@@ -42,6 +37,12 @@ def greet(
4237
"name": name
4338
}
4439

40+
class SystemInfo(TypedDict):
41+
platform: Annotated[str, "Operating system platform"]
42+
python_version: Annotated[str, "Python version"]
43+
machine: Annotated[str, "Machine architecture"]
44+
timestamp: Annotated[float, "Current timestamp"]
45+
4546
@mcp.tool
4647
def get_system_info() -> SystemInfo:
4748
"""Get system information"""
@@ -78,6 +79,16 @@ def struct_get(
7879
for name in (names if isinstance(names, list) else [names])
7980
]
8081

82+
@mcp.tool
83+
def random_dict(param: dict[str, int] | None) -> dict:
84+
"""Return a random dictionary for testing serialization"""
85+
return {
86+
**(param or {}),
87+
"x": 42,
88+
"y": 7,
89+
"z": 99,
90+
}
91+
8192
@mcp.resource("example://system_info")
8293
def system_info_resource() -> SystemInfo:
8394
"""Resource providing system information"""
@@ -90,28 +101,32 @@ def greeting_resource(
90101
"""Resource providing greeting message"""
91102
return greet(name)
92103

104+
@mcp.resource("example://error")
105+
def error_resource() -> None:
106+
"""Resource that always fails (for testing error handling)"""
107+
raise McpToolError("This is a resource error for testing purposes.")
108+
93109
if __name__ == "__main__":
94110
parser = argparse.ArgumentParser(description="MCP Example Server")
95-
parser.add_argument("--stdio", action="store_true", help="Run MCP server over stdio")
111+
parser.add_argument("--transport", help="Transport (stdio or http://host:port)", default="http://127.0.0.1:5001")
96112
args = parser.parse_args()
97-
if args.stdio:
113+
if args.transport == "stdio":
98114
mcp.stdio()
99115
else:
116+
url = urlparse(args.transport)
117+
if url.hostname is None or url.port is None:
118+
raise Exception(f"Invalid transport URL: {args.transport}")
119+
100120
print("Starting MCP Example Server...")
101121
print("\nAvailable tools:")
102122
for name in mcp._tools.methods.keys():
103123
func = mcp._tools.methods[name]
104124
print(f" - {name}: {func.__doc__}")
105125

106-
mcp.serve("127.0.0.1", 5001)
107-
108-
print("\n" + "="*60)
109-
print("Server is running. Press Ctrl+C to stop.")
110-
print("="*60)
126+
mcp.serve(url.hostname, url.port)
111127

112128
try:
113-
while True:
114-
time.sleep(1)
115-
except KeyboardInterrupt:
129+
input("Server is running, press Enter or Ctrl+C to stop.")
130+
except (KeyboardInterrupt, EOFError):
116131
print("\n\nStopping server...")
117132
mcp.stop()

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "zeromcp"
3-
version = "1.0.0"
3+
version = "0.0.0"
44
description = "Zero-dependency MCP server implementation"
55
readme = "README.md"
66
requires-python = ">=3.11"
@@ -15,3 +15,12 @@ Issues = "https://github.com/mrexodia/zeromcp/issues"
1515
[build-system]
1616
requires = ["hatchling"]
1717
build-backend = "hatchling.build"
18+
19+
[dependency-groups]
20+
dev = [
21+
"coverage>=7.11.3",
22+
"mcp>=1.21.2",
23+
]
24+
25+
[tool.coverage.report]
26+
omit = ["tests/*"]

src/zeromcp/jsonrpc.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import traceback
55
from typing import Any, Callable, get_type_hints, get_origin, get_args, Union, TypedDict, TypeAlias, NotRequired, is_typeddict
6+
from types import UnionType
67

78
JsonRpcId: TypeAlias = str | int | float | None
89
JsonRpcParams: TypeAlias = dict[str, Any] | list[Any] | None
@@ -113,12 +114,12 @@ def _call(self, method: str, params: Any) -> Any:
113114
-32602,
114115
f"Invalid params: expected at least {len(required_params)} arguments, got {len(params)}"
115116
)
116-
if len(params) > len(hints):
117+
if len(params) > len(sig.parameters):
117118
raise JsonRpcException(
118119
-32602,
119-
f"Invalid params: expected at most {len(hints)} arguments, got {len(params)}"
120+
f"Invalid params: expected at most {len(sig.parameters)} arguments, got {len(params)}"
120121
)
121-
params = dict(zip(hints.keys(), params))
122+
params = dict(zip(sig.parameters.keys(), params))
122123

123124
# Validate dict params
124125
if isinstance(params, dict):
@@ -131,19 +132,22 @@ def _call(self, method: str, params: Any) -> Any:
131132
)
132133

133134
# Check no extra params
134-
extra = set(params.keys()) - set(hints.keys())
135+
extra = set(params.keys()) - set(sig.parameters.keys())
135136
if extra:
136137
raise JsonRpcException(
137138
-32602,
138139
f"Invalid params: unexpected parameters: {list(extra)}"
139140
)
140141

141142
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
143+
for param_name, value in params.items():
144+
# If no type hint, pass through without validation
145+
if param_name not in hints:
146+
validated_params[param_name] = value
147+
continue
145148

146-
value = params[param_name]
149+
# Has type hint, validate
150+
expected_type = hints[param_name]
147151

148152
# Inline type validation
149153
origin = get_origin(expected_type)
@@ -153,13 +157,13 @@ def _call(self, method: str, params: Any) -> Any:
153157
if value is None:
154158
if expected_type is not type(None):
155159
# Check if None is allowed in a Union
156-
if not (origin is Union and type(None) in args):
160+
if not (origin in (Union, UnionType) and type(None) in args):
157161
raise JsonRpcException(-32602, f"Invalid params: {param_name} cannot be null")
158162
validated_params[param_name] = None
159163
continue
160164

161165
# Handle Union types (int | str, Optional[int], etc.)
162-
if origin is Union or (hasattr(types, 'UnionType') and origin is types.UnionType):
166+
if origin in (Union, UnionType):
163167
type_matched = False
164168
for arg_type in args:
165169
if arg_type is type(None):
@@ -177,7 +181,7 @@ def _call(self, method: str, params: Any) -> Any:
177181
break
178182

179183
if not type_matched:
180-
raise JsonRpcException(-32602, f"Invalid params: {param_name} has invalid type")
184+
raise JsonRpcException(-32602, f"Invalid params: {param_name} union does not contain {type(value).__name__}")
181185
validated_params[param_name] = value
182186
continue
183187

@@ -201,6 +205,11 @@ def _call(self, method: str, params: Any) -> Any:
201205
validated_params[param_name] = value
202206
continue
203207

208+
# Handle Any
209+
if expected_type is Any:
210+
validated_params[param_name] = value
211+
continue
212+
204213
# Handle basic types
205214
if isinstance(expected_type, type):
206215
# Allow int -> float conversion
@@ -215,9 +224,6 @@ def _call(self, method: str, params: Any) -> Any:
215224
validated_params[param_name] = value
216225
continue
217226

218-
# Fallback for Any or unknown
219-
validated_params[param_name] = value
220-
221227
return func(**validated_params)
222228

223229
else:

0 commit comments

Comments
 (0)