Fix MCP capabilities, examples, tests, and more (#657)
# PR Description
Consider this PR the result of a full pass through of this repository.
## Add helper for adding tools to an `MCPApp`
You can now add all of the tools in a module to an `MCPApp` via
`app.add_tools_from_module(...)`
## Edit what `arcade new` generates
First, I updated the backend to use hatchling.
Second, the structure generated before this PR was simple, but did not
create a proper Python module.
This hindered developers in the following ways:
1. Difficult to add the tools in your server to an evaluation suite
2. Difficult to add more than one tool to an MCPApp at a time
3. All other niceties that come with being able to import modules
```
# Before
server/
├── .env.example
├── server.py
└── pyproject.toml
```
This PR updates the structure generated such that a valid Python module
is generated:
```
# After
server/
├── pyproject.toml
└── src/
└── server/
├── __init__.py
├── .env.example
└── server.py
```
## Fix Tool Chaining
`self._ctx.server.executor.run(...)` was being called, but `MCPServer`
does not have an instance of `ToolExecutor` (and it's not intended to be
an instance anyways). I updated `Tool.call_raw` to pass the programmatic
tool call through the `MCPServer._handle_call_tool`. This means that the
programmatic tool calls now go through the same steps that a typical
tool call (initiated by the MCP client) would.
This means that **toolA**, which specifies **requirementsA**, is
permitted to call **toolB**, which specifies **requirementsB**, without
needing to explicitly declare or satisfy **requirementsB**. I believe
this is acceptable because the secrets and/or auth token associated with
**toolB's** `Context` are not exposed to **toolA**, and the secrets
and/or auth token associated with **toolA's** `Context` are not exposed
to **toolB**.
## Fix User Elicitation
1. The read & write streams were created with a maximum queue size of 0.
I increased this to 100.
2. I updated `ServerSession`'s run loop to both read messages from the
stream & process them concurrently. This enables server initiated
requests (like user elicitation and progress reporting) to be handled
while tools are being executed. Otherwise, the server initiated requests
would wait for the tool to finish executing and the tool execution would
wait for the server initiated request to finish.
3.
## Fix Progress Reporting
Progress tokens sent by the client were not being stored. Therefore
there was no way to notify a client with progress updates. I am now
storing the `progressToken`, along with other `_meta` sent from the
client, in the `ServerSession`'s `_request_meta`. I am setting
`_request_meta` whenever the `MCPServer` is handling an incoming message
from a client.
## Fix handling of server names with spaces
Before:
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "The_simple_server_name_WhisperSecret"
After
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "TheSimpleServerName_WhisperSecret"
## Add Integration Tests
The stdio integration test is much more comprehensive than the http
integration test. These tests will let me sleep a bit more at night
## Add Example MCP Servers
Example servers for sampling, user-elicitation, progress reporting,
logging, tool chaining, combining prebuilt tools with custom tools, tool
secrets, tool auth, evaluations, and more!
## Add Docker template
Added a Docker template for running an MCP server in Docker (and removed
the old docker stuff)
This commit is contained in:
parent
447177e6a8
commit
e727af3a21
83 changed files with 2634 additions and 266 deletions
|
|
@ -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:
|
||||
|
|
|
|||
33
examples/docker-template/.dockerignore
Normal file
33
examples/docker-template/.dockerignore
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Docker
|
||||
docker/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
67
examples/docker-template/README.md
Normal file
67
examples/docker-template/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Docker Template for MCP Servers
|
||||
|
||||
This is a generalized Docker setup template that can be applied to any MCP server built with Arcade MCP
|
||||
|
||||
The Dockerfile automatically detects your package name from `pyproject.toml` and expects your server file at `src/<package_name>/server.py`.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Option 1: Using the Setup Script (Recommended)
|
||||
|
||||
Run the setup script to automatically copy the Docker files to your MCP server:
|
||||
|
||||
```bash
|
||||
cd examples/docker-template
|
||||
./setup-docker.sh ../path/to/your-server-name
|
||||
```
|
||||
|
||||
This will copy all necessary Docker files to your server directory.
|
||||
|
||||
### Option 2: Manual Setup
|
||||
|
||||
Copy the `docker/` directory to your MCP server:
|
||||
|
||||
```bash
|
||||
cp -r examples/docker-template/docker your-server-name/
|
||||
cp examples/docker-template/.dockerignore your-server-name/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After setup, navigate to your MCP server directory and build/run:
|
||||
|
||||
```bash
|
||||
cd your-server-name
|
||||
|
||||
# Build and run with docker-compose
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
|
||||
# Or build and run manually
|
||||
docker build -f docker/Dockerfile -t your-server .
|
||||
docker run -p 8001:8001 your-server
|
||||
```
|
||||
|
||||
The package name is automatically detected from `pyproject.toml`
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `docker/docker-compose.yml` to configure:
|
||||
- `ARCADE_SERVER_PORT`: Server port (default: 8001)
|
||||
- `ARCADE_SERVER_HOST`: Bind host (default: 0.0.0.0)
|
||||
- `ARCADE_SERVER_TRANSPORT`: Transport type (default: http)
|
||||
|
||||
The package name is automatically detected from `pyproject.toml`
|
||||
|
||||
## What Gets Copied
|
||||
|
||||
The setup script copies these files to your MCP server:
|
||||
- `docker/Dockerfile` - Docker image build instructions
|
||||
- `docker/docker-compose.yml` - Docker Compose configuration
|
||||
- `docker/README.md` - Detailed usage documentation
|
||||
- `.dockerignore` - Files to exclude from Docker build
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- MCP server with `pyproject.toml` and `uv.lock`
|
||||
- Server file at `src/<package>/server.py`
|
||||
45
examples/docker-template/docker/Dockerfile
Normal file
45
examples/docker-template/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Auto-detect package name from pyproject.toml
|
||||
# First try using Python's tomllib
|
||||
# Fallback to grep/sed for compatibility
|
||||
RUN PACKAGE_NAME=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); print(data['project']['name'])" 2>/dev/null || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E "s/.*name\s*=\s*[\"']([^\"']+)[\"'].*/\1/" || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E 's/.*name\s*=\s*([^ ]+).*/\1/') && \
|
||||
if [ -z "$PACKAGE_NAME" ]; then \
|
||||
echo "ERROR: Could not detect package name from pyproject.toml" && exit 1; \
|
||||
fi && \
|
||||
echo "Detected package: $PACKAGE_NAME" && \
|
||||
echo "$PACKAGE_NAME" > /tmp/package_name.txt
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 8001
|
||||
|
||||
# Run the server from src/<package>/server.py
|
||||
CMD PACKAGE_NAME=$(cat /tmp/package_name.txt) && \
|
||||
if [ -f "src/${PACKAGE_NAME}/server.py" ]; then \
|
||||
uv run src/${PACKAGE_NAME}/server.py; \
|
||||
else \
|
||||
echo "ERROR: Could not find server.py at src/${PACKAGE_NAME}/server.py" && \
|
||||
echo " Package detected: ${PACKAGE_NAME}" && \
|
||||
echo " Available directories in src/:" && \
|
||||
ls -la src/ 2>/dev/null || echo " src/ directory not found" && \
|
||||
exit 1; \
|
||||
fi
|
||||
89
examples/docker-template/docker/README.md
Normal file
89
examples/docker-template/docker/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Docker Setup for MCP Servers
|
||||
|
||||
This directory contains a generalized Docker configuration template that can be used with any MCP server in this repository.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy the Docker files to your MCP server directory:**
|
||||
|
||||
```bash
|
||||
cp -r examples/docker-template/docker your-mcp-server/
|
||||
cp examples/docker-template/.dockerignore your-mcp-server/
|
||||
```
|
||||
|
||||
2. **Build and run:**
|
||||
|
||||
```bash
|
||||
cd your-mcp-server
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Package Detection
|
||||
|
||||
The Dockerfile uses the package name from `pyproject.toml` by reading the `[project] name` field. It expects your server file at `src/<package_name>/server.py` (where `<package_name>` is from `pyproject.toml`).
|
||||
|
||||
If the server file is not found at this location, then the build will fail with an error message showing the detected package name and available directories in `src/`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `ARCADE_SERVER_TRANSPORT`: The transport protocol to use
|
||||
- Default: `http`
|
||||
- Options: `http`, `stdio`
|
||||
- `ARCADE_SERVER_PORT`: The port to run the server on
|
||||
- Default: `8001`
|
||||
- `ARCADE_SERVER_HOST`: The host to bind to
|
||||
- Default: `0.0.0.0`
|
||||
|
||||
### Example: Simple MCP Server
|
||||
|
||||
```bash
|
||||
# From examples/mcp_servers/simple/
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
You can customize the port by editing `docker/docker-compose.yml` and changing both the `ARCADE_SERVER_PORT` environment variable and the port mapping.
|
||||
|
||||
## Building the Image
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
-f docker/Dockerfile \
|
||||
-t your-mcp-server \
|
||||
.
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8001:8001 \
|
||||
-e ARCADE_SERVER_TRANSPORT=http \
|
||||
-e ARCADE_SERVER_HOST=0.0.0.0 \
|
||||
-e ARCADE_SERVER_PORT=8001 \
|
||||
your-mcp-server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic package detection**: Reads package name from `pyproject.toml`
|
||||
- **Standard server location**: Expects server file at `src/<package>/server.py`
|
||||
- **Secure by default**: Runs as non-root user
|
||||
- **Arcade environment variable support**: Uses `ARCADE_SERVER_*` environment variables
|
||||
- **Environment-based config**: Easy customization via environment variables
|
||||
- **uv integration**: Uses uv for fast dependency management
|
||||
- **Lightweight**: Based on Python 3.11 Bookworm slim image with uv
|
||||
|
||||
## Connecting from Cursor
|
||||
|
||||
Add to your `~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
"your-server-name": {
|
||||
"name": "your-server-name",
|
||||
"type": "stream",
|
||||
"url": "http://localhost:8001"
|
||||
}
|
||||
```
|
||||
|
||||
Then restart Cursor to connect to the server.
|
||||
11
examples/docker-template/docker/docker-compose.yml
Normal file
11
examples/docker-template/docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
- ARCADE_SERVER_TRANSPORT=http
|
||||
- ARCADE_SERVER_HOST=0.0.0.0
|
||||
- ARCADE_SERVER_PORT=8001
|
||||
70
examples/docker-template/setup-docker.sh
Executable file
70
examples/docker-template/setup-docker.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Setup Docker for MCP Server
|
||||
# This script copies the Docker template files to your MCP server directory
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Setting up Docker for MCP Server${NC}"
|
||||
echo ""
|
||||
|
||||
# Get the template directory (where this script is located)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEMPLATE_DIR="$SCRIPT_DIR"
|
||||
|
||||
# Default target directory is the parent directory
|
||||
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Allow specifying a target directory
|
||||
if [ -n "$1" ]; then
|
||||
TARGET_DIR="$1"
|
||||
fi
|
||||
|
||||
echo "Template directory: $TEMPLATE_DIR"
|
||||
echo "Target directory: $TARGET_DIR"
|
||||
echo ""
|
||||
|
||||
# Check if target is a valid MCP server
|
||||
if [ ! -f "$TARGET_DIR/pyproject.toml" ]; then
|
||||
echo -e "${RED}Error: $TARGET_DIR is not a valid MCP server (missing pyproject.toml)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker directory already exists
|
||||
if [ -d "$TARGET_DIR/docker" ]; then
|
||||
echo -e "${YELLOW}Warning: $TARGET_DIR/docker already exists${NC}"
|
||||
read -p "Overwrite? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$TARGET_DIR/docker"
|
||||
fi
|
||||
|
||||
# Create docker directory
|
||||
mkdir -p "$TARGET_DIR/docker"
|
||||
|
||||
# Copy files
|
||||
echo "Copying Docker files..."
|
||||
cp "$TEMPLATE_DIR/docker/Dockerfile" "$TARGET_DIR/docker/"
|
||||
cp "$TEMPLATE_DIR/docker/docker-compose.yml" "$TARGET_DIR/docker/"
|
||||
cp "$TEMPLATE_DIR/docker/README.md" "$TARGET_DIR/docker/"
|
||||
cp "$TEMPLATE_DIR/.dockerignore" "$TARGET_DIR/"
|
||||
|
||||
echo -e "${GREEN}✓ Docker setup complete!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. (Optional) Update docker-compose.yml to customize:"
|
||||
echo " - ARCADE_SERVER_PORT (default: 8001)"
|
||||
echo " - ARCADE_SERVER_HOST (default: 0.0.0.0)"
|
||||
echo " 2. Build and run:"
|
||||
echo " cd $TARGET_DIR"
|
||||
echo " docker-compose -f docker/docker-compose.yml up --build"
|
||||
echo ""
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
27
examples/mcp_servers/echo/pyproject.toml
Normal file
27
examples/mcp_servers/echo/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[project]
|
||||
name = "echo"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/echo"]
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
0
examples/mcp_servers/echo/src/echo/__init__.py
Normal file
0
examples/mcp_servers/echo/src/echo/__init__.py
Normal file
15
examples/mcp_servers/echo/src/echo/server.py
Normal file
15
examples/mcp_servers/echo/src/echo/server.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import MCPApp
|
||||
|
||||
app = MCPApp("Echo server")
|
||||
|
||||
|
||||
@app.tool
|
||||
def echo(message: Annotated[str, "The message to echo"]) -> str:
|
||||
"""Echo a message back to the caller."""
|
||||
return message
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(transport="http")
|
||||
44
examples/mcp_servers/local_filesystem/pyproject.toml
Normal file
44
examples/mcp_servers/local_filesystem/pyproject.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[project]
|
||||
name = "local_filesystem"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/local_filesystem"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "simple"
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
33
examples/mcp_servers/logging/.dockerignore
Normal file
33
examples/mcp_servers/logging/.dockerignore
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Docker
|
||||
docker/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
45
examples/mcp_servers/logging/docker/Dockerfile
Normal file
45
examples/mcp_servers/logging/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Auto-detect package name from pyproject.toml
|
||||
# First try using Python's tomllib
|
||||
# Fallback to grep/sed for compatibility
|
||||
RUN PACKAGE_NAME=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); print(data['project']['name'])" 2>/dev/null || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E "s/.*name\s*=\s*[\"']([^\"']+)[\"'].*/\1/" || \
|
||||
grep -E '^name\s*=' pyproject.toml | head -1 | sed -E 's/.*name\s*=\s*([^ ]+).*/\1/') && \
|
||||
if [ -z "$PACKAGE_NAME" ]; then \
|
||||
echo "ERROR: Could not detect package name from pyproject.toml" && exit 1; \
|
||||
fi && \
|
||||
echo "Detected package: $PACKAGE_NAME" && \
|
||||
echo "$PACKAGE_NAME" > /tmp/package_name.txt
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 8001
|
||||
|
||||
# Run the server from src/<package>/server.py
|
||||
CMD PACKAGE_NAME=$(cat /tmp/package_name.txt) && \
|
||||
if [ -f "src/${PACKAGE_NAME}/server.py" ]; then \
|
||||
uv run src/${PACKAGE_NAME}/server.py; \
|
||||
else \
|
||||
echo "ERROR: Could not find server.py at src/${PACKAGE_NAME}/server.py" && \
|
||||
echo " Package detected: ${PACKAGE_NAME}" && \
|
||||
echo " Available directories in src/:" && \
|
||||
ls -la src/ 2>/dev/null || echo " src/ directory not found" && \
|
||||
exit 1; \
|
||||
fi
|
||||
89
examples/mcp_servers/logging/docker/README.md
Normal file
89
examples/mcp_servers/logging/docker/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Docker Setup for MCP Servers
|
||||
|
||||
This directory contains a generalized Docker configuration template that can be used with any MCP server in this repository.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy the Docker files to your MCP server directory:**
|
||||
|
||||
```bash
|
||||
cp -r examples/docker-template/docker your-mcp-server/
|
||||
cp examples/docker-template/.dockerignore your-mcp-server/
|
||||
```
|
||||
|
||||
2. **Build and run:**
|
||||
|
||||
```bash
|
||||
cd your-mcp-server
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Package Detection
|
||||
|
||||
The Dockerfile uses the package name from `pyproject.toml` by reading the `[project] name` field. It expects your server file at `src/<package_name>/server.py` (where `<package_name>` is from `pyproject.toml`).
|
||||
|
||||
If the server file is not found at this location, then the build will fail with an error message showing the detected package name and available directories in `src/`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `ARCADE_SERVER_TRANSPORT`: The transport protocol to use
|
||||
- Default: `http`
|
||||
- Options: `http`, `stdio`
|
||||
- `ARCADE_SERVER_PORT`: The port to run the server on
|
||||
- Default: `8001`
|
||||
- `ARCADE_SERVER_HOST`: The host to bind to
|
||||
- Default: `0.0.0.0`
|
||||
|
||||
### Example: Simple MCP Server
|
||||
|
||||
```bash
|
||||
# From examples/mcp_servers/simple/
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
You can customize the port by editing `docker/docker-compose.yml` and changing both the `ARCADE_SERVER_PORT` environment variable and the port mapping.
|
||||
|
||||
## Building the Image
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
-f docker/Dockerfile \
|
||||
-t your-mcp-server \
|
||||
.
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8001:8001 \
|
||||
-e ARCADE_SERVER_TRANSPORT=http \
|
||||
-e ARCADE_SERVER_HOST=0.0.0.0 \
|
||||
-e ARCADE_SERVER_PORT=8001 \
|
||||
your-mcp-server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic package detection**: Reads package name from `pyproject.toml`
|
||||
- **Standard server location**: Expects server file at `src/<package>/server.py`
|
||||
- **Secure by default**: Runs as non-root user
|
||||
- **Arcade environment variable support**: Uses `ARCADE_SERVER_*` environment variables
|
||||
- **Environment-based config**: Easy customization via environment variables
|
||||
- **uv integration**: Uses uv for fast dependency management
|
||||
- **Lightweight**: Based on Python 3.11 Bookworm slim image with uv
|
||||
|
||||
## Connecting from Cursor
|
||||
|
||||
Add to your `~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
"your-server-name": {
|
||||
"name": "your-server-name",
|
||||
"type": "stream",
|
||||
"url": "http://localhost:8001"
|
||||
}
|
||||
```
|
||||
|
||||
Then restart Cursor to connect to the server.
|
||||
11
examples/mcp_servers/logging/docker/docker-compose.yml
Normal file
11
examples/mcp_servers/logging/docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
- ARCADE_SERVER_TRANSPORT=http
|
||||
- ARCADE_SERVER_HOST=0.0.0.0
|
||||
- ARCADE_SERVER_PORT=8001
|
||||
27
examples/mcp_servers/logging/pyproject.toml
Normal file
27
examples/mcp_servers/logging/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[project]
|
||||
name = "logging"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/logging"]
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
0
examples/mcp_servers/logging/src/logging/__init__.py
Normal file
0
examples/mcp_servers/logging/src/logging/__init__.py
Normal file
25
examples/mcp_servers/logging/src/logging/server.py
Normal file
25
examples/mcp_servers/logging/src/logging/server.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import Context, MCPApp
|
||||
from loguru import logger
|
||||
|
||||
app = MCPApp("Logging server")
|
||||
|
||||
|
||||
@app.tool
|
||||
async def log_message(context: Context, message: Annotated[str, "The message to log"]) -> str:
|
||||
"""Log a message at varying levels."""
|
||||
await context.log.log("debug", f"Debug via log.log: {message}")
|
||||
await context.log.debug(f"Debug via log.debug: {message}")
|
||||
await context.log.info(f"Info via log.info: {message}")
|
||||
await context.log.warning(f"Warning via log.warning: {message}")
|
||||
await context.log.error(f"Error via log.error: {message}")
|
||||
await context.log.log("info", f"Info via log.log: {message}")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Just about to start running the server...")
|
||||
app.run(transport="http")
|
||||
logger.info("Server has finished running...")
|
||||
45
examples/mcp_servers/progress_reporting/pyproject.toml
Normal file
45
examples/mcp_servers/progress_reporting/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "progress_reporting"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "progress_reporting"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/progress_reporting"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -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)
|
||||
45
examples/mcp_servers/sampling/pyproject.toml
Normal file
45
examples/mcp_servers/sampling/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "sampling"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "sampling"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/sampling"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
0
examples/mcp_servers/sampling/src/sampling/__init__.py
Normal file
0
examples/mcp_servers/sampling/src/sampling/__init__.py
Normal file
30
examples/mcp_servers/sampling/src/sampling/server.py
Normal file
30
examples/mcp_servers/sampling/src/sampling/server.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
"""sampling MCP server"""
|
||||
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import Context, MCPApp
|
||||
|
||||
app = MCPApp(name="sampling", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
|
||||
@app.tool
|
||||
async def summarize_text(
|
||||
context: Context, text: Annotated[str, "The text to be summarized by the client's model"]
|
||||
) -> str:
|
||||
"""Summarize the text using the client's model."""
|
||||
result = await context.sampling.create_message(
|
||||
messages=text,
|
||||
system_prompt=(
|
||||
"You are a helpful assistant that summarizes text. "
|
||||
"Given a text, you should summarize it in a few sentences."
|
||||
),
|
||||
)
|
||||
return result.text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
|
||||
|
||||
app.run(transport=transport, host="127.0.0.1", port=8000)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
46
examples/mcp_servers/server_with_evaluations/pyproject.toml
Normal file
46
examples/mcp_servers/server_with_evaluations/pyproject.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
[project]
|
||||
name = "server_with_evaluations"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "server_with_evaluations"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/server_with_evaluations"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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}!"
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
45
examples/mcp_servers/simple/pyproject.toml
Normal file
45
examples/mcp_servers/simple/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "simple"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/simple"]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "simple"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
0
examples/mcp_servers/simple/src/simple/__init__.py
Normal file
0
examples/mcp_servers/simple/src/simple/__init__.py
Normal file
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
"""simple_server MCP server"""
|
||||
"""simple MCP server"""
|
||||
|
||||
import sys
|
||||
from typing import Annotated
|
||||
|
|
@ -8,7 +8,7 @@ import httpx
|
|||
from arcade_mcp_server import Context, MCPApp
|
||||
from arcade_mcp_server.auth import Reddit
|
||||
|
||||
app = MCPApp(name="simple_server", version="1.0.0", log_level="DEBUG")
|
||||
app = MCPApp(name="simple", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
|
||||
@app.tool
|
||||
|
|
@ -17,7 +17,7 @@ def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
|
|||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
# To use this tool, you need to either set the secret in the .env file or as an environment variable
|
||||
# To use this tool locally, you need to either set the secret in the .env file or as an environment variable
|
||||
@app.tool(requires_secrets=["MY_SECRET_KEY"])
|
||||
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
|
||||
"""Reveal the last 4 characters of a secret"""
|
||||
|
|
@ -32,8 +32,8 @@ def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of
|
|||
return "The last 4 characters of the secret are: " + secret[-4:]
|
||||
|
||||
|
||||
# To use this tool, you need to either set your ARCADE_API_KEY as an environment variable or
|
||||
# use the Arcade CLI (uv pip install arcade-mcp) and run 'arcade login' to authenticate.
|
||||
# To use this tool locally, you need to install the Arcade CLI (uv tool install arcade-mcp)
|
||||
# and then run 'arcade login' to authenticate.
|
||||
@app.tool(requires_auth=Reddit(scopes=["read"]))
|
||||
async def get_posts_in_subreddit(
|
||||
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
|
||||
|
|
@ -48,7 +48,7 @@ async def get_posts_in_subreddit(
|
|||
oauth_token = context.get_auth_token_or_empty()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {oauth_token}",
|
||||
"User-Agent": "simple_server-mcp-server",
|
||||
"User-Agent": "simple-mcp-server",
|
||||
}
|
||||
params = {"limit": 5}
|
||||
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
|
||||
|
|
@ -64,10 +64,13 @@ async def get_posts_in_subreddit(
|
|||
|
||||
# Run with specific transport
|
||||
if __name__ == "__main__":
|
||||
# Get transport from command line argument, default to "http"
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
|
||||
# Get transport from command line argument, default to "stdio"
|
||||
# - "stdio" (default): Standard I/O for Claude Desktop, CLI tools, etc.
|
||||
# Supports tools that require_auth or require_secrets out-of-the-box
|
||||
# - "http": HTTPS streaming for Cursor, VS Code, etc.
|
||||
# Does not support tools that require_auth or require_secrets unless the server is deployed
|
||||
# using 'arcade deploy' or added in the Arcade Developer Dashboard with 'Arcade' server type
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
|
||||
|
||||
# Run the server
|
||||
# - "http" (default): HTTPS streaming for Cursor, VS Code, etc.
|
||||
# - "stdio": Standard I/O for Claude Desktop, CLI tools, etc.
|
||||
app.run(transport=transport, host="127.0.0.1", port=8000)
|
||||
|
|
@ -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
|
||||
45
examples/mcp_servers/tool_chaining/pyproject.toml
Normal file
45
examples/mcp_servers/tool_chaining/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "tool_chaining"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "tool_chaining"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tool_chaining"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
API_KEY=ae_12345
|
||||
PASSWORD=pass123
|
||||
|
|
@ -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")
|
||||
45
examples/mcp_servers/user_elicitation/pyproject.toml
Normal file
45
examples/mcp_servers/user_elicitation/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "user_elicitation"
|
||||
version = "0.1.0"
|
||||
description = "MCP Server created with Arcade.dev"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-mcp[all]>=1.4.0,<2.0.0",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
# Tell Arcade.dev that this package has Arcade tools
|
||||
[project.entry-points.arcade_toolkits]
|
||||
toolkit_name = "user_elicitation"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/user_elicitation"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
|
||||
# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode
|
||||
# # Otherwise, you will install the following packages from PyPI
|
||||
# [tool.uv.sources]
|
||||
# arcade-mcp = { path = "../../../", editable = true }
|
||||
# arcade-serve = { path = "../../../libs/arcade-serve/", editable = true }
|
||||
# arcade-mcp-server = { path = "../../../libs/arcade-mcp-server/", editable = true }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
0
libs/tests/arcade_mcp_server/integration/__init__.py
Normal file
0
libs/tests/arcade_mcp_server/integration/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from typing import Annotated
|
||||
|
||||
from arcade_mcp_server import Context, tool
|
||||
|
||||
|
||||
@tool
|
||||
async def logging_tool(context: Context, message: Annotated[str, "The message to log"]) -> str:
|
||||
"""Log a message at varying levels."""
|
||||
await context.log.log("debug", f"Debug via log.log: {message}")
|
||||
await context.log.debug(f"Debug via log.debug: {message}")
|
||||
await context.log.info(f"Info via log.info: {message}")
|
||||
await context.log.warning(f"Warning via log.warning: {message}")
|
||||
await context.log.error(f"Error via log.error: {message}")
|
||||
|
||||
return message
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
36
libs/tests/arcade_mcp_server/integration/server/server.py
Normal file
36
libs/tests/arcade_mcp_server/integration/server/server.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env python3
|
||||
"""E2E integration test MCP server"""
|
||||
|
||||
import sys
|
||||
|
||||
from arcade_mcp_server import MCPApp
|
||||
from logging_tools import logging_tool
|
||||
from progress_tools import reporting_progress
|
||||
from sampling_tools import sampling
|
||||
from tool_chaining_tools import (
|
||||
call_other_tool,
|
||||
the_other_tool,
|
||||
)
|
||||
from user_elicitation_tools import elicit_nickname
|
||||
|
||||
app = MCPApp(name="Test", version="1.0.0", log_level="DEBUG")
|
||||
|
||||
# Logging
|
||||
app.add_tool(logging_tool)
|
||||
|
||||
# Report progress
|
||||
app.add_tool(reporting_progress)
|
||||
|
||||
# Sampling
|
||||
app.add_tool(sampling)
|
||||
|
||||
# User elicitation
|
||||
app.add_tool(elicit_nickname)
|
||||
|
||||
# Tool chaining
|
||||
app.add_tool(call_other_tool)
|
||||
app.add_tool(the_other_tool)
|
||||
|
||||
if __name__ == "__main__":
|
||||
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
|
||||
app.run(transport=transport, host="127.0.0.1", port=8000)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
627
libs/tests/arcade_mcp_server/integration/test_end_to_end.py
Normal file
627
libs/tests/arcade_mcp_server/integration/test_end_to_end.py
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
"""End-to-end integration tests for arcade_mcp_server.
|
||||
|
||||
Tests the full MCP protocol flow for both stdio and HTTP transports,
|
||||
including initialize, ping, list tools, and tool execution with all key features.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Helper Functions
|
||||
|
||||
|
||||
def get_server_path() -> str:
|
||||
"""Get the path to the test server entrypoint."""
|
||||
return str(Path(__file__).parent / "server" / "server.py")
|
||||
|
||||
|
||||
def start_mcp_server(
|
||||
transport: str, port: int | None = None
|
||||
) -> tuple[subprocess.Popen, int | None]:
|
||||
"""
|
||||
Start the MCP server as a subprocess.
|
||||
|
||||
Args:
|
||||
transport: Transport type ("stdio" or "http")
|
||||
port: Port for HTTP transport (optional, will be random if not provided)
|
||||
|
||||
Returns:
|
||||
Tuple of (process, port). Port is None for stdio transport.
|
||||
"""
|
||||
server_path = get_server_path()
|
||||
|
||||
if transport == "stdio":
|
||||
cmd = [sys.executable, server_path, "stdio"]
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1, # Line buffered
|
||||
)
|
||||
return process, None
|
||||
|
||||
elif transport == "http":
|
||||
if port is None:
|
||||
port = random.randint(8000, 9000) # noqa: S311
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"ARCADE_SERVER_HOST": "127.0.0.1",
|
||||
"ARCADE_SERVER_PORT": str(port),
|
||||
"ARCADE_SERVER_TRANSPORT": "http",
|
||||
"ARCADE_AUTH_DISABLED": "true",
|
||||
}
|
||||
|
||||
cmd = [sys.executable, server_path, "http"]
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
return process, port
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid transport: {transport}")
|
||||
|
||||
|
||||
def wait_for_http_server_ready(port: int, timeout: int = 30) -> None:
|
||||
"""
|
||||
Wait for HTTP server to become healthy.
|
||||
|
||||
Args:
|
||||
port: Server port
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Raises:
|
||||
TimeoutError: If server doesn't become healthy within timeout
|
||||
"""
|
||||
health_url = f"http://127.0.0.1:{port}/worker/health"
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response = httpx.get(health_url, timeout=2.0)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except (httpx.ConnectError, httpx.TimeoutException):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
raise TimeoutError(f"Server failed to become healthy within {timeout} seconds")
|
||||
|
||||
|
||||
def build_jsonrpc_request(
|
||||
method: str, params: dict | None = None, request_id: int | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Build a JSON-RPC 2.0 request.
|
||||
|
||||
Args:
|
||||
method: Method name
|
||||
params: Method parameters
|
||||
request_id: Request ID (omit for notifications)
|
||||
|
||||
Returns:
|
||||
JSON-RPC request dict
|
||||
"""
|
||||
request: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
}
|
||||
|
||||
if params is not None:
|
||||
request["params"] = params
|
||||
|
||||
if request_id is not None:
|
||||
request["id"] = request_id
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def parse_jsonrpc_message(line: str) -> dict | None:
|
||||
"""
|
||||
Parse a JSON-RPC message from a line of text.
|
||||
|
||||
Args:
|
||||
line: Line of text containing JSON
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None if parsing fails
|
||||
"""
|
||||
if not line or not line.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(line.strip())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def mock_sampling_response(request_id: str | int) -> dict:
|
||||
"""
|
||||
Create a mock response for sampling/createMessage request.
|
||||
|
||||
Args:
|
||||
request_id: Request ID from the server's request
|
||||
|
||||
Returns:
|
||||
JSON-RPC response with mock sampling result
|
||||
"""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"model": "mock-model",
|
||||
"role": "assistant",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "This is a mock summary of the text.",
|
||||
},
|
||||
"stopReason": "endTurn",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def mock_elicitation_response(request_id: str | int) -> dict:
|
||||
"""
|
||||
Create a mock response for user elicitation request.
|
||||
|
||||
Args:
|
||||
request_id: Request ID from the server's request
|
||||
|
||||
Returns:
|
||||
JSON-RPC response with mock elicitation acceptance
|
||||
"""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"action": "accept",
|
||||
"content": {"nickname": "TestUser"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StdioClient:
|
||||
"""Helper class to communicate with stdio MCP server."""
|
||||
|
||||
def __init__(self, process: subprocess.Popen):
|
||||
self.process = process
|
||||
self._next_id = 1
|
||||
|
||||
def send_request(self, method: str, params: dict | None = None) -> int:
|
||||
"""Send a request and return the request ID."""
|
||||
request_id = self._next_id
|
||||
self._next_id += 1
|
||||
|
||||
request = build_jsonrpc_request(method, params, request_id)
|
||||
message = json.dumps(request) + "\n"
|
||||
|
||||
if self.process.stdin:
|
||||
self.process.stdin.write(message)
|
||||
self.process.stdin.flush()
|
||||
|
||||
return request_id
|
||||
|
||||
def send_notification(self, method: str, params: dict | None = None) -> None:
|
||||
"""Send a notification (no response expected)."""
|
||||
notification = build_jsonrpc_request(method, params, request_id=None)
|
||||
message = json.dumps(notification) + "\n"
|
||||
|
||||
if self.process.stdin:
|
||||
self.process.stdin.write(message)
|
||||
self.process.stdin.flush()
|
||||
|
||||
def send_response(self, request_id: str | int, result: dict) -> None:
|
||||
"""Send a response to a server-initiated request."""
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": result,
|
||||
}
|
||||
message = json.dumps(response) + "\n"
|
||||
|
||||
if self.process.stdin:
|
||||
self.process.stdin.write(message)
|
||||
self.process.stdin.flush()
|
||||
|
||||
def read_response(self, timeout: float = 10.0) -> dict:
|
||||
"""Read a response from the server."""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if self.process.stdout:
|
||||
line = self.process.stdout.readline()
|
||||
if line:
|
||||
message = parse_jsonrpc_message(line)
|
||||
if message:
|
||||
return message
|
||||
time.sleep(0.01)
|
||||
|
||||
raise TimeoutError("Timeout waiting for response")
|
||||
|
||||
def handle_bidirectional_request(self, message: dict) -> None:
|
||||
"""Handle a server-initiated request by sending appropriate mock response."""
|
||||
if "method" not in message or "id" not in message:
|
||||
return
|
||||
|
||||
method = message["method"]
|
||||
request_id = message["id"]
|
||||
|
||||
if method == "sampling/createMessage":
|
||||
response = mock_sampling_response(request_id)
|
||||
self.send_response(request_id, response["result"])
|
||||
elif method == "elicitation/create":
|
||||
response = mock_elicitation_response(request_id)
|
||||
self.send_response(request_id, response["result"])
|
||||
|
||||
|
||||
# Tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_e2e():
|
||||
"""End-to-end test for stdio transport."""
|
||||
process, _ = start_mcp_server("stdio")
|
||||
client = StdioClient(process)
|
||||
|
||||
try:
|
||||
# Give server a moment to start
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 1. Send initialize request
|
||||
init_id = client.send_request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {"roots": {"listChanged": True}, "sampling": {}, "elicitation": {}},
|
||||
"clientInfo": {"name": "test-client", "title": "Test Client", "version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
|
||||
init_response = client.read_response()
|
||||
assert init_response["jsonrpc"] == "2.0"
|
||||
assert init_response["id"] == init_id
|
||||
assert "result" in init_response
|
||||
assert "error" not in init_response
|
||||
assert init_response["result"]["serverInfo"]["name"] == "Test"
|
||||
assert init_response["result"]["serverInfo"]["version"] == "1.0.0"
|
||||
|
||||
# 2. Send initialized notification
|
||||
client.send_notification("notifications/initialized")
|
||||
|
||||
# 3. Send ping request
|
||||
ping_id = client.send_request("ping")
|
||||
ping_response = client.read_response()
|
||||
assert ping_response["jsonrpc"] == "2.0"
|
||||
assert ping_response["id"] == ping_id
|
||||
assert "error" not in ping_response
|
||||
|
||||
# 4. List tools
|
||||
list_tools_id = client.send_request("tools/list")
|
||||
list_tools_response = client.read_response()
|
||||
assert list_tools_response["jsonrpc"] == "2.0"
|
||||
assert list_tools_response["id"] == list_tools_id
|
||||
assert "result" in list_tools_response
|
||||
assert "tools" in list_tools_response["result"]
|
||||
tools = list_tools_response["result"]["tools"]
|
||||
assert len(tools) == 6
|
||||
|
||||
# 5. Call logging_tool
|
||||
logging_id = client.send_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_LoggingTool",
|
||||
"arguments": {"message": "test message"},
|
||||
},
|
||||
)
|
||||
|
||||
# Read response (may have logs interspersed)
|
||||
logging_tool_response = None
|
||||
expected_log_levels = ["debug", "debug", "info", "warning", "error"]
|
||||
actual_log_levels = []
|
||||
for _ in range(20): # Allow for extra logs from before/after tool execution, just in case
|
||||
msg = client.read_response(timeout=2.0)
|
||||
if msg.get("method") == "notifications/message":
|
||||
actual_log_levels.append(msg.get("params").get("level"))
|
||||
if msg.get("id") == logging_id: # call tool response (no more tool logs after this)
|
||||
logging_tool_response = msg
|
||||
break
|
||||
|
||||
assert logging_tool_response is not None
|
||||
assert logging_tool_response["jsonrpc"] == "2.0"
|
||||
assert actual_log_levels == expected_log_levels
|
||||
assert "result" in logging_tool_response
|
||||
|
||||
# 6. Call reporting_progress
|
||||
progress_id = client.send_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_ReportingProgress",
|
||||
"arguments": {},
|
||||
"_meta": {
|
||||
"progressToken": "test-progress-token",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Read response (may have progress notifications interspersed)
|
||||
progress_response = None
|
||||
actual_progress_messages = []
|
||||
expected_progress_messages = [
|
||||
"Step 1 of 5",
|
||||
"Step 2 of 5",
|
||||
"Step 3 of 5",
|
||||
"Step 4 of 5",
|
||||
"Step 5 of 5",
|
||||
]
|
||||
for _ in range(20): # Allow for multiple progress messages
|
||||
msg = client.read_response(timeout=2.0)
|
||||
if msg.get("method") == "notifications/progress":
|
||||
actual_progress_messages.append(msg.get("params").get("message"))
|
||||
if msg.get("id") == progress_id:
|
||||
progress_response = msg
|
||||
break
|
||||
|
||||
assert progress_response is not None
|
||||
assert progress_response["jsonrpc"] == "2.0"
|
||||
assert "error" not in progress_response
|
||||
assert "result" in progress_response
|
||||
|
||||
assert actual_progress_messages == expected_progress_messages
|
||||
|
||||
# 7. Call call_other_tool (tests tool chaining)
|
||||
chaining_id = client.send_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_CallOtherTool",
|
||||
"arguments": {},
|
||||
},
|
||||
)
|
||||
|
||||
chaining_response = client.read_response(timeout=5.0)
|
||||
assert chaining_response["jsonrpc"] == "2.0"
|
||||
assert chaining_response["id"] == chaining_id
|
||||
assert "SUCCESS" in chaining_response["result"]["content"][0]["text"]
|
||||
|
||||
# 8. Call sampling (tests client model sampling with mock response)
|
||||
sampling_id = client.send_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_Sampling",
|
||||
"arguments": {"text": "This is some text to summarize."},
|
||||
},
|
||||
)
|
||||
|
||||
# Server will send a sampling/createMessage request back to the client
|
||||
sampling_request = None
|
||||
actual_sampling_message = None
|
||||
expected_sampling_message = "This is some text to summarize."
|
||||
tool_response = None
|
||||
for _ in range(10): # Allow for messages from before/after tool execution, just in case
|
||||
msg = client.read_response(timeout=5.0)
|
||||
if msg.get("method") == "sampling/createMessage":
|
||||
actual_sampling_message = (
|
||||
msg.get("params", {})
|
||||
.get("messages", [{}])[0]
|
||||
.get("content", {})
|
||||
.get("text", "")
|
||||
)
|
||||
# Sampling request was received by the client, we now send a response back to the server
|
||||
sampling_request = msg
|
||||
client.handle_bidirectional_request(msg)
|
||||
|
||||
elif msg.get("id") == sampling_id:
|
||||
# Tool finished executing and returned a response back to the client
|
||||
tool_response = msg
|
||||
break
|
||||
|
||||
assert sampling_request is not None, "Should have received sampling/createMessage request"
|
||||
assert actual_sampling_message == expected_sampling_message
|
||||
assert tool_response is not None, "Should have received tool call response"
|
||||
assert tool_response["jsonrpc"] == "2.0"
|
||||
assert tool_response["id"] == sampling_id
|
||||
assert "error" not in tool_response
|
||||
assert "result" in tool_response
|
||||
|
||||
# 9. Call elicit_nickname (tests user elicitation with mock response)
|
||||
elicit_id = client.send_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_ElicitNickname",
|
||||
"arguments": {},
|
||||
},
|
||||
)
|
||||
|
||||
# Server will send an elicitation request back the client
|
||||
elicit_request = None
|
||||
actual_elicitation_message = None
|
||||
expected_elicitation_message = "What is your nickname?"
|
||||
tool_response = None
|
||||
for _ in range(10):
|
||||
msg = client.read_response(timeout=5.0)
|
||||
if "method" in msg and msg["method"] == "elicitation/create":
|
||||
actual_elicitation_message = msg.get("params", {}).get("message", "")
|
||||
# Elicitation request was received by the client, we now send a response back to the server
|
||||
elicit_request = msg
|
||||
client.handle_bidirectional_request(msg)
|
||||
elif msg.get("id") == elicit_id:
|
||||
# Tool finished executing and returned a response back to the client
|
||||
tool_response = msg
|
||||
break
|
||||
|
||||
assert elicit_request is not None, "Should have received elicitation request"
|
||||
assert actual_elicitation_message == expected_elicitation_message
|
||||
assert tool_response is not None, "Should have received tool call response"
|
||||
assert tool_response["jsonrpc"] == "2.0"
|
||||
assert tool_response["id"] == elicit_id
|
||||
assert "error" not in tool_response
|
||||
assert "result" in tool_response
|
||||
|
||||
# Verify process is still running
|
||||
assert process.poll() is None
|
||||
|
||||
finally:
|
||||
# Clean shutdown
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_e2e():
|
||||
"""End-to-end test for HTTP transport."""
|
||||
process, port = start_mcp_server("http")
|
||||
assert port is not None
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
try:
|
||||
wait_for_http_server_ready(port, timeout=10)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
client = httpx.Client(base_url=base_url, timeout=30.0, headers=headers)
|
||||
|
||||
# 1. Send initialize request
|
||||
init_request = build_jsonrpc_request(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {"roots": {"listChanged": True}, "sampling": {}, "elicitation": {}},
|
||||
"clientInfo": {"name": "test-client", "title": "Test Client", "version": "1.0.0"},
|
||||
},
|
||||
request_id=1,
|
||||
)
|
||||
|
||||
init_response = client.post("/mcp", json=init_request)
|
||||
assert init_response.status_code == 200
|
||||
init_data = init_response.json()
|
||||
assert init_data["jsonrpc"] == "2.0"
|
||||
assert init_data["id"] == 1
|
||||
assert "result" in init_data
|
||||
assert "error" not in init_data
|
||||
assert init_data["result"]["serverInfo"]["name"] == "Test"
|
||||
assert init_data["result"]["serverInfo"]["version"] == "1.0.0"
|
||||
|
||||
session_id = init_response.headers.get("mcp-session-id")
|
||||
assert session_id is not None, "Session ID not found in initialize response headers"
|
||||
|
||||
# All subsequent requests from the client must include the session ID
|
||||
client.headers.update({"Mcp-Session-Id": session_id})
|
||||
|
||||
# 2. Send initialized notification
|
||||
init_notif = build_jsonrpc_request(
|
||||
"notifications/initialized", params=None, request_id=None
|
||||
)
|
||||
notif_response = client.post("/mcp", json=init_notif)
|
||||
assert notif_response.status_code == 202
|
||||
|
||||
# 3. Send ping request
|
||||
ping_request = build_jsonrpc_request("ping", request_id=2)
|
||||
ping_response = client.post("/mcp", json=ping_request)
|
||||
assert ping_response.status_code == 200
|
||||
ping_data = ping_response.json()
|
||||
assert ping_data["jsonrpc"] == "2.0"
|
||||
assert ping_data["id"] == 2
|
||||
assert "error" not in ping_data
|
||||
|
||||
# 4. List tools
|
||||
list_tools_request = build_jsonrpc_request("tools/list", request_id=3)
|
||||
list_tools_response = client.post("/mcp", json=list_tools_request)
|
||||
assert list_tools_response.status_code == 200
|
||||
list_tools_data = list_tools_response.json()
|
||||
assert list_tools_data["jsonrpc"] == "2.0"
|
||||
assert list_tools_data["id"] == 3
|
||||
assert "result" in list_tools_data
|
||||
assert "tools" in list_tools_data["result"]
|
||||
tools = list_tools_data["result"]["tools"]
|
||||
assert len(tools) == 6
|
||||
|
||||
# 5. Call logging_tool
|
||||
logging_request = build_jsonrpc_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_LoggingTool",
|
||||
"arguments": {"message": "test message"},
|
||||
},
|
||||
request_id=4,
|
||||
)
|
||||
|
||||
logging_response = client.post("/mcp", json=logging_request)
|
||||
|
||||
assert logging_response.status_code == 200
|
||||
logging_data = logging_response.json()
|
||||
assert logging_data["jsonrpc"] == "2.0"
|
||||
assert logging_data["id"] == 4
|
||||
assert "error" not in logging_data
|
||||
assert "result" in logging_data
|
||||
|
||||
# 6. Call reporting_progress
|
||||
progress_request = build_jsonrpc_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_ReportingProgress",
|
||||
"arguments": {},
|
||||
},
|
||||
request_id=5,
|
||||
)
|
||||
|
||||
progress_response = client.post("/mcp", json=progress_request)
|
||||
assert progress_response.status_code == 200
|
||||
progress_data = progress_response.json()
|
||||
assert progress_data["jsonrpc"] == "2.0"
|
||||
assert progress_data["id"] == 5
|
||||
assert "error" not in progress_data
|
||||
assert "result" in progress_data
|
||||
|
||||
# 7. Call call_other_tool (tests tool chaining)
|
||||
chaining_request = build_jsonrpc_request(
|
||||
"tools/call",
|
||||
{
|
||||
"name": "Test_CallOtherTool",
|
||||
"arguments": {},
|
||||
},
|
||||
request_id=6,
|
||||
)
|
||||
|
||||
chaining_response = client.post("/mcp", json=chaining_request)
|
||||
assert chaining_response.status_code == 200
|
||||
chaining_data = chaining_response.json()
|
||||
assert chaining_data["jsonrpc"] == "2.0"
|
||||
assert chaining_data["id"] == 6
|
||||
assert "error" not in chaining_data
|
||||
assert "SUCCESS" in chaining_data["result"]["content"][0]["text"]
|
||||
|
||||
# TODO: Implement an HTTP client that can handle bidirectional communication (sampling/elicitation)
|
||||
|
||||
assert process.poll() is None
|
||||
|
||||
client.close()
|
||||
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
worker.toml
14
worker.toml
|
|
@ -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"]
|
||||
Loading…
Reference in a new issue