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:
parent
4dfd0522a6
commit
66a126bba5
11 changed files with 557 additions and 125 deletions
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" }]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue