Skip to content

Commit 747ec01

Browse files
authored
feat: simple MCP server (#712)
1 parent f7cbe13 commit 747ec01

File tree

7 files changed

+483
-3
lines changed

7 files changed

+483
-3
lines changed

docs/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased] - ??
99

10+
### Added
11+
12+
- Added `rsconnect mcp-server` command to expose rsconnect-python functionality to
13+
LLM clients via the Model Context Protocol. The server provides a `get_command_info`
14+
tool that returns parameter schemas for any rsconnect command, allowing LLMs
15+
to more easily construct valid CLI commands.
16+
1017
### Fixed
1118

1219
- Snowflake SPCS (Snowpark Container Services) authentication now properly handles API keys

docs/commands/mcp-server.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
::: mkdocs-click
2+
:module: rsconnect.main
3+
:command: mcp_server

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ nav:
5050
- system: commands/system.md
5151
- version: commands/version.md
5252
- write-manifest: commands/write-manifest.md
53+
- mcp-server: commands/mcp-server.md
5354

5455

5556
theme:

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ dependencies = [
1313
"semver>=2.0.0,<4.0.0",
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
16-
"toml>=0.10; python_version < '3.11'"
16+
"toml>=0.10; python_version < '3.11'",
1717
]
1818

1919
dynamic = ["version"]
@@ -37,8 +37,10 @@ test = [
3737
"setuptools_scm[toml]>=3.4",
3838
"twine",
3939
"types-Flask",
40+
"fastmcp==2.12.4; python_version >= '3.10'",
4041
]
4142
snowflake = ["snowflake-cli"]
43+
mcp = ["fastmcp==2.12.4; python_version >= '3.10'"]
4244
docs = [
4345
"mkdocs-material",
4446
"mkdocs-click",

rsconnect/main.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@
88
import traceback
99
from functools import wraps
1010
from os.path import abspath, dirname, exists, isdir, join
11-
from typing import Callable, ItemsView, Literal, Optional, Sequence, TypeVar, cast
11+
from typing import (
12+
Any,
13+
Callable,
14+
Dict,
15+
ItemsView,
16+
Literal,
17+
Optional,
18+
Sequence,
19+
TypeVar,
20+
cast,
21+
)
1222

1323
import click
1424

@@ -392,6 +402,123 @@ def version():
392402
click.echo(VERSION)
393403

394404

405+
@cli.command(
406+
short_help="Start the Model Context Protocol (MCP) server.",
407+
help=(
408+
"Start a Model Context Protocol (MCP) server to expose rsconnect-python capabilities to AI applications "
409+
"through a standardized protocol interface."
410+
"\n\n"
411+
"The MCP server exposes a single tool:\n\n"
412+
"`get_command_info`:\n\n"
413+
" - Provides detailed parameter schemas for any rsconnect command. "
414+
"This provides context for an LLM to understand how to construct valid rsconnect "
415+
"commands dynamically without hard-coded knowledge of the CLI."
416+
"\n\n"
417+
"System Requirements:\n\n"
418+
" - Python>=3.10\n"
419+
" - fastmcp"
420+
"\n\n"
421+
"The server runs in stdio mode, communicating via standard input/output streams."
422+
"\n\n"
423+
"Usage with popular LLM clients:\n\n"
424+
" - [codex](https://developers.openai.com/codex/mcp/#configuration---cli)\n"
425+
" - [claude code](https://docs.claude.com/en/docs/claude-code/mcp#option-3%3A-add-a-local-stdio-server)\n"
426+
" - [VS Code](https://code.visualstudio.com/docs/copilot/customization/mcp-servers#_add-an-mcp-server)\n\n"
427+
"The command `uvx --from rsconnect-python rsconnect mcp-server` is a simple option for use in each of "
428+
"the above options."
429+
),
430+
)
431+
def mcp_server():
432+
try:
433+
from fastmcp import FastMCP
434+
from fastmcp.exceptions import ToolError
435+
except ImportError:
436+
raise RSConnectException(
437+
"The fastmcp package is required for MCP server functionality. "
438+
"Install it with: pip install rsconnect-python[mcp]"
439+
)
440+
441+
mcp = FastMCP("Connect MCP")
442+
443+
# Discover all commands at startup
444+
from .mcp_deploy_context import discover_all_commands
445+
446+
all_commands_info = discover_all_commands(cli)
447+
448+
def get_command_info(
449+
command_path: str,
450+
) -> Dict[str, Any]:
451+
try:
452+
# split the command path into parts
453+
parts = command_path.strip().split()
454+
if not parts:
455+
available_commands = list(all_commands_info["commands"].keys())
456+
return {"error": "Command path cannot be empty", "available_commands": available_commands}
457+
458+
current_info = all_commands_info
459+
current_path = []
460+
461+
for _, part in enumerate(parts):
462+
# error if we find unexpected additional subcommands
463+
if "commands" not in current_info:
464+
return {
465+
"error": f"'{' '.join(current_path)}' is not a command group. Unexpected part: '{part}'",
466+
"type": "command",
467+
"command_path": f"rsconnect {' '.join(current_path)}",
468+
}
469+
470+
# try to return useful messaging for invalid subcommands
471+
if part not in current_info["commands"]:
472+
available = list(current_info["commands"].keys())
473+
path_str = " ".join(current_path) if current_path else "top level"
474+
return {"error": f"Command '{part}' not found in {path_str}", "available_commands": available}
475+
476+
current_info = current_info["commands"][part]
477+
current_path.append(part)
478+
479+
# still return something useful if additional subcommands are needed
480+
if "commands" in current_info:
481+
return {
482+
"type": "command_group",
483+
"name": current_info.get("name", parts[-1]),
484+
"description": current_info.get("description"),
485+
"available_subcommands": list(current_info["commands"].keys()),
486+
"message": f"The '{' '.join(parts)}' command requires a subcommand.",
487+
}
488+
else:
489+
return {
490+
"type": "command",
491+
"command_path": f"rsconnect {' '.join(parts)}",
492+
"name": current_info.get("name", parts[-1]),
493+
"description": current_info.get("description"),
494+
"parameters": current_info.get("parameters", []),
495+
"shell": "bash",
496+
}
497+
except Exception as e:
498+
raise ToolError(f"Failed to retrieve command info: {str(e)}")
499+
500+
# dynamically build docstring with top level commands
501+
# note: excluding mcp-server here
502+
available_commands = sorted(cmd for cmd in all_commands_info["commands"].keys() if cmd != "mcp-server")
503+
commands_list = "\n ".join(f"- {cmd}" for cmd in available_commands)
504+
505+
get_command_info.__doc__ = f"""Get the parameter schema for any rsconnect command.
506+
507+
Returns information about the parameters needed to construct an rsconnect command
508+
that can be executed in a bash shell. Supports nested command groups of arbitrary depth.
509+
510+
Available top-level commands:
511+
{commands_list}
512+
513+
:param command_path: space-separated command path (e.g., 'version', 'deploy notebook', 'content build add')
514+
:return: dictionary with command parameter schema and execution metadata
515+
"""
516+
517+
mcp.tool(get_command_info)
518+
519+
mcp.run()
520+
521+
395522
def _test_server_and_api(server: str, api_key: str, insecure: bool, ca_cert: str | None):
396523
"""
397524
Test the specified server information to make sure it works. If so, a
@@ -433,7 +560,7 @@ def _test_spcs_creds(server: SPCSConnectServer):
433560

434561
@cli.command(
435562
short_help="Create an initial admin user to bootstrap a Connect instance.",
436-
help="Creates an initial admin user to bootstrap a Connect instance. Returns the provisionend API key.",
563+
help="Creates an initial admin user to bootstrap a Connect instance. Returns the provisioned API key.",
437564
no_args_is_help=True,
438565
)
439566
@click.option(

rsconnect/mcp_deploy_context.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Programmatically discover all parameters for rsconnect commands.
3+
This helps MCP tools understand how to use the cli.
4+
"""
5+
6+
import json
7+
from typing import Any, Dict
8+
9+
import click
10+
11+
12+
def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
13+
"""Extract detailed information from a Click parameter."""
14+
info: Dict[str, Any] = {}
15+
16+
if isinstance(param, click.Option) and param.opts:
17+
# Use the longest option name (usually the full form without dashes)
18+
mcp_arg_name = max(param.opts, key=len).lstrip("-").replace("-", "_")
19+
info["name"] = mcp_arg_name
20+
info["cli_flags"] = param.opts
21+
info["param_type"] = "option"
22+
else:
23+
info["name"] = param.name
24+
if isinstance(param, click.Argument):
25+
info["param_type"] = "argument"
26+
27+
# extract help text for added context
28+
help_text = getattr(param, "help", None)
29+
if help_text:
30+
info["description"] = help_text
31+
32+
if isinstance(param, click.Option):
33+
# Boolean flags
34+
if param.is_flag:
35+
info["type"] = "boolean"
36+
info["default"] = param.default or False
37+
38+
# choices
39+
elif param.type and hasattr(param.type, "choices"):
40+
info["type"] = "string"
41+
info["choices"] = list(param.type.choices)
42+
43+
# multiple
44+
elif param.multiple:
45+
info["type"] = "array"
46+
info["items"] = {"type": "string"}
47+
48+
# files
49+
elif isinstance(param.type, click.Path):
50+
info["type"] = "string"
51+
info["format"] = "path"
52+
if param.type.exists:
53+
info["path_must_exist"] = True
54+
if param.type.file_okay and not param.type.dir_okay:
55+
info["path_type"] = "file"
56+
elif param.type.dir_okay and not param.type.file_okay:
57+
info["path_type"] = "directory"
58+
59+
# default
60+
else:
61+
info["type"] = "string"
62+
63+
# defaults (important to avoid noise in returned command)
64+
if param.default is not None and not param.is_flag:
65+
if isinstance(param.default, tuple):
66+
info["default"] = list(param.default)
67+
elif isinstance(param.default, (str, int, float, bool, list, dict)):
68+
info["default"] = param.default
69+
70+
# required params
71+
info["required"] = param.required
72+
73+
return info
74+
75+
76+
def discover_single_command(cmd: click.Command) -> Dict[str, Any]:
77+
"""Discover a single command and its parameters."""
78+
cmd_info = {"name": cmd.name, "description": cmd.help, "parameters": []}
79+
80+
for param in cmd.params:
81+
if param.name in ["verbose", "v"]:
82+
continue
83+
84+
param_info = extract_parameter_info(param)
85+
cmd_info["parameters"].append(param_info)
86+
87+
return cmd_info
88+
89+
90+
def discover_command_group(group: click.Group) -> Dict[str, Any]:
91+
"""Discover all commands in a command group and their parameters."""
92+
result = {"name": group.name, "description": group.help, "commands": {}}
93+
94+
for cmd_name, cmd in group.commands.items():
95+
if isinstance(cmd, click.Group):
96+
# recursively discover nested command groups
97+
result["commands"][cmd_name] = discover_command_group(cmd)
98+
else:
99+
result["commands"][cmd_name] = discover_single_command(cmd)
100+
101+
return result
102+
103+
104+
def discover_all_commands(cli: click.Group) -> Dict[str, Any]:
105+
"""Discover all commands in the CLI and their parameters."""
106+
return discover_command_group(cli)
107+
108+
109+
if __name__ == "__main__":
110+
from rsconnect.main import cli
111+
112+
# Discover all commands in the CLI
113+
# use this for testing/debugging
114+
all_commands = discover_all_commands(cli)
115+
print(json.dumps(all_commands, indent=2))

0 commit comments

Comments
 (0)