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:
Eric Gustin 2025-09-29 16:00:47 -07:00 committed by GitHub
parent 62131dedd0
commit 9e4d36b8e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1113 additions and 772 deletions

39
.vscode/launch.json vendored
View file

@ -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>"
}
]
}

View file

@ -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."
)

View file

@ -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

View file

@ -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"

View file

@ -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.")

View file

@ -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)

View file

@ -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(

View file

@ -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"])

View file

@ -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"}

View 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",
]

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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()

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",
]

View file

@ -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

View file

@ -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 = [

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",