Skip to content

Commit 63197bf

Browse files
committed
change fastmcp dependency approach
1 parent cd9687f commit 63197bf

File tree

4 files changed

+60
-33
lines changed

4 files changed

+60
-33
lines changed

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "The Posit Connect command-line interface."
55
authors = [{ name = "Posit, PBC", email = "[email protected]" }]
66
license = { file = "LICENSE.md" }
77
readme = { file = "README.md", content-type = "text/markdown" }
8-
requires-python = ">=3.10"
8+
requires-python = ">=3.8"
99

1010
dependencies = [
1111
"typing-extensions>=4.8.0",
@@ -14,7 +14,6 @@ dependencies = [
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
1616
"toml>=0.10; python_version < '3.11'",
17-
"fastmcp==2.12.4"
1817
]
1918

2019
dynamic = ["version"]
@@ -38,8 +37,10 @@ test = [
3837
"setuptools_scm[toml]>=3.4",
3938
"twine",
4039
"types-Flask",
40+
"fastmcp==2.12.4; python_version >= '3.10'",
4141
]
4242
snowflake = ["snowflake-cli"]
43+
mcp = ["fastmcp==2.12.4; python_version >= '3.10'"]
4344
docs = [
4445
"mkdocs-material",
4546
"mkdocs-click",

rsconnect/main.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -404,13 +404,20 @@ def version():
404404

405405
@cli.command(help="Start the MCP server")
406406
def mcp_server():
407-
from fastmcp import FastMCP
408-
from fastmcp.exceptions import ToolError
407+
try:
408+
from fastmcp import FastMCP
409+
from fastmcp.exceptions import ToolError
410+
except ImportError:
411+
raise RSConnectException(
412+
"The fastmcp package is required for MCP server functionality. "
413+
"Install it with: pip install rsconnect-python[mcp]"
414+
)
409415

410416
mcp = FastMCP("Connect MCP")
411417

412418
# Discover all commands at startup
413419
from .mcp_deploy_context import discover_all_commands
420+
414421
all_commands_info = discover_all_commands(cli)
415422

416423
@mcp.tool()
@@ -431,10 +438,7 @@ def get_command_info(
431438
parts = command_path.strip().split()
432439
if not parts:
433440
available_commands = list(all_commands_info["commands"].keys())
434-
return {
435-
"error": "Command path cannot be empty",
436-
"available_commands": available_commands
437-
}
441+
return {"error": "Command path cannot be empty", "available_commands": available_commands}
438442

439443
current_info = all_commands_info
440444
current_path = []
@@ -451,11 +455,8 @@ def get_command_info(
451455
# try to return useful messaging for invalid subcommands
452456
if part not in current_info["commands"]:
453457
available = list(current_info["commands"].keys())
454-
path_str = ' '.join(current_path) if current_path else "top level"
455-
return {
456-
"error": f"Command '{part}' not found in {path_str}",
457-
"available_commands": available
458-
}
458+
path_str = " ".join(current_path) if current_path else "top level"
459+
return {"error": f"Command '{part}' not found in {path_str}", "available_commands": available}
459460

460461
current_info = current_info["commands"][part]
461462
current_path.append(part)
@@ -467,7 +468,7 @@ def get_command_info(
467468
"name": current_info.get("name", parts[-1]),
468469
"description": current_info.get("description"),
469470
"available_subcommands": list(current_info["commands"].keys()),
470-
"message": f"The '{' '.join(parts)}' command requires a subcommand."
471+
"message": f"The '{' '.join(parts)}' command requires a subcommand.",
471472
}
472473
else:
473474
return {
@@ -476,7 +477,7 @@ def get_command_info(
476477
"name": current_info.get("name", parts[-1]),
477478
"description": current_info.get("description"),
478479
"parameters": current_info.get("parameters", []),
479-
"shell": "bash"
480+
"shell": "bash",
480481
}
481482
except Exception as e:
482483
raise ToolError(f"Failed to retrieve command info: {str(e)}")

rsconnect/mcp_deploy_context.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
1515

1616
if isinstance(param, click.Option) and param.opts:
1717
# Use the longest option name (usually the full form without dashes)
18-
mcp_arg_name = max(param.opts, key=len).lstrip('-').replace('-', '_')
18+
mcp_arg_name = max(param.opts, key=len).lstrip("-").replace("-", "_")
1919
info["name"] = mcp_arg_name
2020
info["cli_flags"] = param.opts
2121
info["param_type"] = "option"
@@ -25,7 +25,7 @@ def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
2525
info["param_type"] = "argument"
2626

2727
# extract help text for added context
28-
help_text = getattr(param, 'help', None)
28+
help_text = getattr(param, "help", None)
2929
if help_text:
3030
info["description"] = help_text
3131

@@ -36,7 +36,7 @@ def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
3636
info["default"] = param.default or False
3737

3838
# choices
39-
elif param.type and hasattr(param.type, 'choices'):
39+
elif param.type and hasattr(param.type, "choices"):
4040
info["type"] = "string"
4141
info["choices"] = list(param.type.choices)
4242

@@ -75,11 +75,7 @@ def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
7575

7676
def discover_single_command(cmd: click.Command) -> Dict[str, Any]:
7777
"""Discover a single command and its parameters."""
78-
cmd_info = {
79-
"name": cmd.name,
80-
"description": cmd.help,
81-
"parameters": []
82-
}
78+
cmd_info = {"name": cmd.name, "description": cmd.help, "parameters": []}
8379

8480
for param in cmd.params:
8581
if param.name in ["verbose", "v"]:
@@ -93,11 +89,7 @@ def discover_single_command(cmd: click.Command) -> Dict[str, Any]:
9389

9490
def discover_command_group(group: click.Group) -> Dict[str, Any]:
9591
"""Discover all commands in a command group and their parameters."""
96-
result = {
97-
"name": group.name,
98-
"description": group.help,
99-
"commands": {}
100-
}
92+
result = {"name": group.name, "description": group.help, "commands": {}}
10193

10294
for cmd_name, cmd in group.commands.items():
10395
if isinstance(cmd, click.Group):

tests/test_mcp_deploy_context.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""Tests for MCP deploy context."""
22

3-
from unittest import TestCase
3+
import pytest
44

5-
from rsconnect.main import cli
6-
from rsconnect.mcp_deploy_context import discover_all_commands
5+
# Skip entire module if fastmcp is not available (requires Python 3.10+)
6+
pytest.importorskip("fastmcp", reason="fastmcp library not installed (requires Python 3.10+)")
7+
8+
from unittest import TestCase # noqa
9+
10+
from rsconnect.main import cli # noqa
11+
from rsconnect.mcp_deploy_context import discover_all_commands # noqa
712

813

914
class TestDiscoverAllCommands(TestCase):
@@ -16,7 +21,20 @@ def test_discover_rsconnect_cli(self):
1621
def test_top_level_commands(self):
1722
result = discover_all_commands(cli)
1823

19-
expected = ["version", "mcp-server", "add", "list", "remove", "details", "info", "deploy", "write-manifest", "content", "system", "bootstrap"]
24+
expected = [
25+
"version",
26+
"mcp-server",
27+
"add",
28+
"list",
29+
"remove",
30+
"details",
31+
"info",
32+
"deploy",
33+
"write-manifest",
34+
"content",
35+
"system",
36+
"bootstrap",
37+
]
2038
for cmd in expected:
2139
self.assertIn(cmd, result["commands"])
2240

@@ -28,7 +46,22 @@ def test_deploy_subcommands(self):
2846
result = discover_all_commands(cli)
2947

3048
deploy = result["commands"]["deploy"]
31-
expected = ["notebook", "voila", "manifest", "quarto", "tensorflow", "html", "api", "flask", "fastapi", "dash", "streamlit", "bokeh", "shiny", "gradio"]
49+
expected = [
50+
"notebook",
51+
"voila",
52+
"manifest",
53+
"quarto",
54+
"tensorflow",
55+
"html",
56+
"api",
57+
"flask",
58+
"fastapi",
59+
"dash",
60+
"streamlit",
61+
"bokeh",
62+
"shiny",
63+
"gradio",
64+
]
3265
for subcmd in expected:
3366
self.assertIn(subcmd, deploy["commands"])
3467

0 commit comments

Comments
 (0)