Skip to content

Commit 8ec2a07

Browse files
committed
testing new approach
1 parent e579430 commit 8ec2a07

File tree

3 files changed

+213
-79
lines changed

3 files changed

+213
-79
lines changed

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ dependencies = [
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
1616
"toml>=0.10; python_version < '3.11'",
17-
"fastmcp",
18-
"posit-sdk"
17+
"fastmcp"
1918
]
2019

2120
dynamic = ["version"]

rsconnect/main.py

Lines changed: 100 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import functools
44
import json
55
import os
6-
import subprocess
76
import sys
87
import textwrap
98
import traceback
@@ -14,7 +13,6 @@
1413
Callable,
1514
Dict,
1615
ItemsView,
17-
List,
1816
Literal,
1917
Optional,
2018
Sequence,
@@ -405,95 +403,120 @@ def version():
405403

406404

407405
@cli.command(help="Start the MCP server")
408-
@click.option(
409-
"--server",
410-
"-s",
411-
envvar="CONNECT_SERVER",
412-
help="Posit Connect server URL"
413-
)
414-
@click.option(
415-
"--api-key",
416-
"-k",
417-
envvar="CONNECT_API_KEY",
418-
help="The API key to use to authenticate with Posit Connect."
419-
)
420-
def mcp_server(server: str, api_key: str):
406+
def mcp_server():
421407
from fastmcp import FastMCP
422408
from fastmcp.exceptions import ToolError
423-
from posit.connect import Client
424409

425410
mcp = FastMCP("Connect MCP")
426411

427-
def get_content_logs(app_guid: str):
428-
try:
429-
client = Client(server, api_key)
430-
response = client.get(f"v1/content/{app_guid}/jobs")
431-
jobs = response.json()
432-
# first job key is the most recent one
433-
key = jobs[0]["key"]
434-
logs = client.get(f"v1/content/{app_guid}/jobs/{key}/log")
435-
return logs.json()
436-
except Exception as e:
437-
raise ToolError(f"Failed to get logs: {e}")
438-
439-
def list_content():
440-
try:
441-
client = Client(server, api_key)
442-
response = client.get("v1/content")
443-
return response.json()
444-
except Exception as e:
445-
raise ToolError(f"Failed to list content: {e}")
412+
# Discover all deploy commands at startup
413+
from .mcp_deploy_context import discover_deploy_commands
414+
deploy_commands_info = discover_deploy_commands(cli)
446415

447-
def get_content_item(app_guid: str):
448-
try:
449-
client = Client(server, api_key)
450-
response = client.content.get(app_guid)
451-
return response
452-
except Exception as e:
453-
raise ToolError(f"Failed to get content: {e}")
416+
@mcp.tool()
417+
def list_servers() -> Dict[str, Any]:
418+
"""
419+
Show the stored information about each known server nickname.
420+
421+
:return: a dictionary containing the command to run and instructions.
422+
"""
423+
return {
424+
"type": "command",
425+
"command": "rsconnect list"
426+
}
454427

455428
@mcp.tool()
456-
async def deploy_shiny(
457-
directory: str,
458-
name: Optional[str] = None,
459-
title: Optional[str] = None
429+
def add_server(
430+
server_url: str,
431+
nickname: str,
432+
api_key: str,
460433
) -> Dict[str, Any]:
461-
"""Deploy a Shiny application to Posit Connect"""
462-
463-
# Build the CLI command
464-
args = ["deploy", "shiny", directory]
434+
"""
435+
Prompt user to add a Posit Connect server using rsconnect add.
465436
466-
if name:
467-
args.extend(["--name", name])
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.
468440
469-
if title:
470-
args.extend(["--title", title])
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}"
471447

472-
args.extend(["--server", server])
473-
args.extend(["--api-key", api_key])
448+
return {
449+
"type": "command",
450+
"command": command
451+
}
474452

475-
try:
476-
result = subprocess.run(
477-
args,
478-
check=True,
479-
capture_output=True,
480-
text=True
453+
@mcp.tool()
454+
def build_deploy_command(
455+
application_type: str,
456+
parameters: Dict[str, Any]
457+
) -> Dict[str, Any]:
458+
"""
459+
Build a deploy command for any content type using discovered parameters.
460+
461+
This tool automatically builds the correct rsconnect deploy command
462+
with proper parameter names and types based on the command type.
463+
464+
:param application_type: the type of content to deploy (e.g., 'shiny', 'notebook', 'quarto', etc.)
465+
:param parameters: dictionary of parameter names and their values.
466+
:return: dictionary with the generated command and instructions.
467+
"""
468+
# use the deploy commands info discovered at startup
469+
deploy_info = deploy_commands_info
470+
471+
if application_type not in deploy_info["app_type"]:
472+
available = ", ".join(deploy_info["app_type"].keys())
473+
raise ToolError(
474+
f"Unknown application type '{application_type}'. "
475+
f"Available types: {available}"
481476
)
482-
return {
483-
"success": True,
484-
"message": "Deployment completed successfully",
485-
"stdout": result.stdout,
486-
"stderr": result.stderr
487-
}
488-
except subprocess.CalledProcessError as e:
489-
raise ToolError(f"Deployment failed with exit code {e.returncode}: {e.stderr}")
490-
except Exception as e:
491-
raise ToolError(f"Command failed with error: {e}")
492-
493-
494-
mcp.tool(description="Get content logs from Posit Connect")(get_content_logs)
495-
mcp.tool(description="List content from Posit Connect")(list_content)
496-
mcp.tool(description="Get content item from Posit Connect")(get_content_item)
477+
478+
cmd_parts = ["rsconnect", "deploy", application_type]
479+
cmd_info = deploy_info["app_type"][application_type]
480+
481+
arguments = [p for p in cmd_info["parameters"] if p.get("param_type") == "argument"]
482+
options = [p for p in cmd_info["parameters"] if p.get("param_type") == "option"]
483+
484+
# add arguments
485+
for arg in arguments:
486+
if arg["name"] in parameters:
487+
value = parameters[arg["name"]]
488+
if value is not None:
489+
cmd_parts.append(str(value))
490+
491+
# add options
492+
for opt in options:
493+
param_name = opt["name"]
494+
if param_name not in parameters:
495+
continue
496+
497+
value = parameters[param_name]
498+
if value is None:
499+
continue
500+
501+
cli_flag = max(opt.get("cli_flags", []), key=len) if opt.get("cli_flags") else f"--{param_name.replace('_', '-')}"
502+
503+
if opt["type"] == "boolean":
504+
if value:
505+
cmd_parts.append(cli_flag)
506+
elif opt["type"] == "array" and isinstance(value, list):
507+
for item in value:
508+
cmd_parts.extend([cli_flag, str(item)])
509+
else:
510+
cmd_parts.extend([cli_flag, str(value)])
511+
512+
command = " ".join(cmd_parts)
513+
514+
return {
515+
"type": "command",
516+
"command": command,
517+
"application_type": application_type,
518+
"description": cmd_info["description"]
519+
}
497520

498521
mcp.run()
499522

rsconnect/mcp_deploy_context.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Programmatically discover all parameters for rsconnect deploy commands.
3+
This helps MCP tools understand exactly how to use `rsconnect deploy ...`
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+
# defaults (important to avoid noise in returned command)
60+
if param.default is not None and not param.is_flag:
61+
info["default"] = param.default
62+
63+
# required params
64+
info["required"] = param.required
65+
66+
return info
67+
68+
69+
def discover_deploy_commands(cli_group: click.Group) -> Dict[str, Any]:
70+
"""Discover all deploy commands and their parameters."""
71+
72+
if "deploy" not in cli_group.commands:
73+
return {"error": "deploy command group not found"}
74+
75+
deploy_group = cli_group.commands["deploy"]
76+
77+
if not isinstance(deploy_group, click.Group):
78+
return {"error": "deploy is not a command group"}
79+
80+
result = {
81+
"group_name": "deploy",
82+
"description": deploy_group.help or "Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.",
83+
"app_type": {}
84+
}
85+
86+
for cmd_name, cmd in deploy_group.commands.items():
87+
cmd_info = {
88+
"name": cmd_name,
89+
"description": cmd.help or cmd.short_help or f"Deploy {cmd_name}",
90+
"short_help": cmd.short_help,
91+
"parameters": []
92+
}
93+
for param in cmd.params:
94+
if isinstance(param, click.Context):
95+
continue
96+
97+
if param.name in ["verbose", "v"]:
98+
continue
99+
100+
param_info = extract_parameter_info(param)
101+
cmd_info["parameters"].append(param_info)
102+
103+
result["app_type"][cmd_name] = cmd_info
104+
105+
return result
106+
107+
108+
if __name__ == "__main__":
109+
from rsconnect.main import cli
110+
111+
deploy_commands_info = discover_deploy_commands(cli)["app_type"]["shiny"]
112+
print(json.dumps(deploy_commands_info, indent=2))

0 commit comments

Comments
 (0)