diff --git a/README.md b/README.md index 815cacea..d8373bac 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/examples/docker-template/.dockerignore b/examples/docker-template/.dockerignore new file mode 100644 index 00000000..ea8cf11c --- /dev/null +++ b/examples/docker-template/.dockerignore @@ -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 diff --git a/examples/docker-template/README.md b/examples/docker-template/README.md new file mode 100644 index 00000000..9338a5d3 --- /dev/null +++ b/examples/docker-template/README.md @@ -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//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//server.py` diff --git a/examples/docker-template/docker/Dockerfile b/examples/docker-template/docker/Dockerfile new file mode 100644 index 00000000..ffeeae71 --- /dev/null +++ b/examples/docker-template/docker/Dockerfile @@ -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//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 diff --git a/examples/docker-template/docker/README.md b/examples/docker-template/docker/README.md new file mode 100644 index 00000000..daf7a141 --- /dev/null +++ b/examples/docker-template/docker/README.md @@ -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//server.py` (where `` 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//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. diff --git a/examples/docker-template/docker/docker-compose.yml b/examples/docker-template/docker/docker-compose.yml new file mode 100644 index 00000000..27c69567 --- /dev/null +++ b/examples/docker-template/docker/docker-compose.yml @@ -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 diff --git a/examples/docker-template/setup-docker.sh b/examples/docker-template/setup-docker.sh new file mode 100755 index 00000000..1cd526cd --- /dev/null +++ b/examples/docker-template/setup-docker.sh @@ -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 "" diff --git a/examples/mcp_servers/custom_server_with_prebuilt_tools/pyproject.toml b/examples/mcp_servers/custom_server_with_prebuilt_tools/pyproject.toml new file mode 100644 index 00000000..233f796e --- /dev/null +++ b/examples/mcp_servers/custom_server_with_prebuilt_tools/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/custom_server_with_prebuilt_tools/server.py b/examples/mcp_servers/custom_server_with_prebuilt_tools/server.py new file mode 100644 index 00000000..75c89225 --- /dev/null +++ b/examples/mcp_servers/custom_server_with_prebuilt_tools/server.py @@ -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) diff --git a/examples/mcp_servers/echo/pyproject.toml b/examples/mcp_servers/echo/pyproject.toml new file mode 100644 index 00000000..dc1bc362 --- /dev/null +++ b/examples/mcp_servers/echo/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/echo/src/echo/__init__.py b/examples/mcp_servers/echo/src/echo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/echo/src/echo/server.py b/examples/mcp_servers/echo/src/echo/server.py new file mode 100644 index 00000000..9fe9cea9 --- /dev/null +++ b/examples/mcp_servers/echo/src/echo/server.py @@ -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") diff --git a/examples/mcp_servers/local_filesystem/pyproject.toml b/examples/mcp_servers/local_filesystem/pyproject.toml new file mode 100644 index 00000000..e30cf00a --- /dev/null +++ b/examples/mcp_servers/local_filesystem/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/local_filesystem/src/local_filesystem/__init__.py b/examples/mcp_servers/local_filesystem/src/local_filesystem/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/local_filesystem/src/local_filesystem/server.py b/examples/mcp_servers/local_filesystem/src/local_filesystem/server.py new file mode 100644 index 00000000..141a5658 --- /dev/null +++ b/examples/mcp_servers/local_filesystem/src/local_filesystem/server.py @@ -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) diff --git a/examples/mcp_servers/local_filesystem_server/tools.py b/examples/mcp_servers/local_filesystem/src/local_filesystem/tools.py similarity index 91% rename from examples/mcp_servers/local_filesystem_server/tools.py rename to examples/mcp_servers/local_filesystem/src/local_filesystem/tools.py index 3dfb0460..3adc4f48 100644 --- a/examples/mcp_servers/local_filesystem_server/tools.py +++ b/examples/mcp_servers/local_filesystem/src/local_filesystem/tools.py @@ -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 diff --git a/examples/mcp_servers/local_filesystem/src/local_filesystem/utils.py b/examples/mcp_servers/local_filesystem/src/local_filesystem/utils.py new file mode 100644 index 00000000..6a01194b --- /dev/null +++ b/examples/mcp_servers/local_filesystem/src/local_filesystem/utils.py @@ -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" diff --git a/examples/mcp_servers/local_filesystem_server/pyproject.toml b/examples/mcp_servers/local_filesystem_server/pyproject.toml deleted file mode 100644 index 539acfe3..00000000 --- a/examples/mcp_servers/local_filesystem_server/pyproject.toml +++ /dev/null @@ -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 diff --git a/examples/mcp_servers/local_filesystem_server/server.py b/examples/mcp_servers/local_filesystem_server/server.py deleted file mode 100644 index 43320d9e..00000000 --- a/examples/mcp_servers/local_filesystem_server/server.py +++ /dev/null @@ -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) diff --git a/examples/mcp_servers/logging/.dockerignore b/examples/mcp_servers/logging/.dockerignore new file mode 100644 index 00000000..ea8cf11c --- /dev/null +++ b/examples/mcp_servers/logging/.dockerignore @@ -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 diff --git a/examples/mcp_servers/logging/docker/Dockerfile b/examples/mcp_servers/logging/docker/Dockerfile new file mode 100644 index 00000000..ffeeae71 --- /dev/null +++ b/examples/mcp_servers/logging/docker/Dockerfile @@ -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//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 diff --git a/examples/mcp_servers/logging/docker/README.md b/examples/mcp_servers/logging/docker/README.md new file mode 100644 index 00000000..daf7a141 --- /dev/null +++ b/examples/mcp_servers/logging/docker/README.md @@ -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//server.py` (where `` 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//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. diff --git a/examples/mcp_servers/logging/docker/docker-compose.yml b/examples/mcp_servers/logging/docker/docker-compose.yml new file mode 100644 index 00000000..27c69567 --- /dev/null +++ b/examples/mcp_servers/logging/docker/docker-compose.yml @@ -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 diff --git a/examples/mcp_servers/logging/pyproject.toml b/examples/mcp_servers/logging/pyproject.toml new file mode 100644 index 00000000..aec3fb71 --- /dev/null +++ b/examples/mcp_servers/logging/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/logging/src/logging/__init__.py b/examples/mcp_servers/logging/src/logging/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/logging/src/logging/server.py b/examples/mcp_servers/logging/src/logging/server.py new file mode 100644 index 00000000..a24ddc25 --- /dev/null +++ b/examples/mcp_servers/logging/src/logging/server.py @@ -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...") diff --git a/examples/mcp_servers/progress_reporting/pyproject.toml b/examples/mcp_servers/progress_reporting/pyproject.toml new file mode 100644 index 00000000..a3b51542 --- /dev/null +++ b/examples/mcp_servers/progress_reporting/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/progress_reporting/src/progress_reporting/__init__.py b/examples/mcp_servers/progress_reporting/src/progress_reporting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/progress_reporting/src/progress_reporting/server.py b/examples/mcp_servers/progress_reporting/src/progress_reporting/server.py new file mode 100644 index 00000000..ca3ef33b --- /dev/null +++ b/examples/mcp_servers/progress_reporting/src/progress_reporting/server.py @@ -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) diff --git a/examples/mcp_servers/sampling/pyproject.toml b/examples/mcp_servers/sampling/pyproject.toml new file mode 100644 index 00000000..10326aac --- /dev/null +++ b/examples/mcp_servers/sampling/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/sampling/src/sampling/__init__.py b/examples/mcp_servers/sampling/src/sampling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/sampling/src/sampling/server.py b/examples/mcp_servers/sampling/src/sampling/server.py new file mode 100644 index 00000000..b751ced5 --- /dev/null +++ b/examples/mcp_servers/sampling/src/sampling/server.py @@ -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) diff --git a/examples/mcp_servers/server_with_evaluations/evals/eval_using_binary_critic.py b/examples/mcp_servers/server_with_evaluations/evals/eval_using_binary_critic.py new file mode 100644 index 00000000..41440de5 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/evals/eval_using_binary_critic.py @@ -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 diff --git a/examples/mcp_servers/server_with_evaluations/evals/eval_using_datetime_critic.py b/examples/mcp_servers/server_with_evaluations/evals/eval_using_datetime_critic.py new file mode 100644 index 00000000..f1f07daa --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/evals/eval_using_datetime_critic.py @@ -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 diff --git a/examples/mcp_servers/server_with_evaluations/evals/eval_using_numeric_critic.py b/examples/mcp_servers/server_with_evaluations/evals/eval_using_numeric_critic.py new file mode 100644 index 00000000..b19ea71c --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/evals/eval_using_numeric_critic.py @@ -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 diff --git a/examples/mcp_servers/server_with_evaluations/evals/eval_using_similarity_critic.py b/examples/mcp_servers/server_with_evaluations/evals/eval_using_similarity_critic.py new file mode 100644 index 00000000..b51eefdc --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/evals/eval_using_similarity_critic.py @@ -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 diff --git a/examples/mcp_servers/server_with_evaluations/pyproject.toml b/examples/mcp_servers/server_with_evaluations/pyproject.toml new file mode 100644 index 00000000..e3632f40 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/__init__.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/server.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/server.py new file mode 100644 index 00000000..a8b5d986 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/server.py @@ -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") diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/__init__.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/__init__.py new file mode 100644 index 00000000..eb791d11 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/__init__.py @@ -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", +] diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_binary_critic.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_binary_critic.py new file mode 100644 index 00000000..dd125d69 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_binary_critic.py @@ -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}!" diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_datetime_critic.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_datetime_critic.py new file mode 100644 index 00000000..ca05d48d --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_datetime_critic.py @@ -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, + } diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_numeric_critic.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_numeric_critic.py new file mode 100644 index 00000000..e3a8bf71 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_numeric_critic.py @@ -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 diff --git a/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_similarity_critic.py b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_similarity_critic.py new file mode 100644 index 00000000..5d45eef1 --- /dev/null +++ b/examples/mcp_servers/server_with_evaluations/src/server_with_evaluations/tools/tools_for_similarity_critic.py @@ -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, + } diff --git a/examples/mcp_servers/simple/pyproject.toml b/examples/mcp_servers/simple/pyproject.toml new file mode 100644 index 00000000..85227a7f --- /dev/null +++ b/examples/mcp_servers/simple/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/simple_server/.env.example b/examples/mcp_servers/simple/src/simple/.env.example similarity index 100% rename from examples/mcp_servers/simple_server/.env.example rename to examples/mcp_servers/simple/src/simple/.env.example diff --git a/examples/mcp_servers/simple/src/simple/__init__.py b/examples/mcp_servers/simple/src/simple/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/simple_server/server.py b/examples/mcp_servers/simple/src/simple/server.py similarity index 67% rename from examples/mcp_servers/simple_server/server.py rename to examples/mcp_servers/simple/src/simple/server.py index 3f9e343e..84da28e9 100644 --- a/examples/mcp_servers/simple_server/server.py +++ b/examples/mcp_servers/simple/src/simple/server.py @@ -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) diff --git a/examples/mcp_servers/simple_server/pyproject.toml b/examples/mcp_servers/simple_server/pyproject.toml deleted file mode 100644 index 0656d926..00000000 --- a/examples/mcp_servers/simple_server/pyproject.toml +++ /dev/null @@ -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 diff --git a/examples/mcp_servers/tool_chaining/pyproject.toml b/examples/mcp_servers/tool_chaining/pyproject.toml new file mode 100644 index 00000000..52905f18 --- /dev/null +++ b/examples/mcp_servers/tool_chaining/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/tool_chaining/src/tool_chaining/.env.example b/examples/mcp_servers/tool_chaining/src/tool_chaining/.env.example new file mode 100644 index 00000000..97b23cc0 --- /dev/null +++ b/examples/mcp_servers/tool_chaining/src/tool_chaining/.env.example @@ -0,0 +1,2 @@ +API_KEY=ae_12345 +PASSWORD=pass123 diff --git a/examples/mcp_servers/tool_chaining/src/tool_chaining/__init__.py b/examples/mcp_servers/tool_chaining/src/tool_chaining/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/tool_chaining/src/tool_chaining/server.py b/examples/mcp_servers/tool_chaining/src/tool_chaining/server.py new file mode 100644 index 00000000..07b4cf93 --- /dev/null +++ b/examples/mcp_servers/tool_chaining/src/tool_chaining/server.py @@ -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") diff --git a/examples/mcp_servers/user_elicitation/pyproject.toml b/examples/mcp_servers/user_elicitation/pyproject.toml new file mode 100644 index 00000000..e0d72f80 --- /dev/null +++ b/examples/mcp_servers/user_elicitation/pyproject.toml @@ -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 } diff --git a/examples/mcp_servers/user_elicitation/src/user_elicitation/__init__.py b/examples/mcp_servers/user_elicitation/src/user_elicitation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/mcp_servers/user_elicitation/src/user_elicitation/server.py b/examples/mcp_servers/user_elicitation/src/user_elicitation/server.py new file mode 100644 index 00000000..2f064a56 --- /dev/null +++ b/examples/mcp_servers/user_elicitation/src/user_elicitation/server.py @@ -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) diff --git a/libs/arcade-cli/arcade_cli/new.py b/libs/arcade-cli/arcade_cli/new.py index 16f4a711..4ec32e1e 100644 --- a/libs/arcade-cli/arcade_cli/new.py +++ b/libs/arcade-cli/arcade_cli/new.py @@ -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" diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/pyproject.toml b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/pyproject.toml index 59411b62..9c0b638d 100644 --- a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/pyproject.toml +++ b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/pyproject.toml @@ -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 diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/.env.example b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/.env.example similarity index 100% rename from libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/.env.example rename to libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/.env.example diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/__init__.py b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py b/libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/server.py similarity index 100% rename from libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/server.py rename to libs/arcade-cli/arcade_cli/templates/minimal/{{ toolkit_name }}/src/{{ toolkit_name }}/server.py diff --git a/libs/arcade-core/arcade_core/catalog.py b/libs/arcade-core/arcade_core/catalog.py index 7bf78dd6..6ba78ddb 100644 --- a/libs/arcade-core/arcade_core/catalog.py +++ b/libs/arcade-core/arcade_core/catalog.py @@ -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, ) diff --git a/libs/arcade-core/arcade_core/toolkit.py b/libs/arcade-core/arcade_core/toolkit.py index b976e574..42a5f209 100644 --- a/libs/arcade-core/arcade_core/toolkit.py +++ b/libs/arcade-core/arcade_core/toolkit.py @@ -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"): diff --git a/libs/arcade-core/arcade_core/utils.py b/libs/arcade-core/arcade_core/utils.py index 7117cf04..50b77e12 100644 --- a/libs/arcade-core/arcade_core/utils.py +++ b/libs/arcade-core/arcade_core/utils.py @@ -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. diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 7999ce6d..7c2bec1d 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -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"} diff --git a/libs/arcade-mcp-server/arcade_mcp_server/context.py b/libs/arcade-mcp-server/arcade_mcp_server/context.py index a0f09da1..9fc7a5e1 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/context.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/context.py @@ -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): diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py index 98acd816..5dcc1432 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py @@ -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" ) diff --git a/libs/arcade-mcp-server/arcade_mcp_server/server.py b/libs/arcade-mcp-server/arcade_mcp_server/server.py index c1e48a16..34dc1c22 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/server.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/server.py @@ -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: diff --git a/libs/arcade-mcp-server/arcade_mcp_server/session.py b/libs/arcade-mcp-server/arcade_mcp_server/session.py index 1d7b744f..bc667057 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/session.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/session.py @@ -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.""" diff --git a/libs/arcade-mcp-server/arcade_mcp_server/transports/http_streamable.py b/libs/arcade-mcp-server/arcade_mcp_server/transports/http_streamable.py index 6bd895cb..fd4ad486 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/transports/http_streamable.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/transports/http_streamable.py @@ -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 diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 7bb974bc..93b95b10 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -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", diff --git a/libs/tests/arcade_mcp_server/integration/__init__.py b/libs/tests/arcade_mcp_server/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/tests/arcade_mcp_server/integration/server/logging_tools.py b/libs/tests/arcade_mcp_server/integration/server/logging_tools.py new file mode 100644 index 00000000..2b569abc --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/logging_tools.py @@ -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 diff --git a/libs/tests/arcade_mcp_server/integration/server/progress_tools.py b/libs/tests/arcade_mcp_server/integration/server/progress_tools.py new file mode 100644 index 00000000..214ae91b --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/progress_tools.py @@ -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" diff --git a/libs/tests/arcade_mcp_server/integration/server/sampling_tools.py b/libs/tests/arcade_mcp_server/integration/server/sampling_tools.py new file mode 100644 index 00000000..0c6933da --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/sampling_tools.py @@ -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 diff --git a/libs/tests/arcade_mcp_server/integration/server/server.py b/libs/tests/arcade_mcp_server/integration/server/server.py new file mode 100644 index 00000000..c51832b4 --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/server.py @@ -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) diff --git a/libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py b/libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py new file mode 100644 index 00000000..6a33ef9d --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py @@ -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"] diff --git a/libs/tests/arcade_mcp_server/integration/server/user_elicitation_tools.py b/libs/tests/arcade_mcp_server/integration/server/user_elicitation_tools.py new file mode 100644 index 00000000..6fa03dd6 --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/user_elicitation_tools.py @@ -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" diff --git a/libs/tests/arcade_mcp_server/integration/test_end_to_end.py b/libs/tests/arcade_mcp_server/integration/test_end_to_end.py new file mode 100644 index 00000000..5c1759ba --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/test_end_to_end.py @@ -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() diff --git a/libs/tests/arcade_mcp_server/test_context.py b/libs/tests/arcade_mcp_server/test_context.py index a2a5557e..39a4c734 100644 --- a/libs/tests/arcade_mcp_server/test_context.py +++ b/libs/tests/arcade_mcp_server/test_context.py @@ -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.""" diff --git a/libs/tests/core/utils/test_casing.py b/libs/tests/core/utils/test_casing.py index 585043fe..446da18f 100644 --- a/libs/tests/core/utils/test_casing.py +++ b/libs/tests/core/utils/test_casing.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index c2089085..847d9a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/worker.toml b/worker.toml deleted file mode 100644 index bbe742f7..00000000 --- a/worker.toml +++ /dev/null @@ -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"]