Skip to content

Commit cd9687f

Browse files
committed
generalize. tests written by claude
1 parent 92a7431 commit cd9687f

File tree

4 files changed

+294
-77
lines changed

4 files changed

+294
-77
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies = [
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
1616
"toml>=0.10; python_version < '3.11'",
17-
"fastmcp"
17+
"fastmcp==2.12.4"
1818
]
1919

2020
dynamic = ["version"]

rsconnect/main.py

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import functools
44
import json
55
import os
6-
import shlex
76
import sys
87
import textwrap
98
import traceback
@@ -405,70 +404,82 @@ def version():
405404

406405
@cli.command(help="Start the MCP server")
407406
def mcp_server():
408-
import subprocess
409-
410407
from fastmcp import FastMCP
411408
from fastmcp.exceptions import ToolError
412409

413410
mcp = FastMCP("Connect MCP")
414411

415-
# Discover all deploy commands at startup
416-
from .mcp_deploy_context import discover_deploy_commands
417-
deploy_commands_info = discover_deploy_commands(cli)
418-
419-
@mcp.tool()
420-
def list_servers():
421-
"""Show the stored information about each known server nickname."""
422-
try:
423-
result = subprocess.run(["rsconnect", "list"], capture_output=True, text=True, check=True)
424-
return result.stdout
425-
except subprocess.CalledProcessError as e:
426-
raise ToolError(f"Command failed with error: {e}")
427-
428-
@mcp.tool()
429-
def add_server(
430-
server_url: str,
431-
nickname: str,
432-
api_key: str,
433-
) -> Dict[str, Any]:
434-
"""
435-
Prompt user to add a Posit Connect server using rsconnect add.
436-
437-
This tool guides users through registering a Connect server so they can use
438-
rsconnect deployment commands. The server nickname allows you to reference
439-
the server in future deployment commands.
440-
441-
:param server_url: the URL of your Posit Connect server (e.g., https://my.connect.server/)
442-
:param nickname: a nickname you choose for the server (e.g., myServer)
443-
:param api_key: your personal API key for authentication
444-
:return: a dictionary containing the command to run and instructions.
445-
"""
446-
command = f"rsconnect add --server {server_url} --name {nickname} --api-key {api_key}"
447-
448-
return {
449-
"type": "command",
450-
"command": command
451-
}
412+
# Discover all commands at startup
413+
from .mcp_deploy_context import discover_all_commands
414+
all_commands_info = discover_all_commands(cli)
452415

453416
@mcp.tool()
454-
def deploy_command_context(
455-
content_type: str
417+
def get_command_info(
418+
command_path: str,
456419
) -> Dict[str, Any]:
457420
"""
458-
Get the parameter schema for rsconnect deploy commands that should be executed via bash.
421+
Get the parameter schema for any rsconnect command.
459422
460-
Returns information about the parameters needed to construct an rsconnect deploy
461-
command that can be executed in a bash shell.
423+
Returns information about the parameters needed to construct an rsconnect command
424+
that can be executed in a bash shell. Supports nested command groups of arbitrary depth.
462425
463-
:param content_type: the type of content (e.g., 'shiny', 'notebook', 'quarto')
426+
:param command_path: space-separated command path (e.g., 'version', 'deploy notebook', 'content build add')
464427
:return: dictionary with command parameter schema and execution metadata
465428
"""
466-
ctx = deploy_commands_info["content_type"][content_type]
467-
return {
468-
"context": ctx,
469-
"command_usage": "rsconnect deploy COMMAND [OPTIONS] DIRECTORY [ARGS]...",
470-
"shell": "bash"
471-
}
429+
try:
430+
# split the command path into parts
431+
parts = command_path.strip().split()
432+
if not parts:
433+
available_commands = list(all_commands_info["commands"].keys())
434+
return {
435+
"error": "Command path cannot be empty",
436+
"available_commands": available_commands
437+
}
438+
439+
current_info = all_commands_info
440+
current_path = []
441+
442+
for _, part in enumerate(parts):
443+
# error if we find unexpected additional subcommands
444+
if "commands" not in current_info:
445+
return {
446+
"error": f"'{' '.join(current_path)}' is not a command group. Unexpected part: '{part}'",
447+
"type": "command",
448+
"command_path": f"rsconnect {' '.join(current_path)}",
449+
}
450+
451+
# try to return useful messaging for invalid subcommands
452+
if part not in current_info["commands"]:
453+
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+
}
459+
460+
current_info = current_info["commands"][part]
461+
current_path.append(part)
462+
463+
# still return something useful if additional subcommands are needed
464+
if "commands" in current_info:
465+
return {
466+
"type": "command_group",
467+
"name": current_info.get("name", parts[-1]),
468+
"description": current_info.get("description"),
469+
"available_subcommands": list(current_info["commands"].keys()),
470+
"message": f"The '{' '.join(parts)}' command requires a subcommand."
471+
}
472+
else:
473+
return {
474+
"type": "command",
475+
"command_path": f"rsconnect {' '.join(parts)}",
476+
"name": current_info.get("name", parts[-1]),
477+
"description": current_info.get("description"),
478+
"parameters": current_info.get("parameters", []),
479+
"shell": "bash"
480+
}
481+
except Exception as e:
482+
raise ToolError(f"Failed to retrieve command info: {str(e)}")
472483

473484
mcp.run()
474485

@@ -514,7 +525,7 @@ def _test_spcs_creds(server: SPCSConnectServer):
514525

515526
@cli.command(
516527
short_help="Create an initial admin user to bootstrap a Connect instance.",
517-
help="Creates an initial admin user to bootstrap a Connect instance. Returns the provisionend API key.",
528+
help="Creates an initial admin user to bootstrap a Connect instance. Returns the provisioned API key.",
518529
no_args_is_help=True,
519530
)
520531
@click.option(

rsconnect/mcp_deploy_context.py

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
2-
Programmatically discover all parameters for rsconnect deploy commands.
3-
This helps MCP tools understand exactly how to use `rsconnect deploy ...`
2+
Programmatically discover all parameters for rsconnect commands.
3+
This helps MCP tools understand how to use the cli.
44
"""
55

66
import json
@@ -73,37 +73,51 @@ def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
7373
return info
7474

7575

76-
def discover_deploy_commands(cli_group: click.Group) -> Dict[str, Any]:
77-
"""Discover all deploy commands and their parameters."""
76+
def discover_single_command(cmd: click.Command) -> Dict[str, Any]:
77+
"""Discover a single command and its parameters."""
78+
cmd_info = {
79+
"name": cmd.name,
80+
"description": cmd.help,
81+
"parameters": []
82+
}
83+
84+
for param in cmd.params:
85+
if param.name in ["verbose", "v"]:
86+
continue
87+
88+
param_info = extract_parameter_info(param)
89+
cmd_info["parameters"].append(param_info)
90+
91+
return cmd_info
7892

79-
deploy_group = cli_group.commands["deploy"]
8093

94+
def discover_command_group(group: click.Group) -> Dict[str, Any]:
95+
"""Discover all commands in a command group and their parameters."""
8196
result = {
82-
"group_name": "deploy",
83-
"description": deploy_group.help,
84-
"content_type": {}
97+
"name": group.name,
98+
"description": group.help,
99+
"commands": {}
85100
}
86101

87-
for cmd_name, cmd in deploy_group.commands.items():
88-
cmd_info = {
89-
"name": cmd_name,
90-
"description": cmd.help,
91-
"parameters": []
92-
}
93-
for param in cmd.params:
94-
if param.name in ["verbose", "v"]:
95-
continue
102+
for cmd_name, cmd in group.commands.items():
103+
if isinstance(cmd, click.Group):
104+
# recursively discover nested command groups
105+
result["commands"][cmd_name] = discover_command_group(cmd)
106+
else:
107+
result["commands"][cmd_name] = discover_single_command(cmd)
96108

97-
param_info = extract_parameter_info(param)
98-
cmd_info["parameters"].append(param_info)
109+
return result
99110

100-
result["content_type"][cmd_name] = cmd_info
101111

102-
return result
112+
def discover_all_commands(cli: click.Group) -> Dict[str, Any]:
113+
"""Discover all commands in the CLI and their parameters."""
114+
return discover_command_group(cli)
103115

104116

105117
if __name__ == "__main__":
106118
from rsconnect.main import cli
107119

108-
deploy_commands_info = discover_deploy_commands(cli)["content_type"]["shiny"]
109-
print(json.dumps(deploy_commands_info, indent=2))
120+
# Discover all commands in the CLI
121+
# use this for testing/debugging
122+
all_commands = discover_all_commands(cli)
123+
print(json.dumps(all_commands, indent=2))

0 commit comments

Comments
 (0)