From 66a126bba5c0f89a13568fd8a53d85de4d159391 Mon Sep 17 00:00:00 2001
From: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
Date: Wed, 22 Oct 2025 13:14:46 -0700
Subject: [PATCH] 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
---------
Co-authored-by: Evan Tahler
---
libs/arcade-cli/arcade_cli/configure.py | 276 ++++++++++++------
libs/arcade-cli/arcade_cli/main.py | 71 +++--
libs/arcade-cli/arcade_cli/new.py | 4 +-
.../minimal/{{ toolkit_name }}/server.py | 11 +-
.../arcade_mcp_server/mcp_app.py | 6 +-
.../arcade_mcp_server/server.py | 28 +-
.../transports/http_session_manager.py | 2 +
.../arcade_mcp_server/transports/stdio.py | 5 +-
libs/arcade-mcp-server/pyproject.toml | 2 +-
libs/tests/arcade_mcp_server/test_server.py | 273 ++++++++++++++++-
pyproject.toml | 4 +-
11 files changed, 557 insertions(+), 125 deletions(-)
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",