Skip to content

Commit 3581711

Browse files
committed
testing new approach
1 parent e579430 commit 3581711

File tree

3 files changed

+218
-79
lines changed

3 files changed

+218
-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: 101 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import functools
44
import json
55
import os
6-
import subprocess
6+
import shlex
77
import sys
88
import textwrap
99
import traceback
@@ -14,7 +14,6 @@
1414
Callable,
1515
Dict,
1616
ItemsView,
17-
List,
1817
Literal,
1918
Optional,
2019
Sequence,
@@ -405,95 +404,120 @@ def version():
405404

406405

407406
@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):
407+
def mcp_server():
421408
from fastmcp import FastMCP
422409
from fastmcp.exceptions import ToolError
423-
from posit.connect import Client
424410

425411
mcp = FastMCP("Connect MCP")
426412

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}")
413+
# Discover all deploy commands at startup
414+
from .mcp_deploy_context import discover_deploy_commands
415+
deploy_commands_info = discover_deploy_commands(cli)
446416

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}")
417+
@mcp.tool()
418+
def list_servers() -> Dict[str, Any]:
419+
"""
420+
Show the stored information about each known server nickname.
421+
422+
:return: a dictionary containing the command to run and instructions.
423+
"""
424+
return {
425+
"type": "command",
426+
"command": "rsconnect list"
427+
}
454428

455429
@mcp.tool()
456-
async def deploy_shiny(
457-
directory: str,
458-
name: Optional[str] = None,
459-
title: Optional[str] = None
430+
def add_server(
431+
server_url: str,
432+
nickname: str,
433+
api_key: str,
460434
) -> Dict[str, Any]:
461-
"""Deploy a Shiny application to Posit Connect"""
462-
463-
# Build the CLI command
464-
args = ["deploy", "shiny", directory]
435+
"""
436+
Prompt user to add a Posit Connect server using rsconnect add.
465437
466-
if name:
467-
args.extend(["--name", name])
438+
This tool guides users through registering a Connect server so they can use
439+
rsconnect deployment commands. The server nickname allows you to reference
440+
the server in future deployment commands.
468441
469-
if title:
470-
args.extend(["--title", title])
442+
:param server_url: the URL of your Posit Connect server (e.g., https://my.connect.server/)
443+
:param nickname: a nickname you choose for the server (e.g., myServer)
444+
:param api_key: your personal API key for authentication
445+
:return: a dictionary containing the command to run and instructions.
446+
"""
447+
command = f"rsconnect add --server {server_url} --name {nickname} --api-key {api_key}"
471448

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

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

498522
mcp.run()
499523

rsconnect/mcp_deploy_context.py

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

0 commit comments

Comments
 (0)