Ability to run multiple uvicorn workers (#721)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds --workers to HTTP mode with validation, refactors server
startup/discovery for multi-process uvicorn, and removes all
Docker-related files/configs.
> 
> - **MCP Server (HTTP mode)**
> - Add `--workers` arg to run multiple uvicorn workers; block `workers
> 1` with `stdio`, and `reload` with multiple workers.
> - Refactor startup: move tool discovery/config into
`create_arcade_mcp_factory()` driven by env vars; use `uvicorn.run(...,
workers=...)` for multi-worker/reload; retain `serve_with_force_quit()`
only for single-worker.
> - Adjust CLI to only discover tools in `stdio` path; HTTP path now
delegates discovery to the factory.
> - **MCPApp**
> - Minor run path cleanup; continue using `serve_with_force_quit()` for
single-worker HTTP.
> - **Ops/Packaging**
> - Remove `docker/` directory and all Dockerfiles, compose/configs, and
docs.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c5700ac8855173c1e82c6f7e41b30ca173aaec14. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Sterling Dreyer 2025-12-10 11:06:24 -08:00 committed by GitHub
parent 0fc9e21308
commit 99c22f0ebb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 68 additions and 700 deletions

View file

@ -1,74 +0,0 @@
FROM python:3.11-slim
# Define build arguments with default values
ARG PORT=8001
ARG HOST=0.0.0.0
ARG INSTALL_TOOLKITS=true
# Set environment variables using the build arguments
ENV PORT=${PORT}
ENV HOST=${HOST}
ENV OTEL_ENABLE=false
ENV ARCADE_WORK_DIR=/app
# Install system dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
libssl-dev \
python3-dev \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade setuptools>=78.1.1
WORKDIR /app
# Copy the dist directory contents into the container
COPY ./dist /app/dist/
# Copy the toolkits.txt file into the container
COPY ./docker/toolkits.txt /app/
# Expose the port
EXPOSE $PORT
# List wheel files for debugging purposes
RUN ls -la /app/dist/
# Install the worker and CLI package
RUN python -m pip install \
/app/dist/arcade_serve-*.whl \
/app/dist/arcade_mcp-*.whl
/app/dist/arcade_mcp_server-*.whl
# Conditionally install toolkit wheels from dist directory if INSTALL_TOOLKITS is true and the toolkit is in toolkits.txt
RUN if [ "$INSTALL_TOOLKITS" = "true" ] ; then \
while IFS= read -r toolkit; do \
# Skip empty lines and comments (lines starting with #)
if [ -n "$toolkit" ] && [ "${toolkit#\#}" = "$toolkit" ]; then \
# Convert toolkit name to match wheel filename format (replace - with _)
wheel_name=$(echo "$toolkit" | sed 's/-/_/g'); \
wheel_file="/app/dist/${wheel_name}-"*.whl; \
# Check if this is not a core package and if the wheel file exists
if [ "$wheel_name" != "arcade_core" ] && \
[ "$wheel_name" != "arcade_serve" ] && \
[ "$wheel_name" != "arcade_mcp" ] && \
[ "$wheel_name" != "arcade_mcp_server" ] && \
[ "$wheel_name" != "arcade_tdk" ]; then \
if ls $wheel_file 1> /dev/null 2>&1; then \
echo "Installing $toolkit from $wheel_file"; \
python -m pip install $wheel_file; \
else \
echo "Warning: Wheel file not found for $toolkit (looked for $wheel_file)"; \
fi; \
else \
echo "Skipping core package: $toolkit"; \
fi; \
fi; \
done < /app/toolkits.txt ; \
fi
# Run the arcade worker
COPY docker/start.sh /app/start.sh
RUN chmod +x /app/start.sh
CMD ["/app/start.sh"]

View file

@ -1,103 +0,0 @@
VENDOR ?= ArcadeAI
PROJECT ?= ArcadeAI
SOURCE ?= https://github.com/ArcadeAI/arcade-mcp
LICENSE ?= MIT
DESCRIPTION ?= "Arcade Worker for LLM Tool Serving"
REPOSITORY ?= arcadeai/worker
ARCH ?= $(shell uname -m)
VERSION ?= 0.1.0.dev0
COMMIT ?= $(shell git describe --dirty --always --abbrev=15)
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
IMAGE_NAME ?= worker
PORT ?= 8002
INSTALL_TOOLKITS ?= true
# If INSTALL_TOOLKITS is true, we are building the worker image with all toolkits
# Otherwise, we are building the worker image with no toolkits
ifeq ($(INSTALL_TOOLKITS), true)
REPOSITORY := $(REPOSITORY)
else
REPOSITORY := $(REPOSITORY)-base
endif
VERSION_TAG := $(VERSION)-$(ARCH)
.PHONY: docker-build
docker-build: ## Build the Docker container
@echo "🛠️ Building Docker image ($(VERSION_TAG)).."
@echo "- Commit: $(COMMIT)"
@echo "- Build Date: $(BUILD_DATE)"
@docker build --build-arg PORT=$(PORT) -f Dockerfile -t $(REPOSITORY):$(VERSION_TAG) \
--build-arg INSTALL_TOOLKITS=$(INSTALL_TOOLKITS) \
--build-arg PORT=$(PORT) \
--build-arg VERSION="$(VERSION)" \
--build-arg COMMIT="$(COMMIT)" \
--build-arg BUILD_DATE="$(BUILD_DATE)" \
--label=org.opencontainers.image.vendor="$(VENDOR)" \
--label=org.opencontainers.image.title="$(PROJECT)" \
--label=org.opencontainers.image.revision="$(COMMIT)" \
--label=org.opencontainers.image.version="$(VERSION_TAG)" \
--label=org.opencontainers.image.created="$(BUILD_DATE)" \
--label=org.opencontainers.image.source="$(SOURCE)" \
--label=org.opencontainers.image.licenses="$(LICENSE)" \
--label=org.opencontainers.image.description=$(DESCRIPTION) \
..
ghcr-manifest: ## Make a manifest file for the image
@echo "🛠️ Build manifest file for $(REPOSITORY):$(VERSION).."
@echo "- Commit: $(COMMIT)"
@echo "- Build Date: $(BUILD_DATE)"
@export DOCKER_CLI_EXPERIMENTAL=enabled
@echo "- Creating manifest ghcr.io/$(REPOSITORY):$(VERSION)"
@docker manifest create ghcr.io/$(REPOSITORY):$(VERSION) \
--amend ghcr.io/$(REPOSITORY):$(VERSION)-arm64 \
--amend ghcr.io/$(REPOSITORY):$(VERSION)-amd64
@echo "- Creating manifest ghcr.io/$(REPOSITORY):latest"
@docker manifest create ghcr.io/$(REPOSITORY):latest \
--amend ghcr.io/$(REPOSITORY):$(VERSION)-arm64 \
--amend ghcr.io/$(REPOSITORY):$(VERSION)-amd64
@echo "- Inspecting manifest ghcr.io/$(REPOSITORY):$(VERSION)"
@docker manifest inspect ghcr.io/$(REPOSITORY):$(VERSION)
@echo "- Inspecting manifest ghcr.io/$(REPOSITORY):latest"
@docker manifest inspect ghcr.io/$(REPOSITORY):latest
@echo "- Pushing manifest ghcr.io/$(REPOSITORY):$(VERSION)"
@docker manifest push ghcr.io/$(REPOSITORY):$(VERSION)
@echo "- Pushing manifest ghcr.io/$(REPOSITORY):latest"
@docker manifest push ghcr.io/$(REPOSITORY):latest
.PHONY: docker-run
docker-run: ## Run the Docker container
@echo "\n🚀 Run the container with the following ..."
@echo ">>> docker run -d -p $(PORT):$(PORT) $(REPOSITORY):$(VERSION_TAG)"
.PHONY: publish-ghcr
publish-ghcr:
@echo "🚚 Pushing the Agent image to GHCR.."
@docker tag $(REPOSITORY):$(VERSION_TAG) ghcr.io/$(REPOSITORY):$(VERSION_TAG)
@echo "- pushing ghcr.io/$(REPOSITORY):$(VERSION_TAG)"
@docker push ghcr.io/$(REPOSITORY):$(VERSION_TAG)
@echo $(VERSION) | grep -q $(RC_PART) || { \
docker tag $(REPOSITORY):$(VERSION_TAG) ghcr.io/$(REPOSITORY):latest-$(ARCH); \
echo "- pushing ghcr.io/$(REPOSITORY):latest-$(ARCH)"; \
docker push ghcr.io/$(REPOSITORY):latest-$(ARCH); \
}
.PHONY: gh-login
gh-login: ## Login to GHCR
@echo "🚚 Logging in to GHCR..."
@if [ -z "$(GHCR_PAT)" ]; then \
echo "Error: GHCR_PAT environment variable is not set"; \
exit 1; \
fi
@echo $(GHCR_PAT) | docker login ghcr.io -u arcadeai --password-stdin
.PHONY: help
help:
@echo "🛠️ Worker Docker Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help

View file

@ -1,123 +0,0 @@
# Arcade Docker Compose Guide
This guide provides detailed instructions on how to set up and run Arcade using Docker Compose.
## Prerequisites
- **Docker** installed on your system. [Install Docker](https://docs.docker.com/get-docker/)
- **Docker Compose** installed. It comes bundled with Docker Desktop on Windows and macOS. For Linux, follow the [Docker Compose installation guide](https://docs.docker.com/compose/install/).
## Getting Started
### 1. Clone the Repository
Begin by cloning the Arcade repository:
```bash
git clone https://github.com/ArcadeAI/arcade-mcp.git
```
### 2. Build package wheels
From the root of the arcade-mcp repository:
```bash
make full-dist
```
### 3. Copy and Configure Environment Variables
Change to the `docker` directory:
```bash
cd arcade-mcp/docker
```
Copy the example environment file to `.env`:
```bash
cp env.example .env
```
Open the `.env` file in your preferred text editor and fill in the required values. At a minimum, you **must** provide the `OPENAI_API_KEY`:
```env:.env
### LLM ###
OPENAI_API_KEY=your_openai_api_key_here
```
If you plan to use other Large Language Model (LLM) providers, add their API keys as well:
```env:.env
ANTHROPIC_API_KEY=your_anthropic_api_key_here
```
### 4. Run Docker Compose
Start the Arcade services using Docker Compose:
```bash
docker compose up
```
This command will build and start all the services defined in the `docker-compose.yml` file and make their ports available to your host machine.
### 5. Verify the Engine is Running
In a separate terminal window, check if the engine is running:
```bash
curl http://localhost:9099/v1/health
```
You should receive a response indicating that the engine is healthy:
```json
{ "healthy": "true" }
```
Open a browser and navigate to http://localhost:9099/dashboard to view the Arcade dashboard.
## Adding Authentication Providers
Arcade supports various authentication providers. To add an auth provider, follow these steps:
### 1. Enable the Auth Provider in the Configuration
Edit the `docker.engine.yaml` file to enable the desired auth provider. For example, to enable Google authentication, modify the file as follows:
```yaml:docker.engine.yaml
auth:
providers:
- id: google
enabled: true # Change from false to true
```
### 2. Add Client ID and Secret to the `.env` File
Obtain the client ID and client secret from your auth provider and add them to the `.env` file:
```env:.env
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
```
Repeat this step for any other auth providers you wish to enable.
### 3. Restart the Docker Compose Services
After making changes to the configuration, restart the services:
```bash
docker compose down
docker compose up
```
## Troubleshooting
- **Engine Health Check Fails**: Ensure that all environment variables are correctly set in the `.env` file and that the services have started without errors.
- **Port Conflicts**: If the default ports are already in use, modify the ports in the `docker-compose.yml` file.
- **Authentication Errors**: Double-check the client IDs and secrets provided for auth providers.
NOTE: `arcade login` will not work within a docker container, you must copy your credentials into the container if you would like to use it.

View file

@ -1,61 +0,0 @@
version: '3.8'
services:
engine:
image: ghcr.io/arcadeai/engine:latest
container_name: arcade-engine
volumes:
- ./docker.engine.yaml:/bin/engine.yaml
- ./.env:/bin/.env
- ./db/:/app/
ports:
- "9099:9099"
depends_on:
redis:
condition: service_healthy
worker:
condition: service_started
networks:
arcade-network:
command: /bin/arcade-engine --config /bin/engine.yaml --env /bin/.env --migrate
worker:
image: ghcr.io/arcadeai/worker:latest
container_name: arcade-worker
ports:
- "8002:8002"
networks:
arcade-network:
redis:
image: redis/redis-stack:latest
container_name: arcade-redis
ports:
- "6379:6379"
- "8004:8002"
depends_on:
worker:
condition: service_started
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 3s
timeout: 3s
retries: 5
networks:
arcade-network:
nginx:
image: nginx:stable-alpine
container_name: arcade-nginx
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- engine
networks:
- arcade-network
networks:
arcade-network:
driver: bridge

View file

@ -1,185 +0,0 @@
telemetry:
environment: local
version: ${env:VERSION}
logging:
level: debug # debug, info, warn, error
encoding: console
api:
development: true
host: 0.0.0.0
port: 9099
analytics:
enabled: false
# Uncomment to enable rate limiter
# rate_limit:
# redis:
# addr: "localhost:6379"
# password: ""
# db: 2
# time_unit: m
# limit: 2
# write_timeout: 10
# read_timeout: 10
llm:
models:
- id: oai
openai:
api_key: ${env:OPENAI_API_KEY}
# - id: anthropic
# anthropic:
# api_key: ${env:ANTHROPIC_API_KEY}
# # model: "claude-3-5-sonnet-20240620"
#- id: ollama
# openai:
# base_url: "http://localhost:11434/v1"
# api_key: "ollama"
tools:
directors:
- id: default
enabled: true
max_tools: 64
workers:
- id: "localworker"
enabled: true
http:
uri: ${env:WORKER_URL}
timeout: 30
retry: 3
secret: ${env:ARCADE_WORKER_SECRET} # If not set, defaults to "dev" in development mode only
# Uncomment mock and comment http to start engine without live worker
# mock:
# enabled: true
security:
root_keys:
- id: key1
default: true
value: ${env:ROOT_KEY_1}
# - id: key2
# value: ${env:ROOT_KEY_2}
storage:
# postgres:
# user: postgres
# password: 123456
# host: localhost
# port: 5432
# db: arcade_engine
# sslmode: disable
sqlite: # Default for local development
connection_string: "/app/arcade-engine.sqlite3"
cache:
api_key_ttl: "10s"
redis:
addr: ${env:REDIS_HOST}:${env:REDIS_PORT}
password: ${env:REDIS_PASSWORD}
auth:
token_store:
redis:
addr: ${env:REDIS_HOST}:${env:REDIS_PORT}
password: ${env:REDIS_PASSWORD}
providers:
- id: default-atlassian
description: "The default Atlassian provider"
enabled: false
type: oauth2
provider_id: atlassian
client_id: ${env:ATLASSIAN_CLIENT_ID}
client_secret: ${env:ATLASSIAN_CLIENT_SECRET}
- id: default-discord
description: "The default Discord provider"
enabled: false
type: oauth2
provider_id: discord
client_id: ${env:DISCORD_CLIENT_ID}
client_secret: ${env:DISCORD_CLIENT_SECRET}
- id: default-dropbox
description: "The default Dropbox provider"
enabled: false
type: oauth2
provider_id: dropbox
client_id: ${env:DROPBOX_CLIENT_ID}
client_secret: ${env:DROPBOX_CLIENT_SECRET}
- id: default-github
description: "The default GitHub provider"
enabled: false
type: oauth2
provider_id: github
client_id: ${env:GITHUB_CLIENT_ID}
client_secret: ${env:GITHUB_CLIENT_SECRET}
- id: default-google
description: "The default Google provider"
enabled: false
type: oauth2
provider_id: google
client_id: ${env:GOOGLE_CLIENT_ID}
client_secret: ${env:GOOGLE_CLIENT_SECRET}
- id: default-linkedin
description: "The default LinkedIn provider"
enabled: false
type: oauth2
provider_id: linkedin
client_id: ${env:LINKEDIN_CLIENT_ID}
client_secret: ${env:LINKEDIN_CLIENT_SECRET}
- id: arcade-linear
description: "The Linear provider for Arcade"
enabled: false
type: oauth2
provider_id: linear
client_id: ${env:LINEAR_CLIENT_ID}
client_secret: ${env:LINEAR_CLIENT_SECRET}
- id: default-microsoft
description: "The default Microsoft provider"
enabled: false
type: oauth2
provider_id: microsoft
client_id: ${env:MICROSOFT_CLIENT_ID}
client_secret: ${env:MICROSOFT_CLIENT_SECRET}
- id: default-slack
description: "The default Slack provider"
enabled: false
type: oauth2
provider_id: slack
client_id: ${env:SLACK_CLIENT_ID}
client_secret: ${env:SLACK_CLIENT_SECRET}
- id: default-spotify
description: "The default Spotify provider"
enabled: false
type: oauth2
provider_id: spotify
client_id: ${env:SPOTIFY_CLIENT_ID}
client_secret: ${env:SPOTIFY_CLIENT_SECRET}
- id: default-x
description: "The default X provider"
enabled: false
type: oauth2
provider_id: x
client_id: ${env:X_CLIENT_ID}
client_secret: ${env:X_CLIENT_SECRET}
- id: default-zoom
description: "The default Zoom provider"
enabled: false
type: oauth2
provider_id: zoom
client_id: ${env:ZOOM_CLIENT_ID}
client_secret: ${env:ZOOM_CLIENT_SECRET}
dashboard:
redirect_uri: "http://localhost:9099/dashboard"

View file

@ -1,51 +0,0 @@
### LLM ###
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
### Auth providers ###
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=""
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=""
SLACK_CLIENT_ID=""
SLACK_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
X_CLIENT_ID=
X_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
### WORKER ###
WORKER_URL="http://arcade-worker:8002"
### Redis ###
REDIS_HOST=arcade-redis
REDIS_PORT=6379
REDIS_PASSWORD=""
RATE_LIMIT_REQS_PER_MIN=100
### ARCADE ###
ARCADE_HOME=/app
ARCADE_WORKER_SECRET=dev
ROOT_KEY_1=supersecret

View file

@ -1,47 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
client_max_body_size 5M;
client_body_buffer_size 5M;
gzip on;
gzip_comp_level 2;
gzip_types text/plain text/css text/javascript application/javascript application/xml image/jpeg image/gif image/png;
gzip_vary on;
keepalive_timeout 300;
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name arcade-engine:9099;
root /app;
client_max_body_size 10m;
location / {
proxy_pass http://arcade-engine:9099;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}
}

View file

@ -1,4 +0,0 @@
#!/bin/bash
echo "Starting arcade..."
arcade serve --host $HOST --port $PORT $([ "$OTEL_ENABLE" = "true" ] && echo "--otel-enable")

View file

@ -1,4 +0,0 @@
arcade-brightdata
arcade-linkedin
arcade-math
arcade-zendesk

View file

@ -277,6 +277,12 @@ Auto-discovery looks for Python files with @tool decorated functions in:
"--cwd",
help="Directory to change to before running (for tool discovery)",
)
parser.add_argument(
"--workers",
default=1,
type=int,
help=argparse.SUPPRESS,
)
args = parser.parse_args()
@ -300,6 +306,10 @@ Auto-discovery looks for Python files with @tool decorated functions in:
log_level = "DEBUG" if args.debug else "INFO"
setup_logging(level=log_level, stdio_mode=(args.transport == "stdio"))
if args.workers > 1 and args.transport == "stdio":
logger.error("Cannot use --workers > 1 with stdio transport")
sys.exit(1)
# Build kwargs for server
server_kwargs = {}
if args.name:
@ -307,18 +317,17 @@ Auto-discovery looks for Python files with @tool decorated functions in:
if args.version:
server_kwargs["version"] = args.version
# Discover tools
catalog = initialize_tool_catalog(
tool_package=args.tool_package,
show_packages=args.show_packages,
discover_installed=args.discover_installed,
server_name=server_kwargs.get("name"),
server_version=server_kwargs.get("version"),
)
# Run appropriate server
try:
if args.transport == "stdio":
# Discover tools only for stdio mode (HTTP mode handles its own discovery)
catalog = initialize_tool_catalog(
tool_package=args.tool_package,
show_packages=args.show_packages,
discover_installed=args.discover_installed,
server_name=server_kwargs.get("name"),
server_version=server_kwargs.get("version"),
)
logger.info("Starting MCP server with stdio transport")
asyncio.run(
run_stdio_server(catalog, debug=args.debug, env_file=args.env_file, **server_kwargs)
@ -328,7 +337,6 @@ Auto-discovery looks for Python files with @tool decorated functions in:
from arcade_mcp_server.worker import run_arcade_mcp
run_arcade_mcp(
catalog=catalog,
host=args.host,
port=args.port,
reload=args.reload,
@ -337,6 +345,7 @@ Auto-discovery looks for Python files with @tool decorated functions in:
tool_package=args.tool_package,
discover_installed=args.discover_installed,
show_packages=args.show_packages,
workers=args.workers,
**server_kwargs,
)
except (KeyboardInterrupt, asyncio.CancelledError):

View file

@ -414,14 +414,7 @@ class MCPApp:
tool_count=len(self._catalog),
)
asyncio.run(
serve_with_force_quit(
app=app,
host=host,
port=port,
log_level=log_level,
)
)
asyncio.run(serve_with_force_quit(app=app, host=host, port=port, log_level=log_level))
@staticmethod
def _get_configuration_overrides(

View file

@ -347,10 +347,10 @@ async def serve_with_force_quit(
def run_arcade_mcp(
catalog: ToolCatalog,
host: str = "127.0.0.1",
port: int = 8000,
reload: bool = False,
workers: int = 1,
debug: bool = False,
otel_enable: bool = False,
tool_package: str | None = None,
@ -364,33 +364,55 @@ def run_arcade_mcp(
This is used for module execution (`arcade mcp` and `python -m arcade_mcp_server`) only.
MCPApp has its own reload mechanism.
Args:
workers: Number of uvicorn worker processes. When workers > 1, force-quit
capability is disabled (standard uvicorn signal handling is used).
Cannot be combined with reload=True.
Raises:
ValueError: If both reload=True and workers > 1 are specified, as uvicorn
does not support multiple workers in reload mode.
"""
if reload and workers > 1:
raise ValueError(
"Cannot use reload=True with workers > 1. "
"Uvicorn does not support multiple workers in reload mode."
)
log_level = "debug" if debug else "info"
if reload:
# TODO: This reload path uses uvicorn.run(), which bypasses serve_with_force_quit().
# This means that the server will not be able to force quit when there are active
# tool executions or active connections with MCP clients. For this reason, prefer
# to use MCPApp.run() for reload mode.
# Set env vars for the app factory to read
os.environ["ARCADE_MCP_DEBUG"] = str(debug)
os.environ["ARCADE_MCP_OTEL_ENABLE"] = str(otel_enable)
if tool_package:
os.environ["ARCADE_MCP_TOOL_PACKAGE"] = tool_package
os.environ["ARCADE_MCP_DISCOVER_INSTALLED"] = str(discover_installed)
os.environ["ARCADE_MCP_SHOW_PACKAGES"] = str(show_packages)
# Set env vars for the app factory to read later
os.environ["ARCADE_MCP_DEBUG"] = str(debug)
os.environ["ARCADE_MCP_OTEL_ENABLE"] = str(otel_enable)
if tool_package:
os.environ["ARCADE_MCP_TOOL_PACKAGE"] = tool_package
os.environ["ARCADE_MCP_DISCOVER_INSTALLED"] = str(discover_installed)
os.environ["ARCADE_MCP_SHOW_PACKAGES"] = str(show_packages)
if mcp_settings:
os.environ["ARCADE_MCP_SERVER_NAME"] = mcp_settings.server.name
os.environ["ARCADE_MCP_SERVER_VERSION"] = mcp_settings.server.version
if mcp_settings.server.title:
os.environ["ARCADE_MCP_SERVER_TITLE"] = mcp_settings.server.title
if mcp_settings.server.instructions:
os.environ["ARCADE_MCP_SERVER_INSTRUCTIONS"] = mcp_settings.server.instructions
# Handle server name/version from mcp_settings or kwargs
server_name = kwargs.get("name")
server_version = kwargs.get("version")
if mcp_settings:
os.environ["ARCADE_MCP_SERVER_NAME"] = mcp_settings.server.name
os.environ["ARCADE_MCP_SERVER_VERSION"] = mcp_settings.server.version
if mcp_settings.server.title:
os.environ["ARCADE_MCP_SERVER_TITLE"] = mcp_settings.server.title
if mcp_settings.server.instructions:
os.environ["ARCADE_MCP_SERVER_INSTRUCTIONS"] = mcp_settings.server.instructions
else:
if server_name:
os.environ["ARCADE_MCP_SERVER_NAME"] = server_name
if server_version:
os.environ["ARCADE_MCP_SERVER_VERSION"] = server_version
# import string is required for reload mode
app_import_string = "arcade_mcp_server.worker:create_arcade_mcp_factory"
app_import_string = "arcade_mcp_server.worker:create_arcade_mcp_factory"
if reload or workers > 1:
# Reload mode and multi-worker mode (mutually exclusive, validated above)
# use uvicorn.run() which bypasses serve_with_force_quit(). This means the
# server will not be able to force quit when there are active tool executions
# or active connections with MCP clients. For reload mode, prefer MCPApp.run().
uvicorn.run(
app_import_string,
factory=True,
@ -399,16 +421,12 @@ def run_arcade_mcp(
log_level=log_level,
reload=reload,
lifespan="on",
workers=workers,
)
else:
app = create_arcade_mcp(
catalog=catalog,
mcp_settings=mcp_settings,
debug=debug,
otel_enable=otel_enable,
**kwargs,
)
# Single-worker production mode uses serve_with_force_quit() for graceful
# shutdown with force-quit capability on second SIGINT/SIGTERM
app = create_arcade_mcp_factory()
asyncio.run(
serve_with_force_quit(
app=app,