Fix MCP capabilities, examples, tests, and more (#657)

# PR Description
Consider this PR the result of a full pass through of this repository.
## Add helper for adding tools to an `MCPApp`
You can now add all of the tools in a module to an `MCPApp` via
`app.add_tools_from_module(...)`
## Edit what `arcade new` generates
First, I updated the backend to use hatchling.

Second, the structure generated before this PR was simple, but did not
create a proper Python module.
This hindered developers in the following ways:
1. Difficult to add the tools in your server to an evaluation suite
2. Difficult to add more than one tool to an MCPApp at a time
3. All other niceties that come with being able to import modules
```
# Before
server/
├── .env.example
├── server.py
└── pyproject.toml
```
This PR updates the structure generated such that a valid Python module
is generated:
```
# After 
server/
├── pyproject.toml
└── src/
    └── server/
        ├── __init__.py
        ├── .env.example
        └── server.py
```
## Fix Tool Chaining
`self._ctx.server.executor.run(...)` was being called, but `MCPServer`
does not have an instance of `ToolExecutor` (and it's not intended to be
an instance anyways). I updated `Tool.call_raw` to pass the programmatic
tool call through the `MCPServer._handle_call_tool`. This means that the
programmatic tool calls now go through the same steps that a typical
tool call (initiated by the MCP client) would.

This means that **toolA**, which specifies **requirementsA**, is
permitted to call **toolB**, which specifies **requirementsB**, without
needing to explicitly declare or satisfy **requirementsB**. I believe
this is acceptable because the secrets and/or auth token associated with
**toolB's** `Context` are not exposed to **toolA**, and the secrets
and/or auth token associated with **toolA's** `Context` are not exposed
to **toolB**.

## Fix User Elicitation
1. The read & write streams were created with a maximum queue size of 0.
I increased this to 100.
2. I updated `ServerSession`'s run loop to both read messages from the
stream & process them concurrently. This enables server initiated
requests (like user elicitation and progress reporting) to be handled
while tools are being executed. Otherwise, the server initiated requests
would wait for the tool to finish executing and the tool execution would
wait for the server initiated request to finish.
3. 
## Fix Progress Reporting
Progress tokens sent by the client were not being stored. Therefore
there was no way to notify a client with progress updates. I am now
storing the `progressToken`, along with other `_meta` sent from the
client, in the `ServerSession`'s `_request_meta`. I am setting
`_request_meta` whenever the `MCPServer` is handling an incoming message
from a client.

## Fix handling of server names with spaces
Before: 
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "The_simple_server_name_WhisperSecret"

After
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "TheSimpleServerName_WhisperSecret"

## Add Integration Tests
The stdio integration test is much more comprehensive than the http
integration test. These tests will let me sleep a bit more at night

## Add Example MCP Servers
Example servers for sampling, user-elicitation, progress reporting,
logging, tool chaining, combining prebuilt tools with custom tools, tool
secrets, tool auth, evaluations, and more!

## Add Docker template
Added a Docker template for running an MCP server in Docker (and removed
the old docker stuff)
This commit is contained in:
Eric Gustin 2025-10-30 11:59:00 -07:00 committed by GitHub
parent 447177e6a8
commit e727af3a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 2634 additions and 266 deletions

View file

@ -61,7 +61,7 @@ uv tool install arcade-mcp
arcade new my_server
# Navigate to the project
cd my_server
cd my_server/src/my_server
```
This generates a project with:

View file

@ -0,0 +1,33 @@
# Virtual environment
.venv/
venv/
env/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Distribution
dist/
build/
*.egg-info/
# Docker
docker/
.dockerignore
Dockerfile
docker-compose.yml

View file

@ -0,0 +1,67 @@
# Docker Template for MCP Servers
This is a generalized Docker setup template that can be applied to any MCP server built with Arcade MCP
The Dockerfile automatically detects your package name from `pyproject.toml` and expects your server file at `src/<package_name>/server.py`.
## Quick Setup
### Option 1: Using the Setup Script (Recommended)
Run the setup script to automatically copy the Docker files to your MCP server:
```bash
cd examples/docker-template
./setup-docker.sh ../path/to/your-server-name
```
This will copy all necessary Docker files to your server directory.
### Option 2: Manual Setup
Copy the `docker/` directory to your MCP server:
```bash
cp -r examples/docker-template/docker your-server-name/
cp examples/docker-template/.dockerignore your-server-name/
```
## Usage
After setup, navigate to your MCP server directory and build/run:
```bash
cd your-server-name
# Build and run with docker-compose
docker-compose -f docker/docker-compose.yml up --build
# Or build and run manually
docker build -f docker/Dockerfile -t your-server .
docker run -p 8001:8001 your-server
```
The package name is automatically detected from `pyproject.toml`
## Configuration
Edit `docker/docker-compose.yml` to configure:
- `ARCADE_SERVER_PORT`: Server port (default: 8001)
- `ARCADE_SERVER_HOST`: Bind host (default: 0.0.0.0)
- `ARCADE_SERVER_TRANSPORT`: Transport type (default: http)
The package name is automatically detected from `pyproject.toml`
## What Gets Copied
The setup script copies these files to your MCP server:
- `docker/Dockerfile` - Docker image build instructions
- `docker/docker-compose.yml` - Docker Compose configuration
- `docker/README.md` - Detailed usage documentation
- `.dockerignore` - Files to exclude from Docker build
## Requirements
- Docker and Docker Compose installed
- MCP server with `pyproject.toml` and `uv.lock`
- Server file at `src/<package>/server.py`

View file

@ -0,0 +1,45 @@
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
# Create non-root user
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Copy project files
COPY pyproject.toml uv.lock ./
COPY src/ ./src/
# Auto-detect package name from pyproject.toml
# First try using Python's tomllib
# Fallback to grep/sed for compatibility
RUN PACKAGE_NAME=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); print(data['project']['name'])" 2>/dev/null || \
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E "s/.*name\s*=\s*[\"']([^\"']+)[\"'].*/\1/" || \
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E 's/.*name\s*=\s*([^ ]+).*/\1/') && \
if [ -z "$PACKAGE_NAME" ]; then \
echo "ERROR: Could not detect package name from pyproject.toml" && exit 1; \
fi && \
echo "Detected package: $PACKAGE_NAME" && \
echo "$PACKAGE_NAME" > /tmp/package_name.txt
# Install dependencies
RUN uv sync --frozen --no-dev
# Change ownership to non-root user
RUN chown -R appuser:appuser /app
USER appuser
# Expose the port
EXPOSE 8001
# Run the server from src/<package>/server.py
CMD PACKAGE_NAME=$(cat /tmp/package_name.txt) && \
if [ -f "src/${PACKAGE_NAME}/server.py" ]; then \
uv run src/${PACKAGE_NAME}/server.py; \
else \
echo "ERROR: Could not find server.py at src/${PACKAGE_NAME}/server.py" && \
echo " Package detected: ${PACKAGE_NAME}" && \
echo " Available directories in src/:" && \
ls -la src/ 2>/dev/null || echo " src/ directory not found" && \
exit 1; \
fi

View file

@ -0,0 +1,89 @@
# Docker Setup for MCP Servers
This directory contains a generalized Docker configuration template that can be used with any MCP server in this repository.
## Quick Start
1. **Copy the Docker files to your MCP server directory:**
```bash
cp -r examples/docker-template/docker your-mcp-server/
cp examples/docker-template/.dockerignore your-mcp-server/
```
2. **Build and run:**
```bash
cd your-mcp-server
docker-compose -f docker/docker-compose.yml up --build
```
## Configuration
### Package Detection
The Dockerfile uses the package name from `pyproject.toml` by reading the `[project] name` field. It expects your server file at `src/<package_name>/server.py` (where `<package_name>` is from `pyproject.toml`).
If the server file is not found at this location, then the build will fail with an error message showing the detected package name and available directories in `src/`.
### Environment Variables
- `ARCADE_SERVER_TRANSPORT`: The transport protocol to use
- Default: `http`
- Options: `http`, `stdio`
- `ARCADE_SERVER_PORT`: The port to run the server on
- Default: `8001`
- `ARCADE_SERVER_HOST`: The host to bind to
- Default: `0.0.0.0`
### Example: Simple MCP Server
```bash
# From examples/mcp_servers/simple/
docker-compose -f docker/docker-compose.yml up --build
```
You can customize the port by editing `docker/docker-compose.yml` and changing both the `ARCADE_SERVER_PORT` environment variable and the port mapping.
## Building the Image
```bash
docker build \
-f docker/Dockerfile \
-t your-mcp-server \
.
```
## Running with Docker
```bash
docker run -p 8001:8001 \
-e ARCADE_SERVER_TRANSPORT=http \
-e ARCADE_SERVER_HOST=0.0.0.0 \
-e ARCADE_SERVER_PORT=8001 \
your-mcp-server
```
## Features
- **Automatic package detection**: Reads package name from `pyproject.toml`
- **Standard server location**: Expects server file at `src/<package>/server.py`
- **Secure by default**: Runs as non-root user
- **Arcade environment variable support**: Uses `ARCADE_SERVER_*` environment variables
- **Environment-based config**: Easy customization via environment variables
- **uv integration**: Uses uv for fast dependency management
- **Lightweight**: Based on Python 3.11 Bookworm slim image with uv
## Connecting from Cursor
Add to your `~/.cursor/mcp.json`:
```json
"your-server-name": {
"name": "your-server-name",
"type": "stream",
"url": "http://localhost:8001"
}
```
Then restart Cursor to connect to the server.

View file

@ -0,0 +1,11 @@
services:
mcp-server:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "8001:8001"
environment:
- ARCADE_SERVER_TRANSPORT=http
- ARCADE_SERVER_HOST=0.0.0.0
- ARCADE_SERVER_PORT=8001

View file

@ -0,0 +1,70 @@
#!/bin/bash
# Setup Docker for MCP Server
# This script copies the Docker template files to your MCP server directory
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Setting up Docker for MCP Server${NC}"
echo ""
# Get the template directory (where this script is located)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR"
# Default target directory is the parent directory
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
# Allow specifying a target directory
if [ -n "$1" ]; then
TARGET_DIR="$1"
fi
echo "Template directory: $TEMPLATE_DIR"
echo "Target directory: $TARGET_DIR"
echo ""
# Check if target is a valid MCP server
if [ ! -f "$TARGET_DIR/pyproject.toml" ]; then
echo -e "${RED}Error: $TARGET_DIR is not a valid MCP server (missing pyproject.toml)${NC}"
exit 1
fi
# Check if docker directory already exists
if [ -d "$TARGET_DIR/docker" ]; then
echo -e "${YELLOW}Warning: $TARGET_DIR/docker already exists${NC}"
read -p "Overwrite? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted"
exit 0
fi
rm -rf "$TARGET_DIR/docker"
fi
# Create docker directory
mkdir -p "$TARGET_DIR/docker"
# Copy files
echo "Copying Docker files..."
cp "$TEMPLATE_DIR/docker/Dockerfile" "$TARGET_DIR/docker/"
cp "$TEMPLATE_DIR/docker/docker-compose.yml" "$TARGET_DIR/docker/"
cp "$TEMPLATE_DIR/docker/README.md" "$TARGET_DIR/docker/"
cp "$TEMPLATE_DIR/.dockerignore" "$TARGET_DIR/"
echo -e "${GREEN}✓ Docker setup complete!${NC}"
echo ""
echo "Next steps:"
echo " 1. (Optional) Update docker-compose.yml to customize:"
echo " - ARCADE_SERVER_PORT (default: 8001)"
echo " - ARCADE_SERVER_HOST (default: 0.0.0.0)"
echo " 2. Build and run:"
echo " cd $TARGET_DIR"
echo " docker-compose -f docker/docker-compose.yml up --build"
echo ""

View file

@ -0,0 +1,47 @@
[project]
name = "custom_server_with_prebuilt_tools"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-math>=1.0.5,<1.1.0",
"local_filesystem>=0.1.0,<1.0.0",
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "custom_server_with_prebuilt_tools"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
include = ["custom_server_with_prebuilt_tools*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""custom_server_with_prebuilt_tools MCP server"""
import random
import string
import sys
from typing import Annotated
import arcade_math # comes from arcade-math PyPI package
from arcade_math.tools.random import generate_random_int
from arcade_mcp_server import MCPApp
app = MCPApp(name="Math", version="1.0.0", log_level="DEBUG")
app.add_tools_from_module(arcade_math) # adds 20+ math related tools
# A tool that calls another tool in the same server via context.tools
@app.tool
async def generate_random_string(
min_length: Annotated[int, "The minimum length of the string"],
max_length: Annotated[int, "The maximum length of the string"],
) -> str:
"""Generate a random string between min_length and max_length."""
length = generate_random_int(str(min_length), str(max_length))
characters = string.ascii_letters + string.digits
return "".join(random.choices(characters, k=int(length)))
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,27 @@
[project]
name = "echo"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/echo"]
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,15 @@
from typing import Annotated
from arcade_mcp_server import MCPApp
app = MCPApp("Echo server")
@app.tool
def echo(message: Annotated[str, "The message to echo"]) -> str:
"""Echo a message back to the caller."""
return message
if __name__ == "__main__":
app.run(transport="http")

View file

@ -0,0 +1,44 @@
[project]
name = "local_filesystem"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/local_filesystem"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "simple"
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""local_filesystem MCP server"""
import sys
from arcade_mcp_server import MCPApp
import local_filesystem
# import local_filesystem.tools as tools
app = MCPApp(name="local_filesystem", version="1.0.0", log_level="DEBUG")
app.add_tools_from_module(local_filesystem)
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
app.run(transport=transport, host="127.0.0.1", port=8074)

View file

@ -1,46 +1,14 @@
import base64
import fnmatch
import os
import re
import shutil
import stat
from datetime import datetime
from pathlib import Path
from typing import Annotated
from arcade_mcp_server import tool
def _split_globs(globs: str) -> list[str]:
patterns = [p.strip() for p in globs.split(",") if p.strip()]
return patterns
def _is_hidden(path: Path) -> bool:
return path.name.startswith(".")
def _match_any(name: str, patterns: list[str]) -> bool:
if not patterns:
return True
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def _match_none(name: str, patterns: list[str]) -> bool:
return all(not fnmatch.fnmatch(name, pattern) for pattern in patterns)
def _path_type(path: Path, follow_symlinks: bool) -> str:
try:
st = path.stat() if follow_symlinks else path.lstat()
mode = st.st_mode
if stat.S_ISDIR(mode):
return "dir"
if stat.S_ISLNK(mode):
return "symlink"
return "file" # noqa: TRY300
except FileNotFoundError:
return "unknown"
from local_filesystem.utils import is_hidden, match_any, match_none, path_type, split_globs
@tool
@ -56,8 +24,8 @@ def list_directory(
) -> list[dict]:
"""Enumerate files and folders with metadata."""
root = Path(path).expanduser().resolve()
includes = _split_globs(include_globs)
excludes = _split_globs(exclude_globs)
includes = split_globs(include_globs)
excludes = split_globs(exclude_globs)
results: list[dict] = []
entries_count = 0
@ -68,11 +36,11 @@ def list_directory(
st = p.stat() if follow_symlinks else p.lstat()
except FileNotFoundError:
return
entry_type = _path_type(p, follow_symlinks)
if not show_hidden and _is_hidden(p):
entry_type = path_type(p, follow_symlinks)
if not show_hidden and is_hidden(p):
return
name = p.name
if not _match_any(name, includes) or not _match_none(name, excludes):
if not match_any(name, includes) or not match_none(name, excludes):
return
results.append(
{
@ -267,7 +235,7 @@ def stat_path(
info = {
"exists": exists,
"path": str(p),
"type": _path_type(p, follow_symlinks),
"type": path_type(p, follow_symlinks),
"size": int(st.st_size),
"mode": int(st.st_mode),
"uid": int(getattr(st, "st_uid", 0)),
@ -382,8 +350,8 @@ def search_files(
if not base.exists() or not base.is_dir():
return []
includes = _split_globs(name_globs)
excludes = _split_globs(exclude_globs)
includes = split_globs(name_globs)
excludes = split_globs(exclude_globs)
flags = re.IGNORECASE if case_insensitive else 0
pattern = None
@ -404,9 +372,9 @@ def search_files(
return results
if not include_hidden and name.startswith("."):
continue
if includes and not _match_any(name, includes):
if includes and not match_any(name, includes):
continue
if not _match_none(name, excludes):
if not match_none(name, excludes):
continue
file_path = current / name

View file

@ -0,0 +1,35 @@
import fnmatch
import stat
from pathlib import Path
def split_globs(globs: str) -> list[str]:
patterns = [p.strip() for p in globs.split(",") if p.strip()]
return patterns
def is_hidden(path: Path) -> bool:
return path.name.startswith(".")
def match_any(name: str, patterns: list[str]) -> bool:
if not patterns:
return True
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def match_none(name: str, patterns: list[str]) -> bool:
return all(not fnmatch.fnmatch(name, pattern) for pattern in patterns)
def path_type(path: Path, follow_symlinks: bool) -> str:
try:
st = path.stat() if follow_symlinks else path.lstat()
mode = st.st_mode
if stat.S_ISDIR(mode):
return "dir"
if stat.S_ISLNK(mode):
return "symlink"
return "file" # noqa: TRY300
except FileNotFoundError:
return "unknown"

View file

@ -1,37 +0,0 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "local_filesystem"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.0.1,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.0.1,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "local_filesystem"
[tool.setuptools.packages.find]
include = ["local_filesystem*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""local_filesystem MCP server"""
import sys
from arcade_mcp_server import MCPApp
from tools import (
copy_path,
create_directory,
list_directory,
move_path,
read_file,
search_files,
stat_path,
tail_file,
write_file,
)
app = MCPApp(name="local_filesystem", version="1.0.0", log_level="DEBUG")
app.add_tool(list_directory)
app.add_tool(read_file)
app.add_tool(write_file)
app.add_tool(tail_file)
app.add_tool(stat_path)
app.add_tool(create_directory)
app.add_tool(move_path)
app.add_tool(copy_path)
app.add_tool(search_files)
# Run with specific transport
if __name__ == "__main__":
# Get transport from command line argument, default to "http"
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
# Run the server
# - "http" (default): HTTPS streaming for Cursor, VS Code, etc.
# - "stdio": Standard I/O for Claude Desktop, CLI tools, etc.
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,33 @@
# Virtual environment
.venv/
venv/
env/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Distribution
dist/
build/
*.egg-info/
# Docker
docker/
.dockerignore
Dockerfile
docker-compose.yml

View file

@ -0,0 +1,45 @@
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
# Create non-root user
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Copy project files
COPY pyproject.toml uv.lock ./
COPY src/ ./src/
# Auto-detect package name from pyproject.toml
# First try using Python's tomllib
# Fallback to grep/sed for compatibility
RUN PACKAGE_NAME=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); print(data['project']['name'])" 2>/dev/null || \
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E "s/.*name\s*=\s*[\"']([^\"']+)[\"'].*/\1/" || \
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E 's/.*name\s*=\s*([^ ]+).*/\1/') && \
if [ -z "$PACKAGE_NAME" ]; then \
echo "ERROR: Could not detect package name from pyproject.toml" && exit 1; \
fi && \
echo "Detected package: $PACKAGE_NAME" && \
echo "$PACKAGE_NAME" > /tmp/package_name.txt
# Install dependencies
RUN uv sync --frozen --no-dev
# Change ownership to non-root user
RUN chown -R appuser:appuser /app
USER appuser
# Expose the port
EXPOSE 8001
# Run the server from src/<package>/server.py
CMD PACKAGE_NAME=$(cat /tmp/package_name.txt) && \
if [ -f "src/${PACKAGE_NAME}/server.py" ]; then \
uv run src/${PACKAGE_NAME}/server.py; \
else \
echo "ERROR: Could not find server.py at src/${PACKAGE_NAME}/server.py" && \
echo " Package detected: ${PACKAGE_NAME}" && \
echo " Available directories in src/:" && \
ls -la src/ 2>/dev/null || echo " src/ directory not found" && \
exit 1; \
fi

View file

@ -0,0 +1,89 @@
# Docker Setup for MCP Servers
This directory contains a generalized Docker configuration template that can be used with any MCP server in this repository.
## Quick Start
1. **Copy the Docker files to your MCP server directory:**
```bash
cp -r examples/docker-template/docker your-mcp-server/
cp examples/docker-template/.dockerignore your-mcp-server/
```
2. **Build and run:**
```bash
cd your-mcp-server
docker-compose -f docker/docker-compose.yml up --build
```
## Configuration
### Package Detection
The Dockerfile uses the package name from `pyproject.toml` by reading the `[project] name` field. It expects your server file at `src/<package_name>/server.py` (where `<package_name>` is from `pyproject.toml`).
If the server file is not found at this location, then the build will fail with an error message showing the detected package name and available directories in `src/`.
### Environment Variables
- `ARCADE_SERVER_TRANSPORT`: The transport protocol to use
- Default: `http`
- Options: `http`, `stdio`
- `ARCADE_SERVER_PORT`: The port to run the server on
- Default: `8001`
- `ARCADE_SERVER_HOST`: The host to bind to
- Default: `0.0.0.0`
### Example: Simple MCP Server
```bash
# From examples/mcp_servers/simple/
docker-compose -f docker/docker-compose.yml up --build
```
You can customize the port by editing `docker/docker-compose.yml` and changing both the `ARCADE_SERVER_PORT` environment variable and the port mapping.
## Building the Image
```bash
docker build \
-f docker/Dockerfile \
-t your-mcp-server \
.
```
## Running with Docker
```bash
docker run -p 8001:8001 \
-e ARCADE_SERVER_TRANSPORT=http \
-e ARCADE_SERVER_HOST=0.0.0.0 \
-e ARCADE_SERVER_PORT=8001 \
your-mcp-server
```
## Features
- **Automatic package detection**: Reads package name from `pyproject.toml`
- **Standard server location**: Expects server file at `src/<package>/server.py`
- **Secure by default**: Runs as non-root user
- **Arcade environment variable support**: Uses `ARCADE_SERVER_*` environment variables
- **Environment-based config**: Easy customization via environment variables
- **uv integration**: Uses uv for fast dependency management
- **Lightweight**: Based on Python 3.11 Bookworm slim image with uv
## Connecting from Cursor
Add to your `~/.cursor/mcp.json`:
```json
"your-server-name": {
"name": "your-server-name",
"type": "stream",
"url": "http://localhost:8001"
}
```
Then restart Cursor to connect to the server.

View file

@ -0,0 +1,11 @@
services:
mcp-server:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "8001:8001"
environment:
- ARCADE_SERVER_TRANSPORT=http
- ARCADE_SERVER_HOST=0.0.0.0
- ARCADE_SERVER_PORT=8001

View file

@ -0,0 +1,27 @@
[project]
name = "logging"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/logging"]
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,25 @@
from typing import Annotated
from arcade_mcp_server import Context, MCPApp
from loguru import logger
app = MCPApp("Logging server")
@app.tool
async def log_message(context: Context, message: Annotated[str, "The message to log"]) -> str:
"""Log a message at varying levels."""
await context.log.log("debug", f"Debug via log.log: {message}")
await context.log.debug(f"Debug via log.debug: {message}")
await context.log.info(f"Info via log.info: {message}")
await context.log.warning(f"Warning via log.warning: {message}")
await context.log.error(f"Error via log.error: {message}")
await context.log.log("info", f"Info via log.log: {message}")
return message
if __name__ == "__main__":
logger.info("Just about to start running the server...")
app.run(transport="http")
logger.info("Server has finished running...")

View file

@ -0,0 +1,45 @@
[project]
name = "progress_reporting"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "progress_reporting"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/progress_reporting"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""progress_reporting MCP server"""
import asyncio
import sys
from arcade_mcp_server import Context, MCPApp
app = MCPApp(name="progress_reporting", version="1.0.0", log_level="DEBUG")
@app.tool
async def report_progress(context: Context) -> str:
"""Report progress back to the client"""
total = 5
for i in range(total):
await context.progress.report(i + 1, total=total, message=f"Step {i + 1} of {total}")
await asyncio.sleep(1)
return "All progress reported successfully"
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,45 @@
[project]
name = "sampling"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "sampling"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/sampling"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""sampling MCP server"""
import sys
from typing import Annotated
from arcade_mcp_server import Context, MCPApp
app = MCPApp(name="sampling", version="1.0.0", log_level="DEBUG")
@app.tool
async def summarize_text(
context: Context, text: Annotated[str, "The text to be summarized by the client's model"]
) -> str:
"""Summarize the text using the client's model."""
result = await context.sampling.create_message(
messages=text,
system_prompt=(
"You are a helpful assistant that summarizes text. "
"Given a text, you should summarize it in a few sentences."
),
)
return result.text
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,97 @@
from arcade_core import ToolCatalog
from arcade_evals import (
BinaryCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
import server_with_evaluations
from server_with_evaluations.tools import greet
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
# Add all of the tools in the server_with_evaluations module to the catalog
catalog.add_module(server_with_evaluations)
@tool_eval()
def server_with_evaluations_binary_eval_suite() -> EvalSuite:
"""Create an evaluation suite for the greet tool using the BinaryCritic."""
suite = EvalSuite(
name="MCP Server Evaluation",
catalog=catalog,
system_message="You are a helpful assistant.",
rubric=rubric,
)
suite.add_case(
name="Easy Case - Explicitly greet Alice",
user_message="Greet Alice",
expected_tool_calls=[
ExpectedToolCall(
func=greet,
args={
"name": "Alice",
},
)
],
critics=[
BinaryCritic(critic_field="name", weight=1.0),
],
)
suite.add_case(
name="Implicitly tell the model to greet Bob",
user_message="Can you greet the other person now, please",
expected_tool_calls=[
ExpectedToolCall(
func=greet,
args={
"name": "Bob",
},
)
],
critics=[
BinaryCritic(critic_field="name", weight=1.0),
],
additional_messages=[ # Simulate a conversation that happened before this eval case
{
"role": "user",
"content": "I'm here with Alice and Bob. Please greet Alice.",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx",
"type": "function",
"function": {
"name": "ServerWithEvaluations_Greet",
"arguments": '{"name":"Alice"}',
},
}
],
},
{
"role": "tool",
"content": "Hello, Alice!",
"tool_call_id": "call_exyK4LmJEHSDn1Xw5oVfS9Xx",
"name": "ServerWithEvaluations_Greet",
},
{
"role": "assistant",
"content": "Hello, Alice!",
},
],
)
return suite

View file

@ -0,0 +1,95 @@
from datetime import timedelta
from arcade_core import ToolCatalog
from arcade_evals import (
BinaryCritic,
DatetimeCritic,
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
import server_with_evaluations
from server_with_evaluations.tools import calculate_time_until_event, get_meeting_reminder
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
# Add all of the tools in the server_with_evaluations module to the catalog
catalog.add_module(server_with_evaluations)
@tool_eval()
def server_with_evaluations_datetime_eval_suite() -> EvalSuite:
"""
Create an evaluation suite for datetime tools using DatetimeCritic.
DatetimeCritic is useful when:
- Dealing with time-based operations that may have slight variations
- Evaluating meeting scheduling and reminder systems
- Testing time difference calculations where exact precision isn't critical
- Scenarios where timezone handling or clock drift might affect results
"""
suite = EvalSuite(
name="Datetime Tools Evaluation",
catalog=catalog,
system_message="You are a helpful assistant for managing meetings and schedules.",
rubric=rubric,
)
suite.add_case(
name="Calculate time remaining until meeting",
user_message=(
"How long until the meeting on 2025-10-15T14:30:00? "
"Current time is around 2025-10-15T13:45:00 give or take a minute"
),
expected_tool_calls=[
ExpectedToolCall(
func=calculate_time_until_event,
args={
"event_datetime": "2025-10-15T14:30:00",
"from_datetime": "2025-10-15T13:45:00",
},
)
],
critics=[
BinaryCritic(critic_field="from_datetime", weight=0.5),
DatetimeCritic(
critic_field="event_datetime",
weight=0.5,
tolerance=timedelta(seconds=60),
max_difference=timedelta(hours=1),
),
],
)
suite.add_case(
name="Get 15-minute reminder for upcoming appointment",
user_message="Get 15-minute reminder for my upcoming appointment around 2025-10-15T09:00:00+00:00",
expected_tool_calls=[
ExpectedToolCall(
func=get_meeting_reminder,
args={
"event_datetime": "2025-10-15T09:00:00+00:00",
"reminder_minutes": 15,
},
)
],
critics=[
DatetimeCritic(
critic_field="event_datetime",
weight=0.5,
tolerance=timedelta(seconds=600),
max_difference=timedelta(hours=1),
),
BinaryCritic(critic_field="reminder_minutes", weight=0.5),
],
)
return suite

View file

@ -0,0 +1,62 @@
from arcade_core import ToolCatalog
from arcade_evals import (
EvalRubric,
EvalSuite,
ExpectedToolCall,
NumericCritic,
tool_eval,
)
import server_with_evaluations
from server_with_evaluations.tools import get_n_random_numbers
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
# Add all of the tools in the server_with_evaluations module to the catalog
catalog.add_module(server_with_evaluations)
@tool_eval()
def server_with_evaluations_numeric_eval_suite() -> EvalSuite:
"""
Create an evaluation suite for numeric tools using NumericCritic.
NumericCritic is useful when:
- Evaluating calculations where exact precision isn't required
- Cases where numeric values are within an acceptable range
"""
suite = EvalSuite(
name="Numeric Tools Evaluation",
catalog=catalog,
system_message="You are a helpful assistant for data analysis and statistics.",
rubric=rubric,
)
suite.add_case(
name="Generate random numbers",
user_message="Generate some random numbers. Generate at least 10 numbers, but less than or equal to 20 numbers.",
expected_tool_calls=[
ExpectedToolCall(
func=get_n_random_numbers,
args={
"n": 15,
},
)
],
critics=[
NumericCritic(
critic_field="n",
weight=1.0,
value_range=(10, 20), # n must be between 10 and 20 inclusive
match_threshold=1.0,
),
],
)
return suite

View file

@ -0,0 +1,99 @@
from arcade_core import ToolCatalog
from arcade_evals import (
EvalRubric,
EvalSuite,
ExpectedToolCall,
SimilarityCritic,
tool_eval,
)
import server_with_evaluations
from server_with_evaluations.tools import create_email_subject, write_product_description
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
# Add all of the tools in the server_with_evaluations module to the catalog
catalog.add_module(server_with_evaluations)
@tool_eval()
def server_with_evaluations_similarity_eval_suite() -> EvalSuite:
"""
Create an evaluation suite for text tools using SimilarityCritic.
SimilarityCritic evaluates the TEXT INPUTS/ARGUMENTS passed to tools,
not the tool results. It's useful when:
- The model needs to paraphrase or reword user requests as tool arguments
- Content description varies in wording but carries the same meaning
- Multiple valid ways exist to express the same concept
- You want to check semantic similarity rather than exact text match
Example: User says "email about trees on west coast" model might call
tool with "West Coast Trees" or "Trees of the Western Coast" - both valid!
"""
suite = EvalSuite(
name="Similarity Tools Evaluation",
catalog=catalog,
system_message="You are a helpful assistant for text analysis and summarization.",
rubric=rubric,
)
# The model might rephrase "trees in the west coast" in various ways
suite.add_case(
name="Create email subject",
user_message="Create an email subject using the tools accessible to you for trees in west coast content",
expected_tool_calls=[
ExpectedToolCall(
func=create_email_subject,
args={
"email_content": "Trees in the West Coast",
"tone": "professional",
},
)
],
critics=[
SimilarityCritic(
critic_field="email_content",
weight=1.0,
similarity_threshold=0.75, # Allow for paraphrasing
metric="cosine",
)
],
)
# Model might rephrase features in different ways while keeping the same meaning
suite.add_case(
name="Write product description for fitness tracker",
user_message="Write a product description for a fitness tracker. The key features are heart rate monitoring and GPS tracking. Target audience is outdoor enthusiasts.",
expected_tool_calls=[
ExpectedToolCall(
func=write_product_description,
args={
"main_features": "heart rate monitoring and GPS tracking",
"target_audience": "outdoor enthusiasts",
},
)
],
critics=[
SimilarityCritic(
critic_field="main_features",
weight=0.6,
similarity_threshold=0.7, # Slightly lower threshold for feature lists
metric="cosine",
),
SimilarityCritic(
critic_field="target_audience",
weight=0.4,
similarity_threshold=0.75,
metric="cosine",
),
],
)
return suite

View file

@ -0,0 +1,46 @@
[project]
name = "server_with_evaluations"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "server_with_evaluations"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/server_with_evaluations"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""server-with-evaluations MCP server"""
from arcade_mcp_server import MCPApp
import server_with_evaluations
app = MCPApp(name="server-with-evaluations", version="1.0.0", log_level="DEBUG")
app.add_tools_from_module(server_with_evaluations)
if __name__ == "__main__":
app.run(transport="stdio")

View file

@ -0,0 +1,21 @@
from server_with_evaluations.tools.tools_for_binary_critic import greet
from server_with_evaluations.tools.tools_for_datetime_critic import (
calculate_time_until_event,
get_meeting_reminder,
)
from server_with_evaluations.tools.tools_for_numeric_critic import (
get_n_random_numbers,
)
from server_with_evaluations.tools.tools_for_similarity_critic import (
create_email_subject,
write_product_description,
)
__all__ = [
"get_meeting_reminder",
"calculate_time_until_event",
"get_n_random_numbers",
"create_email_subject",
"write_product_description",
"greet",
]

View file

@ -0,0 +1,9 @@
from typing import Annotated
from arcade_mcp_server import tool
@tool
def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"

View file

@ -0,0 +1,46 @@
from datetime import datetime
from typing import Annotated
from arcade_mcp_server import tool
@tool
def get_meeting_reminder(
event_datetime: Annotated[str, "The date and time of the event in ISO format"],
reminder_minutes: Annotated[int, "Minutes before the event to remind"] = 15,
) -> dict[str, str]:
"""
Calculate the reminder time for a meeting.
Useful when you need to schedule reminders for important events.
This tool helps manage calendar-based tasks.
"""
event_dt = datetime.fromisoformat(event_datetime)
reminder_dt = event_dt.replace(minute=event_dt.minute - reminder_minutes)
return {
"reminder_time": reminder_dt.isoformat(),
"event_time": event_datetime,
"reminder_minutes": reminder_minutes,
}
@tool
def calculate_time_until_event(
event_datetime: Annotated[str, "The date and time of the event in ISO format"],
from_datetime: Annotated[str, "The current or reference date and time in ISO format"],
) -> dict[str, str]:
"""
Calculate the time remaining until an event.
Provides a time difference calculation, useful for scheduling
and time management scenarios.
"""
event_dt = datetime.fromisoformat(event_datetime)
from_dt = datetime.fromisoformat(from_datetime)
delta = event_dt - from_dt
return {
"time_remaining": str(delta),
"event_datetime": event_datetime,
"from_datetime": from_datetime,
}

View file

@ -0,0 +1,14 @@
import random
from typing import Annotated
from arcade_mcp_server import tool
@tool
def get_n_random_numbers(
n: Annotated[int, "The number of random numbers to generate"],
) -> Annotated[list[int], "A list of random numbers between 0 and 100"]:
"""
Generate a list of random numbers between 0 and 100.
"""
return [random.randint(0, 100) for _ in range(n)] # noqa: S311

View file

@ -0,0 +1,44 @@
from typing import Annotated
from arcade_mcp_server import tool
@tool
def create_email_subject(
email_content: Annotated[str, "The main topic or content of the email"],
tone: Annotated[str, "Desired tone: 'professional', 'casual', or 'friendly'"] = "professional",
) -> dict[str, str]:
"""
Generate an email subject line based on the email content.
Useful when you need to create engaging subject lines that capture
the essence of the email content. The subject should be concise and
compelling while reflecting the main topic.
"""
# In practice, this would use an AI model to generate the subject
return {
"subject": email_content[:60] + ("..." if len(email_content) > 60 else ""),
"original_content": email_content,
"tone_used": tone,
}
@tool
def write_product_description(
main_features: Annotated[str, "Key features or highlights of the product"],
target_audience: Annotated[str, "Who this product is for"],
) -> dict[str, str]:
"""
Create a marketing description for a product.
The description should emphasize the key features while appealing
to the target audience.
"""
# In practice, this would use an AI model to craft the description
description = f"Experience {main_features} designed for {target_audience}."
return {
"description": description,
"features_highlighted": main_features,
"target_audience": target_audience,
}

View file

@ -0,0 +1,45 @@
[project]
name = "simple"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/simple"]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "simple"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""simple_server MCP server"""
"""simple MCP server"""
import sys
from typing import Annotated
@ -8,7 +8,7 @@ import httpx
from arcade_mcp_server import Context, MCPApp
from arcade_mcp_server.auth import Reddit
app = MCPApp(name="simple_server", version="1.0.0", log_level="DEBUG")
app = MCPApp(name="simple", version="1.0.0", log_level="DEBUG")
@app.tool
@ -17,7 +17,7 @@ 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
# To use this tool locally, 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"""
@ -32,8 +32,8 @@ 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.
# To use this tool locally, you need to install the Arcade CLI (uv tool install arcade-mcp)
# and then 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"]
@ -48,7 +48,7 @@ async def get_posts_in_subreddit(
oauth_token = context.get_auth_token_or_empty()
headers = {
"Authorization": f"Bearer {oauth_token}",
"User-Agent": "simple_server-mcp-server",
"User-Agent": "simple-mcp-server",
}
params = {"limit": 5}
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
@ -64,10 +64,13 @@ async def get_posts_in_subreddit(
# Run with specific transport
if __name__ == "__main__":
# Get transport from command line argument, default to "http"
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
# Get transport from command line argument, default to "stdio"
# - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc.
# Supports tools that require_auth or require_secrets out-of-the-box
# - "http": HTTPS streaming for Cursor, VS Code, etc.
# Does not support tools that require_auth or require_secrets unless the server is deployed
# using 'arcade deploy' or added in the Arcade Developer Dashboard with 'Arcade' server type
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
# Run the server
# - "http" (default): HTTPS streaming for Cursor, VS Code, etc.
# - "stdio": Standard I/O for Claude Desktop, CLI tools, etc.
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -1,37 +0,0 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "simple_server"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.0.1,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.0.1,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "simple_server"
[tool.setuptools.packages.find]
include = ["simple_server*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -0,0 +1,45 @@
[project]
name = "tool_chaining"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "tool_chaining"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tool_chaining"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,2 @@
API_KEY=ae_12345
PASSWORD=pass123

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""tool_calling_other_tools_programmatically MCP server"""
import hashlib
from typing import Annotated
from arcade_mcp_server import Context, MCPApp
app = MCPApp(name="ToolChainer", version="1.0.0", log_level="DEBUG")
@app.tool
def hash_text(text: Annotated[str, "The text to hash"]) -> str:
"""Hash the text"""
return hashlib.sha256(text.encode()).hexdigest()
@app.tool(requires_secrets=["PASSWORD"])
async def get_password_as_hash_value(context: Context) -> str:
"""Get the hash value of the password"""
elicitation_schema = {"type": "object", "properties": {"confirmation": {"type": "boolean"}}}
result = await context.ui.elicit(
"Are you sure you want to get the hash value of the password?",
schema=elicitation_schema,
)
if result.action == "accept":
return hash_text(context.get_secret("PASSWORD"))
else:
raise ValueError("User did not confirm the elicitation")
@app.tool(requires_secrets=["API_KEY"])
async def get_api_key_as_hash_value(context: Context) -> str:
"""Get the hash value of the API key"""
return hash_text(context.get_secret("API_KEY"))
@app.tool
async def get_secret_as_hash_value(
context: Context,
secret_name: Annotated[str, "The name of the secret to get the hash value of"],
) -> str:
"""Get the hash value of a secret"""
tool_name = ""
if secret_name.upper() == "PASSWORD":
tool_name = "ToolChainer_GetPasswordAsHashValue"
elif secret_name.upper() == "API_KEY":
tool_name = "ToolChainer_GetApiKeyAsHashValue"
if not tool_name:
return "Sorry, but I don't know how to get the hash value of that secret."
hash_response = await context.tools.call_raw(tool_name, {})
if hash_response.isError:
return (
"Sorry, but I couldn't get the hash value of the secret, because: "
+ hash_response.structuredContent["error"]
)
return hash_response.structuredContent["result"]
if __name__ == "__main__":
app.run(transport="stdio")

View file

@ -0,0 +1,45 @@
[project]
name = "user_elicitation"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.5.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.4.0,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "user_elicitation"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/user_elicitation"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
# # Otherwise, you will install the following packages from PyPI
# [tool.uv.sources]
# arcade-mcp = { path = "../../../", editable = true }
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }

View file

@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""user_elicitation MCP server"""
import sys
from arcade_mcp_server import Context, MCPApp
app = MCPApp(name="user_elicitation", version="1.0.0", log_level="DEBUG")
elicitation_schema = {"type": "object", "properties": {"nickname": {"type": "string"}}}
@app.tool
async def elicit_nickname(context: Context) -> str:
"""Ask the end user for their nickname, and then use it to greet them."""
result = await context.ui.elicit(
"What is your nickname?",
schema=elicitation_schema,
)
if result.action == "accept":
return f"Hello, {result.content['nickname']}!"
elif result.action == "decline":
return "User declined to provide a nickname."
elif result.action == "cancel":
return "User cancelled the elicitation."
return "Unknown response from client"
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
app.run(transport=transport, host="127.0.0.1", port=8000)

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.3.0" # Default version if unable to fetch
ARCADE_MCP_MIN_VERSION = "1.4.0" # Default version if unable to fetch
ARCADE_MCP_MAX_VERSION = "2.0.0"
ARCADE_TDK_MIN_VERSION = "3.0.0"
ARCADE_TDK_MAX_VERSION = "4.0.0"
ARCADE_SERVE_MIN_VERSION = "3.0.0"
ARCADE_SERVE_MAX_VERSION = "4.0.0"
ARCADE_MCP_SERVER_MIN_VERSION = "1.4.0"
ARCADE_MCP_SERVER_MIN_VERSION = "1.5.0"
ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0"

View file

@ -1,7 +1,3 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{{ toolkit_name }}"
version = "0.1.0"
@ -25,14 +21,18 @@ dev = [
[project.entry-points.arcade_toolkits]
toolkit_name = "{{ toolkit_name }}"
[tool.setuptools.packages.find]
include = ["{{ toolkit_name }}*"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{{ toolkit_name }}"]
[tool.ruff]
line-length = 100
target-version = "py310"
target-version = "py312"
[tool.mypy]
python_version = "3.10"
python_version = "3.12"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -58,6 +58,7 @@ from arcade_core.utils import (
is_string_literal,
is_union,
snake_to_pascal_case,
space_to_snake_case,
)
logger = logging.getLogger(__name__)
@ -418,7 +419,7 @@ class ToolCatalog(BaseModel):
metadata_requirement = create_metadata_requirement(tool, auth_requirement)
toolkit_definition = ToolkitDefinition(
name=snake_to_pascal_case(toolkit_name),
name=snake_to_pascal_case(space_to_snake_case(toolkit_name)),
description=toolkit_desc,
version=toolkit_version,
)

View file

@ -361,8 +361,6 @@ def get_package_directory(package_name: str) -> str:
class Validate:
warn = True
@classmethod
def path(cls, path: str | Path) -> bool:
"""
@ -376,11 +374,6 @@ class Validate:
all_parts = set(posix_path.parts) | set(windows_path.parts)
for part in all_parts:
if (part == "venv" or part.startswith(".")) and cls.warn:
print(
f"⚠️ Your package may contain a venv directory or hidden files. We suggest moving these out of the toolkit directory to avoid deployment issues: {path}"
)
cls.warn = False
if part in {"dist", "build", "__pycache__", "coverage.xml"}:
return False
if part.endswith(".lock"):

View file

@ -29,6 +29,13 @@ def pascal_to_snake_case(name: str) -> str:
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def space_to_snake_case(name: str) -> str:
"""
Converts a space delimited name to snake_case.
"""
return name.replace(" ", "_")
def snake_to_pascal_case(name: str) -> str:
"""
Converts a snake_case name to PascalCase.

View file

@ -1,6 +1,6 @@
[project]
name = "arcade-core"
version = "3.1.0"
version = "3.2.0"
description = "Arcade Core - Core library for Arcade platform"
readme = "README.md"
license = {text = "MIT"}

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio
import logging
import uuid
import weakref
from builtins import list as builtins_list
from contextvars import ContextVar, Token
@ -33,13 +34,19 @@ from typing import Any, cast
from arcade_core.context import ModelContext as ModelContextProtocol
from arcade_core.schema import (
ToolCallOutput,
ToolContext,
)
from arcade_mcp_server.types import (
AudioContent,
CallToolParams,
CallToolRequest,
CallToolResult,
ClientCapabilities,
CreateMessageResult,
ElicitResult,
ImageContent,
JSONRPCError,
LoggingLevel,
ModelHint,
ModelPreferences,
@ -439,7 +446,7 @@ class Progress(_ContextComponent):
if session is None:
return
progress_token = None
if hasattr(session, "_request_meta"):
if hasattr(session, "_request_meta") and session._request_meta is not None:
progress_token = getattr(session._request_meta, "progressToken", None)
if progress_token is None:
return
@ -492,31 +499,24 @@ class Tools(_ContextComponent):
tools = await self._ctx.server._tool_manager.list_tools()
return cast(list[Any], tools)
async def call_raw(self, name: str, params: dict[str, Any]) -> ToolCallOutput:
tool = await self._ctx.server._tool_manager.get_tool(name)
tool_context = await self._ctx.server._create_tool_context(tool, self._ctx._session)
# Attach to current model context for the duration of this call
self._ctx.set_tool_context(tool_context)
func = tool.tool
if asyncio.iscoroutinefunction(func):
async def async_func(**kw: Any) -> Any:
return await func(**kw)
else:
async def async_func(**kw: Any) -> Any:
return func(**kw)
result = await self._ctx.server.executor.run(
func=async_func,
definition=tool.definition,
input_model=tool.input_model,
output_model=tool.output_model,
context=self._ctx,
**params,
async def call_raw(self, name: str, params: dict[str, Any]) -> CallToolResult:
internal_id = f"internal-{uuid.uuid4()}"
request = CallToolRequest(
id=internal_id,
params=CallToolParams(name=name, arguments=params),
)
return cast(ToolCallOutput, result)
response = await self._ctx.server._handle_call_tool(request, self._ctx._session)
if isinstance(response, JSONRPCError):
error_message = response.error.get("message", "Unknown error")
return CallToolResult(
content=[TextContent(type="text", text=error_message)],
structuredContent={"error": error_message},
isError=True,
)
return cast(CallToolResult, response.result)
class Prompts(_ContextComponent):
@ -543,7 +543,7 @@ class Sampling(_ContextComponent):
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
) -> Any:
) -> TextContent | ImageContent | AudioContent | CreateMessageResult:
if self._ctx._session is None:
raise ValueError("Session not available")
@ -580,7 +580,7 @@ class Sampling(_ContextComponent):
model_preferences=parsed_prefs,
)
return result.content if hasattr(result, "content") else result
return result.content if hasattr(result, "content") else result # type: ignore[no-any-return]
class UI(_ContextComponent):

View file

@ -11,7 +11,8 @@ import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable, Literal, ParamSpec, TypeVar
from types import ModuleType
from typing import Any, Callable, Literal, ParamSpec, TypeVar, cast
import uvicorn
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
@ -190,6 +191,10 @@ class MCPApp:
logger.debug(f"Added tool: {func.__name__}")
return func
def add_tools_from_module(self, module: ModuleType) -> None:
"""Add all the tools in a module to the catalog."""
self._catalog.add_module(module)
def tool(
self,
func: Callable[P, T] | None = None,
@ -358,7 +363,7 @@ class MCPApp:
) -> tuple[str, int, TransportType, bool]:
"""Get configuration overrides from environment variables."""
if envvar_transport := os.getenv("ARCADE_SERVER_TRANSPORT"):
transport = envvar_transport
transport = cast(TransportType, envvar_transport)
logger.debug(
f"Using '{transport}' as transport from ARCADE_SERVER_TRANSPORT environment variable"
)

View file

@ -454,6 +454,12 @@ class MCPServer:
# Create context and apply middleware
try:
# Store the request's meta in the session
if session:
params = message.get("params", {})
meta = params.get("_meta")
session.set_request_meta(meta)
# Create request context
context = (
await session.create_request_context()
@ -494,6 +500,7 @@ class MCPServer:
set_current_model_context(None, token)
if session:
await session.cleanup_request_context(context)
session.clear_request_meta()
except Exception:
logger.exception("Error handling message")
@ -667,18 +674,33 @@ class MCPServer:
# Attach tool_context to current model context for this request
mctx = get_current_model_context()
saved_tool_context: ToolContext | None = None
if mctx is not None:
# Save the current tool context so we can restore it after the call
# This prevents context leakage from callee back to caller in the case of tool chaining.
saved_tool_context = ToolContext(
authorization=mctx.authorization,
secrets=mctx.secrets,
metadata=mctx.metadata,
user_id=mctx.user_id,
)
mctx.set_tool_context(tool_context)
# Execute tool
result = await ToolExecutor.run(
func=tool.tool,
definition=tool.definition,
input_model=tool.input_model,
output_model=tool.output_model,
context=mctx if mctx is not None else tool_context,
**input_params,
)
try:
# Execute tool
result = await ToolExecutor.run(
func=tool.tool,
definition=tool.definition,
input_model=tool.input_model,
output_model=tool.output_model,
context=mctx if mctx is not None else tool_context,
**input_params,
)
finally:
# Restore the original tool context to prevent context leakage to parent tools in the case of tool chaining.
if mctx is not None and saved_tool_context is not None:
mctx.set_tool_context(saved_tool_context)
# Convert result
if result.value is not None:

View file

@ -11,8 +11,11 @@ import json
import logging
import uuid
from enum import Enum
from types import SimpleNamespace
from typing import Any
import anyio
from arcade_mcp_server.context import Context
from arcade_mcp_server.exceptions import RequestError, SessionError
from arcade_mcp_server.types import (
@ -138,7 +141,6 @@ class RequestManager:
async with self._lock:
future = self._pending_requests.get(str(request_id))
if future and not future.done():
if "error" in message:
logger.debug(f"Response id={request_id} contains error; propagating")
@ -274,6 +276,7 @@ class ServerSession:
self.initialization_state = InitializationState.NOT_INITIALIZED
self.client_params: InitializeParams | None = None
self._session_data: dict[str, Any] = {}
self._request_meta: Any = None
# Request management
self._request_manager = RequestManager(write_stream) if write_stream else None
@ -335,25 +338,28 @@ class ServerSession:
"""
Run the session message loop.
Reads messages from the stream and processes them.
Reads messages from the stream and processes them concurrently
to allow server-initiated requests to be handled while tools execute.
"""
if not self.read_stream:
raise SessionError("No read stream available")
try:
async for message in self.read_stream:
if message:
await self._process_message(message)
except asyncio.CancelledError:
pass
except Exception as e:
await self.server.logger.exception("Session error")
raise SessionError(f"Session error: {e}") from e
finally:
# Cleanup
if self._request_manager:
# Cancel any pending requests
await self._cleanup_pending_requests()
async with anyio.create_task_group() as tg:
try:
async for message in self.read_stream:
if message:
# Process messages concurrently so the loop can continue reading
tg.start_soon(self._process_message, message)
except asyncio.CancelledError:
pass
except Exception as e:
await self.server.logger.exception("Session error")
raise SessionError(f"Session error: {e}") from e
finally:
# Cleanup
if self._request_manager:
# Cancel any pending requests
await self._cleanup_pending_requests()
async def _process_message(self, message: str) -> None:
"""Process a single message."""
@ -622,6 +628,15 @@ class ServerSession:
return ElicitResult(**result)
# Request metadata management
def set_request_meta(self, meta: dict[str, Any] | None) -> None:
"""Store meta for the current request"""
self._request_meta = SimpleNamespace(**meta) if meta else None
def clear_request_meta(self) -> None:
"""Clear the request's meta after the request is complete"""
self._request_meta = None
# Context management
async def create_request_context(self) -> Context:
"""Create a context for the current request."""

View file

@ -753,10 +753,10 @@ class HTTPStreamableTransport:
the session to the correct per-request stream (or the standalone GET
stream identified by `GET_STREAM_KEY`).
"""
# Create memory streams
read_stream_writer, read_stream = anyio.create_memory_object_stream[str | Exception](0)
# Create memory streams with buffer
read_stream_writer, read_stream = anyio.create_memory_object_stream[str | Exception](100)
write_stream, write_stream_reader = anyio.create_memory_object_stream[str | SessionMessage](
0
100
)
# Store the streams

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
version = "1.4.1"
version = "1.5.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
@ -21,7 +21,7 @@ classifiers = [
]
requires-python = ">=3.10"
dependencies = [
"arcade-core>=3.0.0,<4.0.0",
"arcade-core>=3.2.0,<4.0.0",
"arcade-serve>=3.0.0,<4.0.0",
"arcade-tdk>=3.0.0,<4.0.0",
"arcadepy>=1.5.0",

View file

@ -0,0 +1,15 @@
from typing import Annotated
from arcade_mcp_server import Context, tool
@tool
async def logging_tool(context: Context, message: Annotated[str, "The message to log"]) -> str:
"""Log a message at varying levels."""
await context.log.log("debug", f"Debug via log.log: {message}")
await context.log.debug(f"Debug via log.debug: {message}")
await context.log.info(f"Info via log.info: {message}")
await context.log.warning(f"Warning via log.warning: {message}")
await context.log.error(f"Error via log.error: {message}")
return message

View file

@ -0,0 +1,12 @@
from arcade_mcp_server import Context, tool
@tool
async def reporting_progress(context: Context) -> str:
"""Report progress back to the client"""
total = 5
for i in range(total):
await context.progress.report(i + 1, total=total, message=f"Step {i + 1} of {total}")
return "All progress reported successfully"

View file

@ -0,0 +1,18 @@
from typing import Annotated
from arcade_mcp_server import Context, tool
@tool
async def sampling(
context: Context, text: Annotated[str, "The text to be summarized by the client's model"]
) -> str:
"""Summarize the text using the client's model."""
result = await context.sampling.create_message(
messages=text,
system_prompt=(
"You are a helpful assistant that summarizes text. "
"Given a text, you should summarize it in a few sentences."
),
)
return result.text

View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""E2E integration test MCP server"""
import sys
from arcade_mcp_server import MCPApp
from logging_tools import logging_tool
from progress_tools import reporting_progress
from sampling_tools import sampling
from tool_chaining_tools import (
call_other_tool,
the_other_tool,
)
from user_elicitation_tools import elicit_nickname
app = MCPApp(name="Test", version="1.0.0", log_level="DEBUG")
# Logging
app.add_tool(logging_tool)
# Report progress
app.add_tool(reporting_progress)
# Sampling
app.add_tool(sampling)
# User elicitation
app.add_tool(elicit_nickname)
# Tool chaining
app.add_tool(call_other_tool)
app.add_tool(the_other_tool)
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,24 @@
from arcade_mcp_server import Context, tool
@tool
def the_other_tool() -> str:
"""A tool that is called by a tool"""
return "I am the other tool."
@tool
async def call_other_tool(
context: Context,
) -> str:
"""Get the hash value of a secret"""
other_tool_response = await context.tools.call_raw("Test_TheOtherTool", {})
if other_tool_response.isError:
return (
"Sorry, but I couldn't call the other tool, because: "
+ other_tool_response.structuredContent["error"]
)
return "SUCCESS: " + other_tool_response.structuredContent["result"]

View file

@ -0,0 +1,20 @@
from arcade_mcp_server import Context, tool
@tool
async def elicit_nickname(context: Context) -> str:
"""Ask the end user for their nickname, and then use it to greet them."""
elicitation_schema = {"type": "object", "properties": {"nickname": {"type": "string"}}}
result = await context.ui.elicit(
"What is your nickname?",
schema=elicitation_schema,
)
if result.action == "accept":
return f"Hello, {result.content['nickname']}!"
elif result.action == "decline":
return "User declined to provide a nickname."
elif result.action == "cancel":
return "User cancelled the elicitation."
return "Unknown response from client"

View file

@ -0,0 +1,627 @@
"""End-to-end integration tests for arcade_mcp_server.
Tests the full MCP protocol flow for both stdio and HTTP transports,
including initialize, ping, list tools, and tool execution with all key features.
"""
import asyncio
import json
import os
import random
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
import httpx
import pytest
# Helper Functions
def get_server_path() -> str:
"""Get the path to the test server entrypoint."""
return str(Path(__file__).parent / "server" / "server.py")
def start_mcp_server(
transport: str, port: int | None = None
) -> tuple[subprocess.Popen, int | None]:
"""
Start the MCP server as a subprocess.
Args:
transport: Transport type ("stdio" or "http")
port: Port for HTTP transport (optional, will be random if not provided)
Returns:
Tuple of (process, port). Port is None for stdio transport.
"""
server_path = get_server_path()
if transport == "stdio":
cmd = [sys.executable, server_path, "stdio"]
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # Line buffered
)
return process, None
elif transport == "http":
if port is None:
port = random.randint(8000, 9000) # noqa: S311
env = {
**os.environ,
"ARCADE_SERVER_HOST": "127.0.0.1",
"ARCADE_SERVER_PORT": str(port),
"ARCADE_SERVER_TRANSPORT": "http",
"ARCADE_AUTH_DISABLED": "true",
}
cmd = [sys.executable, server_path, "http"]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
return process, port
else:
raise ValueError(f"Invalid transport: {transport}")
def wait_for_http_server_ready(port: int, timeout: int = 30) -> None:
"""
Wait for HTTP server to become healthy.
Args:
port: Server port
timeout: Maximum time to wait in seconds
Raises:
TimeoutError: If server doesn't become healthy within timeout
"""
health_url = f"http://127.0.0.1:{port}/worker/health"
start_time = time.time()
while time.time() - start_time < timeout:
try:
response = httpx.get(health_url, timeout=2.0)
if response.status_code == 200:
return
except (httpx.ConnectError, httpx.TimeoutException):
pass
time.sleep(0.5)
raise TimeoutError(f"Server failed to become healthy within {timeout} seconds")
def build_jsonrpc_request(
method: str, params: dict | None = None, request_id: int | None = None
) -> dict:
"""
Build a JSON-RPC 2.0 request.
Args:
method: Method name
params: Method parameters
request_id: Request ID (omit for notifications)
Returns:
JSON-RPC request dict
"""
request: dict[str, Any] = {
"jsonrpc": "2.0",
"method": method,
}
if params is not None:
request["params"] = params
if request_id is not None:
request["id"] = request_id
return request
def parse_jsonrpc_message(line: str) -> dict | None:
"""
Parse a JSON-RPC message from a line of text.
Args:
line: Line of text containing JSON
Returns:
Parsed JSON dict or None if parsing fails
"""
if not line or not line.strip():
return None
try:
return json.loads(line.strip())
except json.JSONDecodeError:
return None
def mock_sampling_response(request_id: str | int) -> dict:
"""
Create a mock response for sampling/createMessage request.
Args:
request_id: Request ID from the server's request
Returns:
JSON-RPC response with mock sampling result
"""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"model": "mock-model",
"role": "assistant",
"content": {
"type": "text",
"text": "This is a mock summary of the text.",
},
"stopReason": "endTurn",
},
}
def mock_elicitation_response(request_id: str | int) -> dict:
"""
Create a mock response for user elicitation request.
Args:
request_id: Request ID from the server's request
Returns:
JSON-RPC response with mock elicitation acceptance
"""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"action": "accept",
"content": {"nickname": "TestUser"},
},
}
class StdioClient:
"""Helper class to communicate with stdio MCP server."""
def __init__(self, process: subprocess.Popen):
self.process = process
self._next_id = 1
def send_request(self, method: str, params: dict | None = None) -> int:
"""Send a request and return the request ID."""
request_id = self._next_id
self._next_id += 1
request = build_jsonrpc_request(method, params, request_id)
message = json.dumps(request) + "\n"
if self.process.stdin:
self.process.stdin.write(message)
self.process.stdin.flush()
return request_id
def send_notification(self, method: str, params: dict | None = None) -> None:
"""Send a notification (no response expected)."""
notification = build_jsonrpc_request(method, params, request_id=None)
message = json.dumps(notification) + "\n"
if self.process.stdin:
self.process.stdin.write(message)
self.process.stdin.flush()
def send_response(self, request_id: str | int, result: dict) -> None:
"""Send a response to a server-initiated request."""
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": result,
}
message = json.dumps(response) + "\n"
if self.process.stdin:
self.process.stdin.write(message)
self.process.stdin.flush()
def read_response(self, timeout: float = 10.0) -> dict:
"""Read a response from the server."""
start_time = time.time()
while time.time() - start_time < timeout:
if self.process.stdout:
line = self.process.stdout.readline()
if line:
message = parse_jsonrpc_message(line)
if message:
return message
time.sleep(0.01)
raise TimeoutError("Timeout waiting for response")
def handle_bidirectional_request(self, message: dict) -> None:
"""Handle a server-initiated request by sending appropriate mock response."""
if "method" not in message or "id" not in message:
return
method = message["method"]
request_id = message["id"]
if method == "sampling/createMessage":
response = mock_sampling_response(request_id)
self.send_response(request_id, response["result"])
elif method == "elicitation/create":
response = mock_elicitation_response(request_id)
self.send_response(request_id, response["result"])
# Tests
@pytest.mark.asyncio
async def test_stdio_e2e():
"""End-to-end test for stdio transport."""
process, _ = start_mcp_server("stdio")
client = StdioClient(process)
try:
# Give server a moment to start
await asyncio.sleep(0.5)
# 1. Send initialize request
init_id = client.send_request(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {"roots": {"listChanged": True}, "sampling": {}, "elicitation": {}},
"clientInfo": {"name": "test-client", "title": "Test Client", "version": "1.0.0"},
},
)
init_response = client.read_response()
assert init_response["jsonrpc"] == "2.0"
assert init_response["id"] == init_id
assert "result" in init_response
assert "error" not in init_response
assert init_response["result"]["serverInfo"]["name"] == "Test"
assert init_response["result"]["serverInfo"]["version"] == "1.0.0"
# 2. Send initialized notification
client.send_notification("notifications/initialized")
# 3. Send ping request
ping_id = client.send_request("ping")
ping_response = client.read_response()
assert ping_response["jsonrpc"] == "2.0"
assert ping_response["id"] == ping_id
assert "error" not in ping_response
# 4. List tools
list_tools_id = client.send_request("tools/list")
list_tools_response = client.read_response()
assert list_tools_response["jsonrpc"] == "2.0"
assert list_tools_response["id"] == list_tools_id
assert "result" in list_tools_response
assert "tools" in list_tools_response["result"]
tools = list_tools_response["result"]["tools"]
assert len(tools) == 6
# 5. Call logging_tool
logging_id = client.send_request(
"tools/call",
{
"name": "Test_LoggingTool",
"arguments": {"message": "test message"},
},
)
# Read response (may have logs interspersed)
logging_tool_response = None
expected_log_levels = ["debug", "debug", "info", "warning", "error"]
actual_log_levels = []
for _ in range(20): # Allow for extra logs from before/after tool execution, just in case
msg = client.read_response(timeout=2.0)
if msg.get("method") == "notifications/message":
actual_log_levels.append(msg.get("params").get("level"))
if msg.get("id") == logging_id: # call tool response (no more tool logs after this)
logging_tool_response = msg
break
assert logging_tool_response is not None
assert logging_tool_response["jsonrpc"] == "2.0"
assert actual_log_levels == expected_log_levels
assert "result" in logging_tool_response
# 6. Call reporting_progress
progress_id = client.send_request(
"tools/call",
{
"name": "Test_ReportingProgress",
"arguments": {},
"_meta": {
"progressToken": "test-progress-token",
},
},
)
# Read response (may have progress notifications interspersed)
progress_response = None
actual_progress_messages = []
expected_progress_messages = [
"Step 1 of 5",
"Step 2 of 5",
"Step 3 of 5",
"Step 4 of 5",
"Step 5 of 5",
]
for _ in range(20): # Allow for multiple progress messages
msg = client.read_response(timeout=2.0)
if msg.get("method") == "notifications/progress":
actual_progress_messages.append(msg.get("params").get("message"))
if msg.get("id") == progress_id:
progress_response = msg
break
assert progress_response is not None
assert progress_response["jsonrpc"] == "2.0"
assert "error" not in progress_response
assert "result" in progress_response
assert actual_progress_messages == expected_progress_messages
# 7. Call call_other_tool (tests tool chaining)
chaining_id = client.send_request(
"tools/call",
{
"name": "Test_CallOtherTool",
"arguments": {},
},
)
chaining_response = client.read_response(timeout=5.0)
assert chaining_response["jsonrpc"] == "2.0"
assert chaining_response["id"] == chaining_id
assert "SUCCESS" in chaining_response["result"]["content"][0]["text"]
# 8. Call sampling (tests client model sampling with mock response)
sampling_id = client.send_request(
"tools/call",
{
"name": "Test_Sampling",
"arguments": {"text": "This is some text to summarize."},
},
)
# Server will send a sampling/createMessage request back to the client
sampling_request = None
actual_sampling_message = None
expected_sampling_message = "This is some text to summarize."
tool_response = None
for _ in range(10): # Allow for messages from before/after tool execution, just in case
msg = client.read_response(timeout=5.0)
if msg.get("method") == "sampling/createMessage":
actual_sampling_message = (
msg.get("params", {})
.get("messages", [{}])[0]
.get("content", {})
.get("text", "")
)
# Sampling request was received by the client, we now send a response back to the server
sampling_request = msg
client.handle_bidirectional_request(msg)
elif msg.get("id") == sampling_id:
# Tool finished executing and returned a response back to the client
tool_response = msg
break
assert sampling_request is not None, "Should have received sampling/createMessage request"
assert actual_sampling_message == expected_sampling_message
assert tool_response is not None, "Should have received tool call response"
assert tool_response["jsonrpc"] == "2.0"
assert tool_response["id"] == sampling_id
assert "error" not in tool_response
assert "result" in tool_response
# 9. Call elicit_nickname (tests user elicitation with mock response)
elicit_id = client.send_request(
"tools/call",
{
"name": "Test_ElicitNickname",
"arguments": {},
},
)
# Server will send an elicitation request back the client
elicit_request = None
actual_elicitation_message = None
expected_elicitation_message = "What is your nickname?"
tool_response = None
for _ in range(10):
msg = client.read_response(timeout=5.0)
if "method" in msg and msg["method"] == "elicitation/create":
actual_elicitation_message = msg.get("params", {}).get("message", "")
# Elicitation request was received by the client, we now send a response back to the server
elicit_request = msg
client.handle_bidirectional_request(msg)
elif msg.get("id") == elicit_id:
# Tool finished executing and returned a response back to the client
tool_response = msg
break
assert elicit_request is not None, "Should have received elicitation request"
assert actual_elicitation_message == expected_elicitation_message
assert tool_response is not None, "Should have received tool call response"
assert tool_response["jsonrpc"] == "2.0"
assert tool_response["id"] == elicit_id
assert "error" not in tool_response
assert "result" in tool_response
# Verify process is still running
assert process.poll() is None
finally:
# Clean shutdown
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
@pytest.mark.asyncio
async def test_http_e2e():
"""End-to-end test for HTTP transport."""
process, port = start_mcp_server("http")
assert port is not None
base_url = f"http://127.0.0.1:{port}"
try:
wait_for_http_server_ready(port, timeout=10)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
client = httpx.Client(base_url=base_url, timeout=30.0, headers=headers)
# 1. Send initialize request
init_request = build_jsonrpc_request(
"initialize",
{
"protocolVersion": "2025-06-18",
"capabilities": {"roots": {"listChanged": True}, "sampling": {}, "elicitation": {}},
"clientInfo": {"name": "test-client", "title": "Test Client", "version": "1.0.0"},
},
request_id=1,
)
init_response = client.post("/mcp", json=init_request)
assert init_response.status_code == 200
init_data = init_response.json()
assert init_data["jsonrpc"] == "2.0"
assert init_data["id"] == 1
assert "result" in init_data
assert "error" not in init_data
assert init_data["result"]["serverInfo"]["name"] == "Test"
assert init_data["result"]["serverInfo"]["version"] == "1.0.0"
session_id = init_response.headers.get("mcp-session-id")
assert session_id is not None, "Session ID not found in initialize response headers"
# All subsequent requests from the client must include the session ID
client.headers.update({"Mcp-Session-Id": session_id})
# 2. Send initialized notification
init_notif = build_jsonrpc_request(
"notifications/initialized", params=None, request_id=None
)
notif_response = client.post("/mcp", json=init_notif)
assert notif_response.status_code == 202
# 3. Send ping request
ping_request = build_jsonrpc_request("ping", request_id=2)
ping_response = client.post("/mcp", json=ping_request)
assert ping_response.status_code == 200
ping_data = ping_response.json()
assert ping_data["jsonrpc"] == "2.0"
assert ping_data["id"] == 2
assert "error" not in ping_data
# 4. List tools
list_tools_request = build_jsonrpc_request("tools/list", request_id=3)
list_tools_response = client.post("/mcp", json=list_tools_request)
assert list_tools_response.status_code == 200
list_tools_data = list_tools_response.json()
assert list_tools_data["jsonrpc"] == "2.0"
assert list_tools_data["id"] == 3
assert "result" in list_tools_data
assert "tools" in list_tools_data["result"]
tools = list_tools_data["result"]["tools"]
assert len(tools) == 6
# 5. Call logging_tool
logging_request = build_jsonrpc_request(
"tools/call",
{
"name": "Test_LoggingTool",
"arguments": {"message": "test message"},
},
request_id=4,
)
logging_response = client.post("/mcp", json=logging_request)
assert logging_response.status_code == 200
logging_data = logging_response.json()
assert logging_data["jsonrpc"] == "2.0"
assert logging_data["id"] == 4
assert "error" not in logging_data
assert "result" in logging_data
# 6. Call reporting_progress
progress_request = build_jsonrpc_request(
"tools/call",
{
"name": "Test_ReportingProgress",
"arguments": {},
},
request_id=5,
)
progress_response = client.post("/mcp", json=progress_request)
assert progress_response.status_code == 200
progress_data = progress_response.json()
assert progress_data["jsonrpc"] == "2.0"
assert progress_data["id"] == 5
assert "error" not in progress_data
assert "result" in progress_data
# 7. Call call_other_tool (tests tool chaining)
chaining_request = build_jsonrpc_request(
"tools/call",
{
"name": "Test_CallOtherTool",
"arguments": {},
},
request_id=6,
)
chaining_response = client.post("/mcp", json=chaining_request)
assert chaining_response.status_code == 200
chaining_data = chaining_response.json()
assert chaining_data["jsonrpc"] == "2.0"
assert chaining_data["id"] == 6
assert "error" not in chaining_data
assert "SUCCESS" in chaining_data["result"]["content"][0]["text"]
# TODO: Implement an HTTP client that can handle bidirectional communication (sampling/elicitation)
assert process.poll() is None
client.close()
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()

View file

@ -8,6 +8,7 @@ from arcade_mcp_server.context import Context
from arcade_mcp_server.context import get_current_model_context as get_current_context
from arcade_mcp_server.context import set_current_model_context as set_current_context
from arcade_mcp_server.types import (
MCPTool,
ModelHint,
ModelPreferences,
)
@ -125,6 +126,25 @@ class TestContext:
await context.log.warning("Warning message")
await context.log.error("Error message")
@pytest.mark.asyncio
async def test_tools_methods(self, mcp_server):
"""Test tools methods."""
context = Context(server=mcp_server)
# Test list tools
tools = await context.tools.list()
assert len(tools) == 2
# Test call raw for tool that doesn't exist
result = await context.tools.call_raw("TheLimitDoesNotExist", {"param": "value"})
assert result.isError is True
# Test call raw for tool that exists
result = await context.tools.call_raw(
"TestToolkit_test_tool", {"text": "The text to send to tool"}
)
assert result.isError is False
@pytest.mark.asyncio
async def test_progress_reporting(self, mcp_server):
"""Test progress reporting functionality."""

View file

@ -1,5 +1,5 @@
import pytest
from arcade_core.utils import pascal_to_snake_case, snake_to_pascal_case
from arcade_core.utils import pascal_to_snake_case, snake_to_pascal_case, space_to_snake_case
@pytest.mark.parametrize(
@ -23,3 +23,17 @@ def test_pascal_to_snake_case(input_str: str, expected: str):
)
def test_snake_to_pascal_case(input_str: str, expected: str):
assert snake_to_pascal_case(input_str) == expected
@pytest.mark.parametrize(
"input_str, expected",
[
("the simple server name", "the_simple_server_name"),
(
"the SIMPLE nAME and numbers 456",
"the_SIMPLE_nAME_and_numbers_456",
),
],
)
def test_space_to_snake_case(input_str: str, expected: str):
assert space_to_snake_case(input_str) == expected

View file

@ -1,6 +1,6 @@
[project]
name = "arcade-mcp"
version = "1.3.0"
version = "1.4.0"
description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md"
license = {file = "LICENSE"}
@ -21,7 +21,7 @@ requires-python = ">=3.10"
dependencies = [
# CLI dependencies
"arcade-mcp-server>=1.4.0,<2.0.0",
"arcade-mcp-server>=1.5.0,<2.0.0",
"arcade-core>=3.0.0,<4.0.0",
"typer==0.10.0",
"rich==13.9.4",
@ -42,7 +42,7 @@ all = [
"pytz>=2024.1",
"python-dateutil>=2.8.2",
# mcp
"arcade-mcp-server>=1.3.2,<2.0.0",
"arcade-mcp-server>=1.5.0,<2.0.0",
# serve
"arcade-serve>=3.0.0,<4.0.0",
# tdk

View file

@ -1,14 +0,0 @@
### Worker 1
[[worker]]
[worker.config]
id = "worker-1"
enabled = true
timeout = 10
retries = 3
secret = "test-secret"
[worker.pypi_source]
packages = ["arcade-slack", "arcade-x", "arcade-github"]
[worker.local_source]
packages = ["./toolkits/spotify"]