Local MCP Fixes and Address General Feedback (#586)
# Release Candidate 2 ## This PR: - [x] No more confusing 307 redirect logs when using `/mcp` instead of `/mcp/` (requested by @shubcodes) - [x] Fix bug in `arcade configure` for Python < 3.12 (reported by @evantahler - [x] Fix bug where tools with unsatisfied secret requirements could still be executed (reported by @evantahler, @shubcodes) - [x] Auth providers can now be imported via `from arcade_mcp_server.auth import Reddit` (requested by @shubcodes) - [x] Add complete E2E oauth flow for tool calls with informational errors about how to log into arcade and where to go to authorize (requested by @evantahler, @shubcodes) - [x] Add OAuth tool in `arcade new`'s generated server (requested by @shubcodes) - [x] Standardize on defaulting to running servers on port 8000 - [x] Improve credentials.yaml reading logic - [x] CLI user friendliness (requested by @Spartee) - [x] Remove `arcade serve` CLI command - [x] Fix race condition in `arcade logout` - [x] Update docs for desired developer onboarding flow ## Next PRs: - Get `arcade deploy` working for MCP servers. (Command is hidden for now) - Rename all occurrences of `toolkit` to `server`/`tools` and rename all occurrences of `worker` to `server`
This commit is contained in:
parent
62131dedd0
commit
9e4d36b8e3
34 changed files with 1113 additions and 772 deletions
39
.vscode/launch.json
vendored
39
.vscode/launch.json
vendored
|
|
@ -2,48 +2,15 @@
|
|||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug `arcade workerup --no-auth`",
|
||||
"name": "Debug `arcade mcp`",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/libs/arcade-cli/run_cli.py",
|
||||
"args": ["workerup", "--no-auth"],
|
||||
"args": ["mcp"],
|
||||
"console": "integratedTerminal",
|
||||
"jinja": true,
|
||||
"justMyCode": true,
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug `arcade chat -d -h localhost`",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/libs/arcade-cli/run_cli.py",
|
||||
"args": ["chat", "-d", "-h", "localhost"],
|
||||
"console": "integratedTerminal",
|
||||
"jinja": true,
|
||||
"justMyCode": true,
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Debug `arcade evals -d` on current file",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/libs/arcade-cli/run_cli.py",
|
||||
"args": ["evals", "-d", "${fileDirname}", "-h", "localhost"],
|
||||
"console": "integratedTerminal",
|
||||
"jinja": true,
|
||||
"justMyCode": true,
|
||||
"cwd": ""
|
||||
},
|
||||
{
|
||||
"name": "Debug `arcade serve`",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/libs/arcade-cli/run_cli.py",
|
||||
"args": ["serve"],
|
||||
"console": "integratedTerminal",
|
||||
"jinja": true,
|
||||
"justMyCode": true,
|
||||
"cwd": ""
|
||||
"cwd": "${workspaceFolder}/toolkits/<your_server_name>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,9 +80,8 @@ def configure_claude_local(server_name: str, port: int = 8000, path: Path | None
|
|||
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
|
||||
style="green",
|
||||
)
|
||||
console.print(
|
||||
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
|
||||
)
|
||||
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")
|
||||
console.print(" Restart Claude Desktop for changes to take effect.", style="yellow")
|
||||
|
||||
|
|
@ -123,9 +122,8 @@ def configure_cursor_local(server_name: str, port: int = 8000, path: Path | None
|
|||
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
|
||||
style="green",
|
||||
)
|
||||
console.print(
|
||||
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
|
||||
)
|
||||
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")
|
||||
console.print(" Restart Cursor for changes to take effect.", style="yellow")
|
||||
|
||||
|
|
@ -169,9 +167,8 @@ def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None
|
|||
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
|
||||
style="green",
|
||||
)
|
||||
console.print(
|
||||
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
|
||||
)
|
||||
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")
|
||||
console.print(" Restart VS Code for changes to take effect.", style="yellow")
|
||||
|
||||
|
|
@ -201,12 +198,10 @@ def configure_client(
|
|||
path: Custom path to the MCP client configuration file
|
||||
"""
|
||||
if not from_local and not from_arcade:
|
||||
console.print("[red]Must specify either --from-local or --from-arcade[/red]")
|
||||
raise typer.Exit(1)
|
||||
raise typer.BadParameter("Must specify either --from-local or --from-arcade")
|
||||
|
||||
if from_local and from_arcade:
|
||||
console.print("[red]Cannot specify both --from-local and --from-arcade[/red]")
|
||||
raise typer.Exit(1)
|
||||
raise typer.BadParameter("Cannot specify both --from-local and --from-arcade")
|
||||
|
||||
# Default server name if not provided
|
||||
if not server_name:
|
||||
|
|
@ -231,6 +226,6 @@ def configure_client(
|
|||
else:
|
||||
configure_vscode_arcade(server_name, path)
|
||||
else:
|
||||
console.print(f"[red]Unknown client: {client}[/red]")
|
||||
console.print("Supported clients: claude, cursor, vscode")
|
||||
raise typer.Exit(1)
|
||||
raise typer.BadParameter(
|
||||
f"Unknown client: {client}. Supported clients: claude, cursor, vscode."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import os
|
|||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
|
@ -13,7 +12,6 @@ import httpx
|
|||
import typer
|
||||
from arcadepy import Arcade
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from tqdm import tqdm
|
||||
|
||||
|
|
@ -37,6 +35,7 @@ from arcade_cli.utils import (
|
|||
compute_base_url,
|
||||
compute_login_url,
|
||||
get_eval_files,
|
||||
handle_cli_error,
|
||||
load_eval_suites,
|
||||
log_engine_health,
|
||||
require_dependency,
|
||||
|
|
@ -53,45 +52,30 @@ cli = typer.Typer(
|
|||
pretty_exceptions_show_locals=False,
|
||||
pretty_exceptions_short=True,
|
||||
rich_markup_mode="markdown",
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
help="Arcade CLI - Build, deploy, and manage MCP servers and AI tools. Create new projects, run servers with multiple transports, configure clients, and deploy to Arcade Cloud.",
|
||||
epilog="Pro tip: use --help after any command to see command-specific options.",
|
||||
)
|
||||
|
||||
|
||||
cli.add_typer(
|
||||
worker.app,
|
||||
name="worker",
|
||||
name="server",
|
||||
help="Manage deployments of tool servers (logs, list, etc)",
|
||||
rich_help_panel="Deployment",
|
||||
rich_help_panel="Manage",
|
||||
)
|
||||
|
||||
cli.add_typer(
|
||||
secret.app,
|
||||
name="secret",
|
||||
help="Manage tool secrets in the cloud (set, unset, list)",
|
||||
rich_help_panel="Admin",
|
||||
rich_help_panel="Manage",
|
||||
)
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def handle_cli_error(
|
||||
message: str,
|
||||
error: Optional[Exception] = None,
|
||||
debug: bool = True,
|
||||
should_exit: bool = True,
|
||||
) -> None:
|
||||
"""Handle CLI error reporting with optional debug traceback and exit."""
|
||||
if error and debug:
|
||||
console.print(f"❌ {message}: {traceback.format_exc()}", style="bold red")
|
||||
elif error:
|
||||
console.print(f"❌ {message}: {escape(str(error))}", style="bold red")
|
||||
else:
|
||||
console.print(f"❌ {message}", style="bold red")
|
||||
|
||||
if should_exit:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@cli.command(help="Log in to Arcade Cloud", rich_help_panel="User")
|
||||
def login(
|
||||
host: str = typer.Option(
|
||||
|
|
@ -170,13 +154,13 @@ def logout(
|
|||
|
||||
|
||||
@cli.command(
|
||||
help="Create a new toolkit package directory. Example usage: arcade new my_toolkit",
|
||||
rich_help_panel="Tool Development",
|
||||
help="Create a new server package directory. Example usage: `arcade new my_mcp_server`",
|
||||
rich_help_panel="Build",
|
||||
)
|
||||
def new(
|
||||
toolkit_name: str = typer.Argument(
|
||||
help="The name of the toolkit to create",
|
||||
metavar="TOOLKIT_NAME",
|
||||
server_name: str = typer.Argument(
|
||||
help="The name of the server to create",
|
||||
metavar="SERVER_NAME",
|
||||
),
|
||||
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
|
|
@ -184,27 +168,27 @@ def new(
|
|||
False,
|
||||
"--full",
|
||||
"-f",
|
||||
help="Create a toolkit package with a full scaffolding (includes evals, tests, license, etc)",
|
||||
help="Create a starter MCP server (pyproject.toml, server.py, .env.example)",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Creates a new toolkit with the given name, description, and result type.
|
||||
Creates a new MCP server with the given name
|
||||
"""
|
||||
from arcade_cli.new import create_new_toolkit, create_new_toolkit_minimal
|
||||
|
||||
try:
|
||||
if not full:
|
||||
create_new_toolkit_minimal(directory, toolkit_name)
|
||||
create_new_toolkit_minimal(directory, server_name)
|
||||
else:
|
||||
create_new_toolkit(directory, toolkit_name)
|
||||
create_new_toolkit(directory, server_name)
|
||||
except Exception as e:
|
||||
handle_cli_error("Failed to create new Toolkit", e, debug)
|
||||
handle_cli_error("Failed to create new server", e, debug)
|
||||
|
||||
|
||||
@cli.command(
|
||||
name="mcp",
|
||||
help="Run MCP servers with different transports",
|
||||
rich_help_panel="Launch",
|
||||
rich_help_panel="Run",
|
||||
)
|
||||
def mcp(
|
||||
transport: str = typer.Argument("http", help="Transport type: stdio, http"),
|
||||
|
|
@ -300,12 +284,12 @@ def mcp(
|
|||
|
||||
|
||||
@cli.command(
|
||||
help="Show the installed toolkits or details of a specific tool",
|
||||
rich_help_panel="Tool Development",
|
||||
help="Show the installed tools or details of a specific tool",
|
||||
rich_help_panel="Build",
|
||||
)
|
||||
def show(
|
||||
toolkit: Optional[str] = typer.Option(
|
||||
None, "-T", "--toolkit", help="The toolkit to show the tools of"
|
||||
server: Optional[str] = typer.Option(
|
||||
None, "-T", "--server", help="The server to show the tools of"
|
||||
),
|
||||
tool: Optional[str] = typer.Option(
|
||||
None, "-t", "--tool", help="The specific tool to show details for"
|
||||
|
|
@ -314,7 +298,7 @@ def show(
|
|||
PROD_ENGINE_HOST,
|
||||
"-h",
|
||||
"--host",
|
||||
help="The Arcade Engine address to show the tools/toolkits of.",
|
||||
help="The Arcade Engine address to show the tools/servers of.",
|
||||
),
|
||||
local: bool = typer.Option(
|
||||
False,
|
||||
|
|
@ -338,37 +322,37 @@ def show(
|
|||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
),
|
||||
worker: bool = typer.Option(
|
||||
full: bool = typer.Option(
|
||||
False,
|
||||
"--worker",
|
||||
"-w",
|
||||
help="Show full worker response structure including error, logs, and authorization fields (only applies when used with -t/--tool).",
|
||||
"--full",
|
||||
"-f",
|
||||
help="Show full server response structure including error, logs, and authorization fields (only applies when used with -t/--tool).",
|
||||
),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
) -> None:
|
||||
"""
|
||||
Show the available toolkits or detailed information about a specific tool.
|
||||
Show the available tools or detailed information about a specific tool.
|
||||
"""
|
||||
if worker and not tool:
|
||||
if full and not tool:
|
||||
console.print(
|
||||
"⚠️ The -w/--worker flag only affects output when used with -t/--tool flag",
|
||||
"⚠️ The -f/--full flag only affects output when used with -t/--tool flag",
|
||||
style="bold yellow",
|
||||
)
|
||||
|
||||
show_logic(
|
||||
toolkit=toolkit,
|
||||
toolkit=server,
|
||||
tool=tool,
|
||||
host=host,
|
||||
local=local,
|
||||
port=port,
|
||||
force_tls=force_tls,
|
||||
force_no_tls=force_no_tls,
|
||||
worker=worker,
|
||||
worker=full,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
|
||||
@cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development")
|
||||
@cli.command(help="Run tool calling evaluations", rich_help_panel="Build")
|
||||
def evals(
|
||||
directory: str = typer.Argument(".", help="Directory containing evaluation files"),
|
||||
show_details: bool = typer.Option(False, "--details", "-d", help="Show detailed results"),
|
||||
|
|
@ -489,77 +473,7 @@ def evals(
|
|||
handle_cli_error("Failed to run evaluations", e, debug)
|
||||
|
||||
|
||||
@cli.command(
|
||||
help="Start tool server worker with locally installed tools",
|
||||
rich_help_panel="Launch",
|
||||
hidden=True,
|
||||
)
|
||||
def serve(
|
||||
host: str = typer.Option(
|
||||
"127.0.0.1",
|
||||
help="Host for the app, from settings by default.",
|
||||
show_default=True,
|
||||
),
|
||||
port: int = typer.Option(
|
||||
"8002",
|
||||
"-p",
|
||||
"--port",
|
||||
help="Port for the app, defaults to ",
|
||||
show_default=True,
|
||||
),
|
||||
disable_auth: bool = typer.Option(
|
||||
True,
|
||||
"--no-auth",
|
||||
help="Disable authentication for the worker. Not recommended for production.",
|
||||
show_default=True,
|
||||
),
|
||||
otel_enable: bool = typer.Option(
|
||||
False, "--otel-enable", help="Send logs to OpenTelemetry", show_default=True
|
||||
),
|
||||
mcp: bool = typer.Option(
|
||||
False, "--mcp", help="Run as a local MCP server over stdio", show_default=True
|
||||
),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
reload: bool = typer.Option(
|
||||
False,
|
||||
"--reload",
|
||||
help="Enable auto-reloading when toolkit or server files change.",
|
||||
show_default=True,
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Start a local Arcade Worker server.
|
||||
"""
|
||||
console.log(
|
||||
"⚠️ This command is deprecated and will be removed in a future version.", style="yellow"
|
||||
)
|
||||
require_dependency(
|
||||
package_name="arcade_serve",
|
||||
command_name="serve",
|
||||
install_command=r"pip install 'arcade-serve'",
|
||||
)
|
||||
|
||||
from arcade_cli.serve import serve_default_worker
|
||||
|
||||
try:
|
||||
serve_default_worker(
|
||||
host,
|
||||
port,
|
||||
disable_auth=disable_auth,
|
||||
enable_otel=otel_enable,
|
||||
debug=debug,
|
||||
mcp=mcp,
|
||||
reload=reload,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
typer.Exit()
|
||||
except Exception as e:
|
||||
handle_cli_error("Failed to start Arcade Worker", e, debug)
|
||||
|
||||
|
||||
@cli.command(
|
||||
help="Configure MCP clients to connect to your server", rich_help_panel="Tool Development"
|
||||
)
|
||||
@cli.command(help="Configure MCP clients to connect to your server", rich_help_panel="Manage")
|
||||
def configure(
|
||||
client: str = typer.Argument(
|
||||
...,
|
||||
|
|
@ -605,7 +519,7 @@ def configure(
|
|||
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-toolkit
|
||||
arcade configure claude --from-arcade --server my_server_name
|
||||
"""
|
||||
from arcade_cli.configure import configure_client
|
||||
|
||||
|
|
@ -622,7 +536,7 @@ def configure(
|
|||
handle_cli_error(f"Failed to configure {client}", e, debug)
|
||||
|
||||
|
||||
@cli.command(help="Deploy toolkits to Arcade Cloud", rich_help_panel="Deployment")
|
||||
@cli.command(help="Deploy servers to Arcade Cloud", rich_help_panel="Run", hidden=True)
|
||||
def deploy(
|
||||
deployment_file: str = typer.Option(
|
||||
"worker.toml",
|
||||
|
|
@ -648,7 +562,7 @@ def deploy(
|
|||
PROD_ENGINE_HOST,
|
||||
"--host",
|
||||
"-h",
|
||||
help="The Arcade Engine host to register the worker to.",
|
||||
help="The Arcade Engine host to register the server to.",
|
||||
),
|
||||
port: Optional[int] = typer.Option(
|
||||
None,
|
||||
|
|
@ -669,7 +583,7 @@ def deploy(
|
|||
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
|
||||
) -> None:
|
||||
"""
|
||||
Deploy a worker to Arcade Cloud.
|
||||
Deploy a server to Arcade Cloud.
|
||||
"""
|
||||
|
||||
config = validate_and_get_config()
|
||||
|
|
@ -686,7 +600,7 @@ def deploy(
|
|||
except Exception as e:
|
||||
handle_cli_error("Failed to parse deployment file", e, debug)
|
||||
|
||||
with console.status(f"Deploying {len(deployment.worker)} workers"):
|
||||
with console.status(f"Deploying {len(deployment.worker)} servers"):
|
||||
for worker in deployment.worker:
|
||||
console.log(f"Deploying '{worker.config.id}...'", style="dim")
|
||||
try:
|
||||
|
|
@ -717,11 +631,11 @@ def deploy(
|
|||
# Attempt to deploy worker
|
||||
worker.request().execute(cloud_client, engine_client)
|
||||
console.log(
|
||||
f"✅ Worker '{worker.config.id}' deployed successfully.",
|
||||
f"✅ Server '{worker.config.id}' deployed successfully.",
|
||||
style="dim",
|
||||
)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to deploy worker '{worker.config.id}'", e, debug)
|
||||
handle_cli_error(f"Failed to deploy server '{worker.config.id}'", e, debug)
|
||||
|
||||
|
||||
@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User")
|
||||
|
|
@ -786,25 +700,26 @@ def dashboard(
|
|||
|
||||
@cli.command(
|
||||
help=(
|
||||
"Generate documentation for a toolkit. "
|
||||
"Note: make sure to have the toolkit installed in your current Python environment "
|
||||
"Generate documentation for a server. "
|
||||
"Note: make sure to have the server installed in your current Python environment "
|
||||
"before running this command."
|
||||
),
|
||||
rich_help_panel="Tool Development",
|
||||
rich_help_panel="Document",
|
||||
hidden=True,
|
||||
)
|
||||
def docs(
|
||||
toolkit_name: str = typer.Option(
|
||||
server_name: str = typer.Option(
|
||||
...,
|
||||
"--toolkit-name",
|
||||
"--server-name",
|
||||
"-n",
|
||||
help="The name of the toolkit to generate documentation for.",
|
||||
help="The name of the server to generate documentation for.",
|
||||
),
|
||||
toolkit_dir: str = typer.Option(
|
||||
server_dir: str = typer.Option(
|
||||
...,
|
||||
"--toolkit-dir",
|
||||
"--server-dir",
|
||||
"-t",
|
||||
help=(
|
||||
"The path to the toolkit root directory (where the toolkit code is implemented). "
|
||||
"The path to the server root directory (where the server code is implemented). "
|
||||
"Works with relative and absolute paths."
|
||||
),
|
||||
),
|
||||
|
|
@ -820,8 +735,8 @@ def docs(
|
|||
"-s",
|
||||
help=(
|
||||
"The section of the docs to generate documentation for. E.g. 'productivity', 'sales'. "
|
||||
"This should be the name of the folder in /pages/toolkits. "
|
||||
"Defaults to an empty string (generate the docs in the root of /pages/toolkits)"
|
||||
"This should be the name of the folder in /pages/tools. "
|
||||
"Defaults to an empty string (generate the docs in the root of /pages/tools)"
|
||||
),
|
||||
),
|
||||
openai_model: str = typer.Option(
|
||||
|
|
@ -860,8 +775,8 @@ def docs(
|
|||
try:
|
||||
success = generate_toolkit_docs(
|
||||
console=console,
|
||||
toolkit_name=toolkit_name,
|
||||
toolkit_dir=toolkit_dir,
|
||||
toolkit_name=server_name,
|
||||
toolkit_dir=server_dir,
|
||||
docs_dir=docs_dir,
|
||||
docs_section=docs_section,
|
||||
openai_model=openai_model,
|
||||
|
|
@ -871,7 +786,7 @@ def docs(
|
|||
)
|
||||
except Exception as error:
|
||||
handle_cli_error(
|
||||
message=f"Failed to generate documentation for '{toolkit_name}' in '{docs_dir}'",
|
||||
message=f"Failed to generate documentation for '{server_name}' in '{docs_dir}'",
|
||||
error=error,
|
||||
debug=debug,
|
||||
)
|
||||
|
|
@ -879,98 +794,16 @@ def docs(
|
|||
|
||||
if success:
|
||||
console.print(
|
||||
f"Generated documentation for '{toolkit_name}' in '{docs_dir}'",
|
||||
f"Generated documentation for '{server_name}' in '{docs_dir}'",
|
||||
style="bold green",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"Failed to generate documentation for '{toolkit_name}' in '{docs_dir}'",
|
||||
f"Failed to generate documentation for '{server_name}' in '{docs_dir}'",
|
||||
style="bold red",
|
||||
)
|
||||
|
||||
|
||||
@cli.command(
|
||||
name="generate-toolkit-docs",
|
||||
help=(
|
||||
"Generate documentation for a toolkit. "
|
||||
"Note: make sure to have the toolkit installed in your current Python environment "
|
||||
"before running this command. "
|
||||
"Obs.: this command is here for backwards compatibility, use `arcade docs` instead."
|
||||
),
|
||||
rich_help_panel="Tool Development",
|
||||
hidden=True,
|
||||
)
|
||||
def generate_toolkit_docs_command(
|
||||
toolkit_name: str = typer.Option(
|
||||
...,
|
||||
"--toolkit-name",
|
||||
"-n",
|
||||
help="The name of the toolkit to generate documentation for.",
|
||||
),
|
||||
toolkit_dir: str = typer.Option(
|
||||
...,
|
||||
"--toolkit-dir",
|
||||
"-t",
|
||||
help=(
|
||||
"The path to the toolkit root directory (where the toolkit code is implemented). "
|
||||
"Works with relative and absolute paths."
|
||||
),
|
||||
),
|
||||
docs_dir: str = typer.Option(
|
||||
...,
|
||||
"--docs-dir",
|
||||
"-r",
|
||||
help="The path to the root of the Arcade docs repository. Works with relative and absolute paths.",
|
||||
),
|
||||
docs_section: str = typer.Option(
|
||||
"",
|
||||
"--docs-section",
|
||||
"-s",
|
||||
help=(
|
||||
"The section of the docs to generate documentation for. E.g. 'productivity', 'sales'. "
|
||||
"This should be the name of the folder in /pages/toolkits. "
|
||||
"Defaults to an empty string (generate the docs in the root of /pages/toolkits)"
|
||||
),
|
||||
),
|
||||
openai_model: str = typer.Option(
|
||||
"gpt-4o-mini",
|
||||
"--openai-model",
|
||||
"-m",
|
||||
help=(
|
||||
"A few parts of the documentation are generated using OpenAI API. "
|
||||
"This argument controls which OpenAI model to use. "
|
||||
"E.g. 'gpt-4o', 'gpt-4o-mini'."
|
||||
),
|
||||
show_default=True,
|
||||
),
|
||||
openai_api_key: str = typer.Option(
|
||||
None,
|
||||
"--openai-api-key",
|
||||
"-o",
|
||||
help="The OpenAI API key. If not provided, will get it from the `OPENAI_API_KEY` env var.",
|
||||
),
|
||||
tool_call_examples: bool = typer.Option(
|
||||
True,
|
||||
"--tool-call-examples",
|
||||
"-e",
|
||||
help="Whether to generate tool call examples in Python and Javascript.",
|
||||
show_default=True,
|
||||
),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
) -> None:
|
||||
skip_tool_call_examples = not tool_call_examples
|
||||
docs(
|
||||
toolkit_name=toolkit_name,
|
||||
toolkit_dir=toolkit_dir,
|
||||
docs_dir=docs_dir,
|
||||
docs_section=docs_section,
|
||||
openai_model=openai_model,
|
||||
openai_api_key=openai_api_key,
|
||||
skip_tool_call_examples=skip_tool_call_examples,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
|
||||
@cli.callback()
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
|
|
@ -989,8 +822,10 @@ def main_callback(
|
|||
logout.__name__,
|
||||
dashboard.__name__,
|
||||
evals.__name__,
|
||||
serve.__name__,
|
||||
mcp.__name__,
|
||||
new.__name__,
|
||||
show.__name__,
|
||||
configure.__name__,
|
||||
}
|
||||
if ctx.invoked_subcommand in public_commands:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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.0.0rc1" # Default version if unable to fetch
|
||||
ARCADE_MCP_MIN_VERSION = "1.0.0rc2" # Default version if unable to fetch
|
||||
ARCADE_MCP_MAX_VERSION = "4.0.0"
|
||||
|
||||
ARCADE_TDK_MIN_VERSION = "2.6.0rc1"
|
||||
ARCADE_TDK_MIN_VERSION = "2.6.0rc2"
|
||||
ARCADE_TDK_MAX_VERSION = "3.0.0"
|
||||
ARCADE_SERVE_MIN_VERSION = "2.2.0rc1"
|
||||
ARCADE_SERVE_MIN_VERSION = "2.2.0rc2"
|
||||
ARCADE_SERVE_MAX_VERSION = "3.0.0"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.0.0rc1"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.0.0rc2"
|
||||
ARCADE_MCP_SERVER_MAX_VERSION = "3.0.0"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,300 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import partial
|
||||
from importlib.metadata import version as get_pkg_version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fastapi
|
||||
import uvicorn
|
||||
|
||||
# Watchfiles is used under the hood by Uvicorn's reload feature.
|
||||
# Importing watchfiles here is an explicit acknowledgement that it needs to be installed
|
||||
import watchfiles # noqa: F401
|
||||
from arcade_core.toolkit import Toolkit, get_package_directory
|
||||
from arcade_serve.fastapi.telemetry import OTELHandler
|
||||
from arcade_serve.fastapi.worker import FastAPIWorker
|
||||
from loguru import logger
|
||||
from rich.console import Console
|
||||
|
||||
from arcade_cli.constants import ARCADE_CONFIG_PATH
|
||||
from arcade_cli.utils import (
|
||||
build_tool_catalog,
|
||||
discover_toolkits,
|
||||
load_dotenv,
|
||||
)
|
||||
|
||||
console = Console(width=70, color_system="auto")
|
||||
|
||||
|
||||
# App factory for Uvicorn reload
|
||||
def create_arcade_app() -> fastapi.FastAPI:
|
||||
# TODO: Find a better way to pass these configs to factory used for reload
|
||||
debug_mode = os.environ.get("ARCADE_WORKER_SECRET", "dev") == "dev"
|
||||
otel_enabled = os.environ.get("ARCADE_OTEL_ENABLE", "False").lower() == "true"
|
||||
auth_for_reload = not debug_mode
|
||||
|
||||
# Call setup_logging here to ensure Uvicorn worker processes also get Loguru formatting
|
||||
# for all standard library loggers.
|
||||
# The log_level for Uvicorn itself is set via uvicorn.run(log_level=...),
|
||||
# this call primarily aims to capture third-party library logs into Loguru.
|
||||
setup_logging(log_level=logging.DEBUG if debug_mode else logging.INFO, mcp_mode=False)
|
||||
|
||||
logger.info(f"Debug: {debug_mode}, OTEL: {otel_enabled}, Auth Disabled: {auth_for_reload}")
|
||||
version = get_pkg_version("arcade-mcp")
|
||||
toolkits = discover_toolkits()
|
||||
|
||||
logger.info("Registered toolkits:")
|
||||
for toolkit in toolkits:
|
||||
logger.info(
|
||||
f" - {toolkit.name}: {sum(len(tools) for tools in toolkit.tools.values())} tools"
|
||||
)
|
||||
|
||||
otel_handler = OTELHandler(
|
||||
enable=otel_enabled,
|
||||
log_level=logging.DEBUG if debug_mode else logging.INFO,
|
||||
)
|
||||
|
||||
custom_lifespan = partial(lifespan, otel_handler=otel_handler, enable_otel=otel_enabled)
|
||||
|
||||
app = fastapi.FastAPI(
|
||||
title="Arcade Worker",
|
||||
description="A worker for the Arcade platform.",
|
||||
version=version,
|
||||
docs_url="/docs" if debug_mode else None,
|
||||
redoc_url="/redoc" if debug_mode else None,
|
||||
openapi_url="/openapi.json" if debug_mode else None,
|
||||
lifespan=custom_lifespan,
|
||||
)
|
||||
otel_handler.instrument_app(app)
|
||||
|
||||
secret = os.getenv("ARCADE_WORKER_SECRET", "dev")
|
||||
if secret == "dev" and not os.environ.get("ARCADE_WORKER_SECRET"): # noqa: S105
|
||||
logger.warning("Using default 'dev' for ARCADE_WORKER_SECRET. Set this in production.")
|
||||
|
||||
worker = FastAPIWorker(
|
||||
app=app,
|
||||
secret=secret,
|
||||
disable_auth=not debug_mode, # TODO (Sam): possible unexpected behavior on reload here?
|
||||
otel_meter=otel_handler.get_meter(),
|
||||
)
|
||||
for tk in toolkits:
|
||||
worker.register_toolkit(tk)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _run_mcp_stdio(
|
||||
toolkits: list[Toolkit], *, logging_enabled: bool, env_file: str | None = None
|
||||
) -> None:
|
||||
"""Launch an MCP stdio server; blocks until it exits."""
|
||||
|
||||
from arcade_serve.mcp.stdio import StdioServer
|
||||
|
||||
# Load env vars before launching server (explicit path, config path, cwd)
|
||||
if env_file:
|
||||
load_dotenv(env_file, override=False)
|
||||
else:
|
||||
for candidate in [Path(ARCADE_CONFIG_PATH) / "arcade.env", Path.cwd() / "arcade.env"]:
|
||||
if candidate.is_file():
|
||||
load_dotenv(candidate, override=False)
|
||||
break
|
||||
|
||||
# Set up middleware configuration for stdio mode
|
||||
middleware_config = {
|
||||
"stdio_mode": True, # Ensure logs go to stderr
|
||||
}
|
||||
|
||||
catalog = build_tool_catalog(toolkits)
|
||||
server = StdioServer(
|
||||
catalog,
|
||||
enable_logging=logging_enabled,
|
||||
middleware_config=middleware_config,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(server.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("MCP server stopped by user.")
|
||||
except Exception as exc:
|
||||
logger.exception("Error while running MCP server: %s", exc)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Shutting down Server")
|
||||
logger.complete()
|
||||
logger.remove()
|
||||
|
||||
|
||||
def _run_fastapi_server(
|
||||
host: str,
|
||||
port: int,
|
||||
workers_param: int,
|
||||
timeout_keep_alive: int,
|
||||
reload: bool,
|
||||
toolkits_for_reload_dirs: list[Toolkit] | None,
|
||||
debug_flag: bool,
|
||||
) -> None:
|
||||
app_import_string = "arcade_cli.serve:create_arcade_app"
|
||||
reload_dirs_str_list: list[str] | None = None
|
||||
|
||||
if reload:
|
||||
current_reload_dirs_paths = []
|
||||
if toolkits_for_reload_dirs:
|
||||
for tk in toolkits_for_reload_dirs:
|
||||
try:
|
||||
package_dir_str = get_package_directory(tk.package_name)
|
||||
current_reload_dirs_paths.append(Path(package_dir_str))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting reload path for toolkit {tk.name}: {e}")
|
||||
|
||||
serve_py_dir_path = Path(__file__).resolve().parent
|
||||
current_reload_dirs_paths.append(serve_py_dir_path)
|
||||
|
||||
if current_reload_dirs_paths:
|
||||
reload_dirs_str_list = [str(p) for p in current_reload_dirs_paths]
|
||||
logger.debug(f"Uvicorn reload_dirs: {reload_dirs_str_list}")
|
||||
|
||||
effective_workers = 1 if reload else workers_param
|
||||
log_level_str = logging.getLevelName(logging.DEBUG if debug_flag else logging.INFO).lower()
|
||||
|
||||
logger.debug(
|
||||
f"Calling uvicorn.run with app='{app_import_string}', factory=True, host='{host}', port={port}, "
|
||||
f"workers={effective_workers}, reload={reload}, log_level='{log_level_str}'"
|
||||
)
|
||||
|
||||
uvicorn.run(
|
||||
app_import_string,
|
||||
factory=True,
|
||||
host=host,
|
||||
port=port,
|
||||
workers=effective_workers,
|
||||
log_config=None,
|
||||
log_level=log_level_str,
|
||||
reload=reload,
|
||||
reload_dirs=reload_dirs_str_list,
|
||||
lifespan="on",
|
||||
timeout_keep_alive=timeout_keep_alive,
|
||||
)
|
||||
|
||||
|
||||
class RichInterceptHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = str(record.levelno)
|
||||
logger.opt(exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def setup_logging(log_level: int = logging.INFO, mcp_mode: bool = False) -> None:
|
||||
"""Loguru and intercepts standard logging."""
|
||||
# Set our handler on root
|
||||
logging.root.handlers = [RichInterceptHandler()]
|
||||
logging.root.setLevel(log_level)
|
||||
|
||||
# For all existing loggers, remove their handlers and make them propagate to root.
|
||||
for name in list(logging.root.manager.loggerDict.keys()):
|
||||
existing_logger = logging.getLogger(name)
|
||||
existing_logger.handlers = []
|
||||
existing_logger.propagate = True
|
||||
|
||||
# clear existing loguru handlers to keep worker logging behavior clean
|
||||
# and consistent despite toolkit logging changes
|
||||
logger.remove()
|
||||
|
||||
# set sink destination based on mode
|
||||
# MCP stdio needs to write to stderr to avoid interfering with capture
|
||||
sink_destination = sys.stderr if mcp_mode else sys.stdout
|
||||
|
||||
if log_level == logging.DEBUG:
|
||||
format_string = "<level>{level}</level> | <green>{time:HH:mm:ss}</green> | <cyan>{name}:{file}:{line: <4}</cyan> | <level>{message}</level>"
|
||||
else:
|
||||
format_string = (
|
||||
"<level>{level}</level> | <green>{time:HH:mm:ss}</green> | <level>{message}</level>"
|
||||
)
|
||||
|
||||
logger.configure(
|
||||
handlers=[
|
||||
{
|
||||
"sink": sink_destination,
|
||||
"colorize": True,
|
||||
"level": log_level,
|
||||
"format": format_string,
|
||||
"enqueue": True, # non-blocking logging
|
||||
"diagnose": False, # disable detailed logging TODO: make this configurable
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(
|
||||
app: fastapi.FastAPI, otel_handler: OTELHandler | None = None, enable_otel: bool = False
|
||||
) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
logger.debug(f"Server lifespan startup. OTEL enabled: {enable_otel}")
|
||||
yield
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
logger.debug("Server lifespan cancelled.")
|
||||
raise
|
||||
finally:
|
||||
logger.debug(f"Server lifespan shutdown. OTEL enabled: {enable_otel}")
|
||||
if enable_otel and otel_handler:
|
||||
otel_handler.shutdown()
|
||||
await logger.complete()
|
||||
logger.remove()
|
||||
logger.debug("Server lifespan shutdown complete.")
|
||||
|
||||
|
||||
def serve_default_worker(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8002,
|
||||
disable_auth: bool = False,
|
||||
workers: int = 1,
|
||||
timeout_keep_alive: int = 5,
|
||||
enable_otel: bool = False,
|
||||
debug: bool = False,
|
||||
mcp: bool = False,
|
||||
reload: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# Initial logging setup for the main `arcade serve` process itself.
|
||||
# The Uvicorn worker processes will call setup_logging() again via create_arcade_app().
|
||||
setup_logging(log_level=logging.DEBUG if debug else logging.INFO, mcp_mode=mcp)
|
||||
|
||||
if mcp:
|
||||
logger.info("MCP mode selected.")
|
||||
toolkits_for_mcp = discover_toolkits()
|
||||
_run_mcp_stdio(
|
||||
toolkits_for_mcp, logging_enabled=not debug, env_file=kwargs.pop("env_file", None)
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("FastAPI mode selected. Configuring for Uvicorn with app factory.")
|
||||
os.environ["ARCADE_DEBUG_MODE"] = str(debug)
|
||||
os.environ["ARCADE_OTEL_ENABLE"] = str(enable_otel)
|
||||
os.environ["ARCADE_DISABLE_AUTH"] = str(disable_auth)
|
||||
|
||||
toolkits_for_reload_dirs: list[Toolkit] | None = None
|
||||
if reload:
|
||||
# This discovery is only to tell the main Uvicorn reloader process which project dirs to watch.
|
||||
# The actual app running in the worker will do its own discovery via create_arcade_app.
|
||||
toolkits_for_reload_dirs = discover_toolkits()
|
||||
logger.debug(
|
||||
f"Reload mode: Uvicorn to watch {len(toolkits_for_reload_dirs) if toolkits_for_reload_dirs else 0} directories."
|
||||
)
|
||||
|
||||
_run_fastapi_server(
|
||||
host=host,
|
||||
port=port,
|
||||
workers_param=workers,
|
||||
timeout_keep_alive=timeout_keep_alive,
|
||||
reload=reload,
|
||||
toolkits_for_reload_dirs=toolkits_for_reload_dirs,
|
||||
debug_flag=debug,
|
||||
)
|
||||
logger.info("Arcade serve process finished.")
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from arcade_mcp_server import Context, MCPApp
|
||||
from arcade_mcp_server.auth import Reddit
|
||||
|
||||
app = MCPApp(name="{{ toolkit_name }}", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
|
|
@ -15,11 +17,12 @@ def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
|
|||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
# To use this tool, you need to either set the secret in the .env file or as an environment variable
|
||||
@app.tool(requires_secrets=["MY_SECRET_KEY"])
|
||||
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
|
||||
"""Reveal the last 4 characters of a secret"""
|
||||
# Secrets are injected into the tool context at runtime.
|
||||
# This means that LLMs and MCP clients cannot see or access your secrets
|
||||
# Secrets are injected into the context at runtime.
|
||||
# LLMs and MCP clients cannot see or access your secrets
|
||||
# You can define secrets in a .env file.
|
||||
try:
|
||||
secret = context.get_secret("MY_SECRET_KEY")
|
||||
|
|
@ -28,13 +31,41 @@ def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of
|
|||
|
||||
return "The last 4 characters of the secret are: " + secret[-4:]
|
||||
|
||||
# To use this tool, you need to either set your ARCADE_API_KEY as an environment variable or
|
||||
# use the Arcade CLI (uv pip install arcade-mcp) and run 'arcade login' to authenticate.
|
||||
@app.tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_posts_in_subreddit(
|
||||
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
|
||||
) -> dict:
|
||||
"""Get posts from a specific subreddit"""
|
||||
# Normalize the subreddit name
|
||||
subreddit = subreddit.lower().replace("r/", "").replace(" ", "")
|
||||
|
||||
# Prepare the httpx request
|
||||
# OAuth token is injected into the context at runtime.
|
||||
# LLMs and MCP clients cannot see or access your OAuth tokens.
|
||||
oauth_token = context.get_auth_token_or_empty()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {oauth_token}",
|
||||
"User-Agent": "{{ toolkit_name }}-mcp-server",
|
||||
}
|
||||
params = {"limit": 5}
|
||||
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
|
||||
|
||||
# Make the request
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
# Return the response
|
||||
return response.json()
|
||||
|
||||
# Run with specific transport
|
||||
if __name__ == "__main__":
|
||||
# Get transport from command line argument, default to "stream"
|
||||
# Get transport from command line argument, default to "http"
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
|
||||
|
||||
# Run the server
|
||||
# - "https" (default): HTTPS streaming for Claude Desktop, Claude Code, Cursor
|
||||
# - "stdio": Standard I/O for VS Code and CLI tools
|
||||
# - "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)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ipaddress
|
|||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import traceback
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
|
@ -43,6 +44,7 @@ from pydantic import ValidationError
|
|||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.markdown import Markdown
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from typer.core import TyperGroup
|
||||
from typer.models import Context
|
||||
|
|
@ -78,6 +80,24 @@ class Provider(str, Enum):
|
|||
OPENAI = "openai"
|
||||
|
||||
|
||||
def handle_cli_error(
|
||||
message: str,
|
||||
error: Exception | None = None,
|
||||
debug: bool = True,
|
||||
should_exit: bool = True,
|
||||
) -> None:
|
||||
"""Handle CLI error reporting with optional debug traceback and exit."""
|
||||
if error and debug:
|
||||
console.print(f"❌ {message}: {traceback.format_exc()}", style="bold red")
|
||||
elif error:
|
||||
console.print(f"❌ {message}: {escape(str(error))}", style="bold red")
|
||||
else:
|
||||
console.print(f"❌ {message}", style="bold red")
|
||||
|
||||
if should_exit:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def create_cli_catalog(
|
||||
toolkit: str | None = None,
|
||||
show_toolkits: bool = False,
|
||||
|
|
@ -390,7 +410,11 @@ def validate_and_get_config(
|
|||
"""
|
||||
Validates the configuration, user, and returns the Config object
|
||||
"""
|
||||
from arcade_core.config import config
|
||||
|
||||
try:
|
||||
from arcade_core.config import config
|
||||
except Exception as e:
|
||||
handle_cli_error("Not logged in", e, debug=False)
|
||||
|
||||
if validate_api and (not config.api or not config.api.key):
|
||||
console.print(
|
||||
|
|
|
|||
|
|
@ -99,19 +99,24 @@ class Config(BaseConfig):
|
|||
config_file_path = cls.get_config_file_path()
|
||||
|
||||
if not config_file_path.exists():
|
||||
# Create a file using the default configuration
|
||||
default_config = cls.model_construct(api=ApiConfig.model_construct())
|
||||
default_config.save_to_file()
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found at {config_file_path}. "
|
||||
"Please run 'arcade login' to create your configuration."
|
||||
)
|
||||
|
||||
config_data = yaml.safe_load(config_file_path.read_text())
|
||||
|
||||
if config_data is None:
|
||||
raise ValueError(
|
||||
"Invalid credentials.yaml file. Please ensure it is a valid YAML file."
|
||||
"Invalid credentials.yaml file. Please ensure it is a valid YAML file or"
|
||||
"run `arcade logout`, then `arcade login` to start from a clean slate."
|
||||
)
|
||||
|
||||
if "cloud" not in config_data:
|
||||
raise ValueError("Invalid credentials.yaml file. Expected a 'cloud' key.")
|
||||
raise ValueError(
|
||||
"Invalid credentials.yaml file. Expected a 'cloud' key."
|
||||
"Run `arcade logout`, then `arcade login` to start from a clean slate."
|
||||
)
|
||||
|
||||
try:
|
||||
return cls(**config_data["cloud"])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-core"
|
||||
version = "2.5.0rc1"
|
||||
version = "2.5.0rc2"
|
||||
description = "Arcade Core - Core library for Arcade platform"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
|
|||
45
libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py
Normal file
45
libs/arcade-mcp-server/arcade_mcp_server/auth/__init__.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from arcade_core.auth import (
|
||||
Asana,
|
||||
Atlassian,
|
||||
ClickUp,
|
||||
Discord,
|
||||
Dropbox,
|
||||
GitHub,
|
||||
Google,
|
||||
Hubspot,
|
||||
Linear,
|
||||
LinkedIn,
|
||||
Microsoft,
|
||||
Notion,
|
||||
OAuth2,
|
||||
Reddit,
|
||||
Slack,
|
||||
Spotify,
|
||||
ToolAuthorization,
|
||||
Twitch,
|
||||
X,
|
||||
Zoom,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Asana",
|
||||
"Atlassian",
|
||||
"ClickUp",
|
||||
"Discord",
|
||||
"Dropbox",
|
||||
"GitHub",
|
||||
"Google",
|
||||
"Hubspot",
|
||||
"Linear",
|
||||
"LinkedIn",
|
||||
"Microsoft",
|
||||
"Notion",
|
||||
"OAuth2",
|
||||
"Reddit",
|
||||
"Slack",
|
||||
"Spotify",
|
||||
"ToolAuthorization",
|
||||
"Twitch",
|
||||
"X",
|
||||
"Zoom",
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from collections.abc import Awaitable
|
||||
from typing import Callable, ClassVar
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class AddTrailingSlashToPathMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that adds trailing slashes to specific paths.
|
||||
|
||||
Example:
|
||||
- /mcp -> /mcp/
|
||||
- /mcp/ -> /mcp/
|
||||
"""
|
||||
|
||||
PATHS_TO_ADD_SLASH: ClassVar[list[str]] = ["/mcp"]
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
path = request.scope["path"]
|
||||
if path in self.PATHS_TO_ADD_SLASH and not path.endswith("/"):
|
||||
request.scope["path"] = path + "/"
|
||||
return await call_next(request)
|
||||
|
|
@ -51,7 +51,7 @@ class MCPApp:
|
|||
# await app.prompts.add(prompt, handler)
|
||||
# await app.resources.add(resource)
|
||||
|
||||
app.run(host="127.0.0.1", port=7777)
|
||||
app.run(host="127.0.0.1", port=8000)
|
||||
```
|
||||
"""
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ class MCPApp:
|
|||
log_level: str = "INFO",
|
||||
transport: TransportType = "http",
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 7777,
|
||||
port: int = 8000,
|
||||
reload: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
|
|
@ -199,7 +199,7 @@ class MCPApp:
|
|||
def run(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 7777,
|
||||
port: int = 8000,
|
||||
reload: bool = False,
|
||||
transport: TransportType = "http",
|
||||
**kwargs: Any,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ from typing import Any, Callable, cast
|
|||
|
||||
from arcade_core.catalog import MaterializedTool, ToolCatalog
|
||||
from arcade_core.executor import ToolExecutor
|
||||
from arcade_core.schema import ToolAuthorizationContext, ToolContext
|
||||
from arcade_core.schema import ToolAuthRequirement as CoreToolAuthRequirement
|
||||
from arcade_core.schema import ToolContext
|
||||
from arcadepy import ArcadeError, AsyncArcade
|
||||
from arcadepy.types.auth_authorize_params import AuthRequirement, AuthRequirementOauth2
|
||||
|
||||
|
|
@ -198,6 +198,32 @@ class MCPServer:
|
|||
# Handler registration
|
||||
self._handlers = self._register_handlers()
|
||||
|
||||
def _load_config_values(self) -> tuple[str | None, str | None]:
|
||||
"""Load API key and user_id from credentials file.
|
||||
|
||||
Returns:
|
||||
Tuple of (api_key, user_id) from credentials file, or (None, None) if not available
|
||||
"""
|
||||
try:
|
||||
from arcade_core.config import config
|
||||
|
||||
api_key = config.api.key if config.api else None
|
||||
user_id = config.user.email if config.user else None
|
||||
|
||||
if api_key or user_id:
|
||||
config_path = config.get_config_file_path()
|
||||
if api_key:
|
||||
logger.info(f"Loaded Arcade API key from {config_path}")
|
||||
if user_id:
|
||||
logger.debug(f"Loaded user_id '{user_id}' from {config_path}")
|
||||
return api_key, user_id
|
||||
else:
|
||||
logger.debug("No API key or user_id found in credentials file")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load values from credentials file: {e}")
|
||||
return None, None
|
||||
|
||||
def _init_arcade_client(self, api_key: str | None, api_url: str | None) -> None:
|
||||
"""Initialize Arcade client for runtime authorization."""
|
||||
self.arcade: AsyncArcade | None = None
|
||||
|
|
@ -209,15 +235,8 @@ class MCPServer:
|
|||
|
||||
# If no API key provided, try to load from credentials file
|
||||
if not final_api_key:
|
||||
try:
|
||||
from arcade_core.config import get_config
|
||||
|
||||
config = get_config()
|
||||
final_api_key = config.api.key
|
||||
if final_api_key:
|
||||
logger.info("Loaded Arcade API key from ~/.arcade/credentials.yaml")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load credentials from file: {e}")
|
||||
config_api_key, _ = self._load_config_values()
|
||||
final_api_key = config_api_key
|
||||
|
||||
if final_api_key:
|
||||
logger.info(f"Using Arcade client with API URL: {api_url}")
|
||||
|
|
@ -595,17 +614,10 @@ class MCPServer:
|
|||
env = (self.settings.arcade.environment or "").lower()
|
||||
user_id = self.settings.arcade.user_id
|
||||
|
||||
# If no user_id from env, try config file (like we do for API key)
|
||||
# If no user_id from env, try credentials file
|
||||
if not user_id:
|
||||
try:
|
||||
from arcade_core.config import get_config
|
||||
|
||||
config = get_config()
|
||||
if config.user and config.user.email:
|
||||
user_id = config.user.email
|
||||
logger.debug(f"Context user_id set from config file: {user_id}")
|
||||
except Exception:
|
||||
logger.debug("Could not load user_id from config file")
|
||||
_, config_user_id = self._load_config_values()
|
||||
user_id = config_user_id
|
||||
|
||||
if user_id:
|
||||
tool_context.user_id = user_id
|
||||
|
|
@ -635,31 +647,17 @@ class MCPServer:
|
|||
# Create tool context
|
||||
tool_context = await self._create_tool_context(tool, session)
|
||||
|
||||
# Handle authorization and secrets requirements if required
|
||||
if missing_requirements_response := await self._check_tool_requirements(
|
||||
tool, tool_context, message, tool_name
|
||||
):
|
||||
return missing_requirements_response
|
||||
|
||||
# Attach tool_context to current model context for this request
|
||||
mctx = get_current_model_context()
|
||||
if mctx is not None:
|
||||
mctx.set_tool_context(tool_context)
|
||||
|
||||
# Handle authorization if required
|
||||
if tool.definition.requirements and tool.definition.requirements.authorization:
|
||||
auth_result = await self._check_authorization(tool, tool_context.user_id)
|
||||
if auth_result.status != "completed":
|
||||
tool_response = {
|
||||
"message": "The tool was not executed because it requires authorization. This is not an error, but the end user must click the link to complete the OAuth2 flow before the tool can be executed.",
|
||||
"llm_instructions": f"Please show the following link to the end user formatted as markdown: {auth_result.url} \nInform the end user that the tool requires their authorization to be completed before the tool can be executed.",
|
||||
"authorization_url": auth_result.url,
|
||||
}
|
||||
content = convert_to_mcp_content(tool_response)
|
||||
structured_content = convert_content_to_structured_content(tool_response)
|
||||
return JSONRPCResponse(
|
||||
id=message.id,
|
||||
result=CallToolResult(
|
||||
content=content,
|
||||
structuredContent=structured_content,
|
||||
isError=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Execute tool
|
||||
result = await ToolExecutor.run(
|
||||
func=tool.tool,
|
||||
|
|
@ -723,16 +721,108 @@ class MCPServer:
|
|||
error={"code": -32603, "message": "Internal error calling tool"},
|
||||
)
|
||||
|
||||
def _create_error_response(
|
||||
self, message: CallToolRequest, tool_response: dict[str, Any]
|
||||
) -> JSONRPCResponse[CallToolResult]:
|
||||
"""Create a consistent error response for tool requirement failures"""
|
||||
content = convert_to_mcp_content(tool_response)
|
||||
structured_content = convert_content_to_structured_content(tool_response)
|
||||
return JSONRPCResponse(
|
||||
id=message.id,
|
||||
result=CallToolResult(
|
||||
content=content,
|
||||
structuredContent=structured_content,
|
||||
isError=True,
|
||||
),
|
||||
)
|
||||
|
||||
async def _check_tool_requirements(
|
||||
self,
|
||||
tool: MaterializedTool,
|
||||
tool_context: ToolContext,
|
||||
message: CallToolRequest,
|
||||
tool_name: str,
|
||||
) -> JSONRPCResponse[CallToolResult] | None:
|
||||
"""Check tool requirements before executing the tool"""
|
||||
# Check authorization
|
||||
if tool.definition.requirements and tool.definition.requirements.authorization:
|
||||
# First check if Arcade API key is configured
|
||||
if not self.arcade:
|
||||
tool_response = {
|
||||
"message": f"Tool '{tool_name}' cannot be executed because it requires authorization but no Arcade API key is configured.",
|
||||
"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. "
|
||||
"Once the API key is configured, restart the MCP server for the changes to take effect."
|
||||
),
|
||||
}
|
||||
return self._create_error_response(message, tool_response)
|
||||
|
||||
# Check authorization status
|
||||
try:
|
||||
auth_result = await self._check_authorization(tool, tool_context.user_id)
|
||||
if auth_result.status != "completed":
|
||||
tool_response = {
|
||||
"message": "The tool was not executed because it requires authorization. This is not an error, but the end user must click the link to complete the OAuth2 flow before the tool can be executed.",
|
||||
"llm_instructions": f"Please show the following link to the end user formatted as markdown: {auth_result.url} \nInform the end user that the tool requires their authorization to be completed before the tool can be executed.",
|
||||
"authorization_url": auth_result.url,
|
||||
}
|
||||
return self._create_error_response(message, tool_response)
|
||||
# Inject the authorization token into the tool context
|
||||
tool_context.authorization = ToolAuthorizationContext(
|
||||
token=auth_result.context.token,
|
||||
user_info=auth_result.context.user_info
|
||||
if auth_result.context.user_info
|
||||
else {},
|
||||
)
|
||||
except ToolRuntimeError as e:
|
||||
# Handle any other authorization errors
|
||||
tool_response = {
|
||||
"message": f"Tool '{tool_name}' cannot be executed due to an authorization error: {e}",
|
||||
"llm_instructions": f"The '{tool_name}' tool failed authorization. Error: {e}",
|
||||
}
|
||||
return self._create_error_response(message, tool_response)
|
||||
|
||||
# Check secrets
|
||||
if tool.definition.requirements and tool.definition.requirements.secrets:
|
||||
missing_secrets = []
|
||||
for secret_requirement in tool.definition.requirements.secrets:
|
||||
try:
|
||||
tool_context.get_secret(secret_requirement.key)
|
||||
except ValueError:
|
||||
missing_secrets.append(secret_requirement.key)
|
||||
if missing_secrets:
|
||||
missing_secrets_str = ", ".join(missing_secrets)
|
||||
tool_response = {
|
||||
"message": f"Tool '{tool_name}' cannot be executed because it requires the following secrets that are not available: {missing_secrets_str}",
|
||||
"llm_instructions": (
|
||||
f"The MCP server is missing required secrets for the '{tool_name}' tool. "
|
||||
f"The developer needs to provide these secrets by either: "
|
||||
f"1) Adding them to a .env file in the server's working directory (e.g., {missing_secrets[0]}=your_secret_value), "
|
||||
f"2) Setting them as environment variables before starting the server (e.g., export {missing_secrets[0]}=your_secret_value). "
|
||||
"Once the secrets are configured, restart the MCP server for the changes to take effect."
|
||||
),
|
||||
}
|
||||
return self._create_error_response(message, tool_response)
|
||||
|
||||
return None
|
||||
|
||||
async def _check_authorization(
|
||||
self,
|
||||
tool: MaterializedTool,
|
||||
user_id: str | None = None,
|
||||
) -> Any:
|
||||
"""Check tool authorization."""
|
||||
"""Check tool authorization.
|
||||
|
||||
Note: This method assumes self.arcade is not None. The caller should
|
||||
check for the presence of the Arcade API key before calling this method.
|
||||
"""
|
||||
if not self.arcade:
|
||||
raise ToolRuntimeError(
|
||||
"Authorization required but Arcade API Key is not configured. "
|
||||
"Set ARCADE_API_KEY as environment variable or run 'arcade login'."
|
||||
"Authorization check called without Arcade API key configured. "
|
||||
"This should be checked by the caller."
|
||||
)
|
||||
|
||||
req = tool.definition.requirements.authorization
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from loguru import logger
|
|||
from starlette.responses import Response
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from arcade_mcp_server.fastapi.middleware import AddTrailingSlashToPathMiddleware
|
||||
from arcade_mcp_server.server import MCPServer
|
||||
from arcade_mcp_server.settings import MCPSettings
|
||||
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
|
||||
|
|
@ -124,6 +125,7 @@ def create_arcade_mcp(
|
|||
**kwargs,
|
||||
)
|
||||
otel_handler.instrument_app(app)
|
||||
app.add_middleware(AddTrailingSlashToPathMiddleware)
|
||||
|
||||
# Worker endpoints
|
||||
worker = FastAPIWorker(
|
||||
|
|
@ -261,7 +263,7 @@ def create_arcade_mcp_factory() -> FastAPI:
|
|||
def run_arcade_mcp(
|
||||
catalog: ToolCatalog,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 7777,
|
||||
port: int = 8000,
|
||||
reload: bool = False,
|
||||
debug: bool = False,
|
||||
otel_enable: bool = False,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,17 @@ The stdio (standard input/output) transport is used for direct client connection
|
|||
|
||||
### Usage
|
||||
|
||||
**Recommended: Using Arcade CLI**
|
||||
|
||||
```bash
|
||||
# Run with stdio transport
|
||||
arcade mcp stdio
|
||||
```
|
||||
|
||||
**Alternative: Direct Python**
|
||||
|
||||
```bash
|
||||
# Using the module directly
|
||||
python -m arcade_mcp_server stdio
|
||||
|
||||
# Or with MCPApp
|
||||
|
|
@ -24,14 +33,20 @@ app.run(transport="stdio")
|
|||
|
||||
### Client Configuration
|
||||
|
||||
For Claude Desktop, configure in `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
For Claude Desktop, use the `arcade configure` command:
|
||||
|
||||
```bash
|
||||
arcade configure claude --from-local
|
||||
```
|
||||
|
||||
Or manually edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-tools": {
|
||||
"command": "python",
|
||||
"args": ["-m", "arcade_mcp_server", "stdio"],
|
||||
"command": "arcade",
|
||||
"args": ["mcp", "stdio"],
|
||||
"cwd": "/path/to/your/tools"
|
||||
}
|
||||
}
|
||||
|
|
@ -51,12 +66,21 @@ The HTTP transport provides REST/SSE endpoints for web-based clients.
|
|||
|
||||
### Usage
|
||||
|
||||
**Recommended: Using Arcade CLI**
|
||||
|
||||
```bash
|
||||
# Run with HTTP transport (default)
|
||||
python -m arcade_mcp_server
|
||||
arcade mcp
|
||||
|
||||
# With specific host and port
|
||||
python -m arcade_mcp_server --host 0.0.0.0 --port 8080
|
||||
arcade mcp --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
**Alternative: Direct Python**
|
||||
|
||||
```bash
|
||||
# Using the module directly
|
||||
python -m arcade_mcp_server
|
||||
|
||||
# Or with MCPApp
|
||||
app.run(transport="http", host="0.0.0.0", port=8080)
|
||||
|
|
@ -73,9 +97,11 @@ When running in HTTP mode, the server provides:
|
|||
|
||||
### Development Features
|
||||
|
||||
**With Arcade CLI:**
|
||||
|
||||
```bash
|
||||
# Enable hot reload and debug mode
|
||||
python -m arcade_mcp_server --reload --debug
|
||||
arcade mcp --reload --debug
|
||||
|
||||
# This enables:
|
||||
# - Automatic restart on code changes
|
||||
|
|
|
|||
|
|
@ -1,24 +1,29 @@
|
|||
# CLI
|
||||
# arcade mcp Command
|
||||
|
||||
The `arcade_mcp_server` CLI is a simple tool for running MCP servers.
|
||||
The `arcade mcp` command is the recommended way to run MCP servers. It automatically discovers tools in your project, creates a server, and runs it with your chosen transport.
|
||||
|
||||
It is used to discover tools and run the server.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv pip install arcade-mcp
|
||||
```
|
||||
|
||||
The `arcade-mcp` package includes the CLI and the `arcade-mcp-server` library.
|
||||
|
||||
## Command Line Options
|
||||
|
||||
```
|
||||
usage: python -m arcade_mcp_server [-h] [--host HOST] [--port PORT]
|
||||
[--tool-package PACKAGE] [--discover-installed]
|
||||
[--show-packages] [--reload] [--debug]
|
||||
[--env-file ENV_FILE] [--name NAME] [--version VERSION]
|
||||
[transport]
|
||||
usage: arcade mcp [-h] [--host HOST] [--port PORT]
|
||||
[--tool-package PACKAGE] [--discover-installed]
|
||||
[--show-packages] [--reload] [--debug]
|
||||
[--env-file ENV_FILE] [--name NAME] [--version VERSION]
|
||||
[--cwd CWD]
|
||||
[transport]
|
||||
|
||||
Run Arcade MCP Server
|
||||
|
||||
positional arguments:
|
||||
transport Transport type: stdio, http, streamable-http (default: http)
|
||||
transport Transport type: stdio, http (default: http)
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
|
|
@ -34,6 +39,23 @@ optional arguments:
|
|||
--env-file ENV_FILE Path to environment file
|
||||
--name NAME Server name
|
||||
--version VERSION Server version
|
||||
--cwd CWD Working directory to run from
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Run HTTP server (default)
|
||||
arcade mcp
|
||||
|
||||
# Run stdio server (for Claude Desktop, Cursor, etc.)
|
||||
arcade mcp stdio
|
||||
|
||||
# Run with debug logging
|
||||
arcade mcp --debug
|
||||
|
||||
# Run with hot reload (development mode)
|
||||
arcade mcp --reload --debug
|
||||
```
|
||||
|
||||
## Tool Discovery
|
||||
|
|
@ -63,10 +85,10 @@ Load specific arcade packages installed in your environment:
|
|||
|
||||
```bash
|
||||
# Load arcade-github package
|
||||
python -m arcade_mcp_server --tool-package github
|
||||
arcade mcp --tool-package github
|
||||
|
||||
# Load custom package (tries arcade_ prefix first)
|
||||
python -m arcade_mcp_server -p mycompany_tools
|
||||
arcade mcp -p mycompany_tools
|
||||
```
|
||||
|
||||
### 3. Discover All Installed
|
||||
|
|
@ -75,10 +97,10 @@ Find and load all arcade packages in your Python environment:
|
|||
|
||||
```bash
|
||||
# Load all arcade packages
|
||||
python -m arcade_mcp_server --discover-installed
|
||||
arcade mcp --discover-installed
|
||||
|
||||
# Show what's being loaded
|
||||
python -m arcade_mcp_server --discover-installed --show-packages
|
||||
arcade mcp --discover-installed --show-packages
|
||||
```
|
||||
|
||||
### Example Tool File
|
||||
|
|
@ -101,5 +123,15 @@ def add(a: int, b: int) -> int:
|
|||
|
||||
Then run:
|
||||
```bash
|
||||
python -m arcade_mcp_server # Auto-discovers and loads these tools
|
||||
arcade mcp # Auto-discovers and loads these tools
|
||||
```
|
||||
|
||||
## Alternative: Direct Python Usage
|
||||
|
||||
While we recommend using `arcade mcp`, you can also run the server module directly:
|
||||
|
||||
```bash
|
||||
python -m arcade_mcp_server [options]
|
||||
```
|
||||
|
||||
This provides the same functionality but without the benefits of the Arcade CLI ecosystem (like `arcade configure` for client setup).
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ app = MCPApp(name="my_server", version="1.0.0")
|
|||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
app.run(host="127.0.0.1", port=7777)
|
||||
app.run(host="127.0.0.1", port=8000)
|
||||
```
|
||||
|
||||
#### Class Reference
|
||||
|
|
@ -38,7 +38,7 @@ def echo(text: str) -> str:
|
|||
|
||||
if __name__ == "__main__":
|
||||
# Start an HTTP server (good for local development/testing)
|
||||
app.run(host="0.0.0.0", port=7777, reload=False, debug=True)
|
||||
app.run(host="0.0.0.0", port=8000, reload=False, debug=True)
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async def run_http():
|
|||
server = MCPServer(catalog=catalog)
|
||||
await server._start()
|
||||
try:
|
||||
transport = HTTPStreamableTransport(host="0.0.0.0", port=7777)
|
||||
transport = HTTPStreamableTransport(host="0.0.0.0", port=8000)
|
||||
await transport.run(server)
|
||||
finally:
|
||||
await server._stop()
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,18 +2,34 @@
|
|||
|
||||
This directory contains examples demonstrating how to build MCP servers with your Arcade tools.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The easiest way to get started is with the `arcade new` command:
|
||||
|
||||
```bash
|
||||
# Install the Arcade CLI
|
||||
uv pip install arcade-mcp
|
||||
|
||||
# Create a new server with example tools
|
||||
arcade new my_server
|
||||
cd my_server
|
||||
|
||||
# Run the server
|
||||
arcade mcp
|
||||
```
|
||||
|
||||
## Examples Overview
|
||||
|
||||
### Basic Examples
|
||||
|
||||
1. **[00_hello_world.py](00_hello_world.py)** – Minimal tool example
|
||||
- Single `@tool` function showing the basics
|
||||
- Run: `python -m arcade_mcp_server` (or `python -m arcade_mcp_server stdio`)
|
||||
- Run: `arcade mcp` (or `arcade mcp stdio`)
|
||||
|
||||
2. **[01_tools.py](01_tools.py)** – Creating tools and discovery
|
||||
- Simple parameters, lists, and `TypedDict`
|
||||
- How arcade_mcp_server discovers tools automatically
|
||||
- Run: `python -m arcade_mcp_server`
|
||||
- How the server discovers tools automatically
|
||||
- Run: `arcade mcp`
|
||||
|
||||
3. **[02_building_apps.py](02_building_apps.py)** – Building apps with MCPApp
|
||||
- Create an `MCPApp`, register tools with `@app.tool`
|
||||
|
|
@ -22,11 +38,11 @@ This directory contains examples demonstrating how to build MCP servers with you
|
|||
|
||||
4. **[03_context.py](03_context.py)** – Using `Context`
|
||||
- Access secrets, logging, and user context
|
||||
- Run: `python -m arcade_mcp_server`
|
||||
- Run: `arcade mcp`
|
||||
|
||||
5. **[04_tool_secrets.py](04_tool_secrets.py)** – Working with secrets
|
||||
- Use `requires_secrets` and access masked values
|
||||
- Run: `python -m arcade_mcp_server`
|
||||
- Run: `arcade mcp`
|
||||
|
||||
6. **[05_logging.py](05_logging.py)** – Logging with MCP
|
||||
- Demonstrates debug/info/warning/error levels and structured logs
|
||||
|
|
@ -34,21 +50,37 @@ This directory contains examples demonstrating how to build MCP servers with you
|
|||
|
||||
## Running Examples
|
||||
|
||||
Most examples can be run directly with the arcade_mcp_server CLI:
|
||||
### Recommended: Using the Arcade CLI
|
||||
|
||||
Most examples can be run with the `arcade mcp` command:
|
||||
|
||||
```bash
|
||||
# Auto-discover tools in current directory
|
||||
python -m arcade_mcp_server
|
||||
arcade mcp
|
||||
|
||||
# With specific transport
|
||||
python -m arcade_mcp_server stdio # For Claude Desktop
|
||||
python -m arcade_mcp_server # HTTP by default
|
||||
arcade mcp stdio # For Claude Desktop
|
||||
arcade mcp # HTTP by default
|
||||
|
||||
# With debugging
|
||||
python -m arcade_mcp_server --debug
|
||||
arcade mcp --debug
|
||||
|
||||
# With hot reload (HTTP only)
|
||||
python -m arcade_mcp_server --reload
|
||||
arcade mcp --reload
|
||||
```
|
||||
|
||||
For MCPApp examples, run the script directly to start an HTTP server.
|
||||
### Alternative: Direct Python Execution
|
||||
|
||||
For MCPApp examples, you can run the script directly:
|
||||
|
||||
```bash
|
||||
python 02_building_apps.py
|
||||
```
|
||||
|
||||
Or use the server module directly:
|
||||
|
||||
```bash
|
||||
python -m arcade_mcp_server
|
||||
```
|
||||
|
||||
**Note:** We recommend using `arcade mcp` for a better development experience.
|
||||
|
|
|
|||
|
|
@ -1,57 +1,103 @@
|
|||
# Quick Start
|
||||
|
||||
The `arcade_mcp_server` package provides powerful ways to run MCP servers with your Arcade tools.
|
||||
The `arcade_mcp_server` package provides powerful ways to run MCP servers with your Arcade tools. While you can use the server library directly, **we recommend using the Arcade CLI** for a streamlined development experience.
|
||||
|
||||
## Getting Started
|
||||
## Recommended: Quick Start with Arcade CLI
|
||||
|
||||
### Install
|
||||
### 1. Install the CLI
|
||||
|
||||
```bash
|
||||
uv pip install arcade-mcp
|
||||
```
|
||||
|
||||
The `arcade-mcp` package includes both the CLI tools and the `arcade-mcp-server` library.
|
||||
|
||||
### 2. Create a New Server
|
||||
|
||||
Start with a pre-configured server that includes example tools:
|
||||
|
||||
```bash
|
||||
arcade new my_server
|
||||
cd my_server
|
||||
```
|
||||
|
||||
This creates a starter MCP server with three example tools:
|
||||
- **Simple tool** - A basic greeting function
|
||||
- **Secret-based tool** - Demonstrates using environment secrets
|
||||
- **OAuth tool** - Shows user authentication flow (requires `arcade login`)
|
||||
|
||||
### 3. Run Your Server
|
||||
|
||||
```bash
|
||||
# Run HTTP server (default, great for development)
|
||||
arcade mcp
|
||||
|
||||
# Or run stdio server (for Claude Desktop, Cursor, etc.)
|
||||
arcade mcp stdio
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
|
||||
```text
|
||||
DEBUG | 11:43:11 | arcade_mcp_server.mcp_app:169 | Added tool: greet
|
||||
INFO | 11:43:11 | arcade_mcp_server.mcp_app:211 | Starting server v1.0.0 with 3 tools
|
||||
INFO: Started server process [89481]
|
||||
INFO: Waiting for application startup.
|
||||
INFO | 11:43:12 | arcade_mcp_server.worker:69 | MCP server started and ready for connections
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
View your server's API docs at http://127.0.0.1:8000/docs.
|
||||
|
||||
### 4. Configure MCP Clients
|
||||
|
||||
Connect your server to AI assistants:
|
||||
|
||||
```bash
|
||||
# Configure Claude Desktop
|
||||
arcade configure claude --from-local
|
||||
|
||||
# Configure Cursor IDE
|
||||
arcade configure cursor --from-local
|
||||
|
||||
# Configure VS Code
|
||||
arcade configure vscode --from-local
|
||||
```
|
||||
|
||||
That's it! Your MCP server is running and connected to your AI assistant.
|
||||
|
||||
## Alternative: Direct Python Approach
|
||||
|
||||
If you prefer to use the library directly without the CLI, you can install just the server package:
|
||||
|
||||
```bash
|
||||
uv pip install arcade-mcp-server
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
uv run python -m arcade_mcp_server
|
||||
```
|
||||
|
||||
### Write a tool
|
||||
|
||||
### Write a Tool
|
||||
|
||||
```python
|
||||
from arcade_mcp_server import tool
|
||||
from typing import Annotated
|
||||
|
||||
@tool
|
||||
def greet(Annotated[str, "The name to greet"]) -> Annotated[str, "The greeting"]:
|
||||
def greet(name: Annotated[str, "The name to greet"]) -> Annotated[str, "The greeting"]:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
### Run MCP Server
|
||||
### Run the Server
|
||||
|
||||
You can run the server directly with Python:
|
||||
|
||||
```bash
|
||||
uv run python -m arcade_mcp_server
|
||||
# Using the module directly
|
||||
python -m arcade_mcp_server
|
||||
|
||||
# Or if you have a server.py file with MCPApp
|
||||
python server.py
|
||||
```
|
||||
|
||||
You should see the following output:
|
||||
|
||||
```text
|
||||
INFO | 03:32:05 | Auto-discovering tools from current directory
|
||||
INFO | 03:32:05 | Found 1 tool(s) in 00_hello_world.py: greet
|
||||
INFO: Started server process
|
||||
INFO: Waiting for application startup.
|
||||
INFO | 03:32:05 | Starting MCP server with HTTP transport on 127.0.0.1:7777
|
||||
INFO | 03:32:05 | Starting MCP server: ArcadeMCP
|
||||
INFO | 03:32:05 | HTTP session manager started
|
||||
INFO | 03:32:05 | MCP server started and ready for connections
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:7777 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
View the docs at http://127.0.0.1:7777/docs.
|
||||
|
||||
That's it! You've created an MCP server with a tool.
|
||||
|
||||
Check out the [CLI](../api/cli.md) for more options and [Clients](../clients/README.md) for how to use the server with different clients like Claude Desktop, Cursor, and VSCode.
|
||||
**Note:** While this approach works, we recommend using `arcade mcp` for a better development experience with features like easy client configuration and starter templates.
|
||||
|
||||
|
||||
## Building MCP Servers
|
||||
|
|
@ -89,22 +135,20 @@ if __name__ == "__main__":
|
|||
app.run(host="0.0.0.0", port=8080, reload=True)
|
||||
```
|
||||
|
||||
## `arcade_mcp_server` CLI
|
||||
## Using the `arcade mcp` Command
|
||||
|
||||
The `arcade_mcp_server` CLI is a simple tool for running MCP servers automatically discovering tools, creating a server for you, and running it.
|
||||
|
||||
This is primarily used for development, and running mcp servers locally for desktop clients with stdio.
|
||||
The `arcade mcp` command provides a simple interface for running MCP servers. It automatically discovers tools, creates a server, and runs it with your chosen transport.
|
||||
|
||||
### Auto-Discovery Mode
|
||||
|
||||
The simplest way to run is to let arcade_mcp_server discover tools in your current directory:
|
||||
The simplest way to run is to let the server discover tools in your current directory:
|
||||
|
||||
```bash
|
||||
# Auto-discover @tool decorated functions
|
||||
python -m arcade_mcp_server
|
||||
arcade mcp
|
||||
|
||||
# With stdio transport for Claude Desktop
|
||||
python -m arcade_mcp_server stdio
|
||||
arcade mcp stdio
|
||||
```
|
||||
|
||||
### Loading Installed Packages
|
||||
|
|
@ -113,14 +157,14 @@ Load specific arcade packages or discover all installed ones:
|
|||
|
||||
```bash
|
||||
# Load a specific arcade package
|
||||
python -m arcade_mcp_server --tool-package github
|
||||
python -m arcade_mcp_server -p slack
|
||||
arcade mcp --tool-package github
|
||||
arcade mcp -p slack
|
||||
|
||||
# Discover all installed arcade packages
|
||||
python -m arcade_mcp_server --discover-installed
|
||||
arcade mcp --discover-installed
|
||||
|
||||
# Show which packages are being loaded
|
||||
python -m arcade_mcp_server --discover-installed --show-packages
|
||||
arcade mcp --discover-installed --show-packages
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
|
@ -129,13 +173,13 @@ For active development with hot reload:
|
|||
|
||||
```bash
|
||||
# Run with hot reload and debug logging
|
||||
python -m arcade_mcp_server --reload --debug
|
||||
arcade mcp --reload --debug
|
||||
|
||||
# Specify host and port
|
||||
python -m arcade_mcp_server --host 0.0.0.0 --port 8080
|
||||
arcade mcp --host 0.0.0.0 --port 8000
|
||||
|
||||
# Load environment variables
|
||||
python -m arcade_mcp_server --env-file .env
|
||||
arcade mcp --env-file .env
|
||||
```
|
||||
|
||||
|
||||
|
|
@ -168,7 +212,7 @@ DATABASE_URL="postgresql://..."
|
|||
Use `--reload --debug` for development to automatically restart on code changes:
|
||||
|
||||
```bash
|
||||
python -m arcade_mcp_server --reload --debug
|
||||
arcade mcp --reload --debug
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
|
@ -180,3 +224,10 @@ python -m arcade_mcp_server --reload --debug
|
|||
With HTTP transport and debug mode, access API documentation at:
|
||||
- http://localhost:8000/docs (Swagger UI)
|
||||
- http://localhost:8000/redoc (ReDoc)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Check out the [Examples](../examples/README.md) for detailed tutorials
|
||||
- Learn about [Client Integration](../clients/claude.md) with Claude Desktop, Cursor, and VS Code
|
||||
- Explore the [MCPApp API](../api/mcp_app.md) for advanced server customization
|
||||
- Read about [Transport Modes](../advanced/transports.md) (stdio vs HTTP)
|
||||
|
|
|
|||
|
|
@ -27,11 +27,41 @@ Arcade MCP (Model Context Protocol) enables AI assistants and development tools
|
|||
|
||||
### Installation
|
||||
|
||||
We recommend installing the `arcade-mcp` CLI package, which includes `arcade-mcp-server` and provides a streamlined development workflow:
|
||||
|
||||
```bash
|
||||
pip install arcade-mcp-server
|
||||
uv pip install arcade-mcp
|
||||
```
|
||||
|
||||
### Create Your First Tool
|
||||
Or install just the server library if you prefer a direct Python approach:
|
||||
|
||||
```bash
|
||||
uv pip install arcade-mcp-server
|
||||
```
|
||||
|
||||
### Quick Start: Create a New Server (Recommended)
|
||||
|
||||
The fastest way to get started is with the `arcade new` command, which creates a starter MCP server with example tools:
|
||||
|
||||
```bash
|
||||
# Create a new server project
|
||||
arcade new my_server
|
||||
|
||||
# Navigate to the project
|
||||
cd my_server
|
||||
|
||||
# Run the server
|
||||
arcade mcp
|
||||
```
|
||||
|
||||
The generated server includes three example tools:
|
||||
- **Simple tool** - A basic function to get you started
|
||||
- **Secret-based tool** - Shows how to use environment secrets
|
||||
- **OAuth tool** - Demonstrates how to use a OAuth tool (requires `arcade login`)
|
||||
|
||||
### Manual Setup: Create Your First Tool
|
||||
|
||||
If you prefer to create tools manually, you can use the `MCPApp` interface:
|
||||
|
||||
```python
|
||||
from arcade_mcp_server import MCPApp
|
||||
|
|
@ -48,19 +78,44 @@ if __name__ == "__main__":
|
|||
app.run()
|
||||
```
|
||||
|
||||
### Run Your Server
|
||||
### Running Your Server
|
||||
|
||||
**Recommended: Use the Arcade CLI**
|
||||
|
||||
```bash
|
||||
# For development
|
||||
python my_tools.py
|
||||
# Run HTTP server (default)
|
||||
arcade mcp
|
||||
|
||||
# For Claude Desktop
|
||||
python -m arcade_mcp_server stdio
|
||||
# Run stdio server (for Claude Desktop, Cursor, etc.)
|
||||
arcade mcp stdio
|
||||
|
||||
# For HTTP clients
|
||||
python -m arcade_mcp_server --host 0.0.0.0 --port 8080
|
||||
# Run with debug logging and hot reload
|
||||
arcade mcp --debug --reload
|
||||
```
|
||||
|
||||
**Alternative: Direct Python execution**
|
||||
|
||||
```bash
|
||||
# Run your server.py file directly
|
||||
python server.py
|
||||
```
|
||||
|
||||
### Configure MCP Clients
|
||||
|
||||
Once your server is running, connect it to your favorite AI assistant:
|
||||
|
||||
```bash
|
||||
# Configure Claude Desktop (configures for stdio)
|
||||
arcade configure claude --from-local
|
||||
|
||||
# Configure Cursor (configures for http streamable)
|
||||
arcade configure cursor --from-local
|
||||
|
||||
# Configure VS Code (configures for http streamable)
|
||||
arcade configure vscode --from-local
|
||||
```
|
||||
|
||||
|
||||
## Client Integration
|
||||
|
||||
Connect your MCP server with AI assistants and development tools:
|
||||
|
|
@ -80,7 +135,7 @@ Connect your MCP server with AI assistants and development tools:
|
|||
## Community
|
||||
|
||||
- [GitHub Repository](https://github.com/ArcadeAI/arcade-mcp)
|
||||
- [Discord Community](https://discord.gg/arcade-mcp)
|
||||
- [Discord Community](https://discord.com/invite/GUZEMpEZ9p)
|
||||
- [Documentation](https://docs.arcade.dev)
|
||||
|
||||
## License
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "arcade-mcp-server"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Arcade.dev" }]
|
||||
|
|
@ -21,9 +21,9 @@ classifiers = [
|
|||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-core>=2.5.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-core>=2.5.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"arcadepy>=1.5.0",
|
||||
"pydantic>=2.0.0",
|
||||
"fastapi>=0.100.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-serve"
|
||||
version = "2.2.0rc1"
|
||||
version = "2.2.0rc2"
|
||||
description = "Arcade Serve - Serving infrastructure for Arcade tools and workers"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
@ -19,7 +19,7 @@ classifiers = [
|
|||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-core>=2.5.0rc1,<3.0.0",
|
||||
"arcade-core>=2.5.0rc2,<3.0.0",
|
||||
"fastapi>=0.115.3",
|
||||
"uvicorn>=0.30.0",
|
||||
"watchfiles>=1.0.5",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-tdk"
|
||||
version = "2.6.0rc1"
|
||||
version = "2.6.0rc2"
|
||||
description = "Arcade TDK - Toolkit Development Kit for building Arcade tools"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
@ -19,7 +19,7 @@ classifiers = [
|
|||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-core>=2.5.0rc1,<3.0.0",
|
||||
"arcade-core>=2.5.0rc2,<3.0.0",
|
||||
"pydantic>=2.7.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import contextlib
|
|||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from arcade_core.errors import ToolRuntimeError
|
||||
from arcade_core.schema import (
|
||||
ToolAuthRequirement,
|
||||
ToolContext,
|
||||
ToolRequirements,
|
||||
ToolSecretRequirement,
|
||||
)
|
||||
from arcade_mcp_server.middleware import Middleware
|
||||
from arcade_mcp_server.server import MCPServer
|
||||
from arcade_mcp_server.session import InitializationState
|
||||
|
|
@ -162,6 +169,10 @@ class TestMCPServer:
|
|||
async def test_handle_call_tool_with_requires_auth(self, mcp_server):
|
||||
"""Test tool call request handling with authorization."""
|
||||
|
||||
# Mock arcade client so the server thinks API key is configured
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
mock_auth_response = Mock()
|
||||
mock_auth_response.status = "pending"
|
||||
mock_auth_response.url = "https://example.com/auth"
|
||||
|
|
@ -187,6 +198,33 @@ class TestMCPServer:
|
|||
assert "message" in response.result.structuredContent
|
||||
assert "authorization" in response.result.structuredContent["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_call_tool_with_requires_auth_no_api_key(self, mcp_server):
|
||||
"""Test tool call request handling with authorization when no Arcade API key is configured."""
|
||||
|
||||
# Ensure no arcade client is configured
|
||||
mcp_server.arcade = None
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=3,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.sample_tool_with_auth", "arguments": {"text": "Hello"}},
|
||||
)
|
||||
|
||||
response = await mcp_server._handle_call_tool(message)
|
||||
|
||||
assert isinstance(response, JSONRPCResponse)
|
||||
assert response.id == 3
|
||||
assert isinstance(response.result, CallToolResult)
|
||||
assert response.result.structuredContent is not None
|
||||
assert "message" in response.result.structuredContent
|
||||
assert (
|
||||
"requires authorization but no Arcade API key is configured"
|
||||
in response.result.structuredContent["message"]
|
||||
)
|
||||
assert "ARCADE_API_KEY" in response.result.structuredContent["llm_instructions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_call_tool_not_found(self, mcp_server):
|
||||
"""Test calling a non-existent tool."""
|
||||
|
|
@ -364,8 +402,6 @@ class TestMCPServer:
|
|||
@pytest.mark.asyncio
|
||||
async def test_authorization_check(self, mcp_server):
|
||||
"""Test tool authorization checking."""
|
||||
# Create a tool that requires auth
|
||||
from arcade_core.schema import ToolAuthRequirement
|
||||
|
||||
# Ensure the arcade client is not configured in the case that the test environment
|
||||
# unintentionally has the ARCADE_API_KEY set
|
||||
|
|
@ -380,4 +416,395 @@ class TestMCPServer:
|
|||
with pytest.raises(Exception) as exc_info:
|
||||
await mcp_server._check_authorization(tool)
|
||||
|
||||
assert "Authorization required but Arcade API Key is not configured" in str(exc_info.value)
|
||||
assert "Authorization check called without Arcade API key configured" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_no_requirements(self, mcp_server, materialized_tool):
|
||||
"""Test tool requirements checking when tool has no requirements."""
|
||||
|
||||
# Create a tool with no requirements
|
||||
tool = materialized_tool
|
||||
tool.definition.requirements = None
|
||||
|
||||
tool_context = ToolContext()
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.test_tool", "arguments": {"text": "Hello"}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.test_tool"
|
||||
)
|
||||
|
||||
# Should return None when no requirements because this means the tool can be executed
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_auth_no_arcade_client(self, mcp_server):
|
||||
"""Test tool requirements checking when tool requires auth but no Arcade client configured."""
|
||||
|
||||
# Ensure no arcade client is configured
|
||||
mcp_server.arcade = None
|
||||
|
||||
# Create a tool that requires authorization
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
)
|
||||
)
|
||||
|
||||
tool_context = ToolContext()
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.auth_tool"
|
||||
)
|
||||
|
||||
# Should return error response
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert (
|
||||
"requires authorization but no Arcade API key is configured"
|
||||
in result.result.structuredContent["message"]
|
||||
)
|
||||
assert "ARCADE_API_KEY" in result.result.structuredContent["llm_instructions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_auth_pending(self, mcp_server):
|
||||
"""Test tool requirements checking when authorization is pending."""
|
||||
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
# Create a tool that requires authorization
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
)
|
||||
)
|
||||
|
||||
mock_auth_response = Mock()
|
||||
mock_auth_response.status = "pending"
|
||||
mock_auth_response.url = "https://example.com/auth"
|
||||
|
||||
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
||||
|
||||
tool_context = ToolContext()
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.auth_tool"
|
||||
)
|
||||
|
||||
# Should return error response with authorization URL
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert "authorization_url" in result.result.structuredContent
|
||||
assert result.result.structuredContent["authorization_url"] == "https://example.com/auth"
|
||||
assert "requires authorization" in result.result.structuredContent["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_auth_completed(self, mcp_server):
|
||||
"""Test tool requirements checking when authorization is completed."""
|
||||
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
# Create a tool that requires authorization
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
)
|
||||
)
|
||||
|
||||
# Mock authorization response as completed
|
||||
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 = {"user_id": "test-user"}
|
||||
|
||||
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
||||
|
||||
tool_context = ToolContext()
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.auth_tool"
|
||||
)
|
||||
|
||||
# Should return None (no error) and set authorization context
|
||||
assert result is None
|
||||
assert tool_context.authorization is not None
|
||||
assert tool_context.authorization.token == "test-token"
|
||||
assert tool_context.authorization.user_info == {"user_id": "test-user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_auth_error(self, mcp_server):
|
||||
"""Test tool requirements checking when authorization fails."""
|
||||
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
# Create a tool that requires authorization
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
)
|
||||
)
|
||||
|
||||
# Mock authorization to raise an error
|
||||
mcp_server._check_authorization = AsyncMock(side_effect=ToolRuntimeError("Auth failed"))
|
||||
|
||||
tool_context = ToolContext()
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.auth_tool"
|
||||
)
|
||||
|
||||
# Should return error response
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert "authorization error" in result.result.structuredContent["message"]
|
||||
assert "Auth failed" in result.result.structuredContent["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_secrets_missing(self, mcp_server):
|
||||
"""Test tool requirements checking when required secrets are missing."""
|
||||
|
||||
# Create a tool that requires secrets
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
secrets=[
|
||||
ToolSecretRequirement(key="API_KEY"),
|
||||
ToolSecretRequirement(key="DATABASE_URL"),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock tool context to raise ValueError for missing secrets
|
||||
tool_context = Mock(spec=ToolContext)
|
||||
tool_context.get_secret = Mock(side_effect=ValueError("Secret not found"))
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.secret_tool"
|
||||
)
|
||||
|
||||
# Should return error response
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert "requires the following secrets" in result.result.structuredContent["message"]
|
||||
assert "API_KEY, DATABASE_URL" in result.result.structuredContent["message"]
|
||||
assert ".env file" in result.result.structuredContent["llm_instructions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_secrets_partial_missing(self, mcp_server):
|
||||
"""Test tool requirements checking when some required secrets are missing."""
|
||||
|
||||
# Create a tool that requires secrets
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
secrets=[
|
||||
ToolSecretRequirement(key="API_KEY"),
|
||||
ToolSecretRequirement(key="DATABASE_URL"),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock tool context to return a strict subset of the required secrets
|
||||
tool_context = Mock(spec=ToolContext)
|
||||
|
||||
def mock_get_secret(key):
|
||||
if key == "API_KEY":
|
||||
return "test-api-key"
|
||||
else:
|
||||
raise ValueError("Secret not found")
|
||||
|
||||
tool_context.get_secret = Mock(side_effect=mock_get_secret)
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.secret_tool"
|
||||
)
|
||||
|
||||
# Should return error response for missing DATABASE_URL
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert "DATABASE_URL" in result.result.structuredContent["message"]
|
||||
assert "API_KEY" not in result.result.structuredContent["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_secrets_available(self, mcp_server):
|
||||
"""Test tool requirements checking when all required secrets are available."""
|
||||
|
||||
# Create a tool that requires secrets
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
secrets=[
|
||||
ToolSecretRequirement(key="API_KEY"),
|
||||
ToolSecretRequirement(key="DATABASE_URL"),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock tool context to return all secrets
|
||||
tool_context = Mock(spec=ToolContext)
|
||||
|
||||
def mock_get_secret(key):
|
||||
return f"test-{key.lower()}-value"
|
||||
|
||||
tool_context.get_secret = Mock(side_effect=mock_get_secret)
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.secret_tool"
|
||||
)
|
||||
|
||||
# Should return None (no error) when all secrets are available
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_combined_auth_and_secrets(self, mcp_server):
|
||||
"""Test tool requirements checking with both auth and secrets requirements."""
|
||||
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
# Create a tool that requires both auth and secrets
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
),
|
||||
secrets=[
|
||||
ToolSecretRequirement(key="API_KEY"),
|
||||
],
|
||||
)
|
||||
|
||||
# Mock successful authorization
|
||||
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 = {"user_id": "test-user"}
|
||||
|
||||
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
||||
|
||||
tool_context = ToolContext()
|
||||
tool_context.set_secret("API_KEY", "test-api-key")
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.combined_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.combined_tool"
|
||||
)
|
||||
|
||||
# Should return None (no error) when both requirements are satisfied
|
||||
assert result is None
|
||||
# Authorization context should be set
|
||||
assert tool_context.authorization is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_tool_requirements_combined_auth_fails_first(self, mcp_server):
|
||||
"""Test tool requirements checking when auth fails before secrets are checked."""
|
||||
|
||||
mock_arcade = Mock()
|
||||
mcp_server.arcade = mock_arcade
|
||||
|
||||
# Create a tool that requires both auth and secrets
|
||||
tool = Mock()
|
||||
tool.definition.requirements = ToolRequirements(
|
||||
authorization=ToolAuthRequirement(
|
||||
provider_type="oauth2",
|
||||
provider_id="test-provider",
|
||||
),
|
||||
secrets=[
|
||||
ToolSecretRequirement(key="API_KEY"),
|
||||
],
|
||||
)
|
||||
|
||||
# Mock authorization as pending (should fail before secrets check)
|
||||
mock_auth_response = Mock()
|
||||
mock_auth_response.status = "pending"
|
||||
mock_auth_response.url = "https://example.com/auth"
|
||||
|
||||
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
||||
|
||||
# Create real tool context (secrets check shouldn't be reached)
|
||||
tool_context = ToolContext()
|
||||
tool_context.set_secret("API_KEY", "test-api-key")
|
||||
|
||||
message = CallToolRequest(
|
||||
jsonrpc="2.0",
|
||||
id=1,
|
||||
method="tools/call",
|
||||
params={"name": "TestToolkit.combined_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
result = await mcp_server._check_tool_requirements(
|
||||
tool, tool_context, message, "TestToolkit.combined_tool"
|
||||
)
|
||||
|
||||
# Should return auth error (auth is checked first)
|
||||
assert isinstance(result, JSONRPCResponse)
|
||||
assert isinstance(result.result, CallToolResult)
|
||||
assert result.result.isError is True
|
||||
assert "authorization_url" in result.result.structuredContent
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-mcp"
|
||||
version = "1.0.0rc1"
|
||||
version = "1.0.0rc2"
|
||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
|
@ -21,8 +21,8 @@ requires-python = ">=3.10"
|
|||
|
||||
dependencies = [
|
||||
# CLI dependencies
|
||||
"arcade-mcp-server>=1.0.0rc1,<3.0.0",
|
||||
"arcade-core>=2.5.0rc1,<3.0.0",
|
||||
"arcade-mcp-server>=1.0.0rc2,<3.0.0",
|
||||
"arcade-core>=2.5.0rc2,<3.0.0",
|
||||
"typer==0.10.0",
|
||||
"rich==13.9.4",
|
||||
"Jinja2==3.1.6",
|
||||
|
|
@ -41,11 +41,11 @@ all = [
|
|||
"pytz>=2024.1",
|
||||
"python-dateutil>=2.8.2",
|
||||
# mcp
|
||||
"arcade-mcp-server>=1.0.0rc1,<3.0.0",
|
||||
"arcade-mcp-server>=1.0.0rc2,<3.0.0",
|
||||
# serve
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
# tdk
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
]
|
||||
# Evals also depends on arcade-core and openai, but they are already required deps
|
||||
evals = [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
|||
description = "Tools to query and explore a ClickHouse database"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"clickhouse-connect>=0.7.0",
|
||||
"pydantic>=2.11.7",
|
||||
"sqlalchemy>=2.0.41",
|
||||
|
|
@ -24,8 +24,8 @@ email = "support@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.1.13"
|
|||
description = "Arcade.dev LLM tools for LinkedIn"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"httpx>=0.27.2,<1.0.0",
|
||||
]
|
||||
[[project.authors]]
|
||||
|
|
@ -17,8 +17,8 @@ email = "dev@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-asyncio>=0.24.0,<0.25.0",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "1.0.4"
|
|||
description = "Arcade.dev LLM tools for doing math"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
]
|
||||
[[project.authors]]
|
||||
name = "Arcade"
|
||||
|
|
@ -16,8 +16,8 @@ email = "dev@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-asyncio>=0.24.0,<0.25.0",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
|||
description = "Tools to query and explore a MongoDB database"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"pymongo>=4.10.1",
|
||||
"pydantic>=2.11.7",
|
||||
"motor>=3.6.0",
|
||||
|
|
@ -20,8 +20,8 @@ email = "support@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.3.0"
|
|||
description = "Tools to query and explore a postgres database"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pydantic>=2.11.7",
|
||||
"sqlalchemy>=2.0.41",
|
||||
|
|
@ -23,8 +23,8 @@ email = "support@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
|||
description = "Arcade Wrapper Tools enabling LLMs to interact with low-level Slack API endpoints."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"httpx>=0.27.2,<1.0.0",
|
||||
]
|
||||
[[project.authors]]
|
||||
|
|
@ -18,8 +18,8 @@ email = "support@arcade.dev"
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ name = "arcade_zendesk"
|
|||
version = "0.3.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.6.0rc1,<3.0.0",
|
||||
"arcade-tdk>=2.6.0rc2,<3.0.0",
|
||||
"httpx>=0.25.0,<1.0.0",
|
||||
"beautifulsoup4>=4.0.0,<5"
|
||||
]
|
||||
|
|
@ -15,8 +15,8 @@ dependencies = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.0.0rc1,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc1,<3.0.0",
|
||||
"arcade-mcp[all]>=1.0.0rc2,<3.0.0",
|
||||
"arcade-serve>=2.2.0rc2,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue