Skip to content

Commit 4aed21f

Browse files
committed
Add system tests for MCP (stdio/sse/streamable http)
1 parent b21f844 commit 4aed21f

File tree

5 files changed

+1046
-0
lines changed

5 files changed

+1046
-0
lines changed

.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+
```

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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/*"]

tests/mcp_test.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import os
2+
import sys
3+
import socket
4+
import asyncio
5+
import subprocess
6+
7+
from pydantic import AnyUrl
8+
from mcp import ClientSession, StdioServerParameters, types
9+
from mcp.client.stdio import stdio_client
10+
from mcp.client.sse import sse_client
11+
from mcp.client.streamable_http import streamablehttp_client
12+
13+
example_mcp = os.path.join(os.path.dirname(__file__), "..", "examples", "mcp_example.py")
14+
assert os.path.exists(example_mcp), f"not found: {example_mcp}"
15+
16+
async def test_example_server(prefix: str, session: ClientSession):
17+
# Initialize the connection
18+
await session.initialize()
19+
20+
# Test ping
21+
ping_result = await session.send_ping()
22+
print(f"[{prefix}] Ping result: {ping_result}")
23+
assert isinstance(ping_result, types.EmptyResult), "ping should return EmptyResult"
24+
25+
# List available resources
26+
resources = await session.list_resources()
27+
print(f"[{prefix}] Available resources: {[r.uri for r in resources.resources]}")
28+
assert len(resources.resources) == 2, "expected 2 static resources"
29+
assert str(resources.resources[0].uri) == "example://system_info", "expected system_info resource"
30+
31+
# List available resource templates
32+
template_resources = await session.list_resource_templates()
33+
print(f"[{prefix}] Available resource templates: {[r.uriTemplate for r in template_resources.resourceTemplates]}")
34+
assert len(template_resources.resourceTemplates) == 1, "expected 1 resource template"
35+
assert template_resources.resourceTemplates[0].uriTemplate == "example://greeting/{name}", "expected greeting template"
36+
37+
# List available tools
38+
tools = await session.list_tools()
39+
print(f"[{prefix}] Available tools: {[t.name for t in tools.tools]}")
40+
tool_names = {t.name for t in tools.tools}
41+
assert tool_names == {"divide", "greet", "random_dict", "get_system_info", "failing_tool", "struct_get"}, f"unexpected tools: {tool_names}"
42+
43+
# Read a resource - assert content
44+
resource_content = await session.read_resource(AnyUrl("example://system_info"))
45+
content_block = resource_content.contents[0]
46+
assert isinstance(content_block, types.TextResourceContents), "expected TextResourceContents"
47+
print(f"[{prefix}] Resource content: {content_block.text}")
48+
assert "platform" in content_block.text, "expected platform in system_info"
49+
assert "python_version" in content_block.text, "expected python_version in system_info"
50+
51+
# Read template resource - assert content
52+
template_content = await session.read_resource(AnyUrl("example://greeting/Pythonista"))
53+
template_block = template_content.contents[0]
54+
assert isinstance(template_block, types.TextResourceContents), "expected TextResourceContents"
55+
print(f"[{prefix}] Template resource content: {template_block.text}")
56+
assert "Pythonista" in template_block.text, "expected name in greeting"
57+
assert "message" in template_block.text, "expected message field in greeting"
58+
59+
# Call divide tool
60+
result = await session.call_tool("divide", arguments={"numerator": 42, "denominator": 2})
61+
assert not result.isError, "divide should succeed"
62+
result_unstructured = result.content[0]
63+
assert isinstance(result_unstructured, types.TextContent), "expected TextContent"
64+
print(f"[{prefix}] Divide result: {result_unstructured.text}")
65+
assert "21" in result_unstructured.text, "42/2 should be 21"
66+
67+
# Call greet tool without age
68+
result = await session.call_tool("greet", arguments={"name": "Alice"})
69+
assert not result.isError, "greet should succeed"
70+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
71+
content = result.content[0].text
72+
print(f"[{prefix}] Greet result: {content}")
73+
assert "Alice" in content, "expected name in greeting"
74+
assert "message" in content, "expected message field"
75+
assert "age" not in content or content.count("age") == 1, "age should not have value"
76+
77+
# Call greet tool with age
78+
result = await session.call_tool("greet", arguments={"name": "Bob", "age": 30})
79+
assert not result.isError, "greet with age should succeed"
80+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
81+
content = result.content[0].text
82+
print(f"[{prefix}] Greet with age result: {content}")
83+
assert "Bob" in content and "30" in content, "expected name and age"
84+
85+
# Call get_system_info tool
86+
result = await session.call_tool("get_system_info", arguments={})
87+
assert not result.isError, "get_system_info should succeed"
88+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
89+
content = result.content[0].text
90+
print(f"[{prefix}] System info result: {content}")
91+
assert "platform" in content, "expected platform"
92+
assert "python_version" in content, "expected python_version"
93+
assert "timestamp" in content, "expected timestamp"
94+
95+
# Call struct_get with list
96+
result = await session.call_tool("struct_get", arguments={"names": ["foo", "bar"]})
97+
assert not result.isError, "struct_get with list should succeed"
98+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
99+
content = result.content[0].text
100+
print(f"[{prefix}] Struct_get (list) result: {content}")
101+
assert "foo" in content and "bar" in content, "expected both struct names"
102+
103+
# Call struct_get with string
104+
result = await session.call_tool("struct_get", arguments={"names": "baz"})
105+
assert not result.isError, "struct_get with string should succeed"
106+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
107+
content = result.content[0].text
108+
print(f"[{prefix}] Struct_get (string) result: {content}")
109+
assert "baz" in content, "expected struct name"
110+
111+
# Call failing tool
112+
result = await session.call_tool("failing_tool", arguments={"message": "This is a test error"})
113+
print(f"[{prefix}] Failing tool result: {result}")
114+
assert result.isError, "expected tool call to fail"
115+
assert isinstance(result.content[0], types.TextContent), "expected text content in tool call result"
116+
assert "test error" in result.content[0].text, "expected error message in tool call result"
117+
118+
# Call random_dict tool
119+
result = await session.call_tool("random_dict", arguments={"param": {"x": 112, "other": "yes"}})
120+
assert not result.isError, "random_dict should succeed"
121+
assert isinstance(result.content[0], types.TextContent), "expected TextContent"
122+
content = result.content[0].text
123+
print(f"[{prefix}] Random dict result: {content}")
124+
125+
# Call random_dict tool with null
126+
result = await session.call_tool("random_dict", arguments={"param": None})
127+
assert not result.isError, "random_dict with null should succeed"
128+
129+
async def test_edge_cases(prefix: str, session: ClientSession):
130+
"""Test edge cases and error conditions"""
131+
await session.initialize()
132+
133+
# Test non-existent tool
134+
result = await session.call_tool("nonexistent_tool", arguments={})
135+
assert result.isError, "should error on non-existent tool"
136+
print(f"[{prefix}] Non-existent tool error: {result.content[0] if result.content else 'no content'}")
137+
138+
# Test missing required parameter
139+
result = await session.call_tool("divide", arguments={"numerator": 42})
140+
assert result.isError, "should error on missing denominator"
141+
print(f"[{prefix}] Missing param error: {result.content[0] if result.content else 'no content'}")
142+
143+
# Test division by zero (natural exception)
144+
result = await session.call_tool("divide", arguments={"numerator": 1, "denominator": 0})
145+
assert result.isError, "division by zero should error"
146+
print(f"[{prefix}] Division by zero error: {result.content[0] if result.content else 'no content'}")
147+
148+
# Test invalid resource URI
149+
result = await session.read_resource(AnyUrl("example://invalid_resource"))
150+
assert hasattr(result, "isError") and result.isError, "should error on invalid resource" # type: ignore
151+
content = result.contents[0] # type: ignore
152+
if isinstance(content, types.TextResourceContents):
153+
print(f"[{prefix}] Invalid resource error: {content.text}")
154+
155+
# Test resource template with missing substitution
156+
result = await session.read_resource(AnyUrl("example://greeting/"))
157+
assert hasattr(result, "isError") and result.isError, "should error on malformed template URI" # type: ignore
158+
content = result.contents[0] # type: ignore
159+
if isinstance(content, types.TextResourceContents):
160+
print(f"[{prefix}] Malformed template URI error: {content.text}")
161+
162+
# Test resource that raises an error
163+
result = await session.read_resource(AnyUrl("example://error"))
164+
assert hasattr(result, "isError") and result.isError, "should error on error resource" # type: ignore
165+
content = result.contents[0] # type: ignore
166+
if isinstance(content, types.TextResourceContents):
167+
print(f"[{prefix}] Error resource error: {content.text}")
168+
169+
def coverage_wrap(name: str, args: list[str]) -> list[str]:
170+
if os.environ.get("COVERAGE_RUN"):
171+
args = ["-m", "coverage", "run", f"--data-file=.coverage.{name}"] + args
172+
return args
173+
174+
async def test_stdio():
175+
print("[stdio] Testing...")
176+
server_params = StdioServerParameters(
177+
command=sys.executable,
178+
args=coverage_wrap("stdio", [example_mcp, "--transport", "stdio"]),
179+
)
180+
async with stdio_client(server_params) as (read, write):
181+
async with ClientSession(read, write) as session:
182+
await test_example_server("stdio", session)
183+
await test_edge_cases("stdio", session)
184+
185+
async def test_sse(address: str):
186+
print("[sse] Testing...")
187+
async with sse_client(f"{address}/sse") as (read, write):
188+
async with ClientSession(read, write) as session:
189+
await test_example_server("sse", session)
190+
await test_edge_cases("sse", session)
191+
192+
async def test_streamablehttp(address: str):
193+
print("[streamable] Testing...")
194+
async with streamablehttp_client(f"{address}/mcp") as (read, write, session_callback):
195+
async with ClientSession(read, write) as session:
196+
await test_example_server("streamable", session)
197+
await test_edge_cases("streamable", session)
198+
199+
def find_available_port():
200+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
201+
sock.bind(("", 0))
202+
port = sock.getsockname()[1]
203+
sock.close()
204+
return port
205+
206+
async def test_serve():
207+
print("[serve] Testing...")
208+
209+
# Start example MCP server as subprocess
210+
address = f"http://127.0.0.1:{find_available_port()}"
211+
process = subprocess.Popen(
212+
[sys.executable] + coverage_wrap("serve", [example_mcp, "--transport", address]),
213+
stdin=subprocess.PIPE,
214+
text=True,
215+
encoding="utf-8",
216+
bufsize=1,
217+
)
218+
try:
219+
await test_sse(address)
220+
await test_streamablehttp(address)
221+
finally:
222+
print("[serve] Terminating example MCP server")
223+
process.stdin.close() # type: ignore
224+
process.wait()
225+
pass
226+
227+
async def main():
228+
await test_stdio()
229+
await test_serve()
230+
231+
if __name__ == "__main__":
232+
import os
233+
asyncio.run(main())

0 commit comments

Comments
 (0)