Skip to content

Commit 9793b4c

Browse files
committed
testing new approach
1 parent e579430 commit 9793b4c

File tree

3 files changed

+207
-79
lines changed

3 files changed

+207
-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: 99 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,119 @@ 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_discovery 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+
"command": "rsconnect list",
425+
"message": "Run the following command to list your Posit Connect servers:\n\nrsconnect list\n"
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+
"command": command,
450+
"message": f"Run the following command to add your Posit Connect server:\n\n{command}\n"
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["commands"]:
472+
available = ", ".join(deploy_info["commands"].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["commands"][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:
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 or value is False or value == "":
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" and value:
504+
cmd_parts.append(cli_flag)
505+
elif opt["type"] == "array" and isinstance(value, list):
506+
for item in value:
507+
cmd_parts.extend([cli_flag, str(item)])
508+
else:
509+
cmd_parts.extend([cli_flag, str(value)])
510+
511+
command = " ".join(cmd_parts)
512+
513+
return {
514+
"command": command,
515+
"application_type": application_type,
516+
"description": cmd_info["description"],
517+
"message": f"Run the following command to deploy your {application_type} content:\n\n{command}",
518+
}
497519

498520
mcp.run()
499521

rsconnect/mcp_deploy_discovery.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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+
from typing import Any, Dict
7+
8+
import click
9+
10+
11+
def extract_parameter_info(param: click.Parameter) -> Dict[str, Any]:
12+
"""Extract detailed information from a Click parameter."""
13+
info: Dict[str, Any] = {
14+
"type": "string", # default
15+
}
16+
17+
if isinstance(param, click.Option) and param.opts:
18+
# Use the longest option name (usually the full form without dashes)
19+
mcp_arg_name = max(param.opts, key=len).lstrip('-').replace('-', '_')
20+
info["name"] = mcp_arg_name
21+
info["cli_flags"] = param.opts
22+
info["param_type"] = "option"
23+
else:
24+
info["name"] = param.name
25+
if isinstance(param, click.Argument):
26+
info["param_type"] = "argument"
27+
28+
# extract help text for added context
29+
help_text = getattr(param, 'help', None)
30+
if help_text:
31+
info["description"] = help_text
32+
33+
if isinstance(param, click.Option):
34+
# Boolean flags
35+
if param.is_flag:
36+
info["type"] = "boolean"
37+
info["default"] = param.default or False
38+
39+
# choices
40+
elif param.type and hasattr(param.type, 'choices'):
41+
info["type"] = "string"
42+
info["choices"] = list(param.type.choices)
43+
44+
# multiple
45+
elif param.multiple:
46+
info["type"] = "array"
47+
info["items"] = {"type": "string"}
48+
49+
# files
50+
elif isinstance(param.type, click.Path):
51+
info["type"] = "string"
52+
info["format"] = "path"
53+
if param.type.exists:
54+
info["path_must_exist"] = True
55+
if param.type.file_okay and not param.type.dir_okay:
56+
info["path_type"] = "file"
57+
elif param.type.dir_okay and not param.type.file_okay:
58+
info["path_type"] = "directory"
59+
60+
# defaults (important to avoid noise in returned command)
61+
if param.default is not None and not param.is_flag:
62+
info["default"] = param.default
63+
64+
# required params
65+
info["required"] = param.required
66+
67+
return info
68+
69+
70+
def discover_deploy_commands(cli_group: click.Group) -> Dict[str, Any]:
71+
"""Discover all deploy commands and their parameters."""
72+
73+
if "deploy" not in cli_group.commands:
74+
return {"error": "deploy command group not found"}
75+
76+
deploy_group = cli_group.commands["deploy"]
77+
78+
if not isinstance(deploy_group, click.Group):
79+
return {"error": "deploy is not a command group"}
80+
81+
result = {
82+
"group_name": "deploy",
83+
"description": deploy_group.help or "Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.",
84+
"commands": {}
85+
}
86+
87+
for cmd_name, command in deploy_group.commands.items():
88+
cmd_info = {
89+
"name": cmd_name,
90+
"description": command.help or command.short_help or f"Deploy {cmd_name}",
91+
"short_help": command.short_help,
92+
"parameters": []
93+
}
94+
for param in command.params:
95+
# Skip context parameters
96+
if isinstance(param, click.Context):
97+
continue
98+
99+
if param.name in ["verbose", "v"]:
100+
continue
101+
102+
param_info = extract_parameter_info(param)
103+
cmd_info["parameters"].append(param_info)
104+
105+
result["commands"][cmd_name] = cmd_info
106+
107+
return result

0 commit comments

Comments
 (0)