Skip to content

Commit 0085fc4

Browse files
strickvlclaude
andcommitted
Add automated MCP smoke test workflow with issue creation
- Move test script to scripts/test_mcp_server.py for better organization - Create GitHub workflow that runs every 3 days at 9 AM UTC - Fix ZenML client initialization blocking MCP protocol handshake by making it lazy - Add comprehensive logging and timeout handling for better debugging - Implement automatic issue creation on test failures with @strickvl @htahir1 tagging - Add smart duplicate issue prevention (comments on existing issues) - Configure UV with caching for fast CI builds - Set up environment variables for ZenML server integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent dae5a30 commit 0085fc4

File tree

3 files changed

+426
-51
lines changed

3 files changed

+426
-51
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: MCP Smoke Test
2+
3+
on:
4+
schedule:
5+
# Run every 3 days at 9 AM UTC
6+
- cron: '0 9 */3 * *'
7+
workflow_dispatch:
8+
# Allow manual triggering for testing
9+
10+
permissions:
11+
contents: read
12+
issues: write
13+
14+
jobs:
15+
smoke-test:
16+
runs-on: ubuntu-latest
17+
18+
env:
19+
# ZenML configuration
20+
ZENML_DISABLE_RICH_LOGGING: "1"
21+
ZENML_LOGGING_COLORS_DISABLED: "true"
22+
ZENML_ANALYTICS_OPT_IN: "false"
23+
PYTHONIOENCODING: "UTF-8"
24+
PYTHONUNBUFFERED: "1"
25+
ZENML_STORE_URL: ${{ secrets.ZENML_STORE_URL }}
26+
ZENML_STORE_API_KEY: ${{ secrets.ZENML_STORE_API_KEY }}
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
32+
- name: Install uv
33+
uses: astral-sh/setup-uv@v5
34+
with:
35+
version: "0.7.13"
36+
enable-cache: true
37+
38+
- name: Set up Python
39+
uses: actions/setup-python@v5
40+
with:
41+
python-version: "3.12"
42+
43+
- name: Run MCP smoke test
44+
id: smoke-test
45+
run: |
46+
echo "Running MCP smoke test..."
47+
uv run scripts/test_mcp_server.py zenml_server.py
48+
continue-on-error: true
49+
50+
- name: Create issue on failure
51+
if: steps.smoke-test.outcome == 'failure'
52+
uses: actions/github-script@v7
53+
with:
54+
script: |
55+
// Check for existing open issues with the same title
56+
const issues = await github.rest.issues.listForRepo({
57+
owner: context.repo.owner,
58+
repo: context.repo.repo,
59+
state: 'open',
60+
labels: 'bug'
61+
});
62+
63+
const existingIssue = issues.data.find(issue =>
64+
issue.title.includes('MCP Smoke Test Failed')
65+
);
66+
67+
if (existingIssue) {
68+
console.log(`Existing issue found: #${existingIssue.number}`);
69+
// Add a comment to the existing issue instead of creating a new one
70+
await github.rest.issues.createComment({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
issue_number: existingIssue.number,
74+
body: `
75+
## 🔄 MCP Smoke Test Failed Again
76+
77+
**Workflow Run:** [${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
78+
**Branch:** ${context.ref}
79+
**Commit:** ${context.sha.substring(0, 7)}
80+
**Triggered by:** ${context.eventName}
81+
**Date:** ${new Date().toISOString()}
82+
83+
The MCP smoke test is still failing. Please investigate the recurring issue.
84+
85+
**Logs:** Check the [workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId}) for detailed error logs.
86+
`
87+
});
88+
} else {
89+
// Create a new issue
90+
await github.rest.issues.create({
91+
owner: context.repo.owner,
92+
repo: context.repo.repo,
93+
title: '🚨 MCP Smoke Test Failed',
94+
body: `
95+
## MCP Smoke Test Failure Report
96+
97+
The automated MCP smoke test has failed. This indicates there may be issues with the MCP server functionality.
98+
99+
### Details
100+
101+
**Workflow Run:** [${context.runNumber}](${context.payload.repository.html_url}/actions/runs/${context.runId})
102+
**Branch:** ${context.ref}
103+
**Commit:** ${context.sha.substring(0, 7)}
104+
**Triggered by:** ${context.eventName}
105+
**Date:** ${new Date().toISOString()}
106+
107+
### Investigation Steps
108+
109+
1. Check the [workflow logs](${context.payload.repository.html_url}/actions/runs/${context.runId}) for detailed error information
110+
2. Verify ZenML server connectivity and authentication
111+
3. Test the MCP server locally using: \`uv run scripts/test_mcp_server.py zenml_server.py\`
112+
4. Check for any recent changes that might have affected the MCP server
113+
114+
### Environment
115+
116+
- **ZenML Store URL:** ${process.env.ZENML_STORE_URL ? 'Set (from secrets)' : 'Not set'}
117+
- **ZenML API Key:** ${process.env.ZENML_STORE_API_KEY ? 'Set (from secrets)' : 'Not set'}
118+
119+
@strickvl @htahir1 Please investigate this failure.
120+
`,
121+
labels: ['bug']
122+
});
123+
}
124+
125+
- name: Report success
126+
if: steps.smoke-test.outcome == 'success'
127+
run: |
128+
echo "✅ MCP smoke test passed successfully!"
129+
echo "All MCP server functionality is working as expected."

scripts/test_mcp_server.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Simple MCP client smoke test for ZenML MCP server
4+
"""
5+
6+
import asyncio
7+
import sys
8+
from pathlib import Path
9+
from typing import Any, Dict
10+
11+
from mcp import ClientSession, StdioServerParameters
12+
from mcp.client.stdio import stdio_client
13+
14+
15+
class MCPSmokeTest:
16+
def __init__(self, server_path: str):
17+
"""Initialize the smoke test with the server path."""
18+
self.server_path = Path(server_path)
19+
self.server_params = StdioServerParameters(
20+
command="uv",
21+
args=["run", str(self.server_path)],
22+
)
23+
24+
async def run_smoke_test(self) -> Dict[str, Any]:
25+
"""Run a comprehensive smoke test of the MCP server."""
26+
results = {
27+
"connection": False,
28+
"initialization": False,
29+
"tools": [],
30+
"resources": [],
31+
"prompts": [],
32+
"tool_test_results": {},
33+
"errors": [],
34+
}
35+
36+
try:
37+
print(f"🚀 Starting smoke test for MCP server: {self.server_path}")
38+
39+
# Connect to the server
40+
async with stdio_client(self.server_params) as (read, write):
41+
print("✅ Connected to MCP server")
42+
results["connection"] = True
43+
44+
async with ClientSession(read, write) as session:
45+
# Initialize the session
46+
print("🔄 Initializing session...")
47+
await asyncio.wait_for(session.initialize(), timeout=60.0)
48+
print("✅ Session initialized")
49+
results["initialization"] = True
50+
51+
# List available tools
52+
print("🔄 Listing available tools...")
53+
tools_result = await asyncio.wait_for(
54+
session.list_tools(), timeout=30.0
55+
)
56+
print(
57+
f"🔄 Got tools result: {len(tools_result.tools) if tools_result.tools else 0} tools"
58+
)
59+
if tools_result.tools:
60+
results["tools"] = [
61+
{"name": tool.name, "description": tool.description}
62+
for tool in tools_result.tools
63+
]
64+
print(f"✅ Found {len(tools_result.tools)} tools:")
65+
for tool in tools_result.tools:
66+
print(f" - {tool.name}: {tool.description}")
67+
68+
# List available resources
69+
print("🔄 Listing available resources...")
70+
try:
71+
resources_result = await asyncio.wait_for(
72+
session.list_resources(), timeout=30.0
73+
)
74+
print(
75+
f"🔄 Got resources result: {len(resources_result.resources) if resources_result.resources else 0} resources"
76+
)
77+
if resources_result.resources:
78+
results["resources"] = [
79+
{
80+
"uri": res.uri,
81+
"name": res.name,
82+
"description": res.description,
83+
}
84+
for res in resources_result.resources
85+
]
86+
print(
87+
f"✅ Found {len(resources_result.resources)} resources:"
88+
)
89+
for res in resources_result.resources:
90+
print(f" - {res.name}: {res.description}")
91+
except Exception as e:
92+
print(
93+
f"ℹ️ No resources available or error listing resources: {e}"
94+
)
95+
96+
# List available prompts
97+
print("🔄 Listing available prompts...")
98+
try:
99+
prompts_result = await asyncio.wait_for(
100+
session.list_prompts(), timeout=30.0
101+
)
102+
print(
103+
f"🔄 Got prompts result: {len(prompts_result.prompts) if prompts_result.prompts else 0} prompts"
104+
)
105+
if prompts_result.prompts:
106+
results["prompts"] = [
107+
{"name": prompt.name, "description": prompt.description}
108+
for prompt in prompts_result.prompts
109+
]
110+
print(f"✅ Found {len(prompts_result.prompts)} prompts:")
111+
for prompt in prompts_result.prompts:
112+
print(f" - {prompt.name}: {prompt.description}")
113+
except Exception as e:
114+
print(f"ℹ️ No prompts available or error listing prompts: {e}")
115+
116+
# Test a few basic tools (if available)
117+
print("🔄 Starting tool tests...")
118+
await self._test_basic_tools(session, results)
119+
print("✅ Tool tests completed")
120+
121+
except Exception as e:
122+
error_msg = f"❌ Error during smoke test: {e}"
123+
print(error_msg)
124+
results["errors"].append(error_msg)
125+
126+
return results
127+
128+
async def _test_basic_tools(self, session: ClientSession, results: Dict[str, Any]):
129+
"""Test basic tools that are likely to be safe to call."""
130+
safe_tools_to_test = [
131+
"list_users",
132+
"list_stacks",
133+
"list_pipelines",
134+
"get_server_info",
135+
]
136+
137+
available_tools = {tool["name"] for tool in results["tools"]}
138+
print(f"🔄 Available tools for testing: {available_tools}")
139+
140+
for tool_name in safe_tools_to_test:
141+
if tool_name in available_tools:
142+
try:
143+
print(f"🧪 Testing tool: {tool_name}")
144+
print(f"🔄 Calling tool {tool_name}...")
145+
# Add timeout to prevent hanging
146+
result = await asyncio.wait_for(
147+
session.call_tool(tool_name, {}), timeout=30.0
148+
)
149+
print(f"🔄 Tool {tool_name} returned result")
150+
results["tool_test_results"][tool_name] = {
151+
"success": True,
152+
"content_length": len(str(result.content))
153+
if result.content
154+
else 0,
155+
}
156+
print(f"✅ Tool {tool_name} executed successfully")
157+
except Exception as e:
158+
error_msg = f"Tool {tool_name} failed: {e}"
159+
print(f"❌ {error_msg}")
160+
results["tool_test_results"][tool_name] = {
161+
"success": False,
162+
"error": str(e),
163+
}
164+
else:
165+
print(f"ℹ️ Tool {tool_name} not available in server")
166+
167+
def print_summary(self, results: Dict[str, Any]):
168+
"""Print a summary of the smoke test results."""
169+
print("\n" + "=" * 50)
170+
print("🔍 SMOKE TEST SUMMARY")
171+
print("=" * 50)
172+
173+
print(f"Connection: {'✅ PASS' if results['connection'] else '❌ FAIL'}")
174+
print(
175+
f"Initialization: {'✅ PASS' if results['initialization'] else '❌ FAIL'}"
176+
)
177+
print(f"Tools found: {len(results['tools'])}")
178+
print(f"Resources found: {len(results['resources'])}")
179+
print(f"Prompts found: {len(results['prompts'])}")
180+
181+
if results["tool_test_results"]:
182+
successful_tests = sum(
183+
1 for r in results["tool_test_results"].values() if r["success"]
184+
)
185+
total_tests = len(results["tool_test_results"])
186+
print(f"Tool tests: {successful_tests}/{total_tests} passed")
187+
188+
if results["errors"]:
189+
print(f"Errors: {len(results['errors'])}")
190+
for error in results["errors"]:
191+
print(f" - {error}")
192+
193+
overall_status = (
194+
results["connection"]
195+
and results["initialization"]
196+
and len(results["tools"]) > 0
197+
)
198+
print(f"\nOverall: {'✅ PASS' if overall_status else '❌ FAIL'}")
199+
200+
201+
async def main():
202+
"""Main entry point for the smoke test."""
203+
if len(sys.argv) != 2:
204+
print("Usage: python test_mcp_server.py <path_to_mcp_server.py>")
205+
print("Example: python test_mcp_server.py ./zenml_server.py")
206+
sys.exit(1)
207+
208+
server_path = sys.argv[1]
209+
210+
# Verify server file exists
211+
if not Path(server_path).exists():
212+
print(f"❌ Server file not found: {server_path}")
213+
sys.exit(1)
214+
215+
smoke_test = MCPSmokeTest(server_path)
216+
results = await smoke_test.run_smoke_test()
217+
smoke_test.print_summary(results)
218+
219+
# Exit with appropriate code
220+
if results["connection"] and results["initialization"]:
221+
sys.exit(0)
222+
else:
223+
sys.exit(1)
224+
225+
226+
if __name__ == "__main__":
227+
asyncio.run(main())

0 commit comments

Comments
 (0)