|
8 | 8 | import traceback |
9 | 9 | from functools import wraps |
10 | 10 | 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 | +) |
12 | 22 |
|
13 | 23 | import click |
14 | 24 |
|
@@ -392,6 +402,123 @@ def version(): |
392 | 402 | click.echo(VERSION) |
393 | 403 |
|
394 | 404 |
|
| 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 | + |
395 | 522 | def _test_server_and_api(server: str, api_key: str, insecure: bool, ca_cert: str | None): |
396 | 523 | """ |
397 | 524 | Test the specified server information to make sure it works. If so, a |
@@ -433,7 +560,7 @@ def _test_spcs_creds(server: SPCSConnectServer): |
433 | 560 |
|
434 | 561 | @cli.command( |
435 | 562 | 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.", |
437 | 564 | no_args_is_help=True, |
438 | 565 | ) |
439 | 566 | @click.option( |
|
0 commit comments