Skip to content

Commit 83056f7

Browse files
authored
feat: add better error messages and hot reload (#183)
1 parent e5829b6 commit 83056f7

File tree

6 files changed

+236
-100
lines changed

6 files changed

+236
-100
lines changed

python/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ target/
8282
profile_default/
8383
ipython_config.py
8484

85+
# Other
86+
.spec
87+
8588
# pyenv
8689
# For a library or package, you might want to ignore these files since the code is
8790
# intended to run in multiple environments; otherwise, check them in:

python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "uv_build"
44

55
[project]
66
name = "smithery"
7-
version = "0.1.22"
7+
version = "0.1.23"
88
description = "SDK for using Smithery with Python"
99
readme = "README.md"
1010
requires-python = ">=3.10"

python/src/smithery/cli/dev.py

Lines changed: 201 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
Command-line interface for running Smithery Python MCP servers.
55
"""
66

7-
import inspect
7+
import os
88
import sys
99
from collections.abc import Callable
1010
from importlib import import_module
@@ -14,7 +14,61 @@
1414

1515
from ..server.fastmcp_patch import SmitheryFastMCP
1616
from ..utils.console import console
17-
from ..utils.project import get_server_ref_from_config
17+
18+
19+
def validate_project_setup() -> str:
20+
"""Project Discovery & Validation
21+
22+
Returns: server_reference string (e.g., "my_server.server:create_server")
23+
Raises: SystemExit with clear error messages for each failure scenario
24+
"""
25+
# 1. Find project root and validate pyproject.toml exists
26+
if not os.path.exists("pyproject.toml"):
27+
console.error("No pyproject.toml found in current directory")
28+
console.nested("Make sure you're in a Python project directory")
29+
console.indented("Expected file: [cyan]pyproject.toml[/cyan]")
30+
sys.exit(1)
31+
32+
# 2. Read and parse pyproject.toml
33+
try:
34+
import tomllib
35+
with open("pyproject.toml", "rb") as f:
36+
pyproject = tomllib.load(f)
37+
except Exception as e:
38+
console.error(f"Failed to read pyproject.toml: {e}")
39+
console.nested("Ensure pyproject.toml is valid TOML format")
40+
sys.exit(1)
41+
42+
# 3. Extract [tool.smithery] section
43+
tool_config = pyproject.get("tool", {})
44+
smithery_config = tool_config.get("smithery", {})
45+
46+
if not smithery_config:
47+
console.error("Missing \\[tool.smithery] configuration in pyproject.toml")
48+
console.nested("Add to your pyproject.toml:")
49+
console.indented("[cyan]\\[tool.smithery][/cyan]")
50+
console.indented('[cyan]server = "my_server.server:create_server"[/cyan]')
51+
sys.exit(1)
52+
53+
# 4. Get server reference
54+
server_ref = smithery_config.get("server")
55+
if not server_ref:
56+
console.error("Missing 'server' in \\[tool.smithery] configuration")
57+
console.nested("Add to your pyproject.toml:")
58+
console.indented("[cyan]\\[tool.smithery][/cyan]")
59+
console.indented('[cyan]server = "my_server.server:create_server"[/cyan]')
60+
sys.exit(1)
61+
62+
# 5. Validate server reference format
63+
if ":" not in server_ref:
64+
console.error(f"Invalid server reference format: '{server_ref}'")
65+
console.nested("Expected format: 'module.path:function_name'")
66+
console.indented("Example: 'my_server.server:create_server'")
67+
sys.exit(1)
68+
69+
console.info("✓ Project setup validated", muted=True)
70+
console.rich_console.print(f"Server reference: [blue]{server_ref}[/blue]")
71+
return server_ref
1872

1973

2074
class SmitheryModule(TypedDict, total=False):
@@ -24,142 +78,170 @@ class SmitheryModule(TypedDict, total=False):
2478

2579

2680
def import_server_module(server_ref: str) -> SmitheryModule:
27-
"""Import and validate Smithery server module."""
28-
try:
29-
if ":" not in server_ref:
30-
raise ValueError(f"Server reference must include function name: '{server_ref}'. Expected format: 'module.path:function_name'")
81+
"""Module Resolution & Import
3182
32-
module_path, function_name = server_ref.split(":", 1)
83+
Args: server_ref like "my_server.server:create_server"
84+
Returns: SmitheryModule with function and optional config schema
85+
Raises: SystemExit with clear error messages for import failures
86+
"""
87+
module_path, function_name = server_ref.split(":", 1)
3388

34-
console.info(f"Importing module: {module_path}", muted=True)
35-
console.info(f"Looking for function: {function_name}", muted=True)
89+
console.info(f"Importing module: {module_path}", muted=True)
90+
console.info(f"Looking for function: {function_name}", muted=True)
3691

92+
try:
93+
# Import the module (assumes proper Python environment setup)
3794
module = import_module(module_path)
3895

39-
# Get the specified server function
96+
# Check if function exists
4097
if not hasattr(module, function_name):
41-
raise AttributeError(f"Function '{function_name}' not found in module '{module_path}'")
98+
console.error(f"Function '{function_name}' not found in module '{module_path}'")
99+
console.nested("Check your server module has the function specified in pyproject.toml")
100+
console.indented(f"Expected: def {function_name}() in {module_path}")
101+
sys.exit(1)
42102

103+
# Get the function
43104
server_function = getattr(module, function_name)
44105
if not callable(server_function):
45-
raise AttributeError(f"'{function_name}' is not callable in module '{module_path}'")
46-
47-
# Validate function signature and return type
48-
try:
49-
sig = inspect.signature(server_function)
50-
return_annotation = sig.return_annotation
51-
52-
# Check if return type annotation is present and correct
53-
if return_annotation != inspect.Signature.empty:
54-
if return_annotation != SmitheryFastMCP:
55-
annotation_name = getattr(return_annotation, '__name__', str(return_annotation))
56-
console.warning(f"Function return type is {annotation_name}, expected SmitheryFastMCP")
57-
else:
58-
console.warning("No return type annotation found. Expected: -> SmitheryFastMCP")
106+
console.error(f"'{function_name}' is not callable in module '{module_path}'")
107+
console.nested("The server reference must point to a function")
108+
sys.exit(1)
59109

60-
# Check parameter signature
61-
params = list(sig.parameters.values())
62-
if len(params) != 1:
63-
console.warning(f"Expected exactly 1 parameter (config), found {len(params)}")
64-
elif len(params) == 1 and params[0].name not in ('config', 'cfg', 'configuration'):
65-
console.warning(f"Parameter name '{params[0].name}' should be 'config' for clarity")
66-
67-
except Exception as e:
68-
console.warning(f"Could not validate function signature: {e}")
69-
70-
# Get config schema - check decorator metadata first, then module-level variable
110+
# Get config schema (optional)
71111
config_schema = None
72-
73-
# Priority 1: Check for decorator metadata
74112
if hasattr(server_function, '_smithery_config_schema'):
75113
config_schema = server_function._smithery_config_schema
76114
console.info("Using config schema from @smithery decorator", muted=True)
115+
elif hasattr(module, 'config_schema'):
116+
config_schema = module.config_schema
117+
console.info("Using config schema from module-level variable", muted=True)
77118

78-
# Priority 2: Fall back to module-level variable (backward compatibility)
79-
if not config_schema:
80-
config_schema = getattr(module, 'config_schema', None)
81-
if config_schema:
82-
console.info("Using config schema from module-level variable", muted=True)
83-
84-
# Validate config schema if present
85-
if config_schema and not (inspect.isclass(config_schema) and issubclass(config_schema, BaseModel)):
86-
console.warning(f"config_schema should be a Pydantic BaseModel class, got {type(config_schema).__name__}")
87-
119+
console.info("✓ Module imported successfully", muted=True)
88120
return {
89121
'create_server': server_function,
90122
'config_schema': config_schema,
91123
}
92-
except ModuleNotFoundError as e:
93-
console.error(f"Failed to import server module '{server_ref}': {e}")
94-
console.nested("Module resolution tips:")
95-
console.indented("Make sure your package is built and installed:")
96-
console.indented(" uv sync # Install dependencies")
97-
console.indented(" uv build # Build the package")
98-
console.indented("Or run in development mode:")
99-
console.indented(" uv run dev # Uses editable install")
124+
125+
except ImportError as e:
126+
console.error(f"Failed to import module '{module_path}': {e}")
127+
console.nested("Make sure your project environment is set up correctly")
128+
console.indented("Run: uv sync")
129+
console.indented("Then: uv run smithery dev")
100130
sys.exit(1)
101131
except Exception as e:
102-
console.error(f"Failed to import server module '{server_ref}': {e}")
103-
console.nested("Expected configuration in pyproject.toml:")
104-
console.indented("[tool.smithery]")
105-
console.indented('server = "module.path:function_name"')
106-
console.nested("Expected module contract:")
107-
console.indented("function_name = function(config) -> SmitheryFastMCP")
108-
console.indented("config_schema = class(BaseModel) # Optional")
132+
console.error(f"Unexpected error importing module '{module_path}': {e}")
109133
sys.exit(1)
110134

111135

112-
def run_server(server_ref: str, transport: str = "shttp", port: int = 8081, host: str = "127.0.0.1") -> None:
113-
"""Run Smithery MCP server."""
114-
console.rich_console.print(f"Starting [cyan]Python MCP server[/cyan] with [yellow]{transport}[/yellow] transport...")
115-
console.rich_console.print(f"Server reference: [green]{server_ref}[/green]")
136+
def create_and_run_server(
137+
server_module: SmitheryModule,
138+
transport: str = "shttp",
139+
port: int = 8081,
140+
host: str = "127.0.0.1",
141+
reload: bool = False,
142+
server_ref: str | None = None,
143+
) -> None:
144+
"""Server Creation & Execution
116145
117-
try:
118-
# Import and validate server module
119-
server_module = import_server_module(server_ref)
120-
create_server = server_module['create_server']
121-
config_schema = server_module.get('config_schema')
146+
Args: server_module from Stage 2, transport settings
147+
Raises: SystemExit with clear error messages for server creation/startup failures
148+
"""
149+
create_server = server_module['create_server']
150+
config_schema = server_module.get('config_schema')
122151

152+
console.info("Creating server instance...", muted=True)
153+
154+
try:
123155
# Create config instance
124-
config: Any = {}
125156
if config_schema:
126157
try:
127-
config = config_schema()
128-
console.rich_console.print(f"Using config schema: [blue]{config_schema.__name__}[/blue]")
158+
config_schema()
159+
console.info(f"Using config schema: {config_schema.__name__}", muted=True)
129160
except Exception as e:
130161
console.warning(f"Failed to instantiate config schema: {e}")
131162
console.warning("Proceeding with empty config")
132163

133-
# Create server instance
134-
console.info("Creating server instance...")
135-
server = create_server(config)
164+
# Call user's server creation function (no config parameter needed)
165+
server = create_server()
166+
167+
# Validate server instance
168+
if not hasattr(server, 'run'):
169+
console.error("Server function must return a FastMCP server instance")
170+
console.nested("Expected: return FastMCP(...)")
171+
sys.exit(1)
136172

173+
console.rich_console.print("[green]✓ Server created successfully[/green]")
174+
175+
# Configure and start server
137176
if transport == "shttp":
138-
# Set server configuration for HTTP transport
139-
server.settings.port = port
140-
server.settings.host = host
177+
if reload:
178+
try:
179+
import uvicorn # type: ignore
180+
except Exception:
181+
console.error("Reload requested but 'uvicorn' is not installed")
182+
console.nested("Install it in your project environment:")
183+
console.indented("uv add uvicorn # or: pip install uvicorn")
184+
sys.exit(1)
185+
186+
# Pass server ref for reloader to reconstruct the app in a fresh interpreter
187+
if server_ref:
188+
os.environ["SMITHERY_SERVER_REF"] = server_ref
189+
190+
console.info(f"Starting MCP server with reload on {host}:{port}")
191+
console.info("Transport: streamable HTTP (uvicorn --reload)", muted=True)
192+
193+
# Use import string + factory so uvicorn can reload cleanly
194+
uvicorn.run(
195+
"smithery.cli.dev:get_reloader_streamable_http_app",
196+
host=host,
197+
port=port,
198+
reload=True,
199+
factory=True,
200+
)
201+
else:
202+
server.settings.port = port
203+
server.settings.host = host
141204

142-
console.rich_console.print(f"MCP server starting on [green]{host}:{port}[/green]")
143-
console.rich_console.print("Transport: [cyan]streamable HTTP[/cyan]")
205+
console.info(f"Starting MCP server on {host}:{port}")
206+
console.info("Transport: streamable HTTP", muted=True)
144207

145-
# Run with streamable HTTP transport
146-
server.run(transport="streamable-http")
208+
server.run(transport="streamable-http")
147209

148210
elif transport == "stdio":
149-
console.rich_console.print("MCP server starting with [cyan]stdio[/cyan] transport")
150-
151-
# Run with stdio transport
211+
console.info("Starting MCP server with stdio transport")
152212
server.run(transport="stdio")
153213

154214
else:
155-
raise ValueError(f"Unsupported transport: {transport}")
215+
console.error(f"Unsupported transport: {transport}")
216+
console.nested("Supported transports: shttp, stdio")
217+
sys.exit(1)
218+
219+
except Exception as e:
220+
console.error(f"Failed to create or start server: {e}")
221+
console.nested("Check your server function implementation")
222+
sys.exit(1)
223+
224+
225+
def run_server(server_ref: str | None = None, transport: str = "shttp", port: int = 8081, host: str = "127.0.0.1", reload: bool = False) -> None:
226+
"""Run Smithery MCP server using clean 3-stage approach."""
227+
console.rich_console.print(f"Starting [cyan]Python MCP server[/cyan] with [yellow]{transport}[/yellow] transport...")
228+
229+
try:
230+
# Stage 1: Project Discovery & Validation
231+
if server_ref is None:
232+
server_ref = validate_project_setup()
233+
else:
234+
console.info(f"Using provided server reference: {server_ref}")
235+
236+
# Stage 2: Module Resolution & Import
237+
server_module = import_server_module(server_ref)
238+
239+
# Stage 3: Server Creation & Execution
240+
create_and_run_server(server_module, transport, port, host, reload, server_ref)
156241

157242
except KeyboardInterrupt:
158243
console.info("\nServer stopped by user")
159244
sys.exit(0)
160-
except Exception as e:
161-
console.error(f"Failed to start MCP server: {e}")
162-
sys.exit(1)
163245

164246

165247
def main() -> None:
@@ -174,15 +256,38 @@ def dev_cmd(
174256
server_ref: str | None = typer.Argument(None, help="Server reference (module:function)"),
175257
transport: str = typer.Option("shttp", help="Transport type (shttp or stdio)"),
176258
port: int = typer.Option(8081, help="Port to run on (shttp only)"),
177-
host: str = typer.Option("127.0.0.1", help="Host to bind to (shttp only)")
259+
host: str = typer.Option("127.0.0.1", help="Host to bind to (shttp only)"),
260+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload (shttp only, requires uvicorn)")
178261
):
179262
"""Run Smithery MCP servers in development mode (like uvicorn)."""
180-
# Get server reference from config if not provided explicitly
181-
server_reference = server_ref or get_server_ref_from_config()
182-
run_server(server_reference, transport, port, host)
263+
if reload and transport != "shttp":
264+
console.warning("--reload is only supported with 'shttp' transport; ignoring for stdio")
265+
if reload and transport == "shttp":
266+
console.warning(
267+
"Hot reload resets in-memory server state; stateful clients may need to reinitialize their session after a reload."
268+
)
269+
run_server(server_ref, transport, port, host, reload)
183270

184271
app()
185272

186273

187274
if __name__ == "__main__":
188275
main()
276+
277+
# Factory used by uvicorn --reload (import-string target)
278+
def get_reloader_streamable_http_app():
279+
"""Return a fresh ASGI app for streamable HTTP using env SMITHERY_SERVER_REF.
280+
281+
This function is imported by Uvicorn in a fresh interpreter on reload.
282+
It reconstructs the server from the configured server reference and returns
283+
a new ASGI application callable.
284+
"""
285+
server_ref = os.environ.get("SMITHERY_SERVER_REF")
286+
if not server_ref:
287+
raise RuntimeError("SMITHERY_SERVER_REF not set for reloader")
288+
289+
# Resolve and import
290+
server_module = import_server_module(server_ref)
291+
create_server = server_module['create_server']
292+
server = create_server()
293+
return server.streamable_http_app()

0 commit comments

Comments
 (0)