diff --git a/libs/arcade-cli/arcade_cli/configure.py b/libs/arcade-cli/arcade_cli/configure.py index 8aa728c7..093cbbeb 100644 --- a/libs/arcade-cli/arcade_cli/configure.py +++ b/libs/arcade-cli/arcade_cli/configure.py @@ -3,9 +3,12 @@ import json import os import platform +import re +import shutil from pathlib import Path import typer +from dotenv import dotenv_values from rich.console import Console console = Console() @@ -51,14 +54,21 @@ def get_vscode_config_path() -> Path: return Path.home() / ".config" / "Code" / "User" / "mcp.json" -def configure_claude_local(server_name: str, port: int = 8000, path: Path | None = None) -> None: - """Configure Claude Desktop to add a local MCP server to the configuration.""" - config_path = path or get_claude_config_path() - config_path.parent.mkdir(parents=True, exist_ok=True) +def is_uv_installed() -> bool: + """Check if uv is installed and available in PATH.""" + return shutil.which("uv") is not None - # Assume server.py is the entry point for the server - server_file = Path.cwd() / "server.py" +def get_tool_secrets() -> dict: + """Only useful for stdio servers, because HTTP servers load in envvars at runtime""" + # TODO: Allow for a custom .env file to be used + env_path = Path.cwd() / ".env" + if env_path.exists(): + return dotenv_values(env_path) + return {} + + +def find_python_interpreter() -> Path: # Find the Python interpreter in the virtual environment venv_python = None # Check for .venv first (uv default) @@ -76,47 +86,47 @@ def configure_claude_local(server_name: str, port: int = 8000, path: Path | None venv_python = Path(sys.executable) - # Load existing config or create new one - config = {} - if config_path.exists(): - with open(config_path) as f: - config = json.load(f) - - # Add or update MCP servers configuration - if "mcpServers" not in config: - config["mcpServers"] = {} - - # Claude Desktop uses stdio transport - config["mcpServers"][server_name] = { - "command": str(venv_python), - "args": [str(server_file), "stdio"], - } - - # Write updated config - with open(config_path, "w") as f: - json.dump(config, f, indent=2) - - console.print( - f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration", - style="green", - ) - config_file_path = config_path.as_posix().replace(" ", "\\ ") - console.print(f" MCP client config file: {config_file_path}", style="dim") - console.print(f" Server file: {server_file}", style="dim") - console.print(f" Python interpreter: {venv_python}", style="dim") - console.print(" Restart Claude Desktop for changes to take effect.", style="yellow") + return venv_python -def configure_claude_arcade(server_name: str, path: Path | None = None) -> None: - """Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration.""" - # This would connect to the Arcade Cloud to get the server URL - # For now, this is a placeholder - console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") +def get_stdio_config(entrypoint_file: str, server_name: str) -> dict: + """Get the appropriate stdio configuration based on whether uv is installed.""" + server_file = Path.cwd() / entrypoint_file + + if is_uv_installed(): + return { + "command": "uv", + "args": [ + "run", + "--directory", + str(Path.cwd()), + "python", + entrypoint_file, + ], + "env": get_tool_secrets(), + } + else: + console.print( + "[yellow]Warning: uv is not installed. Install uv for the best experience with arcade configure CLI command.[/yellow]" + ) + venv_python = find_python_interpreter() + return { + "command": str(venv_python), + "args": [str(server_file)], + "env": get_tool_secrets(), + } -def configure_cursor_local(server_name: str, port: int = 8000, path: Path | None = None) -> None: - """Configure Cursor to add a local MCP server to the configuration.""" - config_path = path or get_cursor_config_path() +def configure_claude_local( + entrypoint_file: str, server_name: str, port: int = 8000, config_path: Path | None = None +) -> None: + """Configure Claude Desktop to add a local MCP server to the configuration.""" + config_path = config_path or get_claude_config_path() + + # Handle both absolute and relative config paths + if config_path and not config_path.is_absolute(): + config_path = Path.cwd() / config_path + config_path.parent.mkdir(parents=True, exist_ok=True) # Load existing config or create new one @@ -129,11 +139,75 @@ def configure_cursor_local(server_name: str, port: int = 8000, path: Path | None if "mcpServers" not in config: config["mcpServers"] = {} - config["mcpServers"][server_name] = { - "name": server_name, - "type": "stream", # Cursor prefers stream - "url": f"http://localhost:{port}/mcp", - } + # Claude Desktop uses stdio transport + config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name) + + # Write updated config + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + console.print( + f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration", + style="green", + ) + config_file_path = config_path.as_posix().replace(" ", "\\ ") + console.print(f" MCP client config file: {config_file_path}", style="dim") + console.print(f" Server file: {Path.cwd() / entrypoint_file}", style="dim") + if is_uv_installed(): + console.print(" Using uv to run server", style="dim") + else: + console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") + console.print(" Restart Claude Desktop for changes to take effect.", style="yellow") + + +def configure_claude_arcade( + server_name: str, transport: str, config_path: Path | None = None +) -> None: + """Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration.""" + # This would connect to the Arcade Cloud to get the server URL + # For now, this is a placeholder + console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") + + +def configure_cursor_local( + entrypoint_file: str, + server_name: str, + transport: str, + port: int = 8000, + config_path: Path | None = None, +) -> None: + """Configure Cursor to add a local MCP server to the configuration.""" + + def http_config(server_name: str, port: int = 8000) -> dict: + return { + "name": server_name, + "type": "stream", # Cursor prefers stream + "url": f"http://localhost:{port}/mcp", + } + + config_path = config_path or get_cursor_config_path() + + # Handle both absolute and relative config paths + if config_path and not config_path.is_absolute(): + config_path = Path.cwd() / config_path + + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Load existing config or create new one + config = {} + if config_path.exists(): + with open(config_path) as f: + config = json.load(f) + + # Add or update MCP servers configuration + if "mcpServers" not in config: + config["mcpServers"] = {} + + config["mcpServers"][server_name] = ( + get_stdio_config(entrypoint_file, server_name) + if transport == "stdio" + else http_config(server_name, port) + ) # Write updated config with open(config_path, "w") as f: @@ -145,18 +219,44 @@ def configure_cursor_local(server_name: str, port: int = 8000, path: Path | None ) config_file_path = config_path.as_posix().replace(" ", "\\ ") console.print(f" MCP client config file: {config_file_path}", style="dim") - console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") + if transport == "http": + console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") + elif transport == "stdio": + if is_uv_installed(): + console.print(" Using uv to run server", style="dim") + else: + console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") console.print(" Restart Cursor for changes to take effect.", style="yellow") -def configure_cursor_arcade(server_name: str, path: Path | None = None) -> None: +def configure_cursor_arcade( + server_name: str, transport: str, config_path: Path | None = None +) -> None: """Configure Cursor to add an Arcade Cloud MCP server to the configuration.""" console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") -def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None = None) -> None: +def configure_vscode_local( + entrypoint_file: str, + server_name: str, + transport: str, + port: int = 8000, + config_path: Path | None = None, +) -> None: """Configure VS Code to add a local MCP server to the configuration.""" - config_path = path or get_vscode_config_path() + + def http_config(port: int = 8000) -> dict: + return { + "type": "http", + "url": f"http://localhost:{port}/mcp", + } + + config_path = config_path or get_vscode_config_path() + + # Handle both absolute and relative config paths + if config_path and not config_path.is_absolute(): + config_path = Path.cwd() / config_path + config_path.parent.mkdir(parents=True, exist_ok=True) # Load existing config or create new one config = {} @@ -175,10 +275,11 @@ def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None if "servers" not in config: config["servers"] = {} - config["servers"][server_name] = { - "type": "http", - "url": f"http://localhost:{port}/mcp", - } + config["servers"][server_name] = ( + get_stdio_config(entrypoint_file, server_name) + if transport == "stdio" + else http_config(port) + ) # Write updated config with open(config_path, "w") as f: @@ -190,62 +291,71 @@ def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None ) config_file_path = config_path.as_posix().replace(" ", "\\ ") console.print(f" MCP client config file: {config_file_path}", style="dim") - console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") + if transport == "http": + console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim") + elif transport == "stdio": + if is_uv_installed(): + console.print(" Using uv to run server", style="dim") + else: + console.print(f" Python interpreter: {find_python_interpreter()}", style="dim") console.print(" Restart VS Code for changes to take effect.", style="yellow") -def configure_vscode_arcade(server_name: str, path: Path | None = None) -> None: +def configure_vscode_arcade(server_name: str, transport: str, path: Path | None = None) -> None: """Configure VS Code to add an Arcade Cloud MCP server to the configuration.""" console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") def configure_client( client: str, + entrypoint_file: str, server_name: str | None = None, - from_local: bool = False, - from_arcade: bool = False, + transport: str = "stdio", + host: str = "local", port: int = 8000, - path: Path | None = None, + config_path: Path | None = None, ) -> None: """ Configure an MCP client to connect to a server. Args: client: The MCP client to configure (claude, cursor, vscode) + entrypoint_file: The name of the Python file in the current directory that runs the server. This file must run the server when invoked directly. Only used for stdio servers. server_name: Name of the server to add to the configuration - from_local: Add a local server to the configuration - from_arcade: Add an Arcade Cloud server to the configuration - port: Port for local servers (default: 8000) - path: Custom path to the MCP client configuration file + transport: The transport to use for the MCP server configuration + host: The host of the server to configure (local or arcade) + port: Port for local HTTP servers (default: 8000) + config_path: Custom path to the MCP client configuration file """ - if not from_local and not from_arcade: - raise typer.BadParameter("Must specify either --from-local or --from-arcade") - - if from_local and from_arcade: - raise typer.BadParameter("Cannot specify both --from-local and --from-arcade") - - # Default server name if not provided if not server_name: - # Try to detect from current directory - server_name = Path.cwd().name if Path("server.py").exists() else "arcade-mcp-server" + # Use the name of the current directory as the server name + server_name = Path.cwd().name + + if not bool(re.match(r"^[a-zA-Z0-9_-]+\.py$", entrypoint_file)): + raise ValueError(f"Entrypoint file '{entrypoint_file}' is not a valid Python file name") + + if not (Path.cwd() / entrypoint_file).exists(): + raise ValueError(f"Entrypoint file '{entrypoint_file}' is not in the current directory") client_lower = client.lower() if client_lower == "claude": - if from_local: - configure_claude_local(server_name, port, path) + if transport != "stdio": + raise ValueError("Claude Desktop only supports stdio transport via configuration file") + if host == "local": + configure_claude_local(entrypoint_file, server_name, port, config_path) else: - configure_claude_arcade(server_name, path) + configure_claude_arcade(server_name, transport, config_path) elif client_lower == "cursor": - if from_local: - configure_cursor_local(server_name, port, path) + if host == "local": + configure_cursor_local(entrypoint_file, server_name, transport, port, config_path) else: - configure_cursor_arcade(server_name, path) + configure_cursor_arcade(server_name, transport, config_path) elif client_lower == "vscode": - if from_local: - configure_vscode_local(server_name, port, path) + if host == "local": + configure_vscode_local(entrypoint_file, server_name, transport, port, config_path) else: - configure_vscode_arcade(server_name, path) + configure_vscode_arcade(server_name, transport, config_path) else: raise typer.BadParameter( f"Unknown client: {client}. Supported clients: claude, cursor, vscode." diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index e849dde0..def8b422 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -471,59 +471,80 @@ def configure( client: str = typer.Argument( ..., help="The MCP client to configure (claude, cursor, vscode)", + click_type=click.Choice(["claude", "cursor", "vscode"], case_sensitive=False), + show_choices=True, + ), + entrypoint_file: str = typer.Option( + "server.py", + "--entrypoint", + "-e", + help="The name of the Python file in the current directory that runs the server. This file must run the server when invoked directly. Only used for stdio servers.", + rich_help_panel="Stdio Options", + ), + transport: str = typer.Option( + "stdio", + "--transport", + "-t", + help="The transport to use for the MCP server configuration", + click_type=click.Choice(["stdio", "http"], case_sensitive=False), + show_choices=True, ), server_name: Optional[str] = typer.Option( None, - "--server", - "-s", - help="Name of the server to connect to (defaults to current directory name)", + "--name", + "-n", + help="Optional name of the server to set in the configuration file (defaults to the name of the current directory)", + rich_help_panel="Configuration File Options", ), - from_local: bool = typer.Option( - False, - "--from-local", - help="Connect to a local MCP server", - is_flag=True, - ), - from_arcade: bool = typer.Option( - False, - "--from-arcade", - help="Connect to an Arcade Cloud MCP server", - is_flag=True, + host: str = typer.Option( + "local", + "--host", + "-h", + help="The host of the HTTP server to configure. Use 'local' to connect to a local MCP server or 'arcade' to connect to an Arcade Cloud MCP server.", + click_type=click.Choice(["local", "arcade"], case_sensitive=False), + show_choices=True, + rich_help_panel="HTTP Options", ), port: int = typer.Option( 8000, "--port", "-p", - help="Port for local servers", + help="Port for local HTTP servers", + rich_help_panel="HTTP Options", ), - path: Optional[Path] = typer.Option( + config_path: Optional[Path] = typer.Option( None, - "--path", - "-f", + "--config", + "-c", exists=False, help="Optional path to a specific MCP client config file (overrides default path)", + rich_help_panel="Configuration File Options", ), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ Configure MCP clients to connect to your server. + The default behavior is to configure the specified client for a local stdio server that + runs when the server.py file in the current directory is invoked directly. + Examples: - arcade configure claude --from-local - arcade configure cursor --from-local --port 8080 - arcade configure vscode --from-local --path .vscode/mcp.json - arcade configure claude --from-arcade --server my_server_name + arcade configure claude + arcade configure cursor --transport http --port 8080 + arcade configure vscode --host arcade --entrypoint ../../../mcp/server.py --config .vscode/mcp.json + arcade configure claude --host local --name my_server_name """ from arcade_cli.configure import configure_client try: configure_client( client=client, + entrypoint_file=entrypoint_file, server_name=server_name, - from_local=from_local, - from_arcade=from_arcade, + transport=transport, + host=host, port=port, - path=path, + config_path=config_path, ) except Exception as e: handle_cli_error(f"Failed to configure {client}", e, debug) diff --git a/libs/arcade-cli/arcade_cli/new.py b/libs/arcade-cli/arcade_cli/new.py index 438503d1..16f4a711 100644 --- a/libs/arcade-cli/arcade_cli/new.py +++ b/libs/arcade-cli/arcade_cli/new.py @@ -19,14 +19,14 @@ try: ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0" except Exception as e: console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]") - ARCADE_MCP_MIN_VERSION = "1.1.0" # Default version if unable to fetch + ARCADE_MCP_MIN_VERSION = "1.3.0" # Default version if unable to fetch ARCADE_MCP_MAX_VERSION = "2.0.0" ARCADE_TDK_MIN_VERSION = "3.0.0" ARCADE_TDK_MAX_VERSION = "4.0.0" ARCADE_SERVE_MIN_VERSION = "3.0.0" ARCADE_SERVE_MAX_VERSION = "4.0.0" -ARCADE_MCP_SERVER_MIN_VERSION = "1.3.2" +ARCADE_MCP_SERVER_MIN_VERSION = "1.4.0" ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0" diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py index fae5f277..a438db09 100644 --- a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py +++ b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py @@ -62,10 +62,13 @@ async def get_posts_in_subreddit( # Run with specific transport if __name__ == "__main__": - # Get transport from command line argument, default to "http" - transport = sys.argv[1] if len(sys.argv) > 1 else "http" + # Get transport from command line argument, default to "stdio" + # - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc. + # Supports tools that require_auth or require_secrets out-of-the-box + # - "http": HTTPS streaming for Cursor, VS Code, etc. + # Does not support tools that require_auth or require_secrets unless the server is deployed + # using 'arcade deploy' or added in the Arcade Developer Dashboard with 'Arcade' server type + transport = sys.argv[1] if len(sys.argv) > 1 else "stdio" # Run the server - # - "http" (default): HTTPS streaming for Cursor, VS Code, etc. - # - "stdio": Standard I/O for Claude Desktop, CLI tools, etc. app.run(transport=transport, host="127.0.0.1", port=8000) diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py index c64ec291..8c46fd43 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py @@ -68,7 +68,7 @@ class MCPApp: title: str | None = None, instructions: str | None = None, log_level: str = "INFO", - transport: TransportType = "http", + transport: TransportType = "stdio", host: str = "127.0.0.1", port: int = 8000, reload: bool = False, @@ -83,7 +83,7 @@ class MCPApp: title: Server title for display instructions: Server instructions log_level: Logging level (DEBUG, INFO, WARNING, ERROR) - transport: Transport type ("http") + transport: Transport type ("stdio") host: Host for transport port: Port for transport reload: Enable auto-reload for development @@ -221,7 +221,7 @@ class MCPApp: host: str = "127.0.0.1", port: int = 8000, reload: bool = False, - transport: TransportType = "http", + transport: TransportType = "stdio", **kwargs: Any, ) -> None: if len(self._catalog) == 0: diff --git a/libs/arcade-mcp-server/arcade_mcp_server/server.py b/libs/arcade-mcp-server/arcade_mcp_server/server.py index 3df6389e..c1e48a16 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/server.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/server.py @@ -661,7 +661,7 @@ class MCPServer: # Handle authorization and secrets requirements if required if missing_requirements_response := await self._check_tool_requirements( - tool, tool_context, message, tool_name + tool, tool_context, message, tool_name, session ): return missing_requirements_response @@ -754,8 +754,30 @@ class MCPServer: tool_context: ToolContext, message: CallToolRequest, tool_name: str, + session: ServerSession | None = None, ) -> JSONRPCResponse[CallToolResult] | None: """Check tool requirements before executing the tool""" + # Check transport restrictions for tools requiring auth or secrets + if session and session.init_options: + transport_type = session.init_options.get("transport_type") + if transport_type != "stdio": + requirements = tool.definition.requirements + if requirements and (requirements.authorization or requirements.secrets): + documentation_url = "https://docs.arcade.dev/en/home/compare-server-types" + tool_response = { + "message": ( + f"Tool '{tool_name}' cannot be executed over unauthenticated HTTP transport for security reasons. " + "This tool requires end-user authorization or access to sensitive secrets.\n" + "For more information about server capabilities and transport options, see: " + f"{documentation_url}" + ), + "llm_instructions": ( + f"Please show the following link to the end user formatted as markdown: [Compare Server Types]({documentation_url})\n" + "Inform the end user that the provided link contains documentation on how to configure the server to use the correct transport." + ), + } + return self._create_error_response(message, tool_response) + # Check authorization if tool.definition.requirements and tool.definition.requirements.authorization: # First check if Arcade API key is configured @@ -765,8 +787,8 @@ class MCPServer: "llm_instructions": ( f"The MCP server cannot execute the '{tool_name}' tool because it requires authorization " "but the Arcade API key is not configured. The developer needs to: " - "1) Set the ARCADE_API_KEY environment variable with a valid API key, or " - "2) Run 'arcade login' to authenticate. " + "1) Run 'arcade login' to authenticate, or " + "2) Set the ARCADE_API_KEY environment variable with a valid API key, or " "Once the API key is configured, restart the MCP server for the changes to take effect." ), } diff --git a/libs/arcade-mcp-server/arcade_mcp_server/transports/http_session_manager.py b/libs/arcade-mcp-server/arcade_mcp_server/transports/http_session_manager.py index 22127ca8..b013ad60 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/transports/http_session_manager.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/transports/http_session_manager.py @@ -151,6 +151,7 @@ class HTTPSessionManager: server=self.server, read_stream=read_stream, write_stream=write_stream, + init_options={"transport_type": "http"}, ) # Set the session on the transport @@ -220,6 +221,7 @@ class HTTPSessionManager: server=self.server, read_stream=read_stream, write_stream=write_stream, + init_options={"transport_type": "http"}, ) # Set the session on the transport diff --git a/libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py b/libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py index 8ed830c0..b3ba34d2 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py @@ -175,12 +175,15 @@ class StdioTransport: session_id = str(uuid.uuid4()) read_stream = StdioReadStream(self.read_queue) write_stream = StdioWriteStream(self.write_queue) + + init_options = {"transport_type": "stdio", **options} + session = ServerSession( server=None, # set by the caller using run_connection; not used here session_id=session_id, read_stream=read_stream, write_stream=write_stream, - init_options=options, + init_options=init_options, stateless=True, ) diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 7e97cc8e..bb4f3368 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "arcade-mcp-server" -version = "1.3.2" +version = "1.4.0" description = "Model Context Protocol (MCP) server framework for Arcade.dev" readme = "README.md" authors = [{ name = "Arcade.dev" }] diff --git a/libs/tests/arcade_mcp_server/test_server.py b/libs/tests/arcade_mcp_server/test_server.py index d46d7edf..c8ba3a74 100644 --- a/libs/tests/arcade_mcp_server/test_server.py +++ b/libs/tests/arcade_mcp_server/test_server.py @@ -3,15 +3,24 @@ import asyncio import contextlib from unittest.mock import AsyncMock, Mock - +from typing import Annotated import pytest +from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models from arcade_core.errors import ToolRuntimeError from arcade_core.schema import ( + InputParameter, + OAuth2Requirement, ToolAuthRequirement, ToolContext, + ToolDefinition, + ToolInput, + ToolkitDefinition, + ToolOutput, ToolRequirements, ToolSecretRequirement, + ValueSchema, ) +from arcade_core.auth import OAuth2 from arcade_mcp_server.middleware import Middleware from arcade_mcp_server.server import MCPServer from arcade_mcp_server.session import InitializationState @@ -26,6 +35,7 @@ from arcade_mcp_server.types import ( ListToolsResult, PingRequest, ) +from arcade_mcp_server import tool class TestMCPServer: @@ -928,3 +938,264 @@ class TestMCPServer: assert isinstance(result.result, CallToolResult) assert result.result.isError is True assert "authorization_url" in result.result.structuredContent + + @pytest.mark.asyncio + async def test_http_transport_blocks_tool_with_auth( + self, mcp_server, materialized_tool_with_auth + ): + """Test that HTTP transport blocks tools requiring oauth.""" + # Create a mock session with HTTP transport + session = Mock() + session.init_options = {"transport_type": "http"} + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={ + "name": "TestToolkit.sample_tool_with_auth", + "arguments": {"text": "test"}, + }, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + assert response.result.isError is True + assert "HTTP transport" in response.result.structuredContent["message"] + + @pytest.mark.asyncio + async def test_http_transport_blocks_tool_with_secrets(self, mcp_server): + """Test that HTTP transport blocks tools requiring secrets.""" + from arcade_core.schema import ToolSecretRequirement + + tool_def = ToolDefinition( + name="secret_tool", + fully_qualified_name="TestToolkit.secret_tool", + description="A tool requiring secrets", + toolkit=ToolkitDefinition( + name="TestToolkit", description="Test toolkit", version="1.0.0" + ), + input=ToolInput( + parameters=[ + InputParameter( + name="text", + required=True, + description="Input text", + value_schema=ValueSchema(val_type="string"), + ) + ] + ), + output=ToolOutput( + description="Tool output", value_schema=ValueSchema(val_type="string") + ), + requirements=ToolRequirements( + secrets=[ToolSecretRequirement(key="API_KEY", description="API Key")] + ), + ) + + @tool(requires_secrets=["SECRET_KEY"]) + def secret_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Secret text"]: + """Secret tool function""" + return f"Secret" + + input_model, output_model = create_func_models(secret_tool_func) + meta = ToolMeta(module=secret_tool_func.__module__, toolkit="TestToolkit") + materialized_tool = MaterializedTool( + tool=secret_tool_func, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + + await mcp_server._tool_manager.add_tool(materialized_tool) + + # Create a mock session with HTTP transport + session = Mock() + session.init_options = {"transport_type": "http"} + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={"name": "TestToolkit.secret_tool", "arguments": {"text": "test"}}, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + assert response.result.isError is True + assert "HTTP transport" in response.result.structuredContent["message"] + assert "secrets" in response.result.structuredContent["message"] + + @pytest.mark.asyncio + async def test_http_transport_blocks_tool_with_both_auth_and_secrets(self, mcp_server): + """Test that HTTP transport blocks tools requiring both auth and secrets.""" + from arcade_core.schema import ToolSecretRequirement + + # Create a tool with both auth and secret requirements + tool_def = ToolDefinition( + name="combined_tool", + fully_qualified_name="TestToolkit.combined_tool", + description="A tool requiring both auth and secrets", + toolkit=ToolkitDefinition( + name="TestToolkit", description="Test toolkit", version="1.0.0" + ), + input=ToolInput( + parameters=[ + InputParameter( + name="text", + required=True, + description="Input text", + value_schema=ValueSchema(val_type="string"), + ) + ] + ), + output=ToolOutput( + description="Tool output", value_schema=ValueSchema(val_type="string") + ), + requirements=ToolRequirements( + authorization=ToolAuthRequirement( + provider_type="oauth2", + provider_id="test-provider", + id="test-provider", + oauth2=OAuth2Requirement(scopes=["test.scope"]), + ), + secrets=[ToolSecretRequirement(key="API_KEY", description="API Key")], + ), + ) + + @tool(requires_auth=OAuth2(id="test-provider", scopes=["test.scope"]), requires_secrets=["API_KEY"]) + def combined_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Combined text"]: + """Combined tool function""" + return f"Combined: {text}" + + input_model, output_model = create_func_models(combined_tool_func) + meta = ToolMeta(module=combined_tool_func.__module__, toolkit="TestToolkit") + materialized_tool = MaterializedTool( + tool=combined_tool_func, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + + await mcp_server._tool_manager.add_tool(materialized_tool) + + # Create a mock session with HTTP transport + session = Mock() + session.init_options = {"transport_type": "http"} + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={"name": "TestToolkit.combined_tool", "arguments": {"text": "test"}}, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + assert response.result.isError is True + assert "HTTP transport" in response.result.structuredContent["message"] + assert ( + "authorization or access to sensitive secrets" in response.result.structuredContent["message"] + ) + + @pytest.mark.asyncio + async def test_stdio_transport_allows_tool_with_auth( + self, mcp_server, materialized_tool_with_auth + ): + """Test that stdio transport allows tools requiring authentication.""" + # Mock Arcade client + mcp_server.arcade = Mock() + mock_auth_response = Mock() + mock_auth_response.status = "completed" + mock_auth_response.context = Mock() + mock_auth_response.context.token = "test-token" + mock_auth_response.context.user_info = {} + mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response) + + # Create a mock session with stdio transport + session = Mock() + session.init_options = {"transport_type": "stdio"} + session.session_id = "test-session" + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={ + "name": "TestToolkit.sample_tool_with_auth", + "arguments": {"text": "test"}, + }, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + # Should succeed (isn't blocked by transport check) + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + + assert response.result.isError is False + + @pytest.mark.asyncio + async def test_no_transport_type_allows_tool_with_auth( + self, mcp_server, materialized_tool_with_auth + ): + """Test backwards compatibility: no transport_type specified allows tools.""" + # Mock Arcade client + mcp_server.arcade = Mock() + mock_auth_response = Mock() + mock_auth_response.status = "completed" + mock_auth_response.context = Mock() + mock_auth_response.context.token = "test-token" + mock_auth_response.context.user_info = {} + mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response) + + # Create a mock session without transport_type + session = Mock() + session.init_options = {} # No transport_type + session.session_id = "test-session" + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={ + "name": "TestToolkit.sample_tool_with_auth", + "arguments": {"text": "test"}, + }, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + # Should succeed (no transport restriction applies) + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + assert response.result.isError is False + + @pytest.mark.asyncio + async def test_http_transport_allows_tool_without_requirements(self, mcp_server): + """Test that HTTP transport allows tools without auth/secret requirements.""" + # Create a mock session with HTTP transport + session = Mock() + session.init_options = {"transport_type": "http"} + session.session_id = "test-session" + + message = CallToolRequest( + jsonrpc="2.0", + id=1, + method="tools/call", + params={"name": "TestToolkit.test_tool", "arguments": {"text": "test"}}, + ) + + response = await mcp_server._handle_call_tool(message, session=session) + + assert isinstance(response, JSONRPCResponse) + assert isinstance(response.result, CallToolResult) + assert response.result.isError is False diff --git a/pyproject.toml b/pyproject.toml index c34a508c..c2089085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-mcp" -version = "1.2.1" +version = "1.3.0" description = "Arcade.dev - Tool Calling platform for Agents" readme = "README.md" license = {file = "LICENSE"} @@ -21,7 +21,7 @@ requires-python = ">=3.10" dependencies = [ # CLI dependencies - "arcade-mcp-server>=1.3.2,<2.0.0", + "arcade-mcp-server>=1.4.0,<2.0.0", "arcade-core>=3.0.0,<4.0.0", "typer==0.10.0", "rich==13.9.4",