4
4
Command-line interface for running Smithery Python MCP servers.
5
5
"""
6
6
7
- import inspect
7
+ import os
8
8
import sys
9
9
from collections .abc import Callable
10
10
from importlib import import_module
14
14
15
15
from ..server .fastmcp_patch import SmitheryFastMCP
16
16
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
18
72
19
73
20
74
class SmitheryModule (TypedDict , total = False ):
@@ -24,142 +78,170 @@ class SmitheryModule(TypedDict, total=False):
24
78
25
79
26
80
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
31
82
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 )
33
88
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 )
36
91
92
+ try :
93
+ # Import the module (assumes proper Python environment setup)
37
94
module = import_module (module_path )
38
95
39
- # Get the specified server function
96
+ # Check if function exists
40
97
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 )
42
102
103
+ # Get the function
43
104
server_function = getattr (module , function_name )
44
105
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 )
59
109
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)
71
111
config_schema = None
72
-
73
- # Priority 1: Check for decorator metadata
74
112
if hasattr (server_function , '_smithery_config_schema' ):
75
113
config_schema = server_function ._smithery_config_schema
76
114
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 )
77
118
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 )
88
120
return {
89
121
'create_server' : server_function ,
90
122
'config_schema' : config_schema ,
91
123
}
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" )
100
130
sys .exit (1 )
101
131
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 } " )
109
133
sys .exit (1 )
110
134
111
135
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
116
145
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' )
122
151
152
+ console .info ("Creating server instance..." , muted = True )
153
+
154
+ try :
123
155
# Create config instance
124
- config : Any = {}
125
156
if config_schema :
126
157
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 )
129
160
except Exception as e :
130
161
console .warning (f"Failed to instantiate config schema: { e } " )
131
162
console .warning ("Proceeding with empty config" )
132
163
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 )
136
172
173
+ console .rich_console .print ("[green]✓ Server created successfully[/green]" )
174
+
175
+ # Configure and start server
137
176
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
141
204
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 )
144
207
145
- # Run with streamable HTTP transport
146
- server .run (transport = "streamable-http" )
208
+ server .run (transport = "streamable-http" )
147
209
148
210
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" )
152
212
server .run (transport = "stdio" )
153
213
154
214
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 )
156
241
157
242
except KeyboardInterrupt :
158
243
console .info ("\n Server stopped by user" )
159
244
sys .exit (0 )
160
- except Exception as e :
161
- console .error (f"Failed to start MCP server: { e } " )
162
- sys .exit (1 )
163
245
164
246
165
247
def main () -> None :
@@ -174,15 +256,38 @@ def dev_cmd(
174
256
server_ref : str | None = typer .Argument (None , help = "Server reference (module:function)" ),
175
257
transport : str = typer .Option ("shttp" , help = "Transport type (shttp or stdio)" ),
176
258
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)" )
178
261
):
179
262
"""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 )
183
270
184
271
app ()
185
272
186
273
187
274
if __name__ == "__main__" :
188
275
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