Disallow executing auth/secret tools for unauthenticated servers using HTTP transport (#641)

## PR Description
This PR tackles 3 things:
1. At tool execution runtime, blocks local HTTP servers from executing
tools that have `requires_auth` or `requires_secrets`
2. Make `stdio` the default transport in various locations
3. Improve the `arcade configure` CLI command


<img width="1408" height="1194" alt="image"
src="https://github.com/user-attachments/assets/badf1b55-ec7d-4741-89f5-4b5fee294890"
/>
<img width="3034" height="906" alt="image"
src="https://github.com/user-attachments/assets/aea528c5-4ea6-4eed-b5d7-f946626e58a7"
/>

---------

Co-authored-by: Evan Tahler <evantahler@gmail.com>
This commit is contained in:
Eric Gustin 2025-10-22 13:14:46 -07:00 committed by GitHub
parent 4dfd0522a6
commit 66a126bba5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 557 additions and 125 deletions

View file

@ -3,9 +3,12 @@
import json import json
import os import os
import platform import platform
import re
import shutil
from pathlib import Path from pathlib import Path
import typer import typer
from dotenv import dotenv_values
from rich.console import Console from rich.console import Console
console = Console() console = Console()
@ -51,14 +54,21 @@ def get_vscode_config_path() -> Path:
return Path.home() / ".config" / "Code" / "User" / "mcp.json" return Path.home() / ".config" / "Code" / "User" / "mcp.json"
def configure_claude_local(server_name: str, port: int = 8000, path: Path | None = None) -> None: def is_uv_installed() -> bool:
"""Configure Claude Desktop to add a local MCP server to the configuration.""" """Check if uv is installed and available in PATH."""
config_path = path or get_claude_config_path() return shutil.which("uv") is not None
config_path.parent.mkdir(parents=True, exist_ok=True)
# 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 # Find the Python interpreter in the virtual environment
venv_python = None venv_python = None
# Check for .venv first (uv default) # 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) venv_python = Path(sys.executable)
# Load existing config or create new one return venv_python
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")
def configure_claude_arcade(server_name: str, path: Path | None = None) -> None: def get_stdio_config(entrypoint_file: str, server_name: str) -> dict:
"""Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration.""" """Get the appropriate stdio configuration based on whether uv is installed."""
# This would connect to the Arcade Cloud to get the server URL server_file = Path.cwd() / entrypoint_file
# For now, this is a placeholder
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") 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: def configure_claude_local(
"""Configure Cursor to add a local MCP server to the configuration.""" entrypoint_file: str, server_name: str, port: int = 8000, config_path: Path | None = None
config_path = path or get_cursor_config_path() ) -> 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) config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one # 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: if "mcpServers" not in config:
config["mcpServers"] = {} config["mcpServers"] = {}
config["mcpServers"][server_name] = { # Claude Desktop uses stdio transport
"name": server_name, config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
"type": "stream", # Cursor prefers stream
"url": f"http://localhost:{port}/mcp", # 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 # Write updated config
with open(config_path, "w") as f: 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(" ", "\\ ") config_file_path = config_path.as_posix().replace(" ", "\\ ")
console.print(f" MCP client config file: {config_file_path}", style="dim") 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") 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.""" """Configure Cursor to add an Arcade Cloud MCP server to the configuration."""
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") 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.""" """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) config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one # Load existing config or create new one
config = {} config = {}
@ -175,10 +275,11 @@ def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None
if "servers" not in config: if "servers" not in config:
config["servers"] = {} config["servers"] = {}
config["servers"][server_name] = { config["servers"][server_name] = (
"type": "http", get_stdio_config(entrypoint_file, server_name)
"url": f"http://localhost:{port}/mcp", if transport == "stdio"
} else http_config(port)
)
# Write updated config # Write updated config
with open(config_path, "w") as f: 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(" ", "\\ ") config_file_path = config_path.as_posix().replace(" ", "\\ ")
console.print(f" MCP client config file: {config_file_path}", style="dim") 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") 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.""" """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]") console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_client( def configure_client(
client: str, client: str,
entrypoint_file: str,
server_name: str | None = None, server_name: str | None = None,
from_local: bool = False, transport: str = "stdio",
from_arcade: bool = False, host: str = "local",
port: int = 8000, port: int = 8000,
path: Path | None = None, config_path: Path | None = None,
) -> None: ) -> None:
""" """
Configure an MCP client to connect to a server. Configure an MCP client to connect to a server.
Args: Args:
client: The MCP client to configure (claude, cursor, vscode) 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 server_name: Name of the server to add to the configuration
from_local: Add a local server to the configuration transport: The transport to use for the MCP server configuration
from_arcade: Add an Arcade Cloud server to the configuration host: The host of the server to configure (local or arcade)
port: Port for local servers (default: 8000) port: Port for local HTTP servers (default: 8000)
path: Custom path to the MCP client configuration file 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: if not server_name:
# Try to detect from current directory # Use the name of the current directory as the server name
server_name = Path.cwd().name if Path("server.py").exists() else "arcade-mcp-server" 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() client_lower = client.lower()
if client_lower == "claude": if client_lower == "claude":
if from_local: if transport != "stdio":
configure_claude_local(server_name, port, path) 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: else:
configure_claude_arcade(server_name, path) configure_claude_arcade(server_name, transport, config_path)
elif client_lower == "cursor": elif client_lower == "cursor":
if from_local: if host == "local":
configure_cursor_local(server_name, port, path) configure_cursor_local(entrypoint_file, server_name, transport, port, config_path)
else: else:
configure_cursor_arcade(server_name, path) configure_cursor_arcade(server_name, transport, config_path)
elif client_lower == "vscode": elif client_lower == "vscode":
if from_local: if host == "local":
configure_vscode_local(server_name, port, path) configure_vscode_local(entrypoint_file, server_name, transport, port, config_path)
else: else:
configure_vscode_arcade(server_name, path) configure_vscode_arcade(server_name, transport, config_path)
else: else:
raise typer.BadParameter( raise typer.BadParameter(
f"Unknown client: {client}. Supported clients: claude, cursor, vscode." f"Unknown client: {client}. Supported clients: claude, cursor, vscode."

View file

@ -471,59 +471,80 @@ def configure(
client: str = typer.Argument( client: str = typer.Argument(
..., ...,
help="The MCP client to configure (claude, cursor, vscode)", 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( server_name: Optional[str] = typer.Option(
None, None,
"--server", "--name",
"-s", "-n",
help="Name of the server to connect to (defaults to current directory name)", 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( host: str = typer.Option(
False, "local",
"--from-local", "--host",
help="Connect to a local MCP server", "-h",
is_flag=True, 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),
from_arcade: bool = typer.Option( show_choices=True,
False, rich_help_panel="HTTP Options",
"--from-arcade",
help="Connect to an Arcade Cloud MCP server",
is_flag=True,
), ),
port: int = typer.Option( port: int = typer.Option(
8000, 8000,
"--port", "--port",
"-p", "-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, None,
"--path", "--config",
"-f", "-c",
exists=False, exists=False,
help="Optional path to a specific MCP client config file (overrides default path)", 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"), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None: ) -> None:
""" """
Configure MCP clients to connect to your server. 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: Examples:
arcade configure claude --from-local arcade configure claude
arcade configure cursor --from-local --port 8080 arcade configure cursor --transport http --port 8080
arcade configure vscode --from-local --path .vscode/mcp.json arcade configure vscode --host arcade --entrypoint ../../../mcp/server.py --config .vscode/mcp.json
arcade configure claude --from-arcade --server my_server_name arcade configure claude --host local --name my_server_name
""" """
from arcade_cli.configure import configure_client from arcade_cli.configure import configure_client
try: try:
configure_client( configure_client(
client=client, client=client,
entrypoint_file=entrypoint_file,
server_name=server_name, server_name=server_name,
from_local=from_local, transport=transport,
from_arcade=from_arcade, host=host,
port=port, port=port,
path=path, config_path=config_path,
) )
except Exception as e: except Exception as e:
handle_cli_error(f"Failed to configure {client}", e, debug) handle_cli_error(f"Failed to configure {client}", e, debug)

View file

@ -19,14 +19,14 @@ try:
ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0" ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
except Exception as e: except Exception as e:
console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]") 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_MCP_MAX_VERSION = "2.0.0"
ARCADE_TDK_MIN_VERSION = "3.0.0" ARCADE_TDK_MIN_VERSION = "3.0.0"
ARCADE_TDK_MAX_VERSION = "4.0.0" ARCADE_TDK_MAX_VERSION = "4.0.0"
ARCADE_SERVE_MIN_VERSION = "3.0.0" ARCADE_SERVE_MIN_VERSION = "3.0.0"
ARCADE_SERVE_MAX_VERSION = "4.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" ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0"

View file

@ -62,10 +62,13 @@ async def get_posts_in_subreddit(
# Run with specific transport # Run with specific transport
if __name__ == "__main__": if __name__ == "__main__":
# Get transport from command line argument, default to "http" # Get transport from command line argument, default to "stdio"
transport = sys.argv[1] if len(sys.argv) > 1 else "http" # - "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 # 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) app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -68,7 +68,7 @@ class MCPApp:
title: str | None = None, title: str | None = None,
instructions: str | None = None, instructions: str | None = None,
log_level: str = "INFO", log_level: str = "INFO",
transport: TransportType = "http", transport: TransportType = "stdio",
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 8000, port: int = 8000,
reload: bool = False, reload: bool = False,
@ -83,7 +83,7 @@ class MCPApp:
title: Server title for display title: Server title for display
instructions: Server instructions instructions: Server instructions
log_level: Logging level (DEBUG, INFO, WARNING, ERROR) log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
transport: Transport type ("http") transport: Transport type ("stdio")
host: Host for transport host: Host for transport
port: Port for transport port: Port for transport
reload: Enable auto-reload for development reload: Enable auto-reload for development
@ -221,7 +221,7 @@ class MCPApp:
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 8000, port: int = 8000,
reload: bool = False, reload: bool = False,
transport: TransportType = "http", transport: TransportType = "stdio",
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
if len(self._catalog) == 0: if len(self._catalog) == 0:

View file

@ -661,7 +661,7 @@ class MCPServer:
# Handle authorization and secrets requirements if required # Handle authorization and secrets requirements if required
if missing_requirements_response := await self._check_tool_requirements( 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 return missing_requirements_response
@ -754,8 +754,30 @@ class MCPServer:
tool_context: ToolContext, tool_context: ToolContext,
message: CallToolRequest, message: CallToolRequest,
tool_name: str, tool_name: str,
session: ServerSession | None = None,
) -> JSONRPCResponse[CallToolResult] | None: ) -> JSONRPCResponse[CallToolResult] | None:
"""Check tool requirements before executing the tool""" """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 # Check authorization
if tool.definition.requirements and tool.definition.requirements.authorization: if tool.definition.requirements and tool.definition.requirements.authorization:
# First check if Arcade API key is configured # First check if Arcade API key is configured
@ -765,8 +787,8 @@ class MCPServer:
"llm_instructions": ( "llm_instructions": (
f"The MCP server cannot execute the '{tool_name}' tool because it requires authorization " 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: " "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 " "1) Run 'arcade login' to authenticate, or "
"2) Run 'arcade login' to authenticate. " "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." "Once the API key is configured, restart the MCP server for the changes to take effect."
), ),
} }

View file

@ -151,6 +151,7 @@ class HTTPSessionManager:
server=self.server, server=self.server,
read_stream=read_stream, read_stream=read_stream,
write_stream=write_stream, write_stream=write_stream,
init_options={"transport_type": "http"},
) )
# Set the session on the transport # Set the session on the transport
@ -220,6 +221,7 @@ class HTTPSessionManager:
server=self.server, server=self.server,
read_stream=read_stream, read_stream=read_stream,
write_stream=write_stream, write_stream=write_stream,
init_options={"transport_type": "http"},
) )
# Set the session on the transport # Set the session on the transport

View file

@ -175,12 +175,15 @@ class StdioTransport:
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
read_stream = StdioReadStream(self.read_queue) read_stream = StdioReadStream(self.read_queue)
write_stream = StdioWriteStream(self.write_queue) write_stream = StdioWriteStream(self.write_queue)
init_options = {"transport_type": "stdio", **options}
session = ServerSession( session = ServerSession(
server=None, # set by the caller using run_connection; not used here server=None, # set by the caller using run_connection; not used here
session_id=session_id, session_id=session_id,
read_stream=read_stream, read_stream=read_stream,
write_stream=write_stream, write_stream=write_stream,
init_options=options, init_options=init_options,
stateless=True, stateless=True,
) )

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "arcade-mcp-server" name = "arcade-mcp-server"
version = "1.3.2" version = "1.4.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev" description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md" readme = "README.md"
authors = [{ name = "Arcade.dev" }] authors = [{ name = "Arcade.dev" }]

View file

@ -3,15 +3,24 @@
import asyncio import asyncio
import contextlib import contextlib
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
from typing import Annotated
import pytest import pytest
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
from arcade_core.errors import ToolRuntimeError from arcade_core.errors import ToolRuntimeError
from arcade_core.schema import ( from arcade_core.schema import (
InputParameter,
OAuth2Requirement,
ToolAuthRequirement, ToolAuthRequirement,
ToolContext, ToolContext,
ToolDefinition,
ToolInput,
ToolkitDefinition,
ToolOutput,
ToolRequirements, ToolRequirements,
ToolSecretRequirement, ToolSecretRequirement,
ValueSchema,
) )
from arcade_core.auth import OAuth2
from arcade_mcp_server.middleware import Middleware from arcade_mcp_server.middleware import Middleware
from arcade_mcp_server.server import MCPServer from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.session import InitializationState from arcade_mcp_server.session import InitializationState
@ -26,6 +35,7 @@ from arcade_mcp_server.types import (
ListToolsResult, ListToolsResult,
PingRequest, PingRequest,
) )
from arcade_mcp_server import tool
class TestMCPServer: class TestMCPServer:
@ -928,3 +938,264 @@ class TestMCPServer:
assert isinstance(result.result, CallToolResult) assert isinstance(result.result, CallToolResult)
assert result.result.isError is True assert result.result.isError is True
assert "authorization_url" in result.result.structuredContent 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

View file

@ -1,6 +1,6 @@
[project] [project]
name = "arcade-mcp" name = "arcade-mcp"
version = "1.2.1" version = "1.3.0"
description = "Arcade.dev - Tool Calling platform for Agents" description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
@ -21,7 +21,7 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
# CLI 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", "arcade-core>=3.0.0,<4.0.0",
"typer==0.10.0", "typer==0.10.0",
"rich==13.9.4", "rich==13.9.4",