Jeremiah Lowin commited on
Commit
98262e8
·
1 Parent(s): 02836c7

Refactor from typer to cyclopts

Browse files
pyproject.toml CHANGED
@@ -1,7 +1,7 @@
1
  [project]
2
  name = "fastmcp"
3
  dynamic = ["version"]
4
- description = "The fast, Pythonic way to build MCP servers."
5
  authors = [{ name = "Jeremiah Lowin" }]
6
  dependencies = [
7
  "python-dotenv>=1.1.0",
@@ -10,7 +10,7 @@ dependencies = [
10
  "mcp>=1.10.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
- "typer>=0.15.2",
14
  "authlib>=1.5.2",
15
  "pydantic[email]>=2.11.7",
16
  ]
 
1
  [project]
2
  name = "fastmcp"
3
  dynamic = ["version"]
4
+ description = "The fast, Pythonic way to build MCP servers and clients."
5
  authors = [{ name = "Jeremiah Lowin" }]
6
  dependencies = [
7
  "python-dotenv>=1.1.0",
 
10
  "mcp>=1.10.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
+ "cyclopts>=3.0.0",
14
  "authlib>=1.5.2",
15
  "pydantic[email]>=2.11.7",
16
  ]
src/fastmcp/cli/cli.py CHANGED
@@ -1,6 +1,5 @@
1
- """FastMCP CLI tools."""
2
 
3
- import asyncio
4
  import importlib.metadata
5
  import importlib.util
6
  import os
@@ -8,13 +7,12 @@ import platform
8
  import subprocess
9
  import sys
10
  from pathlib import Path
11
- from typing import Annotated
12
 
13
- import typer
14
  from pydantic import TypeAdapter
15
  from rich.console import Console
16
  from rich.table import Table
17
- from typer import Context, Exit
18
 
19
  import fastmcp
20
  from fastmcp.cli import run as run_module
@@ -26,11 +24,10 @@ from fastmcp.utilities.logging import get_logger
26
  logger = get_logger("cli")
27
  console = Console()
28
 
29
- app = typer.Typer(
30
  name="fastmcp",
31
- help="FastMCP CLI",
32
- add_completion=False,
33
- no_args_is_help=True, # Show help if no args provided
34
  )
35
 
36
 
@@ -87,11 +84,9 @@ def _build_uv_command(
87
  return cmd
88
 
89
 
90
- @app.command()
91
- def version(ctx: Context):
92
- if ctx.resilient_parsing:
93
- return
94
-
95
  info = {
96
  "FastMCP version": fastmcp.__version__,
97
  "MCP version": importlib.metadata.version("mcp"),
@@ -107,56 +102,55 @@ def version(ctx: Context):
107
  g.add_row(k + ":", str(v).replace("\n", " "))
108
  console.print(g)
109
 
110
- raise Exit()
111
 
112
 
113
- @app.command()
114
  def dev(
115
- server_spec: str = typer.Argument(
116
- ...,
117
- help="Python file to run, optionally with :object suffix",
118
- ),
119
  with_editable: Annotated[
120
  Path | None,
121
- typer.Option(
122
- "--with-editable",
123
- "-e",
124
  help="Directory containing pyproject.toml to install in editable mode",
125
- exists=True,
126
- file_okay=False,
127
- resolve_path=True,
128
  ),
129
  ] = None,
130
  with_packages: Annotated[
131
  list[str],
132
- typer.Option(
133
  "--with",
134
  help="Additional packages to install",
 
135
  ),
136
  ] = [],
137
  inspector_version: Annotated[
138
  str | None,
139
- typer.Option(
140
  "--inspector-version",
141
  help="Version of the MCP Inspector to use",
142
  ),
143
  ] = None,
144
  ui_port: Annotated[
145
  int | None,
146
- typer.Option(
147
  "--ui-port",
148
  help="Port for the MCP Inspector UI",
149
  ),
150
  ] = None,
151
  server_port: Annotated[
152
  int | None,
153
- typer.Option(
154
  "--server-port",
155
  help="Port for the MCP Inspector Proxy server",
156
  ),
157
  ] = None,
158
  ) -> None:
159
- """Run a MCP server with the MCP Inspector."""
 
 
 
 
160
  file, server_object = run_module.parse_file_path(server_spec)
161
 
162
  logger.debug(
@@ -229,66 +223,62 @@ def dev(
229
  sys.exit(1)
230
 
231
 
232
- @app.command(context_settings={"allow_extra_args": True})
233
  def run(
234
- ctx: typer.Context,
235
- server_spec: str = typer.Argument(
236
- ...,
237
- help="Python file, object specification (file:obj), or URL",
238
- ),
239
  transport: Annotated[
240
- str | None,
241
- typer.Option(
242
- "--transport",
243
- "-t",
244
- help="Transport protocol to use (stdio, http, or sse)",
245
  ),
246
  ] = None,
247
  host: Annotated[
248
  str | None,
249
- typer.Option(
250
  "--host",
251
  help="Host to bind to when using http transport (default: 127.0.0.1)",
252
  ),
253
  ] = None,
254
  port: Annotated[
255
  int | None,
256
- typer.Option(
257
- "--port",
258
- "-p",
259
  help="Port to bind to when using http transport (default: 8000)",
260
  ),
261
  ] = None,
262
  log_level: Annotated[
263
- str | None,
264
- typer.Option(
265
- "--log-level",
266
- "-l",
267
- help="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
268
  ),
269
  ] = None,
270
  no_banner: Annotated[
271
  bool,
272
- typer.Option(
273
  "--no-banner",
274
  help="Don't show the server banner",
 
275
  ),
276
  ] = False,
277
  ) -> None:
278
- """Run a MCP server or connect to a remote one.
279
 
280
  The server can be specified in three ways:
281
- 1. Module approach: server.py - runs the module directly, looking for an object named mcp/server/app.\n
282
- 2. Import approach: server.py:app - imports and runs the specified server object.\n
283
- 3. URL approach: http://server-url - connects to a remote server and creates a proxy.\n\n
284
-
285
- Note: This command runs the server directly. You are responsible for ensuring
286
- all dependencies are available.
287
 
288
  Server arguments can be passed after -- :
289
  fastmcp run server.py -- --config config.json --debug
 
 
 
290
  """
291
- server_args = ctx.args # extra args after --
 
292
 
293
  logger.debug(
294
  "Running server or client",
@@ -323,39 +313,33 @@ def run(
323
  sys.exit(1)
324
 
325
 
326
- # Add install subcommands
327
- app.add_typer(install_app)
328
-
329
-
330
- @app.command()
331
- def inspect(
332
- server_spec: str = typer.Argument(
333
- ...,
334
- help="Python file to inspect, optionally with :object suffix",
335
- ),
336
  output: Annotated[
337
  Path,
338
- typer.Option(
339
- "--output",
340
- "-o",
341
  help="Output file path for the JSON report (default: server-info.json)",
342
  ),
343
  ] = Path("server-info.json"),
344
  ) -> None:
345
- """Inspect a FastMCP server and generate a JSON report.
346
 
347
- This command analyzes a FastMCP server (v1.x or v2.x) and generates
348
- a comprehensive JSON report containing information about the server's
349
- name, instructions, version, tools, prompts, resources, templates,
350
- and capabilities.
351
 
352
  Examples:
353
  fastmcp inspect server.py
354
  fastmcp inspect server.py -o report.json
355
  fastmcp inspect server.py:mcp -o analysis.json
356
  fastmcp inspect path/to/server.py:app -o /tmp/server-info.json
357
- """
358
 
 
 
 
359
  # Parse the server specification
360
  file, server_object = run_module.parse_file_path(server_spec)
361
 
@@ -372,22 +356,8 @@ def inspect(
372
  # Import the server
373
  server = run_module.import_server(file, server_object)
374
 
375
- # Get server information
376
- async def get_info():
377
- return await inspect_fastmcp(server)
378
-
379
- try:
380
- # Try to use existing event loop if available
381
- asyncio.get_running_loop()
382
- # If there's already a loop running, we need to run in a thread
383
- import concurrent.futures
384
-
385
- with concurrent.futures.ThreadPoolExecutor() as executor:
386
- future = executor.submit(asyncio.run, get_info())
387
- info = future.result()
388
- except RuntimeError:
389
- # No running loop, safe to use asyncio.run
390
- info = asyncio.run(get_info())
391
 
392
  info_json = TypeAdapter(FastMCPInfo).dump_json(info, indent=2)
393
 
@@ -420,3 +390,11 @@ def inspect(
420
  )
421
  console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
422
  sys.exit(1)
 
 
 
 
 
 
 
 
 
1
+ """FastMCP CLI tools using Cyclopts."""
2
 
 
3
  import importlib.metadata
4
  import importlib.util
5
  import os
 
7
  import subprocess
8
  import sys
9
  from pathlib import Path
10
+ from typing import Annotated, Literal
11
 
12
+ import cyclopts
13
  from pydantic import TypeAdapter
14
  from rich.console import Console
15
  from rich.table import Table
 
16
 
17
  import fastmcp
18
  from fastmcp.cli import run as run_module
 
24
  logger = get_logger("cli")
25
  console = Console()
26
 
27
+ app = cyclopts.App(
28
  name="fastmcp",
29
+ help="FastMCP 2.0 - The fast, Pythonic way to build MCP servers and clients.",
30
+ version=fastmcp.__version__,
 
31
  )
32
 
33
 
 
84
  return cmd
85
 
86
 
87
+ @app.command
88
+ def version():
89
+ """Display version information and platform details."""
 
 
90
  info = {
91
  "FastMCP version": fastmcp.__version__,
92
  "MCP version": importlib.metadata.version("mcp"),
 
102
  g.add_row(k + ":", str(v).replace("\n", " "))
103
  console.print(g)
104
 
105
+ sys.exit(0)
106
 
107
 
108
+ @app.command
109
  def dev(
110
+ server_spec: str,
111
+ *,
 
 
112
  with_editable: Annotated[
113
  Path | None,
114
+ cyclopts.Parameter(
115
+ name=["--with-editable", "-e"],
 
116
  help="Directory containing pyproject.toml to install in editable mode",
 
 
 
117
  ),
118
  ] = None,
119
  with_packages: Annotated[
120
  list[str],
121
+ cyclopts.Parameter(
122
  "--with",
123
  help="Additional packages to install",
124
+ negative=False,
125
  ),
126
  ] = [],
127
  inspector_version: Annotated[
128
  str | None,
129
+ cyclopts.Parameter(
130
  "--inspector-version",
131
  help="Version of the MCP Inspector to use",
132
  ),
133
  ] = None,
134
  ui_port: Annotated[
135
  int | None,
136
+ cyclopts.Parameter(
137
  "--ui-port",
138
  help="Port for the MCP Inspector UI",
139
  ),
140
  ] = None,
141
  server_port: Annotated[
142
  int | None,
143
+ cyclopts.Parameter(
144
  "--server-port",
145
  help="Port for the MCP Inspector Proxy server",
146
  ),
147
  ] = None,
148
  ) -> None:
149
+ """Run an MCP server with the MCP Inspector for development.
150
+
151
+ Args:
152
+ server_spec: Python file to run, optionally with :object suffix
153
+ """
154
  file, server_object = run_module.parse_file_path(server_spec)
155
 
156
  logger.debug(
 
223
  sys.exit(1)
224
 
225
 
226
+ @app.command
227
  def run(
228
+ server_spec: str,
229
+ *,
 
 
 
230
  transport: Annotated[
231
+ Literal["stdio", "http", "sse"] | None,
232
+ cyclopts.Parameter(
233
+ name=["--transport", "-t"],
234
+ help="Transport protocol to use",
 
235
  ),
236
  ] = None,
237
  host: Annotated[
238
  str | None,
239
+ cyclopts.Parameter(
240
  "--host",
241
  help="Host to bind to when using http transport (default: 127.0.0.1)",
242
  ),
243
  ] = None,
244
  port: Annotated[
245
  int | None,
246
+ cyclopts.Parameter(
247
+ name=["--port", "-p"],
 
248
  help="Port to bind to when using http transport (default: 8000)",
249
  ),
250
  ] = None,
251
  log_level: Annotated[
252
+ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None,
253
+ cyclopts.Parameter(
254
+ name=["--log-level", "-l"],
255
+ help="Log level",
 
256
  ),
257
  ] = None,
258
  no_banner: Annotated[
259
  bool,
260
+ cyclopts.Parameter(
261
  "--no-banner",
262
  help="Don't show the server banner",
263
+ negative=False,
264
  ),
265
  ] = False,
266
  ) -> None:
267
+ """Run an MCP server or connect to a remote one.
268
 
269
  The server can be specified in three ways:
270
+ 1. Module approach: server.py - runs the module directly, looking for an object named 'mcp', 'server', or 'app'
271
+ 2. Import approach: server.py:app - imports and runs the specified server object
272
+ 3. URL approach: http://server-url - connects to a remote server and creates a proxy
 
 
 
273
 
274
  Server arguments can be passed after -- :
275
  fastmcp run server.py -- --config config.json --debug
276
+
277
+ Args:
278
+ server_spec: Python file, object specification (file:obj), or URL
279
  """
280
+ # TODO: Handle server_args from extra context
281
+ server_args = [] # Will need to handle this with Cyclopts context
282
 
283
  logger.debug(
284
  "Running server or client",
 
313
  sys.exit(1)
314
 
315
 
316
+ @app.command
317
+ async def inspect(
318
+ server_spec: str,
319
+ *,
 
 
 
 
 
 
320
  output: Annotated[
321
  Path,
322
+ cyclopts.Parameter(
323
+ name=["--output", "-o"],
 
324
  help="Output file path for the JSON report (default: server-info.json)",
325
  ),
326
  ] = Path("server-info.json"),
327
  ) -> None:
328
+ """Inspect an MCP server and generate a JSON report.
329
 
330
+ This command analyzes an MCP server and generates a comprehensive JSON report
331
+ containing information about the server's name, instructions, version, tools,
332
+ prompts, resources, templates, and capabilities.
 
333
 
334
  Examples:
335
  fastmcp inspect server.py
336
  fastmcp inspect server.py -o report.json
337
  fastmcp inspect server.py:mcp -o analysis.json
338
  fastmcp inspect path/to/server.py:app -o /tmp/server-info.json
 
339
 
340
+ Args:
341
+ server_spec: Python file to inspect, optionally with :object suffix
342
+ """
343
  # Parse the server specification
344
  file, server_object = run_module.parse_file_path(server_spec)
345
 
 
356
  # Import the server
357
  server = run_module.import_server(file, server_object)
358
 
359
+ # Get server information - using native async support
360
+ info = await inspect_fastmcp(server)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  info_json = TypeAdapter(FastMCPInfo).dump_json(info, indent=2)
363
 
 
390
  )
391
  console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
392
  sys.exit(1)
393
+
394
+
395
+ # Add install subcommands using proper Cyclopts pattern
396
+ app.command(install_app)
397
+
398
+
399
+ if __name__ == "__main__":
400
+ app()
src/fastmcp/cli/install/__init__.py CHANGED
@@ -1,27 +1,20 @@
1
- """Install subcommands for FastMCP CLI."""
2
 
3
- import typer
4
 
5
  from .claude_code import claude_code_command
6
  from .claude_desktop import claude_desktop_command
7
  from .cursor import cursor_command
8
  from .mcp_config import mcp_config_command
9
 
10
- # Create a typer app for install subcommands
11
- install_app = typer.Typer(
12
  name="install",
13
- help="Install MCP servers in various clients and formats",
14
- no_args_is_help=True,
15
  )
16
 
17
  # Register each command from its respective module
18
- install_app.command("claude-code", help="Install a MCP server in Claude Code")(
19
- claude_code_command
20
- )
21
- install_app.command("claude-desktop", help="Install a MCP server in Claude Desktop")(
22
- claude_desktop_command
23
- )
24
- install_app.command("cursor", help="Install a MCP server in Cursor")(cursor_command)
25
- install_app.command(
26
- "mcp-json", help="Generate MCP JSON configuration for manual installation"
27
- )(mcp_config_command)
 
1
+ """Install subcommands for FastMCP CLI using Cyclopts."""
2
 
3
+ import cyclopts
4
 
5
  from .claude_code import claude_code_command
6
  from .claude_desktop import claude_desktop_command
7
  from .cursor import cursor_command
8
  from .mcp_config import mcp_config_command
9
 
10
+ # Create a cyclopts app for install subcommands
11
+ install_app = cyclopts.App(
12
  name="install",
13
+ help="Install MCP servers in various clients and formats.",
 
14
  )
15
 
16
  # Register each command from its respective module
17
+ install_app.command(claude_code_command, name="claude-code")
18
+ install_app.command(claude_desktop_command, name="claude-desktop")
19
+ install_app.command(cursor_command, name="cursor")
20
+ install_app.command(mcp_config_command, name="mcp-json")
 
 
 
 
 
 
src/fastmcp/cli/install/claude_code.py CHANGED
@@ -1,13 +1,11 @@
1
- """Claude Code integration for FastMCP install."""
2
-
3
- from __future__ import annotations
4
 
5
  import subprocess
6
  import sys
7
  from pathlib import Path
8
  from typing import Annotated
9
 
10
- import typer
11
  from rich import print
12
 
13
  from fastmcp.utilities.logging import get_logger
@@ -124,54 +122,51 @@ def install_claude_code(
124
 
125
 
126
  def claude_code_command(
127
- server_spec: Annotated[
128
- str, typer.Argument(help="Python file to run, optionally with :object suffix")
129
- ],
130
  server_name: Annotated[
131
  str | None,
132
- typer.Option(
133
- "--name",
134
- "-n",
135
- help="Custom name for the server (defaults to server's name attribute or file name)",
136
  ),
137
  ] = None,
138
  with_editable: Annotated[
139
  Path | None,
140
- typer.Option(
141
- "--with-editable",
142
- "-e",
143
- help="Directory containing pyproject.toml to install in editable mode",
144
- exists=True,
145
- file_okay=False,
146
- resolve_path=True,
147
  ),
148
  ] = None,
149
  with_packages: Annotated[
150
  list[str],
151
- typer.Option(
152
- "--with", help="Additional packages to install, in PEP 508 format"
 
 
153
  ),
154
  ] = [],
155
  env_vars: Annotated[
156
  list[str],
157
- typer.Option(
158
- "--env-var", "-v", help="Environment variables in KEY=VALUE format"
 
 
159
  ),
160
  ] = [],
161
  env_file: Annotated[
162
  Path | None,
163
- typer.Option(
164
  "--env-file",
165
- "-f",
166
- help="Load environment variables from a .env file",
167
- exists=True,
168
- file_okay=True,
169
- dir_okay=False,
170
- resolve_path=True,
171
  ),
172
  ] = None,
173
  ) -> None:
174
- """Install a MCP server in Claude Code."""
 
 
 
 
175
  file, server_object, name, packages, env_dict = process_common_args(
176
  server_spec, server_name, with_packages, env_vars, env_file
177
  )
@@ -186,8 +181,6 @@ def claude_code_command(
186
  )
187
 
188
  if success:
189
- print(
190
- f"[green bold]Successfully installed '[bold]{name}[/bold]' in Claude Code[/green bold]"
191
- )
192
  else:
193
  sys.exit(1)
 
1
+ """Claude Code integration for FastMCP install using Cyclopts."""
 
 
2
 
3
  import subprocess
4
  import sys
5
  from pathlib import Path
6
  from typing import Annotated
7
 
8
+ import cyclopts
9
  from rich import print
10
 
11
  from fastmcp.utilities.logging import get_logger
 
122
 
123
 
124
  def claude_code_command(
125
+ server_spec: str,
126
+ *,
 
127
  server_name: Annotated[
128
  str | None,
129
+ cyclopts.Parameter(
130
+ name=["--server-name", "-n"],
131
+ help="Custom name for the server in Claude Code",
 
132
  ),
133
  ] = None,
134
  with_editable: Annotated[
135
  Path | None,
136
+ cyclopts.Parameter(
137
+ name=["--with-editable", "-e"],
138
+ help="Directory with pyproject.toml to install in editable mode",
 
 
 
 
139
  ),
140
  ] = None,
141
  with_packages: Annotated[
142
  list[str],
143
+ cyclopts.Parameter(
144
+ "--with",
145
+ help="Additional packages to install",
146
+ negative=False,
147
  ),
148
  ] = [],
149
  env_vars: Annotated[
150
  list[str],
151
+ cyclopts.Parameter(
152
+ "--env",
153
+ help="Environment variables in KEY=VALUE format",
154
+ negative=False,
155
  ),
156
  ] = [],
157
  env_file: Annotated[
158
  Path | None,
159
+ cyclopts.Parameter(
160
  "--env-file",
161
+ help="Load environment variables from .env file",
 
 
 
 
 
162
  ),
163
  ] = None,
164
  ) -> None:
165
+ """Install an MCP server in Claude Code.
166
+
167
+ Args:
168
+ server_spec: Python file to install, optionally with :object suffix
169
+ """
170
  file, server_object, name, packages, env_dict = process_common_args(
171
  server_spec, server_name, with_packages, env_vars, env_file
172
  )
 
181
  )
182
 
183
  if success:
184
+ print(f"[green]Successfully installed '{name}' in Claude Code[/green]")
 
 
185
  else:
186
  sys.exit(1)
src/fastmcp/cli/install/claude_desktop.py CHANGED
@@ -1,13 +1,11 @@
1
- """Claude Desktop integration for FastMCP install."""
2
-
3
- from __future__ import annotations
4
 
5
  import os
6
  import sys
7
  from pathlib import Path
8
  from typing import Annotated
9
 
10
- import typer
11
  from rich import print
12
 
13
  from fastmcp.mcp_config import StdioMCPServer, update_config_file
@@ -61,7 +59,7 @@ def install_claude_desktop(
61
  config_dir = get_claude_config_path()
62
  if not config_dir:
63
  print(
64
- "[red]Claude Desktop config directory not found.[/red]\n"
65
  "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]"
66
  )
67
  return False
@@ -116,65 +114,62 @@ def install_claude_desktop(
116
  merged_env = existing_env
117
  server_config.env = merged_env
118
 
 
119
  update_config_file(config_file, name, server_config)
 
120
  return True
121
  except Exception as e:
122
- print(
123
- f"[red]Failed to install '[bold]{name}[/bold]' in Claude Desktop: {e}[/red]"
124
- )
125
  return False
126
 
127
 
128
  def claude_desktop_command(
129
- server_spec: Annotated[
130
- str, typer.Argument(help="Python file to run, optionally with :object suffix")
131
- ],
132
  server_name: Annotated[
133
  str | None,
134
- typer.Option(
135
- "--name",
136
- "-n",
137
- help="Custom name for the server (defaults to server's name attribute or file name)",
138
  ),
139
  ] = None,
140
  with_editable: Annotated[
141
  Path | None,
142
- typer.Option(
143
- "--with-editable",
144
- "-e",
145
- help="Directory containing pyproject.toml to install in editable mode",
146
- exists=True,
147
- file_okay=False,
148
- resolve_path=True,
149
  ),
150
  ] = None,
151
  with_packages: Annotated[
152
  list[str],
153
- typer.Option(
154
- "--with", help="Additional packages to install, in PEP 508 format"
 
 
155
  ),
156
  ] = [],
157
  env_vars: Annotated[
158
  list[str],
159
- typer.Option(
160
- "--env-var", "-v", help="Environment variables in KEY=VALUE format"
 
 
161
  ),
162
  ] = [],
163
  env_file: Annotated[
164
  Path | None,
165
- typer.Option(
166
  "--env-file",
167
- "-f",
168
- help="Load environment variables from a .env file",
169
- exists=True,
170
- file_okay=True,
171
- dir_okay=False,
172
- resolve_path=True,
173
  ),
174
  ] = None,
175
  ) -> None:
176
- """Install a MCP server in Claude Desktop."""
177
- file, server_object, name, packages, env_dict = process_common_args(
 
 
 
 
178
  server_spec, server_name, with_packages, env_vars, env_file
179
  )
180
 
@@ -183,13 +178,9 @@ def claude_desktop_command(
183
  server_object=server_object,
184
  name=name,
185
  with_editable=with_editable,
186
- with_packages=packages,
187
  env_vars=env_dict,
188
  )
189
 
190
- if success:
191
- print(
192
- f"[green bold]Successfully installed '[bold]{name}[/bold]' in Claude Desktop[/green bold]"
193
- )
194
- else:
195
  sys.exit(1)
 
1
+ """Claude Desktop integration for FastMCP install using Cyclopts."""
 
 
2
 
3
  import os
4
  import sys
5
  from pathlib import Path
6
  from typing import Annotated
7
 
8
+ import cyclopts
9
  from rich import print
10
 
11
  from fastmcp.mcp_config import StdioMCPServer, update_config_file
 
59
  config_dir = get_claude_config_path()
60
  if not config_dir:
61
  print(
62
+ "[red]Claude Desktop config directory not found.[/red]\n"
63
  "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]"
64
  )
65
  return False
 
114
  merged_env = existing_env
115
  server_config.env = merged_env
116
 
117
+ # Update configuration with correct function signature
118
  update_config_file(config_file, name, server_config)
119
+ print(f"[green]Successfully installed '{name}' in Claude Desktop[/green]")
120
  return True
121
  except Exception as e:
122
+ print(f"[red]Failed to install server: {e}[/red]")
 
 
123
  return False
124
 
125
 
126
  def claude_desktop_command(
127
+ server_spec: str,
128
+ *,
 
129
  server_name: Annotated[
130
  str | None,
131
+ cyclopts.Parameter(
132
+ name=["--server-name", "-n"],
133
+ help="Custom name for the server in Claude Desktop's config",
 
134
  ),
135
  ] = None,
136
  with_editable: Annotated[
137
  Path | None,
138
+ cyclopts.Parameter(
139
+ name=["--with-editable", "-e"],
140
+ help="Directory with pyproject.toml to install in editable mode",
 
 
 
 
141
  ),
142
  ] = None,
143
  with_packages: Annotated[
144
  list[str],
145
+ cyclopts.Parameter(
146
+ "--with",
147
+ help="Additional packages to install",
148
+ negative=False,
149
  ),
150
  ] = [],
151
  env_vars: Annotated[
152
  list[str],
153
+ cyclopts.Parameter(
154
+ "--env",
155
+ help="Environment variables in KEY=VALUE format",
156
+ negative=False,
157
  ),
158
  ] = [],
159
  env_file: Annotated[
160
  Path | None,
161
+ cyclopts.Parameter(
162
  "--env-file",
163
+ help="Load environment variables from .env file",
 
 
 
 
 
164
  ),
165
  ] = None,
166
  ) -> None:
167
+ """Install an MCP server in Claude Desktop.
168
+
169
+ Args:
170
+ server_spec: Python file to install, optionally with :object suffix
171
+ """
172
+ file, server_object, name, with_packages, env_dict = process_common_args(
173
  server_spec, server_name, with_packages, env_vars, env_file
174
  )
175
 
 
178
  server_object=server_object,
179
  name=name,
180
  with_editable=with_editable,
181
+ with_packages=with_packages,
182
  env_vars=env_dict,
183
  )
184
 
185
+ if not success:
 
 
 
 
186
  sys.exit(1)
src/fastmcp/cli/install/cursor.py CHANGED
@@ -1,6 +1,4 @@
1
- """Cursor integration for FastMCP install."""
2
-
3
- from __future__ import annotations
4
 
5
  import base64
6
  import subprocess
@@ -8,7 +6,7 @@ import sys
8
  from pathlib import Path
9
  from typing import Annotated
10
 
11
- import typer
12
  from rich import print
13
 
14
  from fastmcp.mcp_config import StdioMCPServer
@@ -33,7 +31,6 @@ def generate_cursor_deeplink(
33
  Deeplink URL that can be clicked to install the server
34
  """
35
  # Create the configuration structure expected by Cursor
36
-
37
  # Base64 encode the configuration (URL-safe for query parameter)
38
  config_json = server_config.model_dump_json(exclude_none=True)
39
  config_b64 = base64.urlsafe_b64encode(config_json.encode()).decode()
@@ -81,7 +78,7 @@ def install_cursor(
81
  Args:
82
  file: Path to the server file
83
  server_object: Optional server object name (for :object suffix)
84
- name: Name for the server in Cursor's config
85
  with_editable: Optional directory to install in editable mode
86
  with_packages: Optional list of additional packages to install
87
  env_vars: Optional dictionary of environment variables
@@ -120,75 +117,69 @@ def install_cursor(
120
  env=env_vars or {},
121
  )
122
 
123
- # Generate and open deeplink
124
- try:
125
- deeplink = generate_cursor_deeplink(name, server_config)
126
 
127
- if open_deeplink(deeplink):
128
- print(
129
- f"[green]Opening Cursor to install '[bold]{name}[/bold]' - please confirm in Cursor to complete installation[/green]"
130
- )
131
- return True
132
- else:
133
- print("[yellow]Could not open Cursor automatically.[/yellow]")
134
- print(f"[blue]Please open this link to install:[/blue] {deeplink}")
135
- return True
136
-
137
- except Exception as e:
138
- print(f"[red]Failed to generate Cursor deeplink: {e}[/red]")
139
  return False
140
 
141
 
142
  def cursor_command(
143
- server_spec: Annotated[
144
- str, typer.Argument(help="Python file to run, optionally with :object suffix")
145
- ],
146
  server_name: Annotated[
147
  str | None,
148
- typer.Option(
149
- "--name",
150
- "-n",
151
- help="Custom name for the server (defaults to server's name attribute or file name)",
152
  ),
153
  ] = None,
154
  with_editable: Annotated[
155
  Path | None,
156
- typer.Option(
157
- "--with-editable",
158
- "-e",
159
- help="Directory containing pyproject.toml to install in editable mode",
160
- exists=True,
161
- file_okay=False,
162
- resolve_path=True,
163
  ),
164
  ] = None,
165
  with_packages: Annotated[
166
  list[str],
167
- typer.Option(
168
- "--with", help="Additional packages to install, in PEP 508 format"
 
 
169
  ),
170
  ] = [],
171
  env_vars: Annotated[
172
  list[str],
173
- typer.Option(
174
- "--env-var", "-v", help="Environment variables in KEY=VALUE format"
 
 
175
  ),
176
  ] = [],
177
  env_file: Annotated[
178
  Path | None,
179
- typer.Option(
180
  "--env-file",
181
- "-f",
182
- help="Load environment variables from a .env file",
183
- exists=True,
184
- file_okay=True,
185
- dir_okay=False,
186
- resolve_path=True,
187
  ),
188
  ] = None,
189
  ) -> None:
190
- """Install a MCP server in Cursor."""
191
- file, server_object, name, packages, env_dict = process_common_args(
 
 
 
 
192
  server_spec, server_name, with_packages, env_vars, env_file
193
  )
194
 
@@ -197,10 +188,9 @@ def cursor_command(
197
  server_object=server_object,
198
  name=name,
199
  with_editable=with_editable,
200
- with_packages=packages,
201
  env_vars=env_dict,
202
  )
203
 
204
- # Cursor handles its own messaging, no generic success message needed
205
  if not success:
206
  sys.exit(1)
 
1
+ """Cursor integration for FastMCP install using Cyclopts."""
 
 
2
 
3
  import base64
4
  import subprocess
 
6
  from pathlib import Path
7
  from typing import Annotated
8
 
9
+ import cyclopts
10
  from rich import print
11
 
12
  from fastmcp.mcp_config import StdioMCPServer
 
31
  Deeplink URL that can be clicked to install the server
32
  """
33
  # Create the configuration structure expected by Cursor
 
34
  # Base64 encode the configuration (URL-safe for query parameter)
35
  config_json = server_config.model_dump_json(exclude_none=True)
36
  config_b64 = base64.urlsafe_b64encode(config_json.encode()).decode()
 
78
  Args:
79
  file: Path to the server file
80
  server_object: Optional server object name (for :object suffix)
81
+ name: Name for the server in Cursor
82
  with_editable: Optional directory to install in editable mode
83
  with_packages: Optional list of additional packages to install
84
  env_vars: Optional dictionary of environment variables
 
117
  env=env_vars or {},
118
  )
119
 
120
+ # Generate deeplink
121
+ deeplink = generate_cursor_deeplink(name, server_config)
 
122
 
123
+ print(f"[blue]Opening Cursor to install '{name}'[/blue]")
124
+
125
+ if open_deeplink(deeplink):
126
+ print("[green]Cursor should now open with the installation dialog[/green]")
127
+ return True
128
+ else:
129
+ print(
130
+ "[red]Could not open Cursor automatically.[/red]\n"
131
+ f"[blue]Please copy this link and open it in Cursor: {deeplink}[/blue]"
132
+ )
 
 
133
  return False
134
 
135
 
136
  def cursor_command(
137
+ server_spec: str,
138
+ *,
 
139
  server_name: Annotated[
140
  str | None,
141
+ cyclopts.Parameter(
142
+ name=["--server-name", "-n"],
143
+ help="Custom name for the server in Cursor",
 
144
  ),
145
  ] = None,
146
  with_editable: Annotated[
147
  Path | None,
148
+ cyclopts.Parameter(
149
+ name=["--with-editable", "-e"],
150
+ help="Directory with pyproject.toml to install in editable mode",
 
 
 
 
151
  ),
152
  ] = None,
153
  with_packages: Annotated[
154
  list[str],
155
+ cyclopts.Parameter(
156
+ "--with",
157
+ help="Additional packages to install",
158
+ negative=False,
159
  ),
160
  ] = [],
161
  env_vars: Annotated[
162
  list[str],
163
+ cyclopts.Parameter(
164
+ "--env",
165
+ help="Environment variables in KEY=VALUE format",
166
+ negative=False,
167
  ),
168
  ] = [],
169
  env_file: Annotated[
170
  Path | None,
171
+ cyclopts.Parameter(
172
  "--env-file",
173
+ help="Load environment variables from .env file",
 
 
 
 
 
174
  ),
175
  ] = None,
176
  ) -> None:
177
+ """Install an MCP server in Cursor.
178
+
179
+ Args:
180
+ server_spec: Python file to install, optionally with :object suffix
181
+ """
182
+ file, server_object, name, with_packages, env_dict = process_common_args(
183
  server_spec, server_name, with_packages, env_vars, env_file
184
  )
185
 
 
188
  server_object=server_object,
189
  name=name,
190
  with_editable=with_editable,
191
+ with_packages=with_packages,
192
  env_vars=env_dict,
193
  )
194
 
 
195
  if not success:
196
  sys.exit(1)
src/fastmcp/cli/install/mcp_config.py CHANGED
@@ -1,13 +1,11 @@
1
- """MCP configuration JSON generation for FastMCP install."""
2
-
3
- from __future__ import annotations
4
 
5
  import json
6
  import sys
7
  from pathlib import Path
8
  from typing import Annotated
9
 
10
- import typer
11
  from rich import print
12
 
13
  from fastmcp.utilities.logging import get_logger
@@ -86,11 +84,11 @@ def install_mcp_config(
86
 
87
  pyperclip.copy(json_output)
88
  print(
89
- f"[green]MCP configuration for '[bold]{name}[/bold]' copied to clipboard[/green]"
90
  )
91
  except ImportError:
92
  print(
93
- "[red]The `--copy` flag requires pyperclip. Please install pyperclip and try again: `pip install pyperclip`[/red]"
94
  )
95
  return False
96
  else:
@@ -100,66 +98,64 @@ def install_mcp_config(
100
  return True
101
 
102
  except Exception as e:
103
- print(f"[red]Failed to generate MCP configuration: {e}[/red]")
104
  return False
105
 
106
 
107
  def mcp_config_command(
108
- server_spec: Annotated[
109
- str, typer.Argument(help="Python file to run, optionally with :object suffix")
110
- ],
111
  server_name: Annotated[
112
  str | None,
113
- typer.Option(
114
- "--name",
115
- "-n",
116
- help="Custom name for the server (defaults to server's name attribute or file name)",
117
  ),
118
  ] = None,
119
  with_editable: Annotated[
120
  Path | None,
121
- typer.Option(
122
- "--with-editable",
123
- "-e",
124
- help="Directory containing pyproject.toml to install in editable mode",
125
- exists=True,
126
- file_okay=False,
127
- resolve_path=True,
128
  ),
129
  ] = None,
130
  with_packages: Annotated[
131
  list[str],
132
- typer.Option(
133
- "--with", help="Additional packages to install, in PEP 508 format"
 
 
134
  ),
135
  ] = [],
136
  env_vars: Annotated[
137
  list[str],
138
- typer.Option(
139
- "--env-var", "-v", help="Environment variables in KEY=VALUE format"
 
 
140
  ),
141
  ] = [],
142
  env_file: Annotated[
143
  Path | None,
144
- typer.Option(
145
  "--env-file",
146
- "-f",
147
- help="Load environment variables from a .env file",
148
- exists=True,
149
- file_okay=True,
150
- dir_okay=False,
151
- resolve_path=True,
152
  ),
153
  ] = None,
154
  copy: Annotated[
155
  bool,
156
- typer.Option(
157
  "--copy",
158
  help="Copy configuration to clipboard instead of printing to stdout",
 
159
  ),
160
  ] = False,
161
  ) -> None:
162
- """Generate MCP configuration JSON for manual installation."""
 
 
 
 
163
  file, server_object, name, packages, env_dict = process_common_args(
164
  server_spec, server_name, with_packages, env_vars, env_file
165
  )
@@ -174,6 +170,5 @@ def mcp_config_command(
174
  copy=copy,
175
  )
176
 
177
- # mcp-config handles its own messaging, no generic success message needed
178
  if not success:
179
  sys.exit(1)
 
1
+ """MCP configuration JSON generation for FastMCP install using Cyclopts."""
 
 
2
 
3
  import json
4
  import sys
5
  from pathlib import Path
6
  from typing import Annotated
7
 
8
+ import cyclopts
9
  from rich import print
10
 
11
  from fastmcp.utilities.logging import get_logger
 
84
 
85
  pyperclip.copy(json_output)
86
  print(
87
+ f"[green]MCP configuration for '{name}' copied to clipboard[/green]"
88
  )
89
  except ImportError:
90
  print(
91
+ "[red]The --copy flag requires pyperclip. Please install pyperclip and try again: pip install pyperclip[/red]"
92
  )
93
  return False
94
  else:
 
98
  return True
99
 
100
  except Exception as e:
101
+ print(f"[red]Failed to generate MCP configuration: {e}[/red]")
102
  return False
103
 
104
 
105
  def mcp_config_command(
106
+ server_spec: str,
107
+ *,
 
108
  server_name: Annotated[
109
  str | None,
110
+ cyclopts.Parameter(
111
+ name=["--server-name", "-n"],
112
+ help="Custom name for the server in MCP config",
 
113
  ),
114
  ] = None,
115
  with_editable: Annotated[
116
  Path | None,
117
+ cyclopts.Parameter(
118
+ name=["--with-editable", "-e"],
119
+ help="Directory with pyproject.toml to install in editable mode",
 
 
 
 
120
  ),
121
  ] = None,
122
  with_packages: Annotated[
123
  list[str],
124
+ cyclopts.Parameter(
125
+ "--with",
126
+ help="Additional packages to install",
127
+ negative=False,
128
  ),
129
  ] = [],
130
  env_vars: Annotated[
131
  list[str],
132
+ cyclopts.Parameter(
133
+ "--env",
134
+ help="Environment variables in KEY=VALUE format",
135
+ negative=False,
136
  ),
137
  ] = [],
138
  env_file: Annotated[
139
  Path | None,
140
+ cyclopts.Parameter(
141
  "--env-file",
142
+ help="Load environment variables from .env file",
 
 
 
 
 
143
  ),
144
  ] = None,
145
  copy: Annotated[
146
  bool,
147
+ cyclopts.Parameter(
148
  "--copy",
149
  help="Copy configuration to clipboard instead of printing to stdout",
150
+ negative=False,
151
  ),
152
  ] = False,
153
  ) -> None:
154
+ """Generate MCP configuration JSON for manual installation.
155
+
156
+ Args:
157
+ server_spec: Python file to install, optionally with :object suffix
158
+ """
159
  file, server_object, name, packages, env_dict = process_common_args(
160
  server_spec, server_name, with_packages, env_vars, env_file
161
  )
 
170
  copy=copy,
171
  )
172
 
 
173
  if not success:
174
  sys.exit(1)
src/fastmcp/cli/install/shared.py CHANGED
@@ -1,7 +1,5 @@
1
  """Shared utilities for install commands."""
2
 
3
- from __future__ import annotations
4
-
5
  import sys
6
  from pathlib import Path
7
 
@@ -18,7 +16,7 @@ def parse_env_var(env_var: str) -> tuple[str, str]:
18
  """Parse environment variable string in format KEY=VALUE."""
19
  if "=" not in env_var:
20
  print(
21
- f"[red]Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]"
22
  )
23
  sys.exit(1)
24
  key, value = env_var.split("=", 1)
@@ -76,7 +74,7 @@ def process_common_args(
76
  k: v for k, v in dotenv_values(env_file).items() if v is not None
77
  }
78
  except Exception as e:
79
- print(f"[red]Failed to load .env file: {e}[/red]")
80
  sys.exit(1)
81
 
82
  # Add command line environment variables
 
1
  """Shared utilities for install commands."""
2
 
 
 
3
  import sys
4
  from pathlib import Path
5
 
 
16
  """Parse environment variable string in format KEY=VALUE."""
17
  if "=" not in env_var:
18
  print(
19
+ f"[red]Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]"
20
  )
21
  sys.exit(1)
22
  key, value = env_var.split("=", 1)
 
74
  k: v for k, v in dotenv_values(env_file).items() if v is not None
75
  }
76
  except Exception as e:
77
+ print(f"[red]Failed to load .env file: {e}[/red]")
78
  sys.exit(1)
79
 
80
  # Add command line environment variables
src/fastmcp/cli/run.py CHANGED
@@ -1,15 +1,19 @@
1
- """FastMCP run command implementation."""
2
 
3
  import importlib.util
4
  import re
5
  import sys
6
  from pathlib import Path
7
- from typing import Any
8
 
9
  from fastmcp.utilities.logging import get_logger
10
 
11
  logger = get_logger("cli.run")
12
 
 
 
 
 
13
 
14
  def is_url(path: str) -> bool:
15
  """Check if a string is a URL."""
@@ -164,10 +168,10 @@ def import_server_with_args(
164
 
165
  def run_command(
166
  server_spec: str,
167
- transport: str | None = None,
168
  host: str | None = None,
169
  port: int | None = None,
170
- log_level: str | None = None,
171
  server_args: list[str] | None = None,
172
  show_banner: bool = True,
173
  ) -> None:
@@ -180,6 +184,7 @@ def run_command(
180
  port: Port to bind to when using http transport
181
  log_level: Log level
182
  server_args: Additional arguments to pass to the server
 
183
  """
184
  if is_url(server_spec):
185
  # Handle URL case
 
1
+ """FastMCP run command implementation with enhanced type hints."""
2
 
3
  import importlib.util
4
  import re
5
  import sys
6
  from pathlib import Path
7
+ from typing import Any, Literal
8
 
9
  from fastmcp.utilities.logging import get_logger
10
 
11
  logger = get_logger("cli.run")
12
 
13
+ # Type aliases for better type safety
14
+ TransportType = Literal["stdio", "http", "sse"]
15
+ LogLevelType = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
16
+
17
 
18
  def is_url(path: str) -> bool:
19
  """Check if a string is a URL."""
 
168
 
169
  def run_command(
170
  server_spec: str,
171
+ transport: TransportType | None = None,
172
  host: str | None = None,
173
  port: int | None = None,
174
+ log_level: LogLevelType | None = None,
175
  server_args: list[str] | None = None,
176
  show_banner: bool = True,
177
  ) -> None:
 
184
  port: Port to bind to when using http transport
185
  log_level: Log level
186
  server_args: Additional arguments to pass to the server
187
+ show_banner: Whether to show the server banner
188
  """
189
  if is_url(server_spec):
190
  # Handle URL case
tests/cli/__init__.py DELETED
File without changes
tests/cli/test_claude_code.py DELETED
@@ -1,264 +0,0 @@
1
- """Tests for Claude Code CLI integration."""
2
-
3
- from pathlib import Path
4
- from unittest.mock import MagicMock, patch
5
-
6
- from fastmcp.cli.install.claude_code import (
7
- check_claude_code_available,
8
- find_claude_command,
9
- install_claude_code,
10
- )
11
-
12
-
13
- class TestFindClaudeCommand:
14
- """Test find_claude_command function."""
15
-
16
- @patch("subprocess.run")
17
- @patch("pathlib.Path.exists")
18
- def test_finds_command_in_default_location(self, mock_exists, mock_run):
19
- """Should find claude in default installation location."""
20
- mock_exists.return_value = True
21
- mock_run.return_value = MagicMock(stdout="1.0.43 (Claude Code)")
22
-
23
- result = find_claude_command()
24
-
25
- expected_path = str(Path.home() / ".claude" / "local" / "claude")
26
- assert result == expected_path
27
- mock_run.assert_called_once_with(
28
- [expected_path, "--version"], check=True, capture_output=True, text=True
29
- )
30
-
31
- @patch("subprocess.run")
32
- @patch("pathlib.Path.exists")
33
- def test_rejects_non_claude_code_binary(self, mock_exists, mock_run):
34
- """Should reject binary that isn't Claude Code."""
35
- mock_exists.return_value = True
36
- mock_run.return_value = MagicMock(stdout="Some other claude 1.0.0")
37
-
38
- result = find_claude_command()
39
-
40
- assert result is None
41
-
42
- @patch("subprocess.run")
43
- @patch("pathlib.Path.exists")
44
- def test_handles_subprocess_error(self, mock_exists, mock_run):
45
- """Should handle subprocess errors gracefully."""
46
- from subprocess import CalledProcessError
47
-
48
- mock_exists.return_value = True
49
- mock_run.side_effect = CalledProcessError(1, "claude")
50
-
51
- result = find_claude_command()
52
-
53
- assert result is None
54
-
55
- @patch("pathlib.Path.exists")
56
- def test_no_command_found(self, mock_exists):
57
- """Should return None when binary doesn't exist."""
58
- mock_exists.return_value = False
59
-
60
- result = find_claude_command()
61
-
62
- assert result is None
63
-
64
-
65
- class TestCheckClaudeCodeAvailable:
66
- """Test check_claude_code_available function."""
67
-
68
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
69
- def test_available_when_command_found(self, mock_find):
70
- """Should return True when claude command is found."""
71
- mock_find.return_value = "/usr/local/bin/claude"
72
-
73
- result = check_claude_code_available()
74
-
75
- assert result is True
76
-
77
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
78
- def test_not_available_when_command_not_found(self, mock_find):
79
- """Should return False when claude command is not found."""
80
- mock_find.return_value = None
81
-
82
- result = check_claude_code_available()
83
-
84
- assert result is False
85
-
86
-
87
- class TestInstallClaudeCode:
88
- """Test install_claude_code function."""
89
-
90
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
91
- @patch("fastmcp.cli.install.claude_code.print")
92
- def test_fails_when_claude_not_found(self, mock_print, mock_find):
93
- """Should return False and print error when Claude Code CLI not found."""
94
- mock_find.return_value = None
95
-
96
- result = install_claude_code(Path("server.py"), None, "test-server")
97
-
98
- assert result is False
99
- mock_print.assert_called_once()
100
- assert "Claude Code CLI not found" in str(mock_print.call_args)
101
-
102
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
103
- @patch("subprocess.run")
104
- def test_successful_installation(self, mock_run, mock_find):
105
- """Should successfully install when command succeeds."""
106
- mock_find.return_value = "/usr/local/bin/claude"
107
- mock_run.return_value = MagicMock()
108
-
109
- result = install_claude_code(Path("server.py"), None, "test-server")
110
-
111
- assert result is True
112
- mock_run.assert_called_once()
113
-
114
- # Check the command that was run
115
- call_args = mock_run.call_args[0][0]
116
- assert call_args[0] == "/usr/local/bin/claude"
117
- assert "mcp" in call_args
118
- assert "add" in call_args
119
- assert "test-server" in call_args
120
- assert "--" in call_args
121
- assert "uv" in call_args
122
-
123
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
124
- @patch("subprocess.run")
125
- @patch("fastmcp.cli.install.claude_code.print")
126
- def test_handles_subprocess_error(self, mock_print, mock_run, mock_find):
127
- """Should handle subprocess errors and return False."""
128
- from subprocess import CalledProcessError
129
-
130
- mock_find.return_value = "/usr/local/bin/claude"
131
- mock_run.side_effect = CalledProcessError(
132
- 1, "claude", stderr="Permission denied"
133
- )
134
-
135
- result = install_claude_code(Path("server.py"), None, "test-server")
136
-
137
- assert result is False
138
- mock_print.assert_called_once()
139
- assert "Failed to install" in str(mock_print.call_args)
140
- assert "Permission denied" in str(mock_print.call_args)
141
-
142
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
143
- @patch("subprocess.run")
144
- def test_builds_correct_command_with_options(self, mock_run, mock_find):
145
- """Should build correct command with all options."""
146
- mock_find.return_value = "/usr/local/bin/claude"
147
- mock_run.return_value = MagicMock()
148
-
149
- install_claude_code(
150
- file=Path("server.py"),
151
- server_object="custom_server",
152
- name="test-server",
153
- with_editable=Path("/path/to/editable"),
154
- with_packages=["pandas", "requests"],
155
- env_vars={"API_KEY": "secret", "DEBUG": "true"},
156
- )
157
-
158
- # Check the command that was run
159
- call_args = mock_run.call_args[0][0]
160
-
161
- # Should have claude command
162
- assert call_args[0] == "/usr/local/bin/claude"
163
- assert "mcp" in call_args
164
- assert "add" in call_args
165
-
166
- # Should have environment variables
167
- assert "-e" in call_args
168
- env_vars = []
169
- for i, arg in enumerate(call_args):
170
- if arg == "-e" and i + 1 < len(call_args):
171
- env_vars.append(call_args[i + 1])
172
- assert "API_KEY=secret" in env_vars
173
- assert "DEBUG=true" in env_vars
174
-
175
- # Should have server name
176
- assert "test-server" in call_args
177
-
178
- # Should have separator
179
- assert "--" in call_args
180
-
181
- # Should have uv command with packages
182
- assert "uv" in call_args
183
- assert "run" in call_args
184
- assert "--with" in call_args
185
- assert "fastmcp" in call_args
186
- assert "pandas" in call_args
187
- assert "requests" in call_args
188
- assert "--with-editable" in call_args
189
- assert str(Path("/path/to/editable")) in call_args
190
-
191
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
192
- @patch("subprocess.run")
193
- def test_resolves_absolute_paths(self, mock_run, mock_find):
194
- """Should resolve server spec to absolute path."""
195
- mock_find.return_value = "/usr/local/bin/claude"
196
- mock_run.return_value = MagicMock()
197
-
198
- install_claude_code(Path("server.py"), None, "test-server")
199
-
200
- call_args = mock_run.call_args[0][0]
201
-
202
- # Find the server spec after "fastmcp run"
203
- server_spec_in_args = None
204
- for i, arg in enumerate(call_args):
205
- if (
206
- arg == "fastmcp"
207
- and i + 2 < len(call_args)
208
- and call_args[i + 1] == "run"
209
- ):
210
- server_spec_in_args = call_args[i + 2]
211
- break
212
-
213
- assert server_spec_in_args is not None
214
- assert str(Path("server.py").resolve()) in server_spec_in_args
215
-
216
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
217
- @patch("subprocess.run")
218
- def test_handles_server_spec_with_object(self, mock_run, mock_find):
219
- """Should correctly handle server spec with object notation."""
220
- mock_find.return_value = "/usr/local/bin/claude"
221
- mock_run.return_value = MagicMock()
222
-
223
- install_claude_code(Path("server.py"), "custom_object", "test-server")
224
-
225
- call_args = mock_run.call_args[0][0]
226
-
227
- # Find the server spec after "fastmcp run"
228
- server_spec_in_args = None
229
- for i, arg in enumerate(call_args):
230
- if (
231
- arg == "fastmcp"
232
- and i + 2 < len(call_args)
233
- and call_args[i + 1] == "run"
234
- ):
235
- server_spec_in_args = call_args[i + 2]
236
- break
237
-
238
- assert server_spec_in_args is not None
239
- assert ":custom_object" in server_spec_in_args
240
- assert str(Path("server.py").resolve()) in server_spec_in_args
241
-
242
- @patch("fastmcp.cli.install.claude_code.find_claude_command")
243
- @patch("subprocess.run")
244
- def test_deduplicates_packages(self, mock_run, mock_find):
245
- """Should deduplicate packages in the command."""
246
- mock_find.return_value = "/usr/local/bin/claude"
247
- mock_run.return_value = MagicMock()
248
-
249
- install_claude_code(
250
- file=Path("server.py"),
251
- server_object=None,
252
- name="test-server",
253
- with_packages=["pandas", "fastmcp", "pandas"], # duplicates
254
- )
255
-
256
- call_args = mock_run.call_args[0][0]
257
-
258
- # Count occurrences of pandas
259
- pandas_count = sum(1 for arg in call_args if arg == "pandas")
260
- fastmcp_count = sum(1 for arg in call_args if arg == "fastmcp")
261
-
262
- # Should only appear once each for the package (fastmcp appears twice: once as package, once as command)
263
- assert pandas_count == 1
264
- assert fastmcp_count == 2 # Once in --with fastmcp, once in fastmcp run
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_cli.py DELETED
@@ -1,472 +0,0 @@
1
- """Tests for the CLI module."""
2
-
3
- import subprocess
4
- from pathlib import Path
5
- from unittest.mock import MagicMock, Mock, patch
6
-
7
- import pytest
8
- from typer.testing import CliRunner
9
-
10
- from fastmcp.cli import cli
11
-
12
- # Set up test runner
13
- runner = CliRunner()
14
-
15
-
16
- @pytest.fixture
17
- def mock_console():
18
- """Mock the rich console to test output."""
19
- with patch("fastmcp.cli.cli.console") as mock_console:
20
- yield mock_console
21
-
22
-
23
- @pytest.fixture
24
- def mock_logger():
25
- """Mock the logger to test logging."""
26
- with patch("fastmcp.cli.cli.logger") as mock_logger:
27
- yield mock_logger
28
-
29
-
30
- @pytest.fixture
31
- def mock_exit():
32
- """Mock sys.exit to prevent tests from exiting."""
33
- with patch("sys.exit") as mock_exit:
34
- yield mock_exit
35
-
36
-
37
- @pytest.fixture
38
- def temp_python_file(tmp_path):
39
- """Create a temporary Python file with a test server."""
40
- server_code = """
41
- from mcp import Server
42
-
43
- class TestServer(Server):
44
- name = "test_server"
45
- dependencies = ["package1", "package2"]
46
-
47
- def run(self, **kwargs):
48
- print("Running server with", kwargs)
49
-
50
- mcp = TestServer()
51
- server = TestServer()
52
- app = TestServer()
53
- custom_server = TestServer()
54
- """
55
- file_path = tmp_path / "test_server.py"
56
- file_path.write_text(server_code)
57
- return file_path
58
-
59
-
60
- @pytest.fixture
61
- def temp_env_file(tmp_path):
62
- """Create a temporary .env file."""
63
- env_content = """
64
- TEST_VAR1=value1
65
- TEST_VAR2=value2
66
- """
67
- env_path = tmp_path / ".env"
68
- env_path.write_text(env_content)
69
- return env_path
70
-
71
-
72
- class TestHelperFunctions:
73
- """Tests for helper functions in cli.py."""
74
-
75
- def test_get_npx_command_unix(self):
76
- """Test getting npx command on unix systems."""
77
- with patch("sys.platform", "linux"):
78
- with patch("subprocess.run") as mock_run:
79
- mock_run.return_value = Mock(returncode=0)
80
- assert cli._get_npx_command() == "npx"
81
-
82
- def test_get_npx_command_windows(self):
83
- """Test getting npx command on Windows."""
84
- with patch("sys.platform", "win32"):
85
- with patch("subprocess.run") as mock_run:
86
- # First try fails, second succeeds
87
- mock_run.side_effect = [
88
- subprocess.CalledProcessError(1, "npx.cmd"),
89
- Mock(returncode=0),
90
- ]
91
- assert cli._get_npx_command() == "npx.exe"
92
-
93
- def test_get_npx_command_not_found(self):
94
- """Test when npx command is not found."""
95
- with patch("sys.platform", "win32"):
96
- with patch("subprocess.run") as mock_run:
97
- mock_run.side_effect = [
98
- subprocess.CalledProcessError(1, "npx.cmd"),
99
- subprocess.CalledProcessError(1, "npx.exe"),
100
- subprocess.CalledProcessError(1, "npx"),
101
- ]
102
- assert cli._get_npx_command() is None
103
-
104
- def test_parse_env_var_valid(self):
105
- """Test parsing valid environment variables."""
106
- assert cli._parse_env_var("KEY=VALUE") == ("KEY", "VALUE")
107
- assert cli._parse_env_var("KEY=") == ("KEY", "")
108
- assert cli._parse_env_var("KEY=VALUE=WITH=EQUALS") == (
109
- "KEY",
110
- "VALUE=WITH=EQUALS",
111
- )
112
- assert cli._parse_env_var(" KEY = VALUE ") == ("KEY", "VALUE")
113
-
114
- def test_build_uv_command_basic(self):
115
- """Test building basic uv command."""
116
- cmd = cli._build_uv_command("file.py")
117
- assert cmd == ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "file.py"]
118
-
119
- def test_build_uv_command_with_editable(self):
120
- """Test building uv command with editable flag."""
121
- project_path = Path("/path/to/project")
122
- cmd = cli._build_uv_command("file.py", with_editable=project_path)
123
- assert cmd == [
124
- "uv",
125
- "run",
126
- "--with",
127
- "fastmcp",
128
- "--with-editable",
129
- str(project_path),
130
- "fastmcp",
131
- "run",
132
- "file.py",
133
- ]
134
-
135
- def test_build_uv_command_with_packages(self):
136
- """Test building uv command with additional packages."""
137
- cmd = cli._build_uv_command("file.py", with_packages=["pkg1", "pkg2"])
138
- assert cmd == [
139
- "uv",
140
- "run",
141
- "--with",
142
- "fastmcp",
143
- "--with",
144
- "pkg1",
145
- "--with",
146
- "pkg2",
147
- "fastmcp",
148
- "run",
149
- "file.py",
150
- ]
151
-
152
- def test_build_uv_command_full(self):
153
- """Test building full uv command with all options."""
154
- project_path = Path("/path/to/project")
155
- cmd = cli._build_uv_command(
156
- "file.py:server",
157
- with_editable=project_path,
158
- with_packages=["pkg1", "pkg2"],
159
- )
160
- assert cmd == [
161
- "uv",
162
- "run",
163
- "--with",
164
- "fastmcp",
165
- "--with-editable",
166
- str(project_path),
167
- "--with",
168
- "pkg1",
169
- "--with",
170
- "pkg2",
171
- "fastmcp",
172
- "run",
173
- "file.py:server",
174
- ]
175
-
176
-
177
- class TestVersionCommand:
178
- """Tests for the version command."""
179
-
180
- def test_version_early_exit_with_resilient_parsing(self):
181
- """Test version command exits early with resilient parsing."""
182
- ctx = MagicMock()
183
- ctx.resilient_parsing = True
184
- result = cli.version(ctx)
185
- assert result is None
186
-
187
-
188
- class TestDevCommand:
189
- """Tests for the dev command."""
190
-
191
- def test_dev_command_success(self, temp_python_file, mock_logger):
192
- """Test successful dev command execution."""
193
- with (
194
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
195
- patch("fastmcp.cli.run.import_server") as mock_import,
196
- patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
197
- patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
198
- patch("subprocess.run") as mock_run,
199
- ):
200
- mock_parse.return_value = (temp_python_file, None)
201
- mock_server = MagicMock()
202
- mock_server.dependencies = ["extra_dep"]
203
- mock_import.return_value = mock_server
204
- mock_get_npx.return_value = "npx"
205
- mock_build_uv.return_value = ["uv", "command"]
206
- mock_run.return_value = MagicMock(returncode=0)
207
-
208
- result = runner.invoke(cli.app, ["dev", str(temp_python_file)])
209
- assert result.exit_code == 0
210
- mock_run.assert_called_once()
211
-
212
- # Check dependencies were passed correctly with no_banner=True
213
- mock_build_uv.assert_called_once_with(
214
- str(temp_python_file), None, ["extra_dep"], no_banner=True
215
- )
216
-
217
- def test_dev_command_with_ui_port(self, temp_python_file):
218
- """Test dev command with UI port."""
219
- with (
220
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
221
- patch("fastmcp.cli.run.import_server") as mock_import,
222
- patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
223
- patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
224
- patch("subprocess.run") as mock_run,
225
- ):
226
- mock_parse.return_value = (temp_python_file, None)
227
- mock_import.return_value = MagicMock(dependencies=[])
228
- mock_get_npx.return_value = "npx"
229
- mock_build_uv.return_value = ["uv", "command"]
230
- mock_run.return_value = MagicMock(returncode=0)
231
-
232
- result = runner.invoke(
233
- cli.app, ["dev", str(temp_python_file), "--ui-port", "3000"]
234
- )
235
- assert result.exit_code == 0
236
-
237
- # Check environment variables were set
238
- env = mock_run.call_args[1]["env"]
239
- assert "CLIENT_PORT" in env
240
- assert env["CLIENT_PORT"] == "3000"
241
-
242
- def test_dev_command_with_server_port(self, temp_python_file):
243
- """Test dev command with server port."""
244
- with (
245
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
246
- patch("fastmcp.cli.run.import_server") as mock_import,
247
- patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
248
- patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
249
- patch("subprocess.run") as mock_run,
250
- ):
251
- mock_parse.return_value = (temp_python_file, None)
252
- mock_import.return_value = MagicMock(dependencies=[])
253
- mock_get_npx.return_value = "npx"
254
- mock_build_uv.return_value = ["uv", "command"]
255
- mock_run.return_value = MagicMock(returncode=0)
256
-
257
- result = runner.invoke(
258
- cli.app, ["dev", str(temp_python_file), "--server-port", "8080"]
259
- )
260
- assert result.exit_code == 0
261
-
262
- # Check environment variables were set
263
- env = mock_run.call_args[1]["env"]
264
- assert "SERVER_PORT" in env
265
- assert env["SERVER_PORT"] == "8080"
266
-
267
- def test_dev_command_inspector_version(self, temp_python_file):
268
- """Test dev command with specific inspector version."""
269
- with (
270
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
271
- patch("fastmcp.cli.run.import_server") as mock_import,
272
- patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
273
- patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
274
- patch("subprocess.run") as mock_run,
275
- ):
276
- mock_parse.return_value = (temp_python_file, None)
277
- mock_import.return_value = MagicMock(dependencies=[])
278
- mock_get_npx.return_value = "npx"
279
- mock_build_uv.return_value = ["uv", "command"]
280
- mock_run.return_value = MagicMock(returncode=0)
281
-
282
- result = runner.invoke(
283
- cli.app, ["dev", str(temp_python_file), "--inspector-version", "1.0.0"]
284
- )
285
- assert result.exit_code == 0
286
-
287
- # Check inspector version was used
288
- inspector_cmd = mock_run.call_args[0][0][1]
289
- assert inspector_cmd == "@modelcontextprotocol/inspector@1.0.0"
290
-
291
-
292
- class TestRunCommand:
293
- """Tests for the run command."""
294
-
295
- def test_run_command_success(self, temp_python_file):
296
- """Test successful run command execution."""
297
- with (
298
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
299
- patch("fastmcp.cli.run.import_server") as mock_import,
300
- patch("fastmcp.cli.run.logger") as mock_logger,
301
- ):
302
- mock_parse.return_value = (temp_python_file, None)
303
- mock_server = MagicMock()
304
- mock_server.name = "test_server"
305
- mock_import.return_value = mock_server
306
-
307
- result = runner.invoke(cli.app, ["run", str(temp_python_file)])
308
- assert result.exit_code == 0
309
- mock_server.run.assert_called_once_with()
310
- mock_logger.debug.assert_called_with(
311
- f'Found server "test_server" in {temp_python_file}'
312
- )
313
-
314
- def test_run_command_with_transport(self, temp_python_file):
315
- """Test run command with transport option."""
316
- with (
317
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
318
- patch("fastmcp.cli.run.import_server") as mock_import,
319
- ):
320
- mock_parse.return_value = (temp_python_file, None)
321
- mock_server = MagicMock()
322
- mock_server.name = "test_server"
323
- mock_import.return_value = mock_server
324
-
325
- result = runner.invoke(
326
- cli.app, ["run", str(temp_python_file), "--transport", "sse"]
327
- )
328
- assert result.exit_code == 0
329
- mock_server.run.assert_called_once_with(transport="sse")
330
-
331
- def test_run_command_with_http_transports(self, temp_python_file):
332
- """Test run command with both http and streamable-http transport options."""
333
- # Test "http" transport
334
- with (
335
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
336
- patch("fastmcp.cli.run.import_server") as mock_import,
337
- ):
338
- mock_parse.return_value = (temp_python_file, None)
339
- mock_server = MagicMock()
340
- mock_server.name = "test_server"
341
- mock_import.return_value = mock_server
342
-
343
- result = runner.invoke(
344
- cli.app, ["run", str(temp_python_file), "--transport", "http"]
345
- )
346
- assert result.exit_code == 0
347
- mock_server.run.assert_called_once_with(transport="http")
348
-
349
- # Test "streamable-http" transport (alias for http)
350
- with (
351
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
352
- patch("fastmcp.cli.run.import_server") as mock_import,
353
- ):
354
- mock_parse.return_value = (temp_python_file, None)
355
- mock_server = MagicMock()
356
- mock_server.name = "test_server"
357
- mock_import.return_value = mock_server
358
-
359
- result = runner.invoke(
360
- cli.app,
361
- ["run", str(temp_python_file), "--transport", "streamable-http"],
362
- )
363
- assert result.exit_code == 0
364
- mock_server.run.assert_called_once_with(transport="streamable-http")
365
-
366
- def test_run_command_with_host(self, temp_python_file):
367
- """Test run command with host option."""
368
- with (
369
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
370
- patch("fastmcp.cli.run.import_server") as mock_import,
371
- ):
372
- mock_parse.return_value = (temp_python_file, None)
373
- mock_server = MagicMock()
374
- mock_server.name = "test_server"
375
- mock_import.return_value = mock_server
376
-
377
- result = runner.invoke(
378
- cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"]
379
- )
380
- assert result.exit_code == 0
381
- mock_server.run.assert_called_once_with(host="0.0.0.0")
382
-
383
- def test_run_command_with_port(self, temp_python_file):
384
- """Test run command with port option."""
385
- with (
386
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
387
- patch("fastmcp.cli.run.import_server") as mock_import,
388
- ):
389
- mock_parse.return_value = (temp_python_file, None)
390
- mock_server = MagicMock()
391
- mock_server.name = "test_server"
392
- mock_import.return_value = mock_server
393
-
394
- result = runner.invoke(
395
- cli.app, ["run", str(temp_python_file), "--port", "8080"]
396
- )
397
- assert result.exit_code == 0
398
- mock_server.run.assert_called_once_with(port=8080)
399
-
400
- def test_run_command_with_log_level(self, temp_python_file):
401
- """Test run command with log level option."""
402
- with (
403
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
404
- patch("fastmcp.cli.run.import_server") as mock_import,
405
- ):
406
- mock_parse.return_value = (temp_python_file, None)
407
- mock_server = MagicMock()
408
- mock_server.name = "test_server"
409
- mock_import.return_value = mock_server
410
-
411
- result = runner.invoke(
412
- cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"]
413
- )
414
- assert result.exit_code == 0
415
- mock_server.run.assert_called_once_with(log_level="DEBUG")
416
-
417
- def test_run_command_with_multiple_options(self, temp_python_file):
418
- """Test run command with multiple options."""
419
- with (
420
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
421
- patch("fastmcp.cli.run.import_server") as mock_import,
422
- ):
423
- mock_parse.return_value = (temp_python_file, None)
424
- mock_server = MagicMock()
425
- mock_server.name = "test_server"
426
- mock_import.return_value = mock_server
427
-
428
- result = runner.invoke(
429
- cli.app,
430
- [
431
- "run",
432
- str(temp_python_file),
433
- "--transport",
434
- "sse",
435
- "--host",
436
- "0.0.0.0",
437
- "--port",
438
- "8080",
439
- "--log-level",
440
- "DEBUG",
441
- ],
442
- )
443
- assert result.exit_code == 0
444
- mock_server.run.assert_called_once_with(
445
- transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG"
446
- )
447
-
448
- def test_run_command_with_server_args(self, temp_python_file):
449
- """Test run command with server arguments using -- pattern."""
450
- with (
451
- patch("fastmcp.cli.run.run_command") as mock_run_command,
452
- ):
453
- result = runner.invoke(
454
- cli.app,
455
- [
456
- "run",
457
- str(temp_python_file),
458
- "--",
459
- "--config",
460
- "config.json",
461
- ],
462
- )
463
- assert result.exit_code == 0
464
- mock_run_command.assert_called_once_with(
465
- server_spec=str(temp_python_file),
466
- transport=None,
467
- host=None,
468
- port=None,
469
- log_level=None,
470
- server_args=["--config", "config.json"],
471
- show_banner=True,
472
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_cursor.py DELETED
@@ -1,276 +0,0 @@
1
- """Tests for Cursor CLI integration."""
2
-
3
- import base64
4
- import json
5
- from pathlib import Path
6
- from unittest.mock import patch
7
-
8
- from fastmcp.cli.install.cursor import (
9
- generate_cursor_deeplink,
10
- install_cursor,
11
- open_deeplink,
12
- )
13
- from fastmcp.mcp_config import StdioMCPServer
14
-
15
-
16
- class TestGenerateCursorDeeplink:
17
- """Test generate_cursor_deeplink function."""
18
-
19
- def test_generates_valid_deeplink(self):
20
- """Should generate a valid Cursor deeplink with base64 encoded config."""
21
- server_config = StdioMCPServer(
22
- command="uv",
23
- args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"],
24
- env={"API_KEY": "secret"},
25
- )
26
-
27
- deeplink = generate_cursor_deeplink("test-server", server_config)
28
-
29
- assert deeplink.startswith("cursor://anysphere.cursor-deeplink/mcp/install?")
30
- assert "name=test-server" in deeplink
31
- assert "config=" in deeplink
32
-
33
- def test_config_is_url_safe_base64(self):
34
- """Should use URL-safe base64 encoding for the config."""
35
- server_config = StdioMCPServer(
36
- command="test",
37
- args=["arg1", "arg2"],
38
- )
39
-
40
- deeplink = generate_cursor_deeplink("test", server_config)
41
-
42
- # Extract the config parameter
43
- config_param = deeplink.split("config=")[1]
44
-
45
- # Should be decodable as URL-safe base64
46
- decoded = base64.urlsafe_b64decode(config_param.encode())
47
- config_data = json.loads(decoded)
48
-
49
- assert config_data["command"] == "test"
50
- assert config_data["args"] == ["arg1", "arg2"]
51
-
52
- def test_excludes_none_values(self):
53
- """Should exclude None values from the configuration."""
54
- server_config = StdioMCPServer(
55
- command="test",
56
- args=["arg1"],
57
- timeout=None, # This should be excluded
58
- )
59
-
60
- deeplink = generate_cursor_deeplink("test", server_config)
61
- config_param = deeplink.split("config=")[1]
62
- decoded = base64.urlsafe_b64decode(config_param.encode())
63
- config_data = json.loads(decoded)
64
-
65
- assert "timeout" not in config_data
66
-
67
-
68
- class TestOpenDeeplink:
69
- """Test open_deeplink function."""
70
-
71
- @patch("subprocess.run")
72
- @patch("fastmcp.cli.install.cursor.sys.platform", "darwin")
73
- def test_opens_on_macos(self, mock_run):
74
- """Should use 'open' command on macOS."""
75
- mock_run.return_value = None
76
-
77
- result = open_deeplink("cursor://test")
78
-
79
- assert result is True
80
- mock_run.assert_called_once_with(
81
- ["open", "cursor://test"], check=True, capture_output=True
82
- )
83
-
84
- @patch("subprocess.run")
85
- @patch("fastmcp.cli.install.cursor.sys.platform", "win32")
86
- def test_opens_on_windows(self, mock_run):
87
- """Should use 'start' command on Windows."""
88
- mock_run.return_value = None
89
-
90
- result = open_deeplink("cursor://test")
91
-
92
- assert result is True
93
- mock_run.assert_called_once_with(
94
- ["start", "cursor://test"], shell=True, check=True, capture_output=True
95
- )
96
-
97
- @patch("subprocess.run")
98
- @patch("fastmcp.cli.install.cursor.sys.platform", "linux")
99
- def test_opens_on_linux(self, mock_run):
100
- """Should use 'xdg-open' command on Linux."""
101
- mock_run.return_value = None
102
-
103
- result = open_deeplink("cursor://test")
104
-
105
- assert result is True
106
- mock_run.assert_called_once_with(
107
- ["xdg-open", "cursor://test"], check=True, capture_output=True
108
- )
109
-
110
- @patch("subprocess.run")
111
- def test_handles_subprocess_error(self, mock_run):
112
- """Should return False when subprocess command fails."""
113
- from subprocess import CalledProcessError
114
-
115
- mock_run.side_effect = CalledProcessError(1, "open")
116
-
117
- result = open_deeplink("cursor://test")
118
-
119
- assert result is False
120
-
121
- @patch("subprocess.run")
122
- def test_handles_file_not_found(self, mock_run):
123
- """Should return False when command is not found."""
124
- mock_run.side_effect = FileNotFoundError()
125
-
126
- result = open_deeplink("cursor://test")
127
-
128
- assert result is False
129
-
130
-
131
- class TestInstallCursor:
132
- """Test install_cursor function."""
133
-
134
- @patch("fastmcp.cli.install.cursor.open_deeplink")
135
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
136
- @patch("fastmcp.cli.install.cursor.print")
137
- def test_successful_installation(
138
- self, mock_print, mock_generate_deeplink, mock_open_deeplink
139
- ):
140
- """Should successfully install when deeplink opens."""
141
- mock_generate_deeplink.return_value = "cursor://test-deeplink"
142
- mock_open_deeplink.return_value = True
143
-
144
- result = install_cursor(Path("server.py"), None, "test-server")
145
-
146
- assert result is True
147
- mock_generate_deeplink.assert_called_once()
148
- mock_open_deeplink.assert_called_once_with("cursor://test-deeplink")
149
- mock_print.assert_called_once()
150
- # Check that the success message was printed
151
- assert "Opening Cursor to install" in str(mock_print.call_args)
152
-
153
- @patch("fastmcp.cli.install.cursor.open_deeplink")
154
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
155
- @patch("fastmcp.cli.install.cursor.print")
156
- def test_fallback_when_deeplink_fails(
157
- self, mock_print, mock_generate_deeplink, mock_open_deeplink
158
- ):
159
- """Should provide manual link when deeplink fails to open."""
160
- mock_generate_deeplink.return_value = "cursor://test-deeplink"
161
- mock_open_deeplink.return_value = False
162
-
163
- result = install_cursor(Path("server.py"), None, "test-server")
164
-
165
- assert result is True
166
- assert mock_print.call_count == 2
167
- # Check that both error and manual link messages were printed
168
- print_calls = [str(call) for call in mock_print.call_args_list]
169
- assert any(
170
- "Could not open Cursor automatically" in call for call in print_calls
171
- )
172
- assert any("Please open this link" in call for call in print_calls)
173
-
174
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
175
- @patch("fastmcp.cli.install.cursor.print")
176
- def test_handles_deeplink_generation_error(
177
- self, mock_print, mock_generate_deeplink
178
- ):
179
- """Should return False when deeplink generation fails."""
180
- mock_generate_deeplink.side_effect = Exception("Test error")
181
-
182
- result = install_cursor(Path("server.py"), None, "test-server")
183
-
184
- assert result is False
185
- mock_print.assert_called_once()
186
- assert "Failed to generate Cursor deeplink" in str(mock_print.call_args)
187
-
188
- @patch("fastmcp.cli.install.cursor.open_deeplink")
189
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
190
- def test_builds_correct_server_config(
191
- self, mock_generate_deeplink, mock_open_deeplink
192
- ):
193
- """Should build correct server configuration with all options."""
194
- mock_generate_deeplink.return_value = "cursor://test"
195
- mock_open_deeplink.return_value = True
196
-
197
- install_cursor(
198
- file=Path("server.py"),
199
- server_object="custom_server",
200
- name="test-server",
201
- with_editable=Path("/path/to/editable"),
202
- with_packages=["pandas", "requests"],
203
- env_vars={"API_KEY": "secret", "DEBUG": "true"},
204
- )
205
-
206
- # Check that generate_cursor_deeplink was called with correct config
207
- call_args = mock_generate_deeplink.call_args
208
- server_name, server_config = call_args[0]
209
-
210
- assert server_name == "test-server"
211
- assert server_config.command == "uv"
212
- assert "run" in server_config.args
213
- assert "--with" in server_config.args
214
- assert "fastmcp" in server_config.args
215
- assert "pandas" in server_config.args
216
- assert "requests" in server_config.args
217
- assert "--with-editable" in server_config.args
218
- assert str(Path("/path/to/editable")) in server_config.args
219
- assert "fastmcp" in server_config.args
220
- assert "run" in server_config.args
221
- assert server_config.env == {"API_KEY": "secret", "DEBUG": "true"}
222
-
223
- @patch("fastmcp.cli.install.cursor.open_deeplink")
224
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
225
- def test_resolves_absolute_paths(self, mock_generate_deeplink, mock_open_deeplink):
226
- """Should resolve server spec to absolute path."""
227
- mock_generate_deeplink.return_value = "cursor://test"
228
- mock_open_deeplink.return_value = True
229
-
230
- install_cursor(Path("server.py"), None, "test-server")
231
-
232
- call_args = mock_generate_deeplink.call_args
233
- _, server_config = call_args[0]
234
-
235
- # Find the server spec after "fastmcp run"
236
- server_spec_in_args = None
237
- for i, arg in enumerate(server_config.args):
238
- if (
239
- arg == "fastmcp"
240
- and i + 2 < len(server_config.args)
241
- and server_config.args[i + 1] == "run"
242
- ):
243
- server_spec_in_args = server_config.args[i + 2]
244
- break
245
-
246
- assert server_spec_in_args is not None
247
- assert str(Path("server.py").resolve()) in server_spec_in_args
248
-
249
- @patch("fastmcp.cli.install.cursor.open_deeplink")
250
- @patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
251
- def test_handles_server_spec_with_object(
252
- self, mock_generate_deeplink, mock_open_deeplink
253
- ):
254
- """Should correctly handle server spec with object notation."""
255
- mock_generate_deeplink.return_value = "cursor://test"
256
- mock_open_deeplink.return_value = True
257
-
258
- install_cursor(Path("server.py"), "custom_object", "test-server")
259
-
260
- call_args = mock_generate_deeplink.call_args
261
- _, server_config = call_args[0]
262
-
263
- # Find the server spec after "fastmcp run"
264
- server_spec_in_args = None
265
- for i, arg in enumerate(server_config.args):
266
- if (
267
- arg == "fastmcp"
268
- and i + 2 < len(server_config.args)
269
- and server_config.args[i + 1] == "run"
270
- ):
271
- server_spec_in_args = server_config.args[i + 2]
272
- break
273
-
274
- assert server_spec_in_args is not None
275
- assert ":custom_object" in server_spec_in_args
276
- assert str(Path("server.py").resolve()) in server_spec_in_args
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_mcp_config.py DELETED
@@ -1,199 +0,0 @@
1
- """Tests for MCP configuration JSON generation."""
2
-
3
- import json
4
- from pathlib import Path
5
- from unittest.mock import MagicMock, patch
6
-
7
- from fastmcp.cli.install.mcp_config import install_mcp_config
8
-
9
-
10
- class TestInstallMcpConfig:
11
- """Test install_mcp_config function."""
12
-
13
- def test_generates_basic_config(self):
14
- """Should generate basic MCP configuration with minimal options."""
15
- result = install_mcp_config(
16
- file=Path("server.py"),
17
- server_object=None,
18
- name="test-server",
19
- )
20
-
21
- assert result is True
22
-
23
- @patch("fastmcp.cli.install.mcp_config.print")
24
- def test_generates_config_with_all_options(self, mock_print):
25
- """Should generate MCP configuration with all options."""
26
- result = install_mcp_config(
27
- file=Path("server.py"),
28
- server_object="custom_server",
29
- name="test-server",
30
- with_editable=Path("/path/to/editable"),
31
- with_packages=["pandas", "requests"],
32
- env_vars={"API_KEY": "secret", "DEBUG": "true"},
33
- )
34
-
35
- assert result is True
36
- mock_print.assert_called_once()
37
-
38
- # Get the JSON output from print call
39
- json_output = mock_print.call_args[0][0]
40
- config = json.loads(json_output)
41
-
42
- # Verify structure (should be just the server config, not wrapped in mcpServers)
43
- server_config = config
44
-
45
- # Verify command and args
46
- assert server_config["command"] == "uv"
47
- assert "run" in server_config["args"]
48
- assert "--with" in server_config["args"]
49
- assert "fastmcp" in server_config["args"]
50
- assert "pandas" in server_config["args"]
51
- assert "requests" in server_config["args"]
52
- assert "--with-editable" in server_config["args"]
53
- assert str(Path("/path/to/editable")) in server_config["args"]
54
-
55
- # Verify server spec with object
56
- server_spec_in_args = None
57
- for i, arg in enumerate(server_config["args"]):
58
- if (
59
- arg == "fastmcp"
60
- and i + 2 < len(server_config["args"])
61
- and server_config["args"][i + 1] == "run"
62
- ):
63
- server_spec_in_args = server_config["args"][i + 2]
64
- break
65
-
66
- assert server_spec_in_args is not None
67
- assert ":custom_server" in server_spec_in_args
68
-
69
- # Verify environment variables
70
- assert server_config["env"] == {"API_KEY": "secret", "DEBUG": "true"}
71
-
72
- @patch("fastmcp.cli.install.mcp_config.print")
73
- def test_generates_config_without_env_vars(self, mock_print):
74
- """Should generate MCP configuration without env section when no env vars."""
75
- result = install_mcp_config(
76
- file=Path("server.py"),
77
- server_object=None,
78
- name="test-server",
79
- )
80
-
81
- assert result is True
82
- json_output = mock_print.call_args[0][0]
83
- config = json.loads(json_output)
84
-
85
- # Should not have env section
86
- assert "env" not in config
87
-
88
- @patch("fastmcp.cli.install.mcp_config.print")
89
- def test_deduplicates_packages(self, mock_print):
90
- """Should deduplicate packages including fastmcp."""
91
- result = install_mcp_config(
92
- file=Path("server.py"),
93
- server_object=None,
94
- name="test-server",
95
- with_packages=["pandas", "fastmcp", "pandas"], # duplicates
96
- )
97
-
98
- assert result is True
99
- json_output = mock_print.call_args[0][0]
100
- config = json.loads(json_output)
101
-
102
- args = config["args"]
103
-
104
- # Count occurrences of packages
105
- pandas_count = sum(1 for arg in args if arg == "pandas")
106
- fastmcp_count = sum(1 for arg in args if arg == "fastmcp")
107
-
108
- # Should only appear once each for the package (fastmcp appears twice: once as package, once as command)
109
- assert pandas_count == 1
110
- assert fastmcp_count == 2 # Once in --with fastmcp, once in fastmcp run
111
-
112
- @patch("fastmcp.cli.install.mcp_config.print")
113
- def test_resolves_absolute_paths(self, mock_print):
114
- """Should resolve server file to absolute path."""
115
- result = install_mcp_config(
116
- file=Path("server.py"),
117
- server_object=None,
118
- name="test-server",
119
- )
120
-
121
- assert result is True
122
- json_output = mock_print.call_args[0][0]
123
- config = json.loads(json_output)
124
-
125
- args = config["args"]
126
-
127
- # Find the server spec after "fastmcp run"
128
- server_spec_in_args = None
129
- for i, arg in enumerate(args):
130
- if arg == "fastmcp" and i + 2 < len(args) and args[i + 1] == "run":
131
- server_spec_in_args = args[i + 2]
132
- break
133
-
134
- assert server_spec_in_args is not None
135
- assert str(Path("server.py").resolve()) in server_spec_in_args
136
-
137
- @patch("fastmcp.cli.install.mcp_config.print")
138
- def test_copy_to_clipboard_success(self, mock_print):
139
- """Should copy configuration to clipboard when copy=True."""
140
- # Mock the pyperclip module at import time
141
- mock_pyperclip = MagicMock()
142
- mock_copy = MagicMock()
143
- mock_pyperclip.copy = mock_copy
144
-
145
- with patch.dict("sys.modules", {"pyperclip": mock_pyperclip}):
146
- result = install_mcp_config(
147
- file=Path("server.py"),
148
- server_object=None,
149
- name="test-server",
150
- copy=True,
151
- )
152
-
153
- assert result is True
154
- mock_copy.assert_called_once()
155
-
156
- # Verify clipboard content is valid JSON
157
- clipboard_content = mock_copy.call_args[0][0]
158
- config = json.loads(clipboard_content) # Should not raise
159
- assert "command" in config # Should be server config, not wrapped
160
-
161
- # Should print success message
162
- mock_print.assert_called_once()
163
- assert "copied to clipboard" in str(mock_print.call_args)
164
-
165
- @patch("fastmcp.cli.install.mcp_config.print")
166
- def test_copy_to_clipboard_import_error(self, mock_print):
167
- """Should handle pyperclip import error gracefully."""
168
- with patch(
169
- "builtins.__import__",
170
- side_effect=ImportError("No module named 'pyperclip'"),
171
- ):
172
- result = install_mcp_config(
173
- file=Path("server.py"),
174
- server_object=None,
175
- name="test-server",
176
- copy=True,
177
- )
178
-
179
- assert result is False
180
-
181
- # Should print error message
182
- mock_print.assert_called_once()
183
- error_call = str(mock_print.call_args)
184
- assert "copy` flag requires pyperclip" in error_call
185
- assert "pip install pyperclip" in error_call
186
-
187
- @patch("fastmcp.cli.install.mcp_config.print")
188
- def test_handles_exception_gracefully(self, mock_print):
189
- """Should handle unexpected exceptions gracefully."""
190
- with patch("json.dumps", side_effect=Exception("JSON error")):
191
- result = install_mcp_config(
192
- file=Path("server.py"),
193
- server_object=None,
194
- name="test-server",
195
- )
196
-
197
- assert result is False
198
- mock_print.assert_called_once()
199
- assert "Failed to generate MCP configuration" in str(mock_print.call_args)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_run.py DELETED
@@ -1,298 +0,0 @@
1
- """Tests for the CLI module."""
2
-
3
- from pathlib import Path
4
- from unittest.mock import MagicMock, patch
5
-
6
- import pytest
7
- from typer.testing import CliRunner
8
-
9
- import fastmcp.cli.run
10
- from fastmcp.cli import cli
11
-
12
- # Set up test runner
13
- runner = CliRunner()
14
-
15
-
16
- @pytest.fixture
17
- def mock_console():
18
- """Mock the rich console to test output."""
19
- with patch("fastmcp.cli.cli.console") as mock_console:
20
- yield mock_console
21
-
22
-
23
- @pytest.fixture
24
- def mock_logger():
25
- """Mock the logger to test logging."""
26
- with patch("fastmcp.cli.cli.logger") as mock_logger:
27
- yield mock_logger
28
-
29
-
30
- @pytest.fixture
31
- def mock_exit():
32
- """Mock sys.exit to prevent tests from exiting."""
33
- with patch("sys.exit") as mock_exit:
34
- yield mock_exit
35
-
36
-
37
- @pytest.fixture
38
- def temp_python_file(tmp_path):
39
- """Create a temporary Python file with a test server."""
40
- server_code = """
41
- from mcp import Server
42
-
43
- class TestServer(Server):
44
- name = "test_server"
45
- dependencies = ["package1", "package2"]
46
-
47
- def run(self, **kwargs):
48
- print("Running server with", kwargs)
49
-
50
- mcp = TestServer()
51
- server = TestServer()
52
- app = TestServer()
53
- custom_server = TestServer()
54
- """
55
- file_path = tmp_path / "test_server.py"
56
- file_path.write_text(server_code)
57
- return file_path
58
-
59
-
60
- @pytest.fixture
61
- def temp_env_file(tmp_path):
62
- """Create a temporary .env file."""
63
- env_content = """
64
- TEST_VAR1=value1
65
- TEST_VAR2=value2
66
- """
67
- env_path = tmp_path / ".env"
68
- env_path.write_text(env_content)
69
- return env_path
70
-
71
-
72
- class TestHelperFunctions:
73
- def test_parse_file_path_simple(self):
74
- """Test parsing simple file path."""
75
- with (
76
- patch("pathlib.Path.exists") as mock_exists,
77
- patch("pathlib.Path.is_file") as mock_is_file,
78
- patch("pathlib.Path.expanduser") as mock_expanduser,
79
- patch("pathlib.Path.resolve") as mock_resolve,
80
- ):
81
- mock_exists.return_value = True
82
- mock_is_file.return_value = True
83
- mock_expanduser.return_value = Path("file.py")
84
- mock_resolve.return_value = Path("file.py")
85
-
86
- path, obj = fastmcp.cli.run.parse_file_path("file.py")
87
- assert path == Path("file.py")
88
- assert obj is None
89
-
90
- def test_parse_file_path_with_object(self):
91
- """Test parsing file path with object."""
92
- with (
93
- patch("pathlib.Path.exists") as mock_exists,
94
- patch("pathlib.Path.is_file") as mock_is_file,
95
- patch("pathlib.Path.expanduser") as mock_expanduser,
96
- patch("pathlib.Path.resolve") as mock_resolve,
97
- ):
98
- mock_exists.return_value = True
99
- mock_is_file.return_value = True
100
- mock_expanduser.return_value = Path("file.py")
101
- mock_resolve.return_value = Path("file.py")
102
-
103
- path, obj = fastmcp.cli.run.parse_file_path("file.py:server")
104
- assert path == Path("file.py")
105
- assert obj == "server"
106
-
107
- def test_parse_file_path_windows(self):
108
- """Test parsing Windows file path."""
109
- with (
110
- patch("pathlib.Path.exists") as mock_exists,
111
- patch("pathlib.Path.is_file") as mock_is_file,
112
- patch("pathlib.Path.expanduser") as mock_expanduser,
113
- patch("pathlib.Path.resolve") as mock_resolve,
114
- ):
115
- mock_exists.return_value = True
116
- mock_is_file.return_value = True
117
- mock_expanduser.return_value = Path("C:/path/file.py")
118
- mock_resolve.return_value = Path("C:/path/file.py")
119
-
120
- path, obj = fastmcp.cli.run.parse_file_path("C:/path/file.py:server")
121
- assert path == Path("C:/path/file.py")
122
- assert obj == "server"
123
-
124
- def test_parse_file_path_not_file(self, mock_exit):
125
- """Test parsing path that is not a file."""
126
- with (
127
- patch("pathlib.Path.exists") as mock_exists,
128
- patch("pathlib.Path.is_file") as mock_is_file,
129
- patch("pathlib.Path.expanduser") as mock_expanduser,
130
- patch("pathlib.Path.resolve") as mock_resolve,
131
- patch("fastmcp.cli.run.logger") as mock_logger,
132
- ):
133
- mock_exists.return_value = True
134
- mock_is_file.return_value = False
135
- mock_expanduser.return_value = Path("directory")
136
- mock_resolve.return_value = Path("directory")
137
-
138
- fastmcp.cli.run.parse_file_path("directory")
139
- mock_logger.error.assert_called_once()
140
- mock_exit.assert_called_once_with(1)
141
-
142
-
143
- class TestRunCommand:
144
- """Tests for the run command."""
145
-
146
- def test_run_command_success(self, temp_python_file):
147
- """Test successful run command execution."""
148
- with (
149
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
150
- patch("fastmcp.cli.run.import_server") as mock_import,
151
- patch("fastmcp.cli.run.logger") as mock_logger,
152
- ):
153
- mock_parse.return_value = (temp_python_file, None)
154
- mock_server = MagicMock()
155
- mock_server.name = "test_server"
156
- mock_import.return_value = mock_server
157
-
158
- result = runner.invoke(cli.app, ["run", str(temp_python_file)])
159
- assert result.exit_code == 0
160
- mock_server.run.assert_called_once_with()
161
- mock_logger.debug.assert_called_with(
162
- f'Found server "test_server" in {temp_python_file}'
163
- )
164
-
165
- def test_run_command_with_transport(self, temp_python_file):
166
- """Test run command with transport option."""
167
- with (
168
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
169
- patch("fastmcp.cli.run.import_server") as mock_import,
170
- ):
171
- mock_parse.return_value = (temp_python_file, None)
172
- mock_server = MagicMock()
173
- mock_server.name = "test_server"
174
- mock_import.return_value = mock_server
175
-
176
- result = runner.invoke(
177
- cli.app, ["run", str(temp_python_file), "--transport", "sse"]
178
- )
179
- assert result.exit_code == 0
180
- mock_server.run.assert_called_once_with(transport="sse")
181
-
182
- def test_run_command_with_host(self, temp_python_file):
183
- """Test run command with host option."""
184
- with (
185
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
186
- patch("fastmcp.cli.run.import_server") as mock_import,
187
- ):
188
- mock_parse.return_value = (temp_python_file, None)
189
- mock_server = MagicMock()
190
- mock_server.name = "test_server"
191
- mock_import.return_value = mock_server
192
-
193
- result = runner.invoke(
194
- cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"]
195
- )
196
- assert result.exit_code == 0
197
- mock_server.run.assert_called_once_with(host="0.0.0.0")
198
-
199
- def test_run_command_with_port(self, temp_python_file):
200
- """Test run command with port option."""
201
- with (
202
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
203
- patch("fastmcp.cli.run.import_server") as mock_import,
204
- ):
205
- mock_parse.return_value = (temp_python_file, None)
206
- mock_server = MagicMock()
207
- mock_server.name = "test_server"
208
- mock_import.return_value = mock_server
209
-
210
- result = runner.invoke(
211
- cli.app, ["run", str(temp_python_file), "--port", "8080"]
212
- )
213
- assert result.exit_code == 0
214
- mock_server.run.assert_called_once_with(port=8080)
215
-
216
- def test_run_command_with_log_level(self, temp_python_file):
217
- """Test run command with log level option."""
218
- with (
219
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
220
- patch("fastmcp.cli.run.import_server") as mock_import,
221
- ):
222
- mock_parse.return_value = (temp_python_file, None)
223
- mock_server = MagicMock()
224
- mock_server.name = "test_server"
225
- mock_import.return_value = mock_server
226
-
227
- result = runner.invoke(
228
- cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"]
229
- )
230
- assert result.exit_code == 0
231
- mock_server.run.assert_called_once_with(log_level="DEBUG")
232
-
233
- def test_run_command_with_multiple_options(self, temp_python_file):
234
- """Test run command with multiple options."""
235
- with (
236
- patch("fastmcp.cli.run.parse_file_path") as mock_parse,
237
- patch("fastmcp.cli.run.import_server") as mock_import,
238
- ):
239
- mock_parse.return_value = (temp_python_file, None)
240
- mock_server = MagicMock()
241
- mock_server.name = "test_server"
242
- mock_import.return_value = mock_server
243
-
244
- result = runner.invoke(
245
- cli.app,
246
- [
247
- "run",
248
- str(temp_python_file),
249
- "--transport",
250
- "sse",
251
- "--host",
252
- "0.0.0.0",
253
- "--port",
254
- "8080",
255
- "--log-level",
256
- "DEBUG",
257
- ],
258
- )
259
- assert result.exit_code == 0
260
- mock_server.run.assert_called_once_with(
261
- transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG"
262
- )
263
-
264
-
265
- class TestImportServerWithArgs:
266
- """Tests for the import_server_with_args function."""
267
-
268
- def test_import_server_with_args_no_args(self, temp_python_file):
269
- """Test importing server without arguments."""
270
- with patch("fastmcp.cli.run.import_server") as mock_import:
271
- mock_server = MagicMock()
272
- mock_import.return_value = mock_server
273
-
274
- result = fastmcp.cli.run.import_server_with_args(
275
- temp_python_file, None, None
276
- )
277
-
278
- assert result == mock_server
279
- mock_import.assert_called_once_with(temp_python_file, None)
280
-
281
- def test_import_server_with_args_with_args(self, temp_python_file):
282
- """Test importing server with arguments."""
283
- import sys
284
-
285
- with patch("fastmcp.cli.run.import_server") as mock_import:
286
- mock_server = MagicMock()
287
- mock_import.return_value = mock_server
288
-
289
- original_argv = sys.argv[:]
290
-
291
- result = fastmcp.cli.run.import_server_with_args(
292
- temp_python_file, "custom_server", ["--config", "test.json", "--debug"]
293
- )
294
-
295
- assert result == mock_server
296
- mock_import.assert_called_once_with(temp_python_file, "custom_server")
297
- # Verify sys.argv was restored
298
- assert sys.argv == original_argv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
uv.lock CHANGED
@@ -351,6 +351,22 @@ wheels = [
351
  { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" },
352
  ]
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  [[package]]
355
  name = "decorator"
356
  version = "5.2.1"
@@ -387,6 +403,24 @@ wheels = [
387
  { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
388
  ]
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  [[package]]
391
  name = "email-validator"
392
  version = "2.2.0"
@@ -462,6 +496,7 @@ name = "fastmcp"
462
  source = { editable = "." }
463
  dependencies = [
464
  { name = "authlib" },
 
465
  { name = "exceptiongroup" },
466
  { name = "httpx" },
467
  { name = "mcp" },
@@ -469,7 +504,6 @@ dependencies = [
469
  { name = "pydantic", extra = ["email"] },
470
  { name = "python-dotenv" },
471
  { name = "rich" },
472
- { name = "typer" },
473
  ]
474
 
475
  [package.optional-dependencies]
@@ -504,6 +538,7 @@ dev = [
504
  [package.metadata]
505
  requires-dist = [
506
  { name = "authlib", specifier = ">=1.5.2" },
 
507
  { name = "exceptiongroup", specifier = ">=1.2.2" },
508
  { name = "httpx", specifier = ">=0.28.1" },
509
  { name = "mcp", specifier = ">=1.10.0" },
@@ -511,7 +546,6 @@ requires-dist = [
511
  { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
512
  { name = "python-dotenv", specifier = ">=1.1.0" },
513
  { name = "rich", specifier = ">=13.9.4" },
514
- { name = "typer", specifier = ">=0.15.2" },
515
  { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" },
516
  ]
517
  provides-extras = ["websockets"]
@@ -1459,6 +1493,19 @@ wheels = [
1459
  { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
1460
  ]
1461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1462
  [[package]]
1463
  name = "rpds-py"
1464
  version = "0.25.1"
 
351
  { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" },
352
  ]
353
 
354
+ [[package]]
355
+ name = "cyclopts"
356
+ version = "3.22.1"
357
+ source = { registry = "https://pypi.org/simple" }
358
+ dependencies = [
359
+ { name = "attrs" },
360
+ { name = "docstring-parser", marker = "python_full_version < '4.0'" },
361
+ { name = "rich" },
362
+ { name = "rich-rst" },
363
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
364
+ ]
365
+ sdist = { url = "https://files.pythonhosted.org/packages/4a/d2/3f81aa0852d0a71b8d7614f355cf72655ea26f33dd1ddc01e01ddb41a0d0/cyclopts-3.22.1.tar.gz", hash = "sha256:4f42c9427f1e31f598c8416d88e37040ad783a177fa496541e53a8650bed1261", size = 74470, upload-time = "2025-07-03T00:35:25.094Z" }
366
+ wheels = [
367
+ { url = "https://files.pythonhosted.org/packages/c5/f2/0155fe8b06890aec0493ee5862aef2635729997da470d2fe0fb18951131e/cyclopts-3.22.1-py3-none-any.whl", hash = "sha256:1ce307fd835f93dcd5dd5e77fff1d91c9a092bc0126f846b24e9e4740b6ea3c3", size = 84534, upload-time = "2025-07-03T00:35:23.917Z" },
368
+ ]
369
+
370
  [[package]]
371
  name = "decorator"
372
  version = "5.2.1"
 
403
  { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
404
  ]
405
 
406
+ [[package]]
407
+ name = "docstring-parser"
408
+ version = "0.16"
409
+ source = { registry = "https://pypi.org/simple" }
410
+ sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565, upload-time = "2024-03-15T10:39:44.419Z" }
411
+ wheels = [
412
+ { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533, upload-time = "2024-03-15T10:39:41.527Z" },
413
+ ]
414
+
415
+ [[package]]
416
+ name = "docutils"
417
+ version = "0.21.2"
418
+ source = { registry = "https://pypi.org/simple" }
419
+ sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
420
+ wheels = [
421
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
422
+ ]
423
+
424
  [[package]]
425
  name = "email-validator"
426
  version = "2.2.0"
 
496
  source = { editable = "." }
497
  dependencies = [
498
  { name = "authlib" },
499
+ { name = "cyclopts" },
500
  { name = "exceptiongroup" },
501
  { name = "httpx" },
502
  { name = "mcp" },
 
504
  { name = "pydantic", extra = ["email"] },
505
  { name = "python-dotenv" },
506
  { name = "rich" },
 
507
  ]
508
 
509
  [package.optional-dependencies]
 
538
  [package.metadata]
539
  requires-dist = [
540
  { name = "authlib", specifier = ">=1.5.2" },
541
+ { name = "cyclopts", specifier = ">=3.0.0" },
542
  { name = "exceptiongroup", specifier = ">=1.2.2" },
543
  { name = "httpx", specifier = ">=0.28.1" },
544
  { name = "mcp", specifier = ">=1.10.0" },
 
546
  { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
547
  { name = "python-dotenv", specifier = ">=1.1.0" },
548
  { name = "rich", specifier = ">=13.9.4" },
 
549
  { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" },
550
  ]
551
  provides-extras = ["websockets"]
 
1493
  { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
1494
  ]
1495
 
1496
+ [[package]]
1497
+ name = "rich-rst"
1498
+ version = "1.3.1"
1499
+ source = { registry = "https://pypi.org/simple" }
1500
+ dependencies = [
1501
+ { name = "docutils" },
1502
+ { name = "rich" },
1503
+ ]
1504
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/69/5514c3a87b5f10f09a34bb011bc0927bc12c596c8dae5915604e71abc386/rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383", size = 13839, upload-time = "2024-04-30T04:40:38.125Z" }
1505
+ wheels = [
1506
+ { url = "https://files.pythonhosted.org/packages/fd/bc/cc4e3dbc5e7992398dcb7a8eda0cbcf4fb792a0cdb93f857b478bf3cf884/rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1", size = 11621, upload-time = "2024-04-30T04:40:32.619Z" },
1507
+ ]
1508
+
1509
  [[package]]
1510
  name = "rpds-py"
1511
  version = "0.25.1"