MCP Local (#563)

Versions:
* arcade-mcp\==1.0.0rc1
* arcade-mcp-server\==1.0.0rc1
* arcade-core\==2.5.0rc1
* arcade-tdk\==2.6.0rc1
* arcade-serve\==2.2.0rc1

### Summary
Adds first-class MCP support across Arcade, introduces a new MCP server
and CLI, unifies the project under the arcade-mcp name, overhauls
templates/scaffolding, and improves developer tooling, secrets
management, and examples.

### Highlights
- **MCP Server & Core**
- New MCP server with stdio and HTTP/SSE transports, session management,
resumability, and lifecycle handling.
- FastAPI-like `MCPApp` for building servers with lazy init; integrated
worker+MCP HTTP app option.
- Middleware system (logging and error handling), robust exception
hierarchy, and Pydantic-based settings.
- Async-safe managers for tools, resources, and prompts backed by
registries and locks.
- Developer-facing, transport-agnostic runtime context interfaces (logs,
tools, prompts, resources, sampling, UI, notifications).
- Conversion from Arcade ToolDefinition to MCP tool schema; OpenAI JSON
tool schema converter.
  - Parser supports `@app.tool`/`@app.tool(...)` decorators.

- **CLI**
  - New `mcp` command to run MCP servers with stdio or HTTP/SSE.
- New `secret` command to set/list/unset tool secrets (supports .env
input, preserves original casing for lookups).
- `new` command refactored; option to create a full toolkit package with
scaffolding.
  - `chat` command removed.
- `serve.py` imports updated to `arcade_serve.fastapi.telemetry`;
version retrieval now uses `arcade-mcp`.
  - `show.py` refactor to use new local catalog utilities.
- `display_tool_details` improved: adds “Default” column and handles
nested properties.

- **Configuration & Discovery**
- New `configure.py` to set up Claude Desktop, Cursor, and VS Code to
connect to local or Arcade Cloud MCP servers.
- Discovery utilities to find/install toolkits, build `ToolCatalog`s,
analyze files for tools, load kits from directories (pyproject parsing),
and build minimal toolkits.
- Better handling of provider API key resolution and evaluation suite
loading.

- **Templates & Scaffolding**
- Reorganized template structure (minimal vs full); moved
`.pre-commit-config.yaml`, `.ruff.toml`, license, Makefile, README,
tests, and tools layout to correct paths.
  - Minimal template adds `.env.example` for runtime secret injection.
- Template pyproject updated for MCP servers; includes sample server
with greeting and secret-reveal tools.
  - Authorization flow in templates simplified.

- **Repo-wide Renaming & Examples**
- Migrates references from `arcade-ai` to `arcade-mcp` across READMEs,
scripts, and package metadata.
- Examples updated (LangChain/LangGraph/AI SDK/TypeScript) and package
name changed to `arcade-mcp-sdk`.

- **Evals & Core Utilities**
- Evals now use OpenAI tooling format (`OpenAIToolList`, `to_openai`);
`tool_eval` takes `provider_api_key`.
- Core utilities: fixed `does_function_return_value` by dedenting before
parse; version bump to `2.5.0rc1` and dependency cleanup.

- **Tooling & CI**
- `setup-uv-env` action splits toolkit vs contrib dependency
installation.
- Pre-commit: excludes `libs/arcade-mcp-server/mkdocs.yml` and
`libs/tests/` from YAML and Ruff hooks; Ruff per-file ignores (e.g.,
C901 in `libs/**/*.py`, TRY400 in server docs paths).
- Makefile updates for uv env setup, quality checks, tests, builds, and
new `shell` target.
  - Added Makefile to MCP server library to streamline dev workflow.

- **Cleanup**
  - Removed `claude.json` config.
- Simplified stdio entrypoint; removed unused imports (`arcade_gmail`,
`arcade_search`).

### Breaking Changes
- **CLI**: `chat` command removed; use `mcp`, `secret`, and updated
`new`.
- **Naming**: All users should update references from `arcade-ai` to
`arcade-mcp`.
- **Templates**: File paths moved; downstream scripts referencing old
template locations may need updates.

### Getting Started
- Run an MCP server:
  - `arcade mcp --stdio --toolkits your_toolkit`
  - `arcade mcp --http --toolkits your_toolkit`
- Manage secrets:
  - `arcade secret set your_toolkit KEY=value`
  - `arcade secret list your_toolkit`
  - `arcade secret unset your_toolkit KEY`
- Configure clients:
- `arcade configure` to set up Claude Desktop, Cursor, and VS Code for
local/Arcade Cloud MCP.

---------

Co-authored-by: Sam Partee <sam@arcade-ai.com>
Co-authored-by: Shub <125150494+shubcodes@users.noreply.github.com>
This commit is contained in:
Eric Gustin 2025-09-25 15:28:15 -07:00 committed by GitHub
parent a270472a09
commit 3424ec8219
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
177 changed files with 18280 additions and 2508 deletions

View file

@ -32,8 +32,16 @@ runs:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- name: Install package dependencies - name: Install toolkit dependencies
if: inputs.is-toolkit == 'true' || inputs.is-contrib == 'true' if: inputs.is-toolkit == 'true'
working-directory: ${{ inputs.working-directory }}
run: |
echo "Installing dependencies for ${{ inputs.working-directory }}"
make install-local
shell: bash
- name: Install contrib dependencies
if: inputs.is-contrib == 'true'
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
run: | run: |
echo "Installing dependencies for ${{ inputs.working-directory }}" echo "Installing dependencies for ${{ inputs.working-directory }}"

View file

@ -7,7 +7,7 @@ repos:
- id: check-toml - id: check-toml
exclude: ".*/templates/.*" exclude: ".*/templates/.*"
- id: check-yaml - id: check-yaml
exclude: ".*/templates/.*" exclude: ".*/templates/.*|libs/arcade-mcp-server/mkdocs.yml"
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: ".*/templates/.*" exclude: ".*/templates/.*"
- id: trailing-whitespace - id: trailing-whitespace
@ -17,6 +17,6 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
args: [--fix] args: [--fix]
exclude: ".*/templates/.*" exclude: "(.*/templates/.*|libs/tests/.*)"
- id: ruff-format - id: ruff-format
exclude: ".*/templates/.*" exclude: "(.*/templates/.*|libs/tests/.*)"

View file

@ -60,6 +60,8 @@ ignore = [
[lint.per-file-ignores] [lint.per-file-ignores]
"**/tests/*" = ["S101"] "**/tests/*" = ["S101"]
"libs/**/*.py" = ["C901"]
"libs/arcade-mcp-server/docs/**" = ["TRY400"]
[format] [format]
preview = true preview = true

View file

@ -1,4 +1,4 @@
# Contributing to `arcade-ai` # Contributing to `arcade-mcp`
Contributions are welcome, and they are greatly appreciated! Contributions are welcome, and they are greatly appreciated!
Every little bit helps, and credit will always be given. Every little bit helps, and credit will always be given.
@ -9,7 +9,7 @@ You can contribute in many ways:
## Report Bugs ## Report Bugs
Report bugs at https://github.com/ArcadeAI/arcade-ai/issues Report bugs at https://github.com/ArcadeAI/arcade-mcp/issues
If you are reporting a bug, please include: If you are reporting a bug, please include:
@ -33,7 +33,7 @@ Arcade could always use more documentation, whether as part of the official docs
## Submit Feedback ## Submit Feedback
The best way to send feedback is to file an issue at https://github.com/ArcadeAI/arcade-ai/issues. The best way to send feedback is to file an issue at https://github.com/ArcadeAI/arcade-mcp/issues.
If you are proposing a new feature: If you are proposing a new feature:
@ -44,22 +44,22 @@ If you are proposing a new feature:
# Get Started! # Get Started!
Ready to contribute? Here's how to set up `arcade-ai` for local development. Ready to contribute? Here's how to set up `arcade-mcp` for local development.
Please note this documentation assumes you already have `uv` and `Git` installed and ready to go. Please note this documentation assumes you already have `uv` and `Git` installed and ready to go.
1. Fork the `arcade-ai` repo on GitHub. 1. Fork the `arcade-mcp` repo on GitHub.
2. Clone your fork locally: 2. Clone your fork locally:
```bash ```bash
cd <directory_in_which_repo_should_be_created> cd <directory_in_which_repo_should_be_created>
git clone git@github.com:YOUR_GITHUB_USERNAME/arcade-ai.git git clone git@github.com:YOUR_GITHUB_USERNAME/arcade-mcp.git
``` ```
3. Now we need to install the environment. Navigate into the directory 3. Now we need to install the environment. Navigate into the directory
```bash ```bash
cd arcade-ai cd arcade-mcp
``` ```
Create your virtual environment Create your virtual environment

View file

@ -2,7 +2,7 @@
.PHONY: install .PHONY: install
install: ## Install the uv environment and all packages with dependencies install: ## Install the uv environment and all packages with dependencies
@echo "🚀 Creating virtual environment and installing all packages using uv workspace" @echo "🚀 Creating virtual environment and installing all packages using uv workspace"
@uv sync --active --dev --extra all @uv sync --dev --extra all
@uv run pre-commit install @uv run pre-commit install
@echo "✅ All packages and dependencies installed via uv workspace" @echo "✅ All packages and dependencies installed via uv workspace"
@ -41,11 +41,11 @@ install-toolkits: ## Install dependencies for all toolkits
check: ## Run code quality tools. check: ## Run code quality tools.
@echo "🚀 Linting code: Running pre-commit" @echo "🚀 Linting code: Running pre-commit"
@uv run pre-commit run -a @uv run pre-commit run -a
@echo "🚀 Static type checking: Running mypy on libs" @echo "🚀 Static type checking: Running mypy on libs"
@for lib in libs/arcade*/ ; do \ @for lib in libs/arcade*/ ; do \
echo "🔍 Type checking $$lib"; \ echo "🔍 Type checking $$lib"; \
(cd $$lib && uv run mypy . || true); \ (cd $$lib && uv run mypy . --exclude tests || true); \
done done
.PHONY: check-libs .PHONY: check-libs
check-libs: ## Run code quality tools for each lib package check-libs: ## Run code quality tools for each lib package
@ -62,16 +62,16 @@ check-toolkits: ## Run code quality tools for each toolkit that has a Makefile
@for dir in toolkits/*/ ; do \ @for dir in toolkits/*/ ; do \
if [ -f "$$dir/Makefile" ]; then \ if [ -f "$$dir/Makefile" ]; then \
echo "🛠️ Checking toolkit $$dir"; \ echo "🛠️ Checking toolkit $$dir"; \
(cd "$$dir" && uv run --active pre-commit run -a && uv run --active mypy --config-file=pyproject.toml); \ (cd "$$dir" && uv run pre-commit run -a && uv run mypy --config-file=pyproject.toml); \
else \ else \
echo "🛠️ Skipping toolkit $$dir (no Makefile found)"; \ echo "🛠️ Skipping toolkit $$dir (no Makefile found)"; \
fi; \ fi; \
done done
.PHONY: test .PHONY: test
test: ## Test the code with pytest test: ## Test the code with pytest
@echo "🚀 Testing libs: Running pytest" @echo "🚀 Testing libs: Running pytest"
@uv run pytest -W ignore -v --cov=libs/tests --cov-config=pyproject.toml --cov-report=xml @uv run pytest -W ignore -v libs/tests --cov=libs --cov-config=pyproject.toml --cov-report=xml
.PHONY: test-libs .PHONY: test-libs
test-libs: ## Test each lib package individually test-libs: ## Test each lib package individually
@ -87,7 +87,7 @@ test-toolkits: ## Iterate over all toolkits and run pytest on each one
@for dir in toolkits/*/ ; do \ @for dir in toolkits/*/ ; do \
toolkit_name=$$(basename "$$dir"); \ toolkit_name=$$(basename "$$dir"); \
echo "🧪 Testing $$toolkit_name toolkit"; \ echo "🧪 Testing $$toolkit_name toolkit"; \
(cd $$dir && uv run --active pytest -W ignore -v --cov=arcade_$$toolkit_name --cov-report=xml || exit 1); \ (cd $$dir && uv run pytest -W ignore -v --cov=arcade_$$toolkit_name --cov-report=xml || exit 1); \
done done
.PHONY: coverage .PHONY: coverage
@ -194,7 +194,7 @@ full-dist: clean-dist ## Build all projects and copy wheels to ./dist
(cd libs/$$lib && uv build); \ (cd libs/$$lib && uv build); \
done done
@echo "🛠️ Building arcade-ai package and copying wheel to ./dist" @echo "🛠️ Building arcade-mcp package and copying wheel to ./dist"
@uv build @uv build
@rm -f dist/*.tar.gz @rm -f dist/*.tar.gz
@ -224,7 +224,9 @@ clean-dist: ## Clean all built distributions
done done
.PHONY: setup .PHONY: setup
setup: install ## Complete development setup (same as install) setup: ## Run uv environment setup script
@chmod +x ./uv_setup.sh
@./uv_setup.sh
.PHONY: lint .PHONY: lint
lint: check ## Alias for check command lint: check ## Alias for check command
@ -238,3 +240,12 @@ help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: shell
shell: ## Open an interactive shell with the virtual environment activated
@if [ -f ".venv/bin/activate" ]; then \
. .venv/bin/activate && exec $$SHELL -l; \
else \
echo "⚠️ Virtual environment not found. Run 'make setup' first."; \
exit 1; \
fi

View file

@ -6,15 +6,15 @@
> >
</h3> </h3>
<div align="center"> <div align="center">
<a href="https://github.com/arcadeai/arcade-ai/blob/main/LICENSE"> <a href="https://github.com/arcadeai/arcade-mcp/blob/main/LICENSE">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License"> <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
</a> </a>
<img src="https://img.shields.io/github/last-commit/ArcadeAI/arcade-ai" alt="GitHub last commit"> <img src="https://img.shields.io/github/last-commit/ArcadeAI/arcade-mcp" alt="GitHub last commit">
<a href="https://github.com/arcadeai/arcade-ai/actions?query=branch%3Amain"> <a href="https://github.com/arcadeai/arcade-mcp/actions?query=branch%3Amain">
<img src="https://img.shields.io/github/actions/workflow/status/arcadeai/arcade-ai/main.yml?branch=main" alt="GitHub Actions Status"> <img src="https://img.shields.io/github/actions/workflow/status/arcadeai/arcade-mcp/main.yml?branch=main" alt="GitHub Actions Status">
</a> </a>
<a href="https://img.shields.io/pypi/pyversions/arcade-ai"> <a href="https://img.shields.io/pypi/pyversions/arcade-mcp">
<img src="https://img.shields.io/pypi/pyversions/arcade-ai" alt="Python Version"> <img src="https://img.shields.io/pypi/pyversions/arcade-mcp" alt="Python Version">
</a> </a>
</div> </div>
<div> <div>
@ -22,7 +22,7 @@
<a href="https://x.com/TryArcade"> <a href="https://x.com/TryArcade">
<img src="https://img.shields.io/badge/Follow%20on%20X-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" style="width: 125px;height: 25px; padding-top: .8px; border-radius: 5px;" /> <img src="https://img.shields.io/badge/Follow%20on%20X-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" style="width: 125px;height: 25px; padding-top: .8px; border-radius: 5px;" />
</a> </a>
<a href="https://www.linkedin.com/company/arcade-ai" > <a href="https://www.linkedin.com/company/arcade-mcp" >
<img src="https://img.shields.io/badge/Follow%20on%20LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white" alt="Follow on LinkedIn" style="width: 150px; padding-top: 1.5px;height: 22px; border-radius: 5px;" /> <img src="https://img.shields.io/badge/Follow%20on%20LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white" alt="Follow on LinkedIn" style="width: 150px; padding-top: 1.5px;height: 22px; border-radius: 5px;" />
</a> </a>
<a href="https://discord.com/invite/GUZEMpEZ9p"> <a href="https://discord.com/invite/GUZEMpEZ9p">
@ -43,11 +43,12 @@ Arcade is a developer platform that lets you build, deploy, and manage tools for
This repository contains the core Arcade libraries, organized as separate packages for maximum flexibility and modularity: This repository contains the core Arcade libraries, organized as separate packages for maximum flexibility and modularity:
- **arcade-core** - Core platform functionality and schemas | [Source code](https://github.com/ArcadeAI/arcade-ai/tree/main/libs/arcade-core) | `pip install arcade-core` | - **arcade-core** - Core platform functionality and schemas | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-core) | `pip install arcade-core` |
- **arcade-tdk** - Tool Development Kit with the `@tool` decorator | [Source code](https://github.com/ArcadeAI/arcade-ai/tree/main/libs/arcade-tdk) | `pip install arcade-tdk` | - **arcade-mcp-server** - MCP Server Development Framework | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-mcp-server) | `pip install arcade-mcp-server` |
- **arcade-serve** - Serving infrastructure for workers and MCP servers | [Source code](https://github.com/ArcadeAI/arcade-ai/tree/main/libs/arcade-serve) | `pip install arcade-serve` | - **arcade-tdk** - Tool Development Kit with the `@tool` decorator | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-tdk) | `pip install arcade-tdk` |
- **arcade-evals** - Evaluation framework for testing tool performance | [Source code](https://github.com/ArcadeAI/arcade-ai/tree/main/libs/arcade-evals) | `pip install 'arcade-ai[evals]` | - **arcade-serve** - Serving infrastructure for workers and MCP servers | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-serve) | `pip install arcade-serve` |
- **arcade-cli** - Command-line interface for the Arcade platform | [Source code](https://github.com/ArcadeAI/arcade-ai/tree/main/libs/arcade-cli) | `pip install arcade-ai` | - **arcade-evals** - Evaluation framework for testing tool performance | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-evals) | `pip install 'arcade-mcp[evals]` |
- **arcade-cli** - Command-line interface for the Arcade platform | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-cli) | `pip install arcade-mcp` |
![diagram](https://github.com/user-attachments/assets/1a567e5f-d6b4-4b1e-9918-c401ad232ebb) ![diagram](https://github.com/user-attachments/assets/1a567e5f-d6b4-4b1e-9918-c401ad232ebb)
@ -55,8 +56,8 @@ This repository contains the core Arcade libraries, organized as separate packag
_Pst. hey, you, give us a star if you like it!_ _Pst. hey, you, give us a star if you like it!_
<a href="https://github.com/ArcadeAI/arcade-ai"> <a href="https://github.com/ArcadeAI/arcade-mcp">
<img src="https://img.shields.io/github/stars/ArcadeAI/arcade-ai.svg" alt="GitHub stars"> <img src="https://img.shields.io/github/stars/ArcadeAI/arcade-mcp.svg" alt="GitHub stars">
</a> </a>
## Quick Start ## Quick Start
@ -76,9 +77,9 @@ make install
For production use, install individual packages as needed: For production use, install individual packages as needed:
```bash ```bash
pip install arcade-ai # CLI pip install arcade-mcp # CLI
pip install 'arcade-ai[evals]' # CLI + Evaluation framework pip install 'arcade-mcp[evals]' # CLI + Evaluation framework
pip install 'arcade-ai[all]' # CLI + Serving infra + eval framework + TDK pip install 'arcade-mcp[all]' # CLI + Serving infra + eval framework + TDK
pip install arcade_serve # Serving infrastructure pip install arcade_serve # Serving infrastructure
pip install arcade-tdk # Tool Development Kit pip install arcade-tdk # Tool Development Kit
``` ```
@ -115,5 +116,5 @@ make help
## Support and Community ## Support and Community
- **Discord:** Join our [Discord community](https://discord.com/invite/GUZEMpEZ9p) for real-time support and discussions. - **Discord:** Join our [Discord community](https://discord.com/invite/GUZEMpEZ9p) for real-time support and discussions.
- **GitHub:** Contribute or report issues on the [Arcade GitHub repository](https://github.com/ArcadeAI/arcade-ai). - **GitHub:** Contribute or report issues on the [Arcade GitHub repository](https://github.com/ArcadeAI/arcade-mcp).
- **Documentation:** Find in-depth guides and API references at [Arcade Documentation](https://docs.arcade.dev). - **Documentation:** Find in-depth guides and API references at [Arcade Documentation](https://docs.arcade.dev).

View file

@ -6,7 +6,7 @@
</h3> </h3>
<div align="center"> <div align="center">
<h3>CrewAI Integration</h3> <h3>CrewAI Integration</h3>
<a href="https://github.com/arcadeai/arcade-ai/blob/main/LICENSE"> <a href="https://github.com/arcadeai/arcade-mcp/blob/main/LICENSE">
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License"> <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
</a> </a>
<a href="https://pepy.tech/project/crewai-arcade"> <a href="https://pepy.tech/project/crewai-arcade">
@ -34,4 +34,4 @@ pip install crewai-arcade
## Usage ## Usage
See the [examples](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/crewai) for usage examples See the [examples](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/crewai) for usage examples

View file

@ -4,7 +4,7 @@ version = "0.1.1"
description = "An integration package connecting Arcade and CrewAI" description = "An integration package connecting Arcade and CrewAI"
authors = ["Arcade <dev@arcade.dev>"] authors = ["Arcade <dev@arcade.dev>"]
readme = "README.md" readme = "README.md"
repository = "https://github.com/arcadeai/arcade-ai/tree/main/contrib/crewai" repository = "https://github.com/arcadeai/arcade-mcp/tree/main/contrib/crewai"
license = "MIT" license = "MIT"
[tool.poetry.dependencies] [tool.poetry.dependencies]

View file

@ -108,7 +108,7 @@ graph = create_react_agent(model, tools)
# Run the agent with the "user_id" field in the config # Run the agent with the "user_id" field in the config
# IMPORTANT the "user_id" field is required for tools that require user authorization # IMPORTANT the "user_id" field is required for tools that require user authorization
config = {"configurable": {"user_id": "user@lgexample.com"}} config = {"configurable": {"user_id": "user@lgexample.com"}}
user_input = {"messages": [("user", "Star the arcadeai/arcade-ai repository on GitHub")]} user_input = {"messages": [("user", "Star the arcadeai/arcade-mcp repository on GitHub")]}
for chunk in graph.stream(user_input, config, debug=True): for chunk in graph.stream(user_input, config, debug=True):
if chunk.get("__interrupt__"): if chunk.get("__interrupt__"):
@ -124,7 +124,7 @@ for chunk in graph.stream(user_input, config, debug=True):
``` ```
See the Functional examples in the [examples directory](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/langchain) that continue the agent after authorization and handle authorization errors gracefully. See the Functional examples in the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain) that continue the agent after authorization and handle authorization errors gracefully.
### Async Support ### Async Support
@ -172,4 +172,4 @@ For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade
## More Examples ## More Examples
For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/langchain). For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain).

View file

@ -1,7 +1,7 @@
from .manager import ArcadeToolManager, AsyncToolManager, ToolManager from .manager import ArcadeToolManager, AsyncToolManager, ToolManager
__all__ = [ __all__ = [
"ToolManager",
"AsyncToolManager",
"ArcadeToolManager", # Deprecated "ArcadeToolManager", # Deprecated
"AsyncToolManager",
"ToolManager",
] ]

View file

@ -7,7 +7,7 @@ name = "langchain-arcade"
version = "1.4.4" version = "1.4.4"
description = "An integration package connecting Arcade and Langchain/LangGraph" description = "An integration package connecting Arcade and Langchain/LangGraph"
readme = "README.md" readme = "README.md"
repository = "https://github.com/arcadeai/arcade-ai/tree/main/contrib/langchain" repository = "https://github.com/arcadeai/arcade-mcp/tree/main/contrib/langchain"
license = "MIT" license = "MIT"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View file

@ -38,7 +38,8 @@ RUN ls -la /app/dist/
# Install the worker and CLI package # Install the worker and CLI package
RUN python -m pip install \ RUN python -m pip install \
/app/dist/arcade_serve-*.whl \ /app/dist/arcade_serve-*.whl \
/app/dist/arcade_ai-*.whl /app/dist/arcade_mcp-*.whl
/app/dist/arcade_mcp_server-*.whl
# Conditionally install toolkit wheels from dist directory if INSTALL_TOOLKITS is true and the toolkit is in toolkits.txt # Conditionally install toolkit wheels from dist directory if INSTALL_TOOLKITS is true and the toolkit is in toolkits.txt
RUN if [ "$INSTALL_TOOLKITS" = "true" ] ; then \ RUN if [ "$INSTALL_TOOLKITS" = "true" ] ; then \
@ -51,7 +52,8 @@ RUN if [ "$INSTALL_TOOLKITS" = "true" ] ; then \
# Check if this is not a core package and if the wheel file exists # Check if this is not a core package and if the wheel file exists
if [ "$wheel_name" != "arcade_core" ] && \ if [ "$wheel_name" != "arcade_core" ] && \
[ "$wheel_name" != "arcade_serve" ] && \ [ "$wheel_name" != "arcade_serve" ] && \
[ "$wheel_name" != "arcade_ai" ] && \ [ "$wheel_name" != "arcade_mcp" ] && \
[ "$wheel_name" != "arcade_mcp_server" ] && \
[ "$wheel_name" != "arcade_tdk" ]; then \ [ "$wheel_name" != "arcade_tdk" ]; then \
if ls $wheel_file 1> /dev/null 2>&1; then \ if ls $wheel_file 1> /dev/null 2>&1; then \
echo "Installing $toolkit from $wheel_file"; \ echo "Installing $toolkit from $wheel_file"; \

View file

@ -1,6 +1,6 @@
VENDOR ?= ArcadeAI VENDOR ?= ArcadeAI
PROJECT ?= ArcadeAI PROJECT ?= ArcadeAI
SOURCE ?= https://github.com/ArcadeAI/arcade-ai SOURCE ?= https://github.com/ArcadeAI/arcade-mcp
LICENSE ?= MIT LICENSE ?= MIT
DESCRIPTION ?= "Arcade Worker for LLM Tool Serving" DESCRIPTION ?= "Arcade Worker for LLM Tool Serving"
REPOSITORY ?= arcadeai/worker REPOSITORY ?= arcadeai/worker

View file

@ -14,12 +14,12 @@ This guide provides detailed instructions on how to set up and run Arcade using
Begin by cloning the Arcade repository: Begin by cloning the Arcade repository:
```bash ```bash
git clone https://github.com/ArcadeAI/arcade-ai.git git clone https://github.com/ArcadeAI/arcade-mcp.git
``` ```
### 2. Build package wheels ### 2. Build package wheels
From the root of the arcade-ai repository: From the root of the arcade-mcp repository:
```bash ```bash
make full-dist make full-dist
@ -30,7 +30,7 @@ make full-dist
Change to the `docker` directory: Change to the `docker` directory:
```bash ```bash
cd arcade-ai/docker cd arcade-mcp/docker
``` ```
Copy the example environment file to `.env`: Copy the example environment file to `.env`:

View file

@ -22,7 +22,7 @@
# Arcade - AI SDK # Arcade - AI SDK
This example demonstrates how to integrate [Arcade](https://docs.arcade.dev) with the [Vercel AI SDK](https://sdk.vercel.ai/) to create powerful AI agents. Arcade provides access to a wide range of tools including Gmail, Slack, LinkedIn, and more. You can also develop custom tools using the [Tool SDK](https://github.com/ArcadeAI/arcade-ai). This example demonstrates how to integrate [Arcade](https://docs.arcade.dev) with the [Vercel AI SDK](https://sdk.vercel.ai/) to create powerful AI agents. Arcade provides access to a wide range of tools including Gmail, Slack, LinkedIn, and more. You can also develop custom tools using the [Tool SDK](https://github.com/ArcadeAI/arcade-mcp).
For a list of all hosted tools and auth providers, see the [Arcade Integrations](https://docs.arcade.dev/toolkits) documentation. For a list of all hosted tools and auth providers, see the [Arcade Integrations](https://docs.arcade.dev/toolkits) documentation.

View file

@ -1,25 +1,25 @@
{ {
"name": "arcade-ai-sdk", "name": "arcade-mcp-sdk",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --env-file=.env index.js", "dev": "node --env-file=.env index.js",
"generateText": "node --env-file=.env generateText.js" "generateText": "node --env-file=.env generateText.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.6.5", "packageManager": "pnpm@10.6.5",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.3.22", "@ai-sdk/openai": "^1.3.22",
"@arcadeai/arcadejs": "latest", "@arcadeai/arcadejs": "latest",
"ai": "^4.3.15" "ai": "^4.3.15"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"form-data@>=4.0.0 <4.0.4": ">=4.0.4" "form-data@>=4.0.0 <4.0.4": ">=4.0.4"
} }
} }
} }

View file

@ -10,7 +10,7 @@ from openai import OpenAI
def call_tool_with_openai(client: OpenAI) -> dict: def call_tool_with_openai(client: OpenAI) -> dict:
response = client.chat.completions.create( response = client.chat.completions.create(
messages=[ messages=[
{"role": "user", "content": "Star the ArcadeAI/arcade-ai repository."}, {"role": "user", "content": "Star the ArcadeAI/arcade-mcp repository."},
], ],
model="gpt-4o-mini", # TODO: Try "claude-3-5-sonnet-20240620" or other models from our supported model providers. Checkout out our docs for a full list https://docs.arcade.dev model="gpt-4o-mini", # TODO: Try "claude-3-5-sonnet-20240620" or other models from our supported model providers. Checkout out our docs for a full list https://docs.arcade.dev
user="you@example.com", user="you@example.com",

View file

@ -129,7 +129,7 @@ const main = async () => {
messages: [ messages: [
{ {
role: "user", role: "user",
content: "Star arcadeai/arcade-ai on github", content: "Star arcadeai/arcade-mcp on github",
}, },
], ],
}; };

View file

@ -44,7 +44,7 @@ graph = create_react_agent(model=bound_model, tools=lc_tools, checkpointer=memor
# 6) Provide basic config and a user query. # 6) Provide basic config and a user query.
# Note: user_id is required for the tool to be authorized # Note: user_id is required for the tool to be authorized
config = {"configurable": {"thread_id": "1", "user_id": "user@example.com"}} config = {"configurable": {"thread_id": "1", "user_id": "user@example.com"}}
user_input = {"messages": [("user", "star the arcadeai/arcade-ai repo on github")]} user_input = {"messages": [("user", "star the arcadeai/arcade-mcp repo on github")]}
# 7) Stream the agent's output. If the tool is unauthorized, it may trigger interrupts # 7) Stream the agent's output. If the tool is unauthorized, it may trigger interrupts
for chunk in graph.stream(user_input, config, stream_mode="values"): for chunk in graph.stream(user_input, config, stream_mode="values"):

View file

@ -93,7 +93,7 @@ if __name__ == "__main__":
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": "Star arcadeai/arcade-ai on github", "content": "Star arcadeai/arcade-mcp on github",
} }
], ],
} }

View file

@ -21,7 +21,7 @@
This example demonstrates how to integrate [Arcade](https://docs.arcade.dev) with [Mastra](https://mastra.ai/en/docs) to create powerful AI agents. Arcade provides access to a wide range of tools including Gmail, Slack, LinkedIn, and more, while Mastra provides a robust framework for building AI agents with TypeScript. This example demonstrates how to integrate [Arcade](https://docs.arcade.dev) with [Mastra](https://mastra.ai/en/docs) to create powerful AI agents. Arcade provides access to a wide range of tools including Gmail, Slack, LinkedIn, and more, while Mastra provides a robust framework for building AI agents with TypeScript.
For a list of all available tools and authentication options, see the [Arcade Integrations](https://docs.arcade.dev/toolkits) documentation. You can also build custom tools with the [Tool SDK](https://github.com/ArcadeAI/arcade-ai) as described in our [documentation](https://docs.arcade.dev/home/build-tools/create-a-toolkit). For a list of all available tools and authentication options, see the [Arcade Integrations](https://docs.arcade.dev/toolkits) documentation. You can also build custom tools with the [Tool SDK](https://github.com/ArcadeAI/arcade-mcp) as described in our [documentation](https://docs.arcade.dev/home/build-tools/create-a-toolkit).
## Prerequisites ## Prerequisites

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"arcade": {
"command": "bash",
"args": ["-c", "export ARCADE_API_KEY=arc_xxxx && /path/to/python /path/to/arcade mcp"]
}
}
}

View file

@ -1,24 +0,0 @@
import arcade_gmail # pip install arcade_gmail
import arcade_search # pip install arcade_search
from arcade_core.catalog import ToolCatalog
from arcade_serve.mcp.stdio import StdioServer
# 2. Create and populate the tool catalog
catalog = ToolCatalog()
catalog.add_module(arcade_gmail) # Registers all tools in the package
catalog.add_module(arcade_search)
# 3. Main entrypoint
async def main():
# Create the worker with the tool catalog
worker = StdioServer(catalog)
# Run the worker
await worker.run()
if __name__ == "__main__":
import asyncio
asyncio.run(main())

View file

@ -14,7 +14,7 @@ Arcade CLI provides a comprehensive command-line interface for the Arcade platfo
## Installation ## Installation
```bash ```bash
pip install arcade-ai pip install arcade-mcp
``` ```
## Usage ## Usage

View file

@ -0,0 +1,236 @@
"""Connect command for configuring MCP clients."""
import json
import os
import platform
from pathlib import Path
import typer
from rich.console import Console
console = Console()
def get_claude_config_path() -> Path:
"""Get the Claude Desktop configuration file path."""
system = platform.system()
if system == "Darwin": # macOS
return (
Path.home()
/ "Library"
/ "Application Support"
/ "Claude"
/ "claude_desktop_config.json"
)
elif system == "Windows":
return Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
else: # Linux
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
def get_cursor_config_path() -> Path:
"""Get the Cursor configuration file path."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / ".cursor" / "mcp.json"
elif system == "Windows":
return Path(os.environ["APPDATA"]) / "Cursor" / "mcp.json"
else: # Linux
return Path.home() / ".config" / "Cursor" / "mcp.json"
def get_vscode_config_path() -> Path:
"""Get the VS Code configuration file path."""
# Paths to global 'Default User' MCP configuration file
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
elif system == "Windows":
return Path(os.environ["APPDATA"]) / "Code" / "User" / "mcp.json"
else: # Linux
return Path.home() / ".config" / "Code" / "User" / "mcp.json"
def configure_claude_local(server_name: str, port: int = 8000, path: Path | None = None) -> None:
"""Configure Claude Desktop to add a local MCP server to the configuration."""
config_path = path or get_claude_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path) as f:
config = json.load(f)
# Add or update MCP servers configuration
if "mcpServers" not in config:
config["mcpServers"] = {}
config["mcpServers"][server_name] = {
"command": "python",
"args": ["-m", "arcade_mcp_server", "stream"],
"url": f"http://localhost:{port}/mcp",
}
# Write updated config
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
style="green",
)
console.print(
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
)
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
console.print(" Restart Claude Desktop for changes to take effect.", style="yellow")
def configure_claude_arcade(server_name: str, path: Path | None = None) -> None:
"""Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration."""
# This would connect to the Arcade Cloud to get the server URL
# For now, this is a placeholder
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_cursor_local(server_name: str, port: int = 8000, path: Path | None = None) -> None:
"""Configure Cursor to add a local MCP server to the configuration."""
config_path = path or get_cursor_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path) as f:
config = json.load(f)
# Add or update MCP servers configuration
if "mcpServers" not in config:
config["mcpServers"] = {}
config["mcpServers"][server_name] = {
"name": server_name,
"type": "stream", # Cursor prefers stream
"url": f"http://localhost:{port}/mcp",
}
# Write updated config
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
style="green",
)
console.print(
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
)
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
console.print(" Restart Cursor for changes to take effect.", style="yellow")
def configure_cursor_arcade(server_name: str, path: Path | None = None) -> None:
"""Configure Cursor to add an Arcade Cloud MCP server to the configuration."""
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_vscode_local(server_name: str, port: int = 8000, path: Path | None = None) -> None:
"""Configure VS Code to add a local MCP server to the configuration."""
config_path = path or get_vscode_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path) as f:
try:
config = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(
f"\n\tFailed to load MCP configuration file at {config_path.as_posix()} "
f"\n\tThe file contains invalid JSON: {e}. "
"\n\tPlease check the file format or delete it to create a new configuration."
)
# Add or update MCP servers configuration
if "servers" not in config:
config["servers"] = {}
config["servers"][server_name] = {
"type": "http",
"url": f"http://localhost:{port}/mcp",
}
# Write updated config
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
console.print(
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
style="green",
)
console.print(
f" MCP client config file: {config_path.as_posix().replace(' ', '\\ ')}", style="dim"
)
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
console.print(" Restart VS Code for changes to take effect.", style="yellow")
def configure_vscode_arcade(server_name: str, path: Path | None = None) -> None:
"""Configure VS Code to add an Arcade Cloud MCP server to the configuration."""
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_client(
client: str,
server_name: str | None = None,
from_local: bool = False,
from_arcade: bool = False,
port: int = 8000,
path: Path | None = None,
) -> None:
"""
Configure an MCP client to connect to a server.
Args:
client: The MCP client to configure (claude, cursor, vscode)
server_name: Name of the server to add to the configuration
from_local: Add a local server to the configuration
from_arcade: Add an Arcade Cloud server to the configuration
port: Port for local servers (default: 8000)
path: Custom path to the MCP client configuration file
"""
if not from_local and not from_arcade:
console.print("[red]Must specify either --from-local or --from-arcade[/red]")
raise typer.Exit(1)
if from_local and from_arcade:
console.print("[red]Cannot specify both --from-local and --from-arcade[/red]")
raise typer.Exit(1)
# Default server name if not provided
if not server_name:
# Try to detect from current directory
server_name = Path.cwd().name if Path("server.py").exists() else "arcade-mcp-server"
client_lower = client.lower()
if client_lower == "claude":
if from_local:
configure_claude_local(server_name, port, path)
else:
configure_claude_arcade(server_name, path)
elif client_lower == "cursor":
if from_local:
configure_cursor_local(server_name, port, path)
else:
configure_cursor_arcade(server_name, path)
elif client_lower == "vscode":
if from_local:
configure_vscode_local(server_name, port, path)
else:
configure_vscode_arcade(server_name, path)
else:
console.print(f"[red]Unknown client: {client}[/red]")
console.print("Supported clients: claude, cursor, vscode")
raise typer.Exit(1)

View file

@ -10,6 +10,7 @@ from typing import Any
import toml import toml
from arcade_core import Toolkit from arcade_core import Toolkit
from arcade_core.catalog import ToolCatalog
from arcade_core.toolkit import Validate from arcade_core.toolkit import Validate
from arcadepy import Arcade, NotFoundError from arcadepy import Arcade, NotFoundError
from httpx import Client, ConnectError, HTTPStatusError, TimeoutException from httpx import Client, ConnectError, HTTPStatusError, TimeoutException
@ -75,12 +76,78 @@ class Secret(BaseModel):
pattern: str | None = None pattern: str | None = None
class AuthProvider(BaseModel):
"""Configuration for a local auth provider."""
provider_id: str
"""The provider ID (e.g., 'google', 'github', 'custom-oauth')"""
provider_type: str = "oauth2"
"""The type of provider, usually 'oauth2'"""
client_id: str
"""OAuth client ID for this provider"""
client_secret: str
"""OAuth client secret for this provider"""
# Mock tokens for local development
mock_tokens: dict[str, str] | None = None
"""
Mock access tokens by user ID for local development.
Example: {"user-123": "mock-google-token-abc", "user-456": "mock-google-token-def"}
"""
scopes: list[str] | None = None
"""Default scopes for this provider"""
class Config(BaseModel): class Config(BaseModel):
"""The configuration for an Arcade worker deployment."""
id: str id: str
"""The unique id for the worker deployment."""
enabled: bool = True enabled: bool = True
timeout: int = 30 """Whether the worker is enabled. Defaults to True."""
retries: int = 3
secret: Secret | None = None secret: Secret | None = None
"""The shared secret between the worker and Arcade Engine server."""
timeout: int = 120
"""The maximum execution time in seconds for a tool in this worker."""
retries: int = 1
"""The number of times to retry a failed tool invocation. Defaults to 1."""
# Local development context - only used when running locally
local_context: dict[str, Any] | None = None
"""
Local context configuration for development. This section is only used when running
'arcade serve' locally and is ignored during deployment. It can include:
- user_id: Default user ID for local testing
- user_info: Dictionary of user metadata
- metadata: Additional metadata fields
Example:
[worker.config.local_context]
user_id = "test-user-123"
user_info = { email = "test@example.com", name = "Test User" }
"""
# Local auth providers - only used when running locally
local_auth_providers: list[AuthProvider] | None = None
"""
Local auth provider configurations for development. These are only used when running
'arcade serve' locally and are ignored during deployment. They define mock OAuth
providers and tokens for testing tools that require authentication.
Example:
[[worker.config.local_auth_providers]]
provider_id = "google"
client_id = "mock-google-client"
client_secret = "mock-google-secret"
[worker.config.local_auth_providers.mock_tokens]
"test-user-123" = "mock-google-access-token"
"""
# Validate and parse the secret if required # Validate and parse the secret if required
@field_validator("secret", mode="before") @field_validator("secret", mode="before")
@ -89,22 +156,19 @@ class Config(BaseModel):
# If the secret is a string, attempt to parse it as an environment variable or return the secret # If the secret is a string, attempt to parse it as an environment variable or return the secret
if isinstance(v, str): if isinstance(v, str):
secret = get_env_secret(v) secret = get_env_secret(v)
# If the secret has been manually set, return it
elif isinstance(v, Secret): elif isinstance(v, Secret):
secret = v secret = v
else: else:
raise TypeError("Secret must be a string or a Secret object") raise TypeError("Secret must be a string or a Secret object")
# Check that the secret is not the default dev secret or empty if secret.value.strip() == "":
if secret.value.strip() == "" or secret.value == "dev": raise ValueError("Secret must be a non-empty string")
raise ValueError("Secret must be a non-empty string and not 'dev'")
return secret return secret
@field_serializer("secret") @field_serializer("secret")
def serialize_secret(self, secret: Secret) -> str: def serialize_secret(self, secret: Secret) -> str:
if secret.pattern: if secret.pattern:
return f"$env:{secret.pattern}" return f"$env:{secret.pattern}"
else: return secret.value
return secret.value
# Cloud request for deploying a worker # Cloud request for deploying a worker
@ -254,7 +318,8 @@ class Worker(BaseModel):
) )
# Validate that we are able to load the package # Validate that we are able to load the package
Toolkit.tools_from_directory(package_dir=package_path, package_name=package_path.name) # Use from_directory to properly resolve src/ layouts and avoid double prefixes
Toolkit.from_directory(package_path)
# Compress the package into a byte stream and tar # Compress the package into a byte stream and tar
byte_stream = io.BytesIO() byte_stream = io.BytesIO()
@ -287,6 +352,22 @@ class Worker(BaseModel):
if dupes: if dupes:
raise ValueError(f"Duplicate packages: {dupes}") raise ValueError(f"Duplicate packages: {dupes}")
def get_required_secrets(self) -> set[str]:
"""Inspect local toolkits and return a set of required secret keys."""
all_secrets = set()
if self.local_source:
catalog = ToolCatalog()
for package_path_str in self.local_source.packages:
package_path = self.toml_path.parent / package_path_str
toolkit = Toolkit.from_directory(package_path)
catalog.add_toolkit(toolkit)
for tool in catalog:
if tool.definition.requirements and tool.definition.requirements.secrets:
for secret in tool.definition.requirements.secrets:
all_secrets.add(secret.key)
return all_secrets
class Deployment(BaseModel): class Deployment(BaseModel):
toml_path: Path toml_path: Path

View file

@ -36,7 +36,7 @@ def display_tools_table(tools: list[ToolDefinition]) -> None:
console.print(table) console.print(table)
def display_tool_details(tool: ToolDefinition, worker: bool = False) -> None: # noqa: C901 def display_tool_details(tool: ToolDefinition, worker: bool = False) -> None:
""" """
Display detailed information about a specific tool using multiple panels. Display detailed information about a specific tool using multiple panels.
@ -59,36 +59,19 @@ def display_tool_details(tool: ToolDefinition, worker: bool = False) -> None: #
inputs_table.add_column("Type", style="magenta") inputs_table.add_column("Type", style="magenta")
inputs_table.add_column("Required", style="yellow") inputs_table.add_column("Required", style="yellow")
inputs_table.add_column("Description", style="white") inputs_table.add_column("Description", style="white")
inputs_table.add_column("Default", style="blue")
for param in inputs: for param in inputs:
# Format the type string properly # Since InputParameter does not have a default field, we use "N/A"
type_str = _format_type_string(param.value_schema) default_value = "N/A"
if param.value_schema.enum:
# Add the main parameter row default_value = f"One of {param.value_schema.enum}"
inputs_table.add_row( inputs_table.add_row(
param.name, param.name,
type_str, param.value_schema.val_type,
str(param.required), str(param.required),
param.description or "", param.description or "",
default_value,
) )
# If this is a json type with properties, show them
if (
param.value_schema.val_type == "json"
and hasattr(param.value_schema, "properties")
and param.value_schema.properties
):
_add_nested_properties(inputs_table, param.value_schema.properties, indent=1)
# Handle arrays with inner properties
elif (
param.value_schema.val_type == "array"
and hasattr(param.value_schema, "inner_properties")
and param.value_schema.inner_properties
):
_add_nested_properties(
inputs_table, param.value_schema.inner_properties, indent=1, is_array_item=True
)
inputs_panel = Panel( inputs_panel = Panel(
inputs_table, inputs_table,
title="Input Parameters", title="Input Parameters",
@ -258,7 +241,7 @@ def _add_nested_properties(
is_array_item: bool = False, is_array_item: bool = False,
) -> None: ) -> None:
""" """
Recursively add nested properties to the table. Recursively add nested properties to the output table.
Args: Args:
table: The Rich table to add rows to table: The Rich table to add rows to
@ -270,14 +253,11 @@ def _add_nested_properties(
# Show array item indicator if needed # Show array item indicator if needed
if is_array_item and indent > 0: if is_array_item and indent > 0:
# Get column count from the table table.add_row(
num_columns = len(table.columns) f"{indent_prefix[:-2]}[item]",
"",
# Create a row with the array indicator in the first column and empty strings for the rest "[dim]Each item in array:[/dim]",
row_data = [f"{indent_prefix[:-2]}[item]"] + [""] * (num_columns - 1) )
if num_columns >= 3:
row_data[2] = "[dim]Each item in array:[/dim]"
table.add_row(*row_data)
for prop_name, prop_schema in properties.items(): for prop_name, prop_schema in properties.items():
# Format the type string # Format the type string
@ -289,19 +269,11 @@ def _add_nested_properties(
if hasattr(prop_schema, "description") and prop_schema.description: if hasattr(prop_schema, "description") and prop_schema.description:
description = prop_schema.description description = prop_schema.description
# Create row data based on number of columns table.add_row(
num_columns = len(table.columns) f"{indent_prefix}{prop_name}",
row_data = [f"{indent_prefix}{prop_name}", type_str] type_str,
f"[dim]{description}[/dim]" if description else "",
# For input parameter tables (4 columns), add empty required column )
if num_columns == 4:
row_data.append("") # Empty "Required" column for nested properties
row_data.append(f"[dim]{description}[/dim]" if description else "")
# For output tables (3 columns), just add description
elif num_columns == 3:
row_data.append(f"[dim]{description}[/dim]" if description else "")
table.add_row(*row_data)
# Recursively add nested properties if this is a json type with properties # Recursively add nested properties if this is a json type with properties
if ( if (

View file

@ -1,52 +1,46 @@
import asyncio import asyncio
import os import os
import subprocess
import sys
import threading import threading
import traceback import traceback
import uuid import uuid
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Optional
import httpx import httpx
import typer import typer
from arcadepy import Arcade from arcadepy import Arcade
from arcadepy.types import AuthorizationResponse
from openai import OpenAI, OpenAIError
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.text import Text from rich.text import Text
from tqdm import tqdm from tqdm import tqdm
import arcade_cli.secret as secret
import arcade_cli.worker as worker import arcade_cli.worker as worker
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
from arcade_cli.constants import ( from arcade_cli.constants import (
CREDENTIALS_FILE_PATH, CREDENTIALS_FILE_PATH,
LOCALHOST,
PROD_CLOUD_HOST, PROD_CLOUD_HOST,
PROD_ENGINE_HOST, PROD_ENGINE_HOST,
) )
from arcade_cli.deployment import Deployment from arcade_cli.deployment import Deployment
from arcade_cli.display import ( from arcade_cli.display import (
display_arcade_chat_header,
display_eval_results, display_eval_results,
display_tool_messages,
) )
from arcade_cli.show import show_logic from arcade_cli.show import show_logic
from arcade_cli.toolkit_docs import generate_toolkit_docs from arcade_cli.toolkit_docs import generate_toolkit_docs
from arcade_cli.utils import ( from arcade_cli.utils import (
OrderCommands, OrderCommands,
Provider,
compute_base_url, compute_base_url,
compute_login_url, compute_login_url,
get_eval_files, get_eval_files,
get_today_context,
get_user_input,
handle_chat_interaction,
handle_tool_authorization,
handle_user_command,
is_authorization_pending,
load_eval_suites, load_eval_suites,
log_engine_health, log_engine_health,
require_dependency, require_dependency,
resolve_provider_api_key,
validate_and_get_config, validate_and_get_config,
version_callback, version_callback,
) )
@ -69,6 +63,13 @@ cli.add_typer(
rich_help_panel="Deployment", rich_help_panel="Deployment",
) )
cli.add_typer(
secret.app,
name="secret",
help="Manage tool secrets in the cloud (set, unset, list)",
rich_help_panel="Admin",
)
console = Console() console = Console()
@ -179,18 +180,119 @@ def new(
), ),
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"), directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
full: bool = typer.Option(
False,
"--full",
"-f",
help="Create a toolkit package with a full scaffolding (includes evals, tests, license, etc)",
),
) -> None: ) -> None:
""" """
Creates a new toolkit with the given name, description, and result type. Creates a new toolkit with the given name, description, and result type.
""" """
from arcade_cli.new import create_new_toolkit from arcade_cli.new import create_new_toolkit, create_new_toolkit_minimal
try: try:
create_new_toolkit(directory, toolkit_name) if not full:
create_new_toolkit_minimal(directory, toolkit_name)
else:
create_new_toolkit(directory, toolkit_name)
except Exception as e: except Exception as e:
handle_cli_error("Failed to create new Toolkit", e, debug) handle_cli_error("Failed to create new Toolkit", e, debug)
@cli.command(
name="mcp",
help="Run MCP servers with different transports",
rich_help_panel="Launch",
)
def mcp(
transport: str = typer.Argument("http", help="Transport type: stdio, http"),
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to (HTTP mode only)"),
port: int = typer.Option(8000, "--port", help="Port to bind to (HTTP mode only)"),
tool_package: Optional[str] = typer.Option(
None,
"--tool-package",
"--package",
"-p",
help="Specific tool package to load (e.g., 'github' for arcade-github)",
),
discover_installed: bool = typer.Option(
False, "--discover-installed", "--all", help="Discover all installed arcade tool packages"
),
show_packages: bool = typer.Option(
False, "--show-packages", help="Show loaded packages during discovery"
),
reload: bool = typer.Option(
False, "--reload", help="Enable auto-reload on code changes (HTTP mode only)"
),
debug: bool = typer.Option(False, "--debug", help="Enable debug mode with verbose logging"),
env_file: Optional[str] = typer.Option(None, "--env-file", help="Path to environment file"),
name: Optional[str] = typer.Option(None, "--name", help="Server name"),
version: Optional[str] = typer.Option(None, "--version", help="Server version"),
cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory to run from"),
) -> None:
"""
Run Arcade MCP Server (passthrough to arcade_mcp_server).
This command provides a unified CLI experience by passing through
all arguments to the arcade_mcp_server module.
Examples:
arcade mcp stdio
arcade mcp http --port 8080
arcade mcp --tool-package github
arcade mcp --discover-installed --show-packages
"""
# Build the command to pass through to arcade_mcp_server
cmd = [sys.executable, "-m", "arcade_mcp_server", transport]
# Add optional arguments
cmd.extend(["--host", host])
cmd.extend(["--port", str(port)])
cmd.append("--debug")
if tool_package:
cmd.extend(["--tool-package", tool_package])
if discover_installed:
cmd.append("--discover-installed")
if show_packages:
cmd.append("--show-packages")
if reload:
cmd.append("--reload")
if env_file:
cmd.extend(["--env-file", env_file])
if name:
cmd.extend(["--name", name])
if version:
cmd.extend(["--version", version])
if cwd:
cmd.extend(["--cwd", cwd])
try:
# Show what command we're running in debug mode
if debug:
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
# Execute the command and pass through all output
result = subprocess.run(cmd, check=False)
# Exit with the same code as the subprocess
if result.returncode != 0:
handle_cli_error("Failed to run MCP server")
except KeyboardInterrupt:
console.print("\n[yellow]MCP server stopped[/yellow]")
raise typer.Exit(0)
except FileNotFoundError:
console.print(
"[red]arcade_mcp_server module not found. Make sure arcade-mcp-server is installed.[/red]"
)
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error running MCP server: {e}[/red]")
raise typer.Exit(1)
@cli.command( @cli.command(
help="Show the installed toolkits or details of a specific tool", help="Show the installed toolkits or details of a specific tool",
rich_help_panel="Tool Development", rich_help_panel="Tool Development",
@ -260,134 +362,6 @@ def show(
) )
@cli.command(
help="Start a chat with a model in the terminal to test tools",
rich_help_panel="Tool Development",
)
def chat(
model: str = typer.Option("gpt-4o", "-m", "--model", help="The model to use for prediction."),
stream: bool = typer.Option(
False, "-s", "--stream", is_flag=True, help="Stream the tool output."
),
prompt: str = typer.Option(None, "--prompt", help="The system prompt to use for the chat."),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
host: str = typer.Option(
PROD_ENGINE_HOST,
"-h",
"--host",
help="The Arcade Engine address to send chat requests to.",
),
port: Optional[int] = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Engine.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine. If not specified, the connection will use TLS if the engine URL uses a 'https' scheme.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
) -> None:
"""
Chat with a language model.
"""
try:
import readline
except ImportError:
console.print(
"Readline is not available on this platform. Command history will be limited.",
style="dim",
)
config = validate_and_get_config()
base_url = compute_base_url(force_tls, force_no_tls, host, port)
client = Arcade(api_key=config.api.key, base_url=base_url)
user_email = config.user.email if config.user else None
try:
# start messages conversation
history: list[dict[str, Any]] = []
# Ground the LLM with today's date and day of the week to help when calling date-related tools
# in case the user refers to relative dates (e.g. next Monday, last month, etc)
today_context = get_today_context()
if prompt:
prompt = f"{today_context} {prompt}"
else:
prompt = today_context
history.append({"role": "system", "content": prompt})
display_arcade_chat_header(base_url, stream)
# Try to hit /health endpoint on engine and warn if it is down
log_engine_health(client)
while True:
console.print(
f"\n[magenta][bold]User[/bold] {user_email}: [/magenta]"
+ "([bold][default]/?[/default][/bold] for help)"
)
user_input = get_user_input()
# Add the input to history
readline.add_history(user_input)
if handle_user_command(
user_input, history, host, port, force_tls, force_no_tls, show_logic
):
continue
history.append({"role": "user", "content": user_input})
try:
# TODO fixup configuration to remove this + "/v1" workaround
openai_client = OpenAI(api_key=config.api.key, base_url=base_url + "/v1")
chat_result = handle_chat_interaction(
openai_client, model, history, user_email, stream
)
history = chat_result.history
tool_messages = chat_result.tool_messages
tool_authorization = chat_result.tool_authorization
# wait for tool authorizations to complete, if any
if tool_authorization and is_authorization_pending(tool_authorization):
chat_result = handle_tool_authorization(
client,
AuthorizationResponse.model_validate(tool_authorization),
history,
openai_client,
model,
user_email,
stream,
)
history = chat_result.history
tool_messages = chat_result.tool_messages
except OpenAIError as e:
handle_cli_error("Arcade Chat failed", e, debug, should_exit=False)
continue
if debug:
display_tool_messages(tool_messages)
except KeyboardInterrupt:
console.print("Chat stopped by user.", style="bold blue")
typer.Exit()
except RuntimeError as e:
handle_cli_error("Failed to run tool", e, debug)
@cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development") @cli.command(help="Run tool calling evaluations", rich_help_panel="Tool Development")
def evals( def evals(
directory: str = typer.Argument(".", help="Directory containing evaluation files"), directory: str = typer.Argument(".", help="Directory containing evaluation files"),
@ -402,34 +376,19 @@ def evals(
"gpt-4o", "gpt-4o",
"--models", "--models",
"-m", "-m",
help="The models to use for evaluation (default: gpt-4o). Use commas to separate multiple models.", help="The models to use for evaluation (default: gpt-4o). Use commas to separate multiple models. All models must belong to the same provider.",
), ),
host: str = typer.Option( provider: Provider = typer.Option(
LOCALHOST, Provider.OPENAI,
"-h", "--provider",
"--host",
help="The Arcade Engine address to send chat requests to.",
),
cloud: bool = typer.Option(
False,
"--cloud",
help="Whether to run evaluations against the Arcade Cloud Engine. Overrides the 'host' option.",
),
port: Optional[int] = typer.Option(
None,
"-p", "-p",
"--port", help="The provider of the models to use for evaluation.",
help="The port of the Arcade Engine.",
), ),
force_tls: bool = typer.Option( provider_api_key: str = typer.Option(
False, None,
"--tls", "--provider-api-key",
help="Whether to force TLS for the connection to the Arcade Engine. If not specified, the connection will use TLS if the engine URL uses a 'https' scheme.", "-k",
), help="The model provider API key. If not provided, will look for the appropriate environment variable based on the provider (e.g., OPENAI_API_KEY for openai provider), first in the current environment, then in the current working directory's .env file.",
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
), ),
debug: bool = typer.Option(False, "--debug", help="Show debug information"), debug: bool = typer.Option(False, "--debug", help="Show debug information"),
) -> None: ) -> None:
@ -440,7 +399,7 @@ def evals(
require_dependency( require_dependency(
package_name="arcade_evals", package_name="arcade_evals",
command_name="evals", command_name="evals",
install_command=r"pip install 'arcade-ai\[evals]'", install_command=r"pip install 'arcade-mcp\[evals]'",
) )
# Although Evals does not depend on the TDK, some evaluations import the # Although Evals does not depend on the TDK, some evaluations import the
# ToolCatalog class from the TDK instead of from arcade_core, so we require # ToolCatalog class from the TDK instead of from arcade_core, so we require
@ -451,27 +410,27 @@ def evals(
install_command=r"pip install arcade-tdk", install_command=r"pip install arcade-tdk",
) )
config = validate_and_get_config()
host = PROD_ENGINE_HOST if cloud else host
base_url = compute_base_url(force_tls, force_no_tls, host, port)
models_list = models.split(",") # Use 'models_list' to avoid shadowing models_list = models.split(",") # Use 'models_list' to avoid shadowing
# Resolve the API key for the provider
resolved_api_key = resolve_provider_api_key(provider, provider_api_key)
if not resolved_api_key:
provider_env_vars = {
Provider.OPENAI: "OPENAI_API_KEY",
}
env_var_name = provider_env_vars.get(provider, f"{provider.upper()}_API_KEY")
handle_cli_error(
f"API key not found for provider '{provider.value}'. "
f"Please provide it via --provider-api-key,-k argument, set the {env_var_name} environment variable, "
f"or add it to a .env file in the current directory.",
should_exit=True,
)
eval_files = get_eval_files(directory) eval_files = get_eval_files(directory)
if not eval_files: if not eval_files:
return return
console.print( console.print("\nRunning evaluations", style="bold")
Text.assemble(
("\nRunning evaluations against Arcade Engine at ", "bold"),
(base_url, "bold blue"),
)
)
# Try to hit /health endpoint on engine and warn if it is down
with Arcade(api_key=config.api.key, base_url=base_url) as client:
log_engine_health(client)
# Use the new function to load eval suites # Use the new function to load eval suites
eval_suites = load_eval_suites(eval_files) eval_suites = load_eval_suites(eval_files)
@ -500,8 +459,7 @@ def evals(
for model in models_list: for model in models_list:
task = asyncio.create_task( task = asyncio.create_task(
suite_func( suite_func(
config=config, provider_api_key=resolved_api_key,
base_url=base_url,
model=model, model=model,
max_concurrency=max_concurrent, max_concurrency=max_concurrent,
) )
@ -528,6 +486,7 @@ def evals(
@cli.command( @cli.command(
help="Start tool server worker with locally installed tools", help="Start tool server worker with locally installed tools",
rich_help_panel="Launch", rich_help_panel="Launch",
hidden=True,
) )
def serve( def serve(
host: str = typer.Option( host: str = typer.Option(
@ -565,6 +524,9 @@ def serve(
""" """
Start a local Arcade Worker server. Start a local Arcade Worker server.
""" """
console.log(
"⚠️ This command is deprecated and will be removed in a future version.", style="yellow"
)
require_dependency( require_dependency(
package_name="arcade_serve", package_name="arcade_serve",
command_name="serve", command_name="serve",
@ -590,58 +552,68 @@ def serve(
@cli.command( @cli.command(
help="Start a server with locally installed Arcade tools", help="Configure MCP clients to connect to your server", rich_help_panel="Tool Development"
rich_help_panel="Launch",
hidden=True,
) )
def workerup( def configure(
host: str = typer.Option( client: str = typer.Argument(
"127.0.0.1", ...,
help="Host for the app, from settings by default.", help="The MCP client to configure (claude, cursor, vscode)",
show_default=True, ),
server_name: Optional[str] = typer.Option(
None,
"--server",
"-s",
help="Name of the server to connect to (defaults to current directory name)",
),
from_local: bool = typer.Option(
False,
"--from-local",
help="Connect to a local MCP server",
is_flag=True,
),
from_arcade: bool = typer.Option(
False,
"--from-arcade",
help="Connect to an Arcade Cloud MCP server",
is_flag=True,
), ),
port: int = typer.Option( port: int = typer.Option(
"8002", 8000,
"-p",
"--port", "--port",
help="Port for the app, defaults to ", "-p",
show_default=True, help="Port for local servers",
), ),
disable_auth: bool = typer.Option( path: Optional[Path] = typer.Option(
False, None,
"--no-auth", "--path",
help="Disable authentication for the worker. Not recommended for production.", "-f",
show_default=True, exists=False,
), help="Optional path to a specific MCP client config file (overrides default path)",
otel_enable: bool = typer.Option(
False, "--otel-enable", help="Send logs to OpenTelemetry", show_default=True
), ),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None: ) -> None:
""" """
Starts the worker with host, port, and reload options. Uses Configure MCP clients to connect to your server.
Uvicorn as ASGI worker. Parameters allow runtime configuration.
"""
require_dependency(
package_name="arcade_serve",
command_name="worker",
install_command=r"pip install 'arcade-serve'",
)
from arcade_cli.serve import serve_default_worker Examples:
arcade configure claude --from-local
arcade configure cursor --from-local --port 8080
arcade configure vscode --from-local --path .vscode/mcp.json
arcade configure claude --from-arcade --server my-toolkit
"""
from arcade_cli.configure import configure_client
try: try:
serve_default_worker( configure_client(
host, client=client,
port, server_name=server_name,
disable_auth=disable_auth, from_local=from_local,
enable_otel=otel_enable, from_arcade=from_arcade,
debug=debug, port=port,
path=path,
) )
except KeyboardInterrupt:
typer.Exit()
except Exception as e: except Exception as e:
handle_cli_error("Failed to start Arcade Toolkit Server", e, debug) handle_cli_error(f"Failed to configure {client}", e, debug)
@cli.command(help="Deploy toolkits to Arcade Cloud", rich_help_panel="Deployment") @cli.command(help="Deploy toolkits to Arcade Cloud", rich_help_panel="Deployment")
@ -712,6 +684,30 @@ def deploy(
for worker in deployment.worker: for worker in deployment.worker:
console.log(f"Deploying '{worker.config.id}...'", style="dim") console.log(f"Deploying '{worker.config.id}...'", style="dim")
try: try:
# Discover and upload secrets
required_secret_keys = worker.get_required_secrets()
for secret_key in required_secret_keys:
secret_value = os.getenv(secret_key)
if not secret_value:
console.log(
f"⚠️ Secret '{secret_key}' not found in environment, skipping.",
style="yellow",
)
continue
try:
secret._upsert_secret_to_engine(
engine_url, config.api.key, secret_key, secret_value
)
except Exception as e:
handle_cli_error(
f"Failed to upload secret '{secret_key}'", e, debug, should_exit=False
)
else:
console.log(
f"✅ Secret '{secret_key}' uploaded successfully",
style="dim green",
)
# Attempt to deploy worker # Attempt to deploy worker
worker.request().execute(cloud_client, engine_client) worker.request().execute(cloud_client, engine_client)
console.log( console.log(
@ -792,7 +788,10 @@ def dashboard(
) )
def docs( def docs(
toolkit_name: str = typer.Option( toolkit_name: str = typer.Option(
..., "--toolkit-name", "-n", help="The name of the toolkit to generate documentation for." ...,
"--toolkit-name",
"-n",
help="The name of the toolkit to generate documentation for.",
), ),
toolkit_dir: str = typer.Option( toolkit_dir: str = typer.Option(
..., ...,
@ -897,7 +896,10 @@ def docs(
) )
def generate_toolkit_docs_command( def generate_toolkit_docs_command(
toolkit_name: str = typer.Option( toolkit_name: str = typer.Option(
..., "--toolkit-name", "-n", help="The name of the toolkit to generate documentation for." ...,
"--toolkit-name",
"-n",
help="The name of the toolkit to generate documentation for.",
), ),
toolkit_dir: str = typer.Option( toolkit_dir: str = typer.Option(
..., ...,
@ -975,14 +977,14 @@ def main_callback(
help="Print version and exit.", help="Print version and exit.",
), ),
) -> None: ) -> None:
excluded_commands = { # Commands that do not require a logged in user
public_commands = {
login.__name__, login.__name__,
logout.__name__, logout.__name__,
serve.__name__,
workerup.__name__,
dashboard.__name__, dashboard.__name__,
evals.__name__,
} }
if ctx.invoked_subcommand in excluded_commands: if ctx.invoked_subcommand in public_commands:
return return
if not check_existing_login(suppress_message=True): if not check_existing_login(suppress_message=True):

View file

@ -9,25 +9,25 @@ import typer
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from rich.console import Console from rich.console import Console
from arcade_cli.deployment import ( from arcade_cli.templates import get_full_template_directory, get_minimal_template_directory
create_demo_deployment,
)
console = Console() console = Console()
# Retrieve the installed version of arcade-ai # Retrieve the installed version of arcade-mcp
try: try:
ARCADE_AI_MIN_VERSION = get_version("arcade-ai") ARCADE_MCP_MIN_VERSION = get_version("arcade-mcp")
ARCADE_AI_MAX_VERSION = str(int(ARCADE_AI_MIN_VERSION.split(".")[0]) + 1) + ".0.0" ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
except Exception as e: except Exception as e:
console.print(f"[red]Failed to get arcade-ai version: {e}[/red]") console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]")
ARCADE_AI_MIN_VERSION = "2.0.0" # Default version if unable to fetch ARCADE_MCP_MIN_VERSION = "1.0.0rc1" # Default version if unable to fetch
ARCADE_AI_MAX_VERSION = "3.0.0" ARCADE_MCP_MAX_VERSION = "4.0.0"
ARCADE_TDK_MIN_VERSION = "2.0.0" ARCADE_TDK_MIN_VERSION = "2.6.0rc1"
ARCADE_TDK_MAX_VERSION = "3.0.0" ARCADE_TDK_MAX_VERSION = "3.0.0"
ARCADE_SERVE_MIN_VERSION = "2.0.0" ARCADE_SERVE_MIN_VERSION = "2.2.0rc1"
ARCADE_SERVE_MAX_VERSION = "3.0.0" ARCADE_SERVE_MAX_VERSION = "3.0.0"
ARCADE_MCP_SERVER_MIN_VERSION = "1.0.0rc1"
ARCADE_MCP_SERVER_MAX_VERSION = "3.0.0"
def ask_question(question: str, default: Optional[str] = None) -> str: def ask_question(question: str, default: Optional[str] = None) -> str:
@ -181,10 +181,10 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
# TODO: this detection mechanism works only for people that didn't change the # TODO: this detection mechanism works only for people that didn't change the
# name of the repo, a better detection method is required here # name of the repo, a better detection method is required here
is_community_toolkit = False is_community_toolkit = False
if cwd.name == "toolkits" and cwd.parent.name == "arcade-ai": if cwd.name == "toolkits" and cwd.parent.name == "arcade-mcp":
prompt = ( prompt = (
"Is your toolkit a community contribution (to be merged into " "Is your toolkit a community contribution (to be merged into "
"\x1b]8;;https://github.com/ArcadeAI/arcade-ai\x1b\\ArcadeAI/arcade-ai\x1b]8;;\x1b\\ repo)?" "\x1b]8;;https://github.com/ArcadeAI/arcade-mcp\x1b\\ArcadeAI/arcade-mcp\x1b]8;;\x1b\\ repo)?"
) )
is_community_toolkit = ask_yes_no_question(prompt, default=True) is_community_toolkit = ask_yes_no_question(prompt, default=True)
@ -200,13 +200,14 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
"arcade_tdk_max_version": ARCADE_TDK_MAX_VERSION, "arcade_tdk_max_version": ARCADE_TDK_MAX_VERSION,
"arcade_serve_min_version": ARCADE_SERVE_MIN_VERSION, "arcade_serve_min_version": ARCADE_SERVE_MIN_VERSION,
"arcade_serve_max_version": ARCADE_SERVE_MAX_VERSION, "arcade_serve_max_version": ARCADE_SERVE_MAX_VERSION,
"arcade_ai_min_version": ARCADE_AI_MIN_VERSION, "arcade_mcp_min_version": ARCADE_MCP_MIN_VERSION,
"arcade_ai_max_version": ARCADE_AI_MAX_VERSION, "arcade_mcp_max_version": ARCADE_MCP_MAX_VERSION,
"creation_year": datetime.now().year, "creation_year": datetime.now().year,
"is_community_toolkit": is_community_toolkit, "is_community_toolkit": is_community_toolkit,
"is_official_toolkit": is_official_toolkit, "is_official_toolkit": is_official_toolkit,
} }
template_directory = Path(__file__).parent / "templates" / "{{ toolkit_name }}"
template_directory = get_full_template_directory() / "{{ toolkit_name }}"
env = Environment( env = Environment(
loader=FileSystemLoader(str(template_directory)), loader=FileSystemLoader(str(template_directory)),
@ -230,10 +231,49 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
def create_deployment(toolkit_directory: Path, toolkit_name: str) -> None: def create_deployment(toolkit_directory: Path, toolkit_name: str) -> None:
worker_toml = toolkit_directory / "worker.toml" # No longer create worker.toml for MCP servers
if not worker_toml.exists(): # The server.py file handles all configuration
create_demo_deployment(worker_toml, toolkit_name) pass
def create_new_toolkit_minimal(output_directory: str, toolkit_name: str) -> None:
"""Create a new toolkit from a template with user input."""
toolkit_directory = Path(output_directory)
# Check for illegal characters in the toolkit name
if re.match(r"^[a-z0-9_]+$", toolkit_name):
if (toolkit_directory / toolkit_name).exists():
console.print(f"[red]Toolkit '{toolkit_name}' already exists.[/red]")
exit(1)
else: else:
pass console.print(
# Disabled pending bug fix "[red]Toolkit name contains illegal characters. "
# update_deployment_with_local_packages(worker_toml, toolkit_name) "Only lowercase alphanumeric characters and underscores are allowed. "
"Please try again.[/red]"
)
exit(1)
context = {
"toolkit_name": toolkit_name,
"arcade_mcp_min_version": ARCADE_MCP_MIN_VERSION,
"arcade_mcp_max_version": ARCADE_MCP_MAX_VERSION,
"arcade_mcp_server_min_version": ARCADE_MCP_SERVER_MIN_VERSION,
"arcade_mcp_server_max_version": ARCADE_MCP_SERVER_MAX_VERSION,
}
template_directory = get_minimal_template_directory() / "{{ toolkit_name }}"
env = Environment(
loader=FileSystemLoader(str(template_directory)),
autoescape=select_autoescape(["html", "xml"]),
)
ignore_pattern = create_ignore_pattern(False, False)
try:
create_package(env, template_directory, toolkit_directory, context, ignore_pattern)
console.print(
f"[green]Toolkit '{toolkit_name}' created successfully at '{toolkit_directory}'.[/green]"
)
except Exception:
remove_toolkit(toolkit_directory, toolkit_name)
raise

View file

@ -0,0 +1,286 @@
import httpx
import typer
from rich.console import Console
from rich.table import Table
from arcade_cli.constants import (
PROD_ENGINE_HOST,
)
from arcade_cli.utils import (
OrderCommands,
compute_base_url,
validate_and_get_config,
)
console = Console()
app = typer.Typer(
cls=OrderCommands,
add_completion=False,
no_args_is_help=True,
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
pretty_exceptions_short=True,
)
state = {
"engine_url": compute_base_url(
host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
)
}
@app.callback()
def main(
host: str = typer.Option(
PROD_ENGINE_HOST,
"--host",
"-h",
help="The Arcade Engine host.",
),
port: int = typer.Option(
None,
"--port",
"-p",
help="The port of the Arcade Engine host.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
) -> None:
"""
Manage tool secrets in Arcade Cloud.
Usage:
arcade secret set KEY1=value1 KEY2="value 2"
arcade secret set --from-env
arcade secret set -from-env --env-file /path/to/.env
arcade secret list
arcade secret unset KEY1 KEY2 KEY3
"""
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
state["engine_url"] = engine_url
@app.command("set", help="Set tool secret(s) using KEY=VALUE pairs or from .env file")
def set_secret(
key_value_pairs: list[str] = typer.Argument(
None,
help="Key-value pairs in the format KEY=VALUE",
),
from_env: bool = typer.Option(
False,
"--from-env",
help="Load all secrets from local .env file",
),
env_file: str = typer.Option(
".env",
"--env-file",
"-f",
help="Path to .env file (default: .env)",
),
) -> None:
"""Set secrets either from .env file or KEY=VALUE pairs."""
if not from_env and not key_value_pairs:
raise typer.BadParameter(
"Either provide KEY=VALUE pairs or use --from-env to load from .env file."
)
if from_env and key_value_pairs:
raise typer.BadParameter("Cannot use both KEY=VALUE pairs and --from-env at the same time.")
config = validate_and_get_config()
if from_env:
secrets = load_env_file(env_file)
else:
secrets = {}
for pair in key_value_pairs:
if (
"=" not in pair
or pair.split("=", 1)[0].strip() == ""
or pair.split("=", 1)[1].strip() == ""
):
raise typer.BadParameter(f"Invalid format '{pair}'. Expected KEY=VALUE")
key, value = pair.split("=", 1)
key = key.strip()
if " " in key:
raise typer.BadParameter(f"Secret key '{key}' cannot contain spaces")
value = value # keep the value as is, including the whitespace
secrets[key] = value
engine_url = state["engine_url"]
for secret_key, secret_value in secrets.items():
try:
_upsert_secret_to_engine(engine_url, config.api.key, secret_key, secret_value)
except Exception as e:
console.print(f"Error setting secret '{secret_key}': {e}", style="bold red")
continue
console.print(
f"Secret '{secret_key}' with value ending in ...{secret_value[-4:]} set successfully"
)
@app.command("list", help="List all tool secrets in Arcade Cloud")
def list_secrets() -> None:
"""List all secrets (keys only, values are masked)."""
config = validate_and_get_config()
engine_url = state["engine_url"]
secrets = _get_secrets_from_engine(engine_url, config.api.key)
print_secret_table(secrets)
@app.command("unset", help="Delete tool secret(s) by key names")
def unset_secret(
keys: list[str] = typer.Argument(
...,
help="Secret keys to delete",
),
) -> None:
"""Delete tool secrets."""
config = validate_and_get_config()
engine_url = state["engine_url"]
secrets = _get_secrets_from_engine(engine_url, config.api.key)
key_to_id = {secret["key"]: secret["id"] for secret in secrets}
for key in set(keys):
secret_id = key_to_id.get(key)
if not secret_id:
console.print(f"Warning: Secret with key '{key}' not found, skipping", style="yellow")
continue
try:
_delete_secret_from_engine(engine_url, config.api.key, secret_id)
console.print(f"Secret '{key}' deleted successfully")
except Exception:
console.print(
f"Failed to delete secret '{key}'. Do you have permission to delete this secret?",
style="bold red",
)
continue
def print_secret_table(secrets: list[dict]) -> None:
"""Print a table of tool secrets (with masked values)."""
table = Table(title="Tool Secrets")
table.add_column("Key", style="cyan")
table.add_column("Type", style="green")
table.add_column("Description", style="green")
table.add_column("Hint", style="green")
table.add_column("Last Accessed", style="green")
table.add_column("Created At", style="green")
for secret in secrets:
table.add_row(
secret["key"],
secret["binding"]["type"],
secret["description"],
"..." + secret["hint"] if secret["hint"] else "-",
secret["last_accessed_at"] if secret["last_accessed_at"] else "Never",
secret["created_at"],
)
console.print(table)
def load_env_file(env_file_path: str) -> dict[str, str]:
"""Load tool secrets from a .env file."""
secrets = {}
with open(env_file_path) as file:
for line in file:
line = line.strip()
if line.startswith("#") or not line:
continue
# Split on first '=' to handle values that contain '='
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
# Remove inline comments, but respect quoted values
value = _remove_inline_comment(value)
value = value.strip()
# Skip entries with empty keys or empty values
if not key or not value:
continue
secrets[key] = value
return secrets
def _remove_inline_comment(value: str) -> str:
"""Remove inline comments from env value, respecting quoted strings."""
value = value.strip()
# Check if value starts with a quote
if value.startswith('"') or value.startswith("'"):
quote_char = value[0]
# Find the matching closing quote (not escaped)
i = 1
while i < len(value):
if value[i] == quote_char:
# Found potential closing quote
# Check if there's anything after it
remaining = value[i + 1 :]
comment_idx = remaining.find(" #")
if comment_idx != -1:
# Remove the comment part and strip quotes
quoted_value = value[: i + 1]
return quoted_value[1:-1] # Remove surrounding quotes
else:
# No comment after closing quote, strip quotes
quoted_value = value[: i + 1]
return quoted_value[1:-1] # Remove surrounding quotes
i += 1
# No closing quote, treat as unquoted
comment_idx = value.find(" #")
if comment_idx != -1:
return value[:comment_idx]
return value
else:
# For unquoted values, remove everything after ' #'
comment_idx = value.find(" #")
if comment_idx != -1:
return value[:comment_idx]
return value
def _upsert_secret_to_engine(
engine_url: str, api_key: str, secret_id: str, secret_value: str
) -> None:
response = httpx.put(
f"{engine_url}/v1/admin/secrets/{secret_id}",
headers={"Authorization": f"Bearer {api_key}"},
json={"description": "Secret set via CLI", "value": secret_value},
)
response.raise_for_status()
def _get_secrets_from_engine(engine_url: str, api_key: str) -> list[dict]:
response = httpx.get(
f"{engine_url}/v1/admin/secrets",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
return response.json()["items"] # type: ignore[no-any-return]
def _delete_secret_from_engine(engine_url: str, api_key: str, secret_id: str) -> None:
response = httpx.delete(
f"{engine_url}/v1/admin/secrets/{secret_id}",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()

View file

@ -15,8 +15,8 @@ import uvicorn
# Watchfiles is used under the hood by Uvicorn's reload feature. # Watchfiles is used under the hood by Uvicorn's reload feature.
# Importing watchfiles here is an explicit acknowledgement that it needs to be installed # Importing watchfiles here is an explicit acknowledgement that it needs to be installed
import watchfiles # noqa: F401 import watchfiles # noqa: F401
from arcade_core.telemetry import OTELHandler
from arcade_core.toolkit import Toolkit, get_package_directory from arcade_core.toolkit import Toolkit, get_package_directory
from arcade_serve.fastapi.telemetry import OTELHandler
from arcade_serve.fastapi.worker import FastAPIWorker from arcade_serve.fastapi.worker import FastAPIWorker
from loguru import logger from loguru import logger
from rich.console import Console from rich.console import Console
@ -45,7 +45,7 @@ def create_arcade_app() -> fastapi.FastAPI:
setup_logging(log_level=logging.DEBUG if debug_mode else logging.INFO, mcp_mode=False) setup_logging(log_level=logging.DEBUG if debug_mode else logging.INFO, mcp_mode=False)
logger.info(f"Debug: {debug_mode}, OTEL: {otel_enabled}, Auth Disabled: {auth_for_reload}") logger.info(f"Debug: {debug_mode}, OTEL: {otel_enabled}, Auth Disabled: {auth_for_reload}")
version = get_pkg_version("arcade-ai") version = get_pkg_version("arcade-mcp")
toolkits = discover_toolkits() toolkits = discover_toolkits()
logger.info("Registered toolkits:") logger.info("Registered toolkits:")

View file

@ -5,7 +5,11 @@ from rich.console import Console
from rich.markup import escape from rich.markup import escape
from arcade_cli.display import display_tool_details, display_tools_table from arcade_cli.display import display_tool_details, display_tools_table
from arcade_cli.utils import create_cli_catalog, get_tools_from_engine from arcade_cli.utils import (
create_cli_catalog,
create_cli_catalog_local,
get_tools_from_engine,
)
def show_logic( def show_logic(
@ -25,7 +29,7 @@ def show_logic(
console = Console() console = Console()
try: try:
if local: if local:
catalog = create_cli_catalog(toolkit=toolkit) catalog = create_cli_catalog() if toolkit else create_cli_catalog_local()
tools = [t.definition for t in list(catalog)] tools = [t.definition for t in list(catalog)]
else: else:
tools = get_tools_from_engine(host, port, force_tls, force_no_tls, toolkit) tools = get_tools_from_engine(host, port, force_tls, force_no_tls, toolkit)

View file

@ -0,0 +1,10 @@
from pathlib import Path
def get_minimal_template_directory() -> Path:
"""Get the path to the templates directory."""
return Path(__file__).parent / "minimal"
def get_full_template_directory() -> Path:
"""Get the path to the templates directory."""
return Path(__file__).parent / "full"

View file

@ -24,7 +24,7 @@ email = "{{ toolkit_author_email }}"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"arcade-ai[evals]>={{ arcade_ai_min_version }},<{{ arcade_ai_max_version }}", "arcade-mcp[evals]>={{ arcade_mcp_min_version }},<{{ arcade_mcp_max_version }}",
"arcade-serve>={{ arcade_serve_min_version }},<{{ arcade_serve_max_version }}", "arcade-serve>={{ arcade_serve_min_version }},<{{ arcade_serve_max_version }}",
"pytest>=8.3.0,<8.4.0", "pytest>=8.3.0,<8.4.0",
"pytest-cov>=4.0.0,<4.1.0", "pytest-cov>=4.0.0,<4.1.0",
@ -43,16 +43,16 @@ toolkit_name = "{{ package_name }}"
{% if is_community_toolkit -%} {% if is_community_toolkit -%}
# Use local path sources for arcade libs when working locally # Use local path sources for arcade libs when working locally
[tool.uv.sources] [tool.uv.sources]
arcade-ai = { path = "../../", editable = true } arcade-mcp = { path = "../../", editable = true }
arcade-serve = { path = "../../libs/arcade-serve/", editable = true } arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true } arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
{% endif -%} {% endif -%}
{% if is_official_toolkit -%} {% if is_official_toolkit -%}
# Use local path sources for arcade libs when working locally # Use local path sources for arcade libs when working locally
[tool.uv.sources] [tool.uv.sources]
arcade-ai = { path = "../../../arcade-ai", editable = true } arcade-mcp = { path = "../../../arcade-mcp", editable = true }
arcade-serve = { path = "../../../arcade-ai/libs/arcade-serve/", editable = true } arcade-serve = { path = "../../../arcade-mcp/libs/arcade-serve/", editable = true }
arcade-tdk = { path = "../../../arcade-ai/libs/arcade-tdk/", editable = true } arcade-tdk = { path = "../../../arcade-mcp/libs/arcade-tdk/", editable = true }
{% endif -%} {% endif -%}
[tool.mypy] [tool.mypy]

View file

@ -0,0 +1 @@
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"

View file

@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{{ toolkit_name }}"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>={{ arcade_mcp_server_min_version }},<{{ arcade_mcp_server_max_version }}",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>={{ arcade_mcp_min_version}},<{{ arcade_mcp_max_version}}",
"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 = "{{ toolkit_name }}"
[tool.setuptools.packages.find]
include = ["{{ toolkit_name }}*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""{{ toolkit_name }} MCP server"""
import sys
from typing import Annotated
from arcade_mcp_server import Context, MCPApp
app = MCPApp(name="{{ toolkit_name }}", version="1.0.0", log_level="DEBUG")
@app.tool
def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"
@app.tool(requires_secrets=["MY_SECRET_KEY"])
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
"""Reveal the last 4 characters of a secret"""
# Secrets are injected into the tool context at runtime.
# This means that LLMs and MCP clients cannot see or access your secrets
# You can define secrets in a .env file.
try:
secret = context.get_secret("MY_SECRET_KEY")
except Exception as e:
return str(e)
return "The last 4 characters of the secret are: " + secret[-4:]
# Run with specific transport
if __name__ == "__main__":
# Get transport from command line argument, default to "stream"
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
# Run the server
# - "https" (default): HTTPS streaming for Claude Desktop, Claude Code, Cursor
# - "stdio": Standard I/O for VS Code and CLI tools
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -97,10 +97,7 @@ const USER_ID = "{{arcade_user_id}}";
const TOOL_NAME = "{tool_fully_qualified_name}"; const TOOL_NAME = "{tool_fully_qualified_name}";
// Start the authorization process // Start the authorization process
const authResponse = await client.tools.authorize({{ const authResponse = await client.tools.authorize({{tool_name: TOOL_NAME}});
tool_name: TOOL_NAME,
user_id: USER_ID
}});
if (authResponse.status !== "completed") {{ if (authResponse.status !== "completed") {{
console.log(`Click this link to authorize: ${{authResponse.url}}`); console.log(`Click this link to authorize: ${{authResponse.url}}`);

View file

@ -2,6 +2,7 @@ import importlib.util
import ipaddress import ipaddress
import os import os
import shlex import shlex
import sys
import webbrowser import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@ -16,6 +17,12 @@ import idna
import typer import typer
from arcade_core import ToolCatalog, Toolkit from arcade_core import ToolCatalog, Toolkit
from arcade_core.config_model import Config from arcade_core.config_model import Config
from arcade_core.discovery import (
analyze_files_for_tools,
build_minimal_toolkit,
collect_tools_from_modules,
find_candidate_tool_files,
)
from arcade_core.errors import ToolkitLoadError from arcade_core.errors import ToolkitLoadError
from arcade_core.schema import ToolDefinition from arcade_core.schema import ToolDefinition
from arcadepy import ( from arcadepy import (
@ -65,6 +72,12 @@ class ChatCommand(str, Enum):
EXIT = "/exit" EXIT = "/exit"
class Provider(str, Enum):
"""Supported model providers for evaluations."""
OPENAI = "openai"
def create_cli_catalog( def create_cli_catalog(
toolkit: str | None = None, toolkit: str | None = None,
show_toolkits: bool = False, show_toolkits: bool = False,
@ -98,6 +111,59 @@ def create_cli_catalog(
return catalog return catalog
def _discover_installed_toolkits(catalog: ToolCatalog) -> ToolCatalog:
for tk in Toolkit.find_all_arcade_toolkits():
catalog.add_toolkit(tk)
return catalog
def create_cli_catalog_local() -> ToolCatalog:
"""
Load a local toolkit from the current working directory if a pyproject.toml is present.
Fallback to environment discovery if not present.
"""
cwd = Path.cwd()
catalog = ToolCatalog()
if not (cwd / "pyproject.toml").is_file():
return _discover_installed_toolkits(catalog)
try:
files = find_candidate_tool_files(cwd)
if not files:
return _discover_installed_toolkits(catalog)
files_with_tools = analyze_files_for_tools(files)
if not files_with_tools:
return _discover_installed_toolkits(catalog)
discovered_tools = collect_tools_from_modules(files_with_tools)
if not discovered_tools:
return _discover_installed_toolkits(catalog)
toolkit = build_minimal_toolkit(
server_name=cwd.name,
server_version="0.1.0dev",
description=f"Local toolkit from {cwd.name}",
)
# Add tools directly to catalog using the discovery approach
for tool_func, module in discovered_tools:
# Register module in sys.modules so it can be found
if module.__name__ not in sys.modules:
sys.modules[module.__name__] = module
catalog.add_tool(tool_func, toolkit, module)
except Exception as e:
console.log(
f"Local file discovery failed: {e}; falling back to installed toolkits",
style="dim",
)
else:
return catalog
# Fallback: discover installed toolkits
return _discover_installed_toolkits(catalog)
def compute_base_url( def compute_base_url(
force_tls: bool, force_tls: bool,
force_no_tls: bool, force_no_tls: bool,
@ -530,7 +596,30 @@ def get_eval_files(directory: str) -> list[Path]:
directory_path = Path(directory).resolve() directory_path = Path(directory).resolve()
if directory_path.is_dir(): if directory_path.is_dir():
eval_files = [f for f in directory_path.rglob("eval_*.py") if f.is_file()] # Directories to exclude from recursive search
exclude_dirs = {
".venv",
"venv",
".env",
"env",
"node_modules",
"__pycache__",
".git",
"build",
"dist",
".tox",
"htmlcov",
"site-packages",
".pytest_cache",
}
eval_files = []
for f in directory_path.rglob("eval_*.py"):
if f.is_file():
# Check if any parent directory is in exclude_dirs
should_exclude = any(part in exclude_dirs for part in f.parts)
if not should_exclude:
eval_files.append(f)
elif directory_path.is_file(): elif directory_path.is_file():
eval_files = ( eval_files = (
[directory_path] [directory_path]
@ -555,48 +644,59 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]:
""" """
Load evaluation suites from the given eval_files by importing the modules Load evaluation suites from the given eval_files by importing the modules
and extracting functions decorated with `@tool_eval`. and extracting functions decorated with `@tool_eval`.
Args: Args:
eval_files: A list of Paths to evaluation files. eval_files: A list of Paths to evaluation files.
Returns: Returns:
A list of callable evaluation suite functions. A list of callable evaluation suite functions.
""" """
eval_suites = [] eval_suites = []
for eval_file_path in eval_files: for eval_file_path in eval_files:
module_name = eval_file_path.stem # filename without extension module_name = eval_file_path.stem # filename without extension
# Now we need to load the module from eval_file_path # Now we need to load the module from eval_file_path
file_path_str = str(eval_file_path) file_path_str = str(eval_file_path)
module_name_str = module_name module_name_str = module_name
# Load using importlib # Add the directory containing the eval file to sys.path temporarily
spec = importlib.util.spec_from_file_location(module_name_str, file_path_str) # so that the eval file can import other modules in the same directory
if spec is None: eval_dir = str(eval_file_path.parent)
console.print(f"Failed to load {eval_file_path}", style="bold red") original_path = sys.path.copy()
if eval_dir not in sys.path:
sys.path.insert(0, eval_dir)
try:
# Load using importlib
spec = importlib.util.spec_from_file_location(module_name_str, file_path_str)
if spec is None:
console.print(f"Failed to load {eval_file_path}", style="bold red")
continue
module = importlib.util.module_from_spec(spec)
if spec.loader is not None:
spec.loader.exec_module(module)
else:
console.print(f"Failed to load module: {module_name}", style="bold red")
continue
eval_suite_funcs = [
obj
for name, obj in module.__dict__.items()
if callable(obj) and hasattr(obj, "__tool_eval__")
]
if not eval_suite_funcs:
console.print(
f"No @tool_eval functions found in {eval_file_path}",
style="bold yellow",
)
continue
eval_suites.extend(eval_suite_funcs)
except Exception as e:
console.print(f"Failed to load {eval_file_path}: {e}", style="bold red")
continue continue
finally:
module = importlib.util.module_from_spec(spec) # Restore the original sys.path
if spec.loader is not None: sys.path[:] = original_path
spec.loader.exec_module(module)
else:
console.print(f"Failed to load module: {module_name}", style="bold red")
continue
eval_suite_funcs = [
obj
for name, obj in module.__dict__.items()
if callable(obj) and hasattr(obj, "__tool_eval__")
]
if not eval_suite_funcs:
console.print(
f"No @tool_eval functions found in {eval_file_path}",
style="bold yellow",
)
continue
eval_suites.extend(eval_suite_funcs)
return eval_suites return eval_suites
@ -698,7 +798,7 @@ def version_callback(value: bool) -> None:
Prints the version of Arcade and exit. Prints the version of Arcade and exit.
""" """
if value: if value:
version = metadata.version("arcade-ai") version = metadata.version("arcade-mcp")
console.print(f"[bold]Arcade CLI[/bold] (version {version})") console.print(f"[bold]Arcade CLI[/bold] (version {version})")
exit() exit()
@ -787,6 +887,45 @@ def load_dotenv(path: str | Path, *, override: bool = False) -> dict[str, str]:
return loaded return loaded
def resolve_provider_api_key(provider: Provider, provider_api_key: str | None = None) -> str | None:
"""
Resolve the API key for a given provider for evals.
Args:
provider: The model provider
provider_api_key: API key provided via CLI argument
Returns:
The resolved API key or None if not found
"""
if provider_api_key:
return provider_api_key
# Map providers to their environment variable names
provider_env_vars = {
Provider.OPENAI: "OPENAI_API_KEY",
}
env_var_name = provider_env_vars.get(provider)
if not env_var_name:
return None
# First check current environment
api_key = os.getenv(env_var_name)
if api_key:
return api_key
# Then check .env file in current working directory
env_file_path = Path.cwd() / ".env"
if env_file_path.exists():
load_dotenv(env_file_path, override=False)
api_key = os.getenv(env_var_name)
if api_key:
return api_key
return None
def require_dependency( def require_dependency(
package_name: str, package_name: str,
command_name: str, command_name: str,
@ -798,7 +937,7 @@ def require_dependency(
Args: Args:
package_name: The name of the package to import (e.g., 'arcade_serve') package_name: The name of the package to import (e.g., 'arcade_serve')
command_name: The command that requires the package (e.g., 'serve') command_name: The command that requires the package (e.g., 'serve')
install_command: The command to install the package (e.g., "pip install 'arcade-ai[evals]'") install_command: The command to install the package (e.g., "pip install 'arcade-mcp[evals]'")
""" """
try: try:
importlib.import_module(package_name.replace("-", "_")) importlib.import_module(package_name.replace("-", "_"))

View file

@ -405,7 +405,9 @@ class ToolCatalog(BaseModel):
# Hard requirement: tools must have descriptions # Hard requirement: tools must have descriptions
tool_description = getattr(tool, "__tool_description__", None) tool_description = getattr(tool, "__tool_description__", None)
if not tool_description: if not tool_description:
raise ToolDefinitionError(f"Tool '{raw_tool_name}' is missing a description") raise ToolDefinitionError(
f"Tool '{raw_tool_name}' is missing a description. Tool descriptions are specified as docstrings for the tool function."
)
# If the function returns a value, it must have a type annotation # If the function returns a value, it must have a type annotation
if does_function_return_value(tool) and tool.__annotations__.get("return") is None: if does_function_return_value(tool) and tool.__annotations__.get("return") is None:
@ -449,7 +451,9 @@ def create_input_definition(func: Callable) -> ToolInput:
tool_context_param_name: str | None = None tool_context_param_name: str | None = None
for _, param in inspect.signature(func, follow_wrapped=True).parameters.items(): for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
if param.annotation is ToolContext: ann = param.annotation
if isinstance(ann, type) and issubclass(ann, ToolContext):
# Soft guidance for developers using legacy ToolContext
if tool_context_param_name is not None: if tool_context_param_name is not None:
raise ToolInputSchemaError( raise ToolInputSchemaError(
f"Only one ToolContext parameter is supported, but tool {func.__name__} has multiple." f"Only one ToolContext parameter is supported, but tool {func.__name__} has multiple."
@ -690,7 +694,9 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
# Final reality check # Final reality check
if param_info.description is None: if param_info.description is None:
raise ToolInputSchemaError(f"Parameter '{param_info.name}' is missing a description") raise ToolInputSchemaError(
f"Parameter '{param_info.name}' is missing a description. Parameter descriptions are specified as string annotations using the typing.Annotated class."
)
if wire_type_info.wire_type is None: if wire_type_info.wire_type is None:
raise ToolInputSchemaError(f"Unknown parameter type: {param_info.field_type}") raise ToolInputSchemaError(f"Unknown parameter type: {param_info.field_type}")
@ -983,8 +989,9 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
if asyncio.iscoroutinefunction(func) and hasattr(func, "__wrapped__"): if asyncio.iscoroutinefunction(func) and hasattr(func, "__wrapped__"):
func = func.__wrapped__ func = func.__wrapped__
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items(): for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
# Skip ToolContext parameters # Skip ToolContext parameters (including subclasses like arcade_mcp_server.Context)
if param.annotation is ToolContext: ann = param.annotation
if isinstance(ann, type) and issubclass(ann, ToolContext):
continue continue
# TODO make this cleaner # TODO make this cleaner
@ -1004,7 +1011,7 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
return input_model, output_model return input_model, output_model
def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901 def determine_output_model(func: Callable) -> type[BaseModel]:
""" """
Determine the output model for a function based on its return annotation. Determine the output model for a function based on its return annotation.
""" """
@ -1149,9 +1156,13 @@ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[
def to_tool_secret_requirements( def to_tool_secret_requirements(
secrets_requirement: list[str], secrets_requirement: list[str],
) -> list[ToolSecretRequirement]: ) -> list[ToolSecretRequirement]:
# Iterate through the list, de-dupe case-insensitively, and convert each string to a ToolSecretRequirement # De-dupe case-insensitively but preserve the original casing for env var lookup
unique_secrets = {name.lower(): name.lower() for name in secrets_requirement}.values() unique_map: dict[str, str] = {}
return [ToolSecretRequirement(key=name) for name in unique_secrets] for name in secrets_requirement:
lowered = str(name).lower()
if lowered not in unique_map:
unique_map[lowered] = str(name)
return [ToolSecretRequirement(key=orig_name) for orig_name in unique_map.values()]
def to_tool_metadata_requirements( def to_tool_metadata_requirements(

View file

@ -0,0 +1,128 @@
"""
Arcade Core Runtime Context Protocols
Defines the developer-facing, transport-agnostic runtime context interfaces
(namespaced APIs: logs, progress, resources, tools, prompts, sampling, UI,
notifications) and the top-level ModelContext Protocol that aggregates them.
Implementations live in runtime packages (e.g., arcade_mcp_server); tool authors should
use `arcade_mcp_server.Context` for concrete usage.
"""
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable
from pydantic import BaseModel
class LogsContext(Protocol):
async def debug(self, message: str, **kwargs: dict[str, Any]) -> None: ...
async def info(self, message: str, **kwargs: dict[str, Any]) -> None: ...
async def warning(self, message: str, **kwargs: dict[str, Any]) -> None: ...
async def error(self, message: str, **kwargs: dict[str, Any]) -> None: ...
class ProgressContext(Protocol):
async def report(
self, progress: float, total: float | None = None, message: str | None = None
) -> None: ...
class ResourcesContext(Protocol):
async def list_(self) -> list[Any]: ...
async def get(self, uri: str) -> Any: ...
async def read(self, uri: str) -> list[Any]: ...
async def list_roots(self) -> list[Any]: ...
async def list_templates(self) -> list[Any]: ...
class ToolsContext(Protocol):
async def list_(self) -> list[Any]: ...
async def call_raw(self, name: str, params: dict[str, Any]) -> BaseModel: ...
class PromptsContext(Protocol):
async def list_(self) -> list[Any]: ...
async def get(self, name: str, arguments: dict[str, str] | None = None) -> Any: ...
class SamplingContext(Protocol):
async def create_message(
self,
messages: str | list[str | Any],
system_prompt: str | None = None,
include_context: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: Any | None = None,
) -> Any: ...
class UIContext(Protocol):
async def elicit(self, message: str, schema: dict[str, Any] | None = None) -> Any: ...
class NotificationsToolsContext(Protocol):
async def list_changed(self) -> None: ...
class NotificationsResourcesContext(Protocol):
async def list_changed(self) -> None: ...
class NotificationsPromptsContext(Protocol):
async def list_changed(self) -> None: ...
class NotificationsContext(Protocol):
@property
def tools(self) -> NotificationsToolsContext: ...
@property
def resources(self) -> NotificationsResourcesContext: ...
@property
def prompts(self) -> NotificationsPromptsContext: ...
@runtime_checkable
class ModelContext(Protocol):
@property
def log(self) -> LogsContext: ...
@property
def progress(self) -> ProgressContext: ...
@property
def resources(self) -> ResourcesContext: ...
@property
def tools(self) -> ToolsContext: ...
@property
def prompts(self) -> PromptsContext: ...
@property
def sampling(self) -> SamplingContext: ...
@property
def ui(self) -> UIContext: ...
@property
def notifications(self) -> NotificationsContext: ...
@property
def request_id(self) -> str | None: ...
@property
def session_id(self) -> str | None: ...

View file

@ -0,0 +1,220 @@
"""Converter for converting Arcade ToolDefinition to OpenAI tool schema."""
from typing import Any, Literal, TypedDict
from arcade_core.catalog import MaterializedTool
from arcade_core.schema import InputParameter, ValueSchema
# ----------------------------------------------------------------------------
# Type definitions for JSON tool schemas used by OpenAI APIs.
# Defines the proper types for tool schemas to ensure
# compatibility with OpenAI's Responses and Chat Completions APIs.
# ----------------------------------------------------------------------------
class OpenAIFunctionParameterProperty(TypedDict, total=False):
"""Type definition for a property within OpenAI function parameters schema."""
type: str | list[str]
"""The JSON Schema type(s) for this property. Can be a single type or list for unions (e.g., ["string", "null"])."""
description: str
"""Description of the property."""
enum: list[Any]
"""Allowed values for enum properties."""
items: dict[str, Any]
"""Schema for array items when type is 'array'."""
properties: dict[str, "OpenAIFunctionParameterProperty"]
"""Nested properties when type is 'object'."""
required: list[str]
"""Required fields for nested objects."""
additionalProperties: Literal[False]
"""Must be False for strict mode compliance."""
class OpenAIFunctionParameters(TypedDict, total=False):
"""Type definition for OpenAI function parameters schema."""
type: Literal["object"]
"""Must be 'object' for function parameters."""
properties: dict[str, OpenAIFunctionParameterProperty]
"""The properties of the function parameters."""
required: list[str]
"""List of required parameter names. In strict mode, all properties should be listed here."""
additionalProperties: Literal[False]
"""Must be False for strict mode compliance."""
class OpenAIFunctionSchema(TypedDict, total=False):
"""Type definition for a function tool parameter matching OpenAI's API."""
name: str
"""The name of the function to call."""
parameters: OpenAIFunctionParameters | None
"""A JSON schema object describing the parameters of the function."""
strict: Literal[True]
"""Always enforce strict parameter validation. Default `true`."""
description: str | None
"""A description of the function.
Used by the model to determine whether or not to call the function.
"""
class OpenAIToolSchema(TypedDict):
"""
Schema for a tool definition passed to OpenAI's `tools` parameter.
A tool wraps a callable function for function-calling. Each tool
includes a type (always 'function') and a `function` payload that
specifies the callable via `OpenAIFunctionSchema`.
"""
type: Literal["function"]
"""The type field, always 'function'."""
function: OpenAIFunctionSchema
"""The function definition."""
# Type alias for a list of openai tool schemas
OpenAIToolList = list[OpenAIToolSchema]
# ----------------------------------------------------------------------------
# Converters
# ----------------------------------------------------------------------------
def to_openai(tool: MaterializedTool) -> OpenAIToolSchema:
"""Convert a MaterializedTool to OpenAI JsonToolSchema format.
Args:
tool: The MaterializedTool to convert
Returns:
The OpenAI JsonToolSchema format (what is passed to the OpenAI API)
"""
name = tool.definition.fully_qualified_name.replace(".", "_")
description = tool.description
parameters_schema = _convert_input_parameters_to_json_schema(tool.definition.input.parameters)
return _create_tool_schema(name, description, parameters_schema)
def _create_tool_schema(
name: str, description: str, parameters: OpenAIFunctionParameters
) -> OpenAIToolSchema:
"""Create a properly typed tool schema.
Args:
name: The name of the function
description: Description of what the function does
parameters: JSON schema for the function parameters
strict: Whether to enforce strict validation (default: True for reliable function calls)
Returns:
A properly typed OpenAIToolSchema
"""
function: OpenAIFunctionSchema = {
"name": name,
"description": description,
"parameters": parameters,
"strict": True,
}
tool: OpenAIToolSchema = {
"type": "function",
"function": function,
}
return tool
def _convert_value_schema_to_json_schema(
value_schema: ValueSchema,
) -> OpenAIFunctionParameterProperty:
"""Convert Arcade ValueSchema to JSON Schema format."""
type_mapping = {
"string": "string",
"integer": "integer",
"number": "number",
"boolean": "boolean",
"json": "object",
"array": "array",
}
schema: OpenAIFunctionParameterProperty = {"type": type_mapping[value_schema.val_type]}
if value_schema.val_type == "array" and value_schema.inner_val_type:
items_schema: dict[str, Any] = {"type": type_mapping[value_schema.inner_val_type]}
# For arrays, enum should be applied to the items, not the array itself
if value_schema.enum:
items_schema["enum"] = value_schema.enum
schema["items"] = items_schema
else:
# Handle enum for non-array types
if value_schema.enum:
schema["enum"] = value_schema.enum
# Handle object properties
if value_schema.val_type == "json" and value_schema.properties:
schema["properties"] = {
name: _convert_value_schema_to_json_schema(nested_schema)
for name, nested_schema in value_schema.properties.items()
}
return schema
def _convert_input_parameters_to_json_schema(
parameters: list[InputParameter],
) -> OpenAIFunctionParameters:
"""Convert list of InputParameter to JSON schema parameters object."""
if not parameters:
# Minimal JSON schema for a tool with no input parameters
return {
"type": "object",
"properties": {},
"additionalProperties": False,
}
properties = {}
required = []
for parameter in parameters:
param_schema = _convert_value_schema_to_json_schema(parameter.value_schema)
# For optional parameters in strict mode, we need to add "null" as a type option
if not parameter.required:
param_type = param_schema.get("type")
if isinstance(param_type, str):
# Convert single type to union with null
param_schema["type"] = [param_type, "null"]
elif isinstance(param_type, list) and "null" not in param_type:
param_schema["type"] = [*param_type, "null"]
if parameter.description:
param_schema["description"] = parameter.description
properties[parameter.name] = param_schema
# In strict mode, all parameters (including optional ones) go in required array
# Optional parameters are handled by adding "null" to their type
required.append(parameter.name)
json_schema: OpenAIFunctionParameters = {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
if not required:
del json_schema["required"]
return json_schema

View file

@ -0,0 +1,253 @@
"""
Discovery utilities for Arcade Tools.
Provides modular, testable functions to discover toolkits and local tool files,
load modules, collect tools, and build a ToolCatalog.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import ModuleType
from typing import Any
from loguru import logger
from arcade_core.catalog import ToolCatalog
from arcade_core.parse import get_tools_from_file
from arcade_core.toolkit import Toolkit, ToolkitLoadError
DISCOVERY_PATTERNS = ["*.py", "tools/*.py", "arcade_tools/*.py", "tools/**/*.py"]
FILTER_PATTERNS = ["_test.py", "test_*.py", "__pycache__", "*.lock", "*.egg-info", "*.pyc"]
def normalize_package_name(package_name: str) -> str:
"""Normalize a package name for import resolution."""
return package_name.lower().replace("-", "_")
def load_toolkit_from_package(package_name: str, show_packages: bool = False) -> Toolkit:
"""Attempt to load a Toolkit from an installed package name."""
toolkit = Toolkit.from_package(package_name)
if show_packages:
logger.info(f"Loading package: {toolkit.name}")
return toolkit
def load_package(package_name: str, show_packages: bool = False) -> Toolkit:
"""Load a toolkit for a specific package name.
Raises ToolkitLoadError if the package is not found.
"""
normalized = normalize_package_name(package_name)
try:
return load_toolkit_from_package(normalized, show_packages)
except ToolkitLoadError:
return load_toolkit_from_package(f"arcade_{normalized}", show_packages)
def find_candidate_tool_files(root: Path | None = None) -> list[Path]:
"""Find candidate Python files for auto-discovery in common locations."""
cwd = root or Path.cwd()
candidates: list[Path] = []
for pattern in DISCOVERY_PATTERNS:
candidates.extend(cwd.glob(pattern))
# Deduplicate candidates (same file might match multiple patterns)
unique_candidates = list(set(candidates))
# Filter out private, cache, and tests
return [
p for p in unique_candidates if not any(p.match(pattern) for pattern in FILTER_PATTERNS)
]
def analyze_files_for_tools(files: list[Path]) -> list[tuple[Path, list[str]]]:
"""Parse files with a fast AST pass to find declared @tool function names."""
results: list[tuple[Path, list[str]]] = []
for file_path in files:
try:
names = get_tools_from_file(file_path)
if names:
logger.info(f"Found {len(names)} tool(s) in {file_path.name}: {', '.join(names)}")
results.append((file_path, names))
except Exception:
logger.exception(f"Could not parse {file_path}")
return results
def load_module_from_path(file_path: Path) -> ModuleType:
"""Dynamically import a Python module from a file path."""
import sys
# Add the directory containing the file to sys.path temporarily
# This allows local imports to work
file_dir = str(file_path.parent)
path_added = False
if file_dir not in sys.path:
sys.path.insert(0, file_dir)
path_added = True
try:
spec = importlib.util.spec_from_file_location(
f"_tools_{file_path.stem}",
file_path,
)
if not spec or not spec.loader:
raise ToolkitLoadError(f"Unable to create import spec for {file_path}")
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except Exception:
logger.exception(f"Failed to load {file_path}")
raise ToolkitLoadError(f"Failed to load {file_path}")
return module
finally:
# Remove the path we added
if path_added and file_dir in sys.path:
sys.path.remove(file_dir)
def collect_tools_from_modules(
files_with_tools: list[tuple[Path, list[str]]],
) -> list[tuple[Any, ModuleType]]:
"""Load modules and collect the expected tool callables.
Returns a list of (callable, module) pairs.
"""
discovered: list[tuple[Any, ModuleType]] = []
for file_path, expected_names in files_with_tools:
logger.debug(f"Loading tools from {file_path}...")
try:
module = load_module_from_path(file_path)
except ToolkitLoadError:
continue
for name in expected_names:
if hasattr(module, name):
attr = getattr(module, name)
if callable(attr) and hasattr(attr, "__tool_name__"):
discovered.append((attr, module))
else:
logger.warning(
f"Expected {name} to be a tool but it wasn't (missing __tool_name__)\n\n"
)
return discovered
def build_minimal_toolkit(
server_name: str | None,
server_version: str | None,
description: str | None = None,
) -> Toolkit:
"""Create a minimal Toolkit to host locally discovered tools."""
name = server_name or "ArcadeMCP"
version = server_version or "0.1.0dev"
pkg = f"{name}.{Path.cwd().name}"
desc = description or f"MCP Server for {name} version {version}"
return Toolkit(name=name, package_name=pkg, version=version, description=desc)
def build_catalog_from_toolkits(toolkits: list[Toolkit]) -> ToolCatalog:
"""Create a ToolCatalog and add the provided toolkits."""
catalog = ToolCatalog()
for tk in toolkits:
catalog.add_toolkit(tk)
return catalog
def add_discovered_tools(
catalog: ToolCatalog,
toolkit: Toolkit,
tools: list[tuple[Any, ModuleType]],
) -> None:
"""Add discovered local tools to the catalog, preserving module context."""
for tool_func, module in tools:
if module.__name__ not in __import__("sys").modules:
__import__("sys").modules[module.__name__] = module
catalog.add_tool(tool_func, toolkit, module)
def load_toolkits_for_option(tool_package: str, show_packages: bool = False) -> list[Toolkit]:
"""
Load toolkits for a given package option.
Args:
tool_package: Package name or comma-separated list of package names
show_packages: Whether to log loaded packages
Returns:
List of loaded toolkits
"""
toolkits = []
packages = [p.strip() for p in tool_package.split(",")]
for package in packages:
try:
toolkit = load_package(package, show_packages)
toolkits.append(toolkit)
except ToolkitLoadError as e:
logger.warning(f"Failed to load package '{package}': {e}")
return toolkits
def load_all_installed_toolkits(show_packages: bool = False) -> list[Toolkit]:
"""
Discover and load all installed arcade toolkits.
Args:
show_packages: Whether to log loaded packages
Returns:
List of all installed toolkits
"""
toolkits = Toolkit.find_all_arcade_toolkits()
if show_packages:
for toolkit in toolkits:
logger.info(f"Loading package: {toolkit.name}")
return toolkits
def discover_tools(
tool_package: str | None = None,
show_packages: bool = False,
discover_installed: bool = False,
server_name: str | None = None,
server_version: str | None = None,
) -> ToolCatalog:
"""High-level discovery that returns a ToolCatalog.
This function is pure (does not sys.exit); callers should handle errors.
"""
# 1) Package-based discovery
if tool_package:
toolkits = load_toolkits_for_option(tool_package, show_packages)
return build_catalog_from_toolkits(toolkits)
# 2) Discover all installed packages
if discover_installed:
toolkits = load_all_installed_toolkits(show_packages)
return build_catalog_from_toolkits(toolkits)
# 3) Local file discovery
logger.info("Auto-discovering tools from current directory")
files = find_candidate_tool_files()
if not files:
# Return empty catalog; caller can decide how to handle
return ToolCatalog()
files_with_tools = analyze_files_for_tools(files)
if not files_with_tools:
return ToolCatalog()
discovered = collect_tools_from_modules(files_with_tools)
catalog = ToolCatalog()
toolkit = build_minimal_toolkit(server_name, server_version)
add_discovered_tools(catalog, toolkit, discovered)
return catalog

View file

@ -36,6 +36,18 @@ def get_function_name_if_decorated(
and isinstance(decorator.func, ast.Name) and isinstance(decorator.func, ast.Name)
and decorator.func.id in decorator_ids and decorator.func.id in decorator_ids
) )
# Support MCPApp tools. e.g., @app.tool or @app.tool(...)
or (
isinstance(decorator, ast.Attribute)
and decorator.attr == "tool"
and isinstance(decorator.value, ast.Name)
)
or (
isinstance(decorator, ast.Call)
and isinstance(decorator.func, ast.Attribute)
and decorator.func.attr == "tool"
and isinstance(decorator.func.value, ast.Name)
)
): ):
return node.name return node.name
return None return None

View file

@ -1,3 +1,21 @@
"""
Arcade Core Schema
Defines transport-agnostic tool schemas and runtime context protocols used
across Arcade libraries. This includes:
- Tool and toolkit specifications (parameters, outputs, requirements)
- Transport-agnostic ToolContext carrying authorization, secrets, metadata
- Runtime ModelContext Protocol and its namespaced sub-protocols for logs,
progress, resources, tools, prompts, sampling, UI, and notifications
Note: ToolContext does not embed runtime capabilities; those are provided by
implementations of ModelContext (e.g., in arcade-mcp-server) that subclasses ToolContext
to expose the namespaced APIs to tools without changing function signatures.
"""
from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@ -23,10 +41,10 @@ class ValueSchema(BaseModel):
enum: list[str] | None = None enum: list[str] | None = None
"""The list of possible values for the value, if it is a closed list.""" """The list of possible values for the value, if it is a closed list."""
properties: dict[str, "ValueSchema"] | None = None properties: dict[str, ValueSchema] | None = None
"""For object types (json), the schema of nested properties.""" """For object types (json), the schema of nested properties."""
inner_properties: dict[str, "ValueSchema"] | None = None inner_properties: dict[str, ValueSchema] | None = None
"""For array types with json items, the schema of properties for each array item.""" """For array types with json items, the schema of properties for each array item."""
description: str | None = None description: str | None = None
@ -100,7 +118,7 @@ class ToolAuthRequirement(BaseModel):
# or # or
# client.auth.authorize(provider=AuthProvider.google, scopes=["profile", "email"]) # client.auth.authorize(provider=AuthProvider.google, scopes=["profile", "email"])
# #
# The Arcade SDK translates these into the appropriate provider ID (Google) and type (OAuth2). # The Arcade TDK translates these into the appropriate provider ID (Google) and type (OAuth2).
# The only time the developer will set these is if they are using a custom auth provider. # The only time the developer will set these is if they are using a custom auth provider.
provider_id: str | None = None provider_id: str | None = None
"""The provider ID configured in Arcade that acts as an alias to well-known configuration.""" """The provider ID configured in Arcade that acts as an alias to well-known configuration."""
@ -200,7 +218,7 @@ class FullyQualifiedName:
(self.toolkit_version or "").lower(), (self.toolkit_version or "").lower(),
)) ))
def equals_ignoring_version(self, other: "FullyQualifiedName") -> bool: def equals_ignoring_version(self, other: FullyQualifiedName) -> bool:
"""Check if two fully-qualified tool names are equal, ignoring the version.""" """Check if two fully-qualified tool names are equal, ignoring the version."""
return ( return (
self.name.lower() == other.name.lower() self.name.lower() == other.name.lower()
@ -208,7 +226,7 @@ class FullyQualifiedName:
) )
@staticmethod @staticmethod
def from_toolkit(tool_name: str, toolkit: ToolkitDefinition) -> "FullyQualifiedName": def from_toolkit(tool_name: str, toolkit: ToolkitDefinition) -> FullyQualifiedName:
"""Creates a fully-qualified tool name from a tool name and a ToolkitDefinition.""" """Creates a fully-qualified tool name from a tool name and a ToolkitDefinition."""
return FullyQualifiedName(tool_name, toolkit.name, toolkit.version) return FullyQualifiedName(tool_name, toolkit.name, toolkit.version)
@ -298,7 +316,16 @@ class ToolMetadataItem(BaseModel):
class ToolContext(BaseModel): class ToolContext(BaseModel):
"""The context for a tool invocation.""" """The context for a tool invocation.
This type is transport-agnostic and contains only authorization,
secret, and metadata information needed by the tool. Runtime-specific
capabilities (logging, resources, etc.) are provided by a separate
runtime context that wraps this object.
Recommendation: For new tools, annotate the parameter as
`arcade_mcp_server.Context` to access namespaced runtime APIs directly.
"""
authorization: ToolAuthorizationContext | None = None authorization: ToolAuthorizationContext | None = None
"""The authorization context for the tool invocation that requires authorization.""" """The authorization context for the tool invocation that requires authorization."""
@ -312,16 +339,35 @@ class ToolContext(BaseModel):
user_id: str | None = None user_id: str | None = None
"""The user ID for the tool invocation (if any).""" """The user ID for the tool invocation (if any)."""
model_config = {"arbitrary_types_allowed": True}
def set_secret(self, key: str, value: str) -> None:
"""Add or update a secret to the tool context."""
if self.secrets is None:
self.secrets = []
# Update existing or add new
for secret in self.secrets:
if secret.key == key:
secret.value = value
return
self.secrets.append(ToolSecretItem(key=key, value=value))
def get_auth_token_or_empty(self) -> str: def get_auth_token_or_empty(self) -> str:
"""Retrieve the authorization token, or return an empty string if not available.""" """Retrieve the authorization token, or return an empty string if not available."""
return self.authorization.token if self.authorization and self.authorization.token else "" return self.authorization.token if self.authorization and self.authorization.token else ""
def get_secret(self, key: str) -> str: def get_secret(self, key: str) -> str:
"""Retrieve the secret for the tool invocation.""" """Retrieve the secret for the tool invocation.
Raises a ValueError if the secret is not found.
"""
return self._get_item(key, self.secrets, "secret") return self._get_item(key, self.secrets, "secret")
def get_metadata(self, key: str) -> str: def get_metadata(self, key: str) -> str:
"""Retrieve the metadata for the tool invocation.""" """Retrieve the metadata for the tool invocation.
Raises a ValueError if the metadata is not found.
"""
return self._get_item(key, self.metadata, "metadata") return self._get_item(key, self.metadata, "metadata")
def _get_item( def _get_item(
@ -335,21 +381,14 @@ class ToolContext(BaseModel):
f"{item_name.capitalize()} key passed to get_{item_name} cannot be empty." f"{item_name.capitalize()} key passed to get_{item_name} cannot be empty."
) )
if not items: if not items:
raise ValueError(f"{item_name.capitalize()}s not found in context.") raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.")
normalized_key = key.lower() normalized_key = key.lower()
for item in items: for item in items:
if item.key.lower() == normalized_key: if item.key.lower() == normalized_key:
return item.value return item.value
raise ValueError(f"{item_name.capitalize()} {key} not found in context.") raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.")
def set_secret(self, key: str, value: str) -> None:
"""Set a secret for the tool invocation."""
if not self.secrets:
self.secrets = []
secret = ToolSecretItem(key=str(key), value=str(value))
self.secrets.append(secret)
class ToolCallRequest(BaseModel): class ToolCallRequest(BaseModel):

View file

@ -6,6 +6,7 @@ import types
from collections import defaultdict from collections import defaultdict
from pathlib import Path, PurePosixPath, PureWindowsPath from pathlib import Path, PurePosixPath, PureWindowsPath
import toml
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
from arcade_core.errors import ToolkitLoadError from arcade_core.errors import ToolkitLoadError
@ -59,6 +60,71 @@ class Toolkit(BaseModel):
""" """
return cls.from_package(module.__name__) return cls.from_package(module.__name__)
@classmethod
def from_directory(cls, directory: Path) -> "Toolkit":
"""
Load a Toolkit from a directory.
"""
pyproject_path = directory / "pyproject.toml"
if not pyproject_path.exists():
raise ToolkitLoadError(f"pyproject.toml not found in {directory}")
try:
with open(pyproject_path) as f:
pyproject_data = toml.load(f)
project_data = pyproject_data.get("project", {})
name = project_data.get("name")
if not name:
def _missing_name_error() -> ToolkitLoadError:
return ToolkitLoadError("name not found in pyproject.toml")
raise _missing_name_error() # noqa: TRY301
package_name = name
version = project_data.get("version", "0.0.0")
description = project_data.get("description", "")
authors = project_data.get("authors", [])
author_names = [author.get("name", "") for author in authors]
# For homepage and repository, you might need to look under project.urls
urls = project_data.get("urls", {})
homepage = urls.get("Homepage")
repo = urls.get("Repository")
except Exception as e:
raise ToolkitLoadError(f"Failed to load metadata from {pyproject_path}: {e}")
# Determine the actual package directory (supports src/ layout and flat layout)
package_dir = directory
try:
src_candidate = directory / "src" / package_name
flat_candidate = directory / package_name
if src_candidate.is_dir():
package_dir = src_candidate
elif flat_candidate.is_dir():
package_dir = flat_candidate
else:
# Fallback to the provided directory; tools_from_directory will de-duplicate prefixes
package_dir = directory
except Exception:
package_dir = directory
toolkit = cls(
name=name,
package_name=package_name,
version=version,
description=description,
author=author_names,
homepage=homepage,
repository=repo,
)
toolkit.tools = cls.tools_from_directory(package_dir, package_name)
return toolkit
@classmethod @classmethod
def from_package(cls, package: str) -> "Toolkit": def from_package(cls, package: str) -> "Toolkit":
""" """
@ -232,9 +298,14 @@ class Toolkit(BaseModel):
for module_path in modules: for module_path in modules:
relative_path = module_path.relative_to(package_dir) relative_path = module_path.relative_to(package_dir)
cls.validate_file(module_path) cls.validate_file(module_path)
import_path = ".".join(relative_path.with_suffix("").parts) # Build import path and avoid duplicating the package prefix if it already exists
import_path = f"{package_name}.{import_path}" relative_parts = relative_path.with_suffix("").parts
tools[import_path] = get_tools_from_file(str(module_path)) import_path = ".".join(relative_parts)
if relative_parts and relative_parts[0] == package_name:
full_import_path = import_path
else:
full_import_path = f"{package_name}.{import_path}" if import_path else package_name
tools[full_import_path] = get_tools_from_file(str(module_path))
if not tools: if not tools:
raise ToolkitLoadError(f"No tools found in package {package_name}") raise ToolkitLoadError(f"No tools found in package {package_name}")

View file

@ -4,6 +4,7 @@ import ast
import inspect import inspect
import re import re
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from textwrap import dedent
from types import UnionType from types import UnionType
from typing import Any, Literal, TypeVar, Union, get_args, get_origin from typing import Any, Literal, TypeVar, Union, get_args, get_origin
@ -75,7 +76,9 @@ def does_function_return_value(func: Callable) -> bool:
if source is None: if source is None:
raise ValueError("Source code not found") raise ValueError("Source code not found")
tree = ast.parse(source) # dedent in case the function is an inner function
dedented_source = dedent(source)
tree = ast.parse(dedented_source)
class ReturnVisitor(ast.NodeVisitor): class ReturnVisitor(ast.NodeVisitor):
def __init__(self) -> None: def __init__(self) -> None:

View file

@ -1,6 +1,6 @@
[project] [project]
name = "arcade-core" name = "arcade-core"
version = "2.4.0" version = "2.5.0rc1"
description = "Arcade Core - Core library for Arcade platform" description = "Arcade Core - Core library for Arcade platform"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@ -28,9 +28,6 @@ dependencies = [
"types-python-dateutil==2.9.0.20241003", "types-python-dateutil==2.9.0.20241003",
"types-pytz==2024.2.0.20241003", "types-pytz==2024.2.0.20241003",
"types-toml==0.10.8.20240310", "types-toml==0.10.8.20240310",
"opentelemetry-instrumentation-fastapi==0.49b2",
"opentelemetry-exporter-otlp-proto-http==1.28.2",
"opentelemetry-exporter-otlp-proto-common==1.28.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -14,7 +14,7 @@ Arcade Evals provides comprehensive evaluation capabilities for Arcade tools:
## Installation ## Installation
```bash ```bash
pip install 'arcade-ai[evals]' pip install 'arcade-mcp[evals]'
``` ```
## Usage ## Usage

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
import numpy as np import numpy as np
from arcade_core.config_model import Config from arcade_core.converters.openai import OpenAIToolList, to_openai
from arcade_core.schema import TOOL_NAME_SEPARATOR from arcade_core.schema import TOOL_NAME_SEPARATOR
from openai import AsyncOpenAI from openai import AsyncOpenAI
from scipy.optimize import linear_sum_assignment from scipy.optimize import linear_sum_assignment
@ -613,14 +613,12 @@ class EvalSuite:
Args: Args:
client: The AsyncOpenAI client instance. client: The AsyncOpenAI client instance.
model: The model to evaluate. model: The model to evaluate.
Returns: Returns:
A dictionary containing the evaluation results. A dictionary containing the evaluation results.
""" """
results: dict[str, Any] = {"model": model, "rubric": self.rubric, "cases": []} results: dict[str, Any] = {"model": model, "rubric": self.rubric, "cases": []}
semaphore = asyncio.Semaphore(self.max_concurrent) semaphore = asyncio.Semaphore(self.max_concurrent)
tool_names = list(self.catalog.get_tool_names())
async def sem_task(case: EvalCase) -> dict[str, Any]: async def sem_task(case: EvalCase) -> dict[str, Any]:
async with semaphore: async with semaphore:
@ -629,12 +627,14 @@ class EvalSuite:
messages.extend(case.additional_messages) messages.extend(case.additional_messages)
messages.append({"role": "user", "content": case.user_message}) messages.append({"role": "user", "content": case.user_message})
tools = get_formatted_tools(self.catalog, tool_format="openai")
# Get the model response # Get the model response
response = await client.chat.completions.create( # type: ignore[call-overload] response = await client.chat.completions.create( # type: ignore[call-overload]
model=model, model=model,
messages=messages, messages=messages,
tool_choice="auto", tool_choice="auto",
tools=(str(name) for name in tool_names), tools=tools,
user="eval_user", user="eval_user",
seed=42, seed=42,
stream=False, stream=False,
@ -675,6 +675,23 @@ class EvalSuite:
return results return results
def get_formatted_tools(catalog: "ToolCatalog", tool_format: str = "openai") -> OpenAIToolList:
"""Get the formatted tools from the catalog.
Args:
catalog: The catalog of Arcade tools.
tool_format: The format of the tools to return
Returns:
The formatted tools.
"""
if tool_format == "openai":
tools = [to_openai(tool) for tool in catalog]
return tools
else:
raise ValueError(f"Tool format for '{tool_format}' is not supported")
def get_tool_args(chat_completion: Any) -> list[tuple[str, dict[str, Any]]]: def get_tool_args(chat_completion: Any) -> list[tuple[str, dict[str, Any]]]:
""" """
Returns the tool arguments from the chat completion object. Returns the tool arguments from the chat completion object.
@ -729,8 +746,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@functools.wraps(func) @functools.wraps(func)
async def wrapper( async def wrapper(
config: Config, provider_api_key: str,
base_url: str,
model: str, model: str,
max_concurrency: int = 1, max_concurrency: int = 1,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -740,8 +756,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
suite.max_concurrent = max_concurrency suite.max_concurrent = max_concurrency
results = [] results = []
async with AsyncOpenAI( async with AsyncOpenAI(
api_key=config.api.key, api_key=provider_api_key,
base_url=base_url + "/v1",
) as client: ) as client:
result = await suite.run(client, model) result = await suite.run(client, model)
results.append(result) results.append(result)

View file

@ -0,0 +1,40 @@
.PHONY: help
help:
@echo "🛠️ Arcade MCP Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: sync
sync: ## Sync dependencies
uv sync --all-extras --all-packages --group dev
.PHONY: format
format: ## Run ruff format
uv run ruff format
.PHONY: lint
lint: ## Run ruff lint
uv run ruff check
.PHONY: mypy
mypy: ## Run mypy
uv run mypy .
.PHONY: test
test: ## Run tests
uv run pytest --cov=arcade_mcp_server --cov-report=term-missing ../tests/arcade_mcp_server
.PHONY: docs
docs: ## Build docs
uv run mkdocs build
uv run mkdocs serve
.PHONY: serve-docs
serve-docs: ## Serve docs locally
uv run mkdocs serve
.PHONY: deploy-docs
deploy-docs: ## Deploy docs to GitHub Pages
uv run mkdocs gh-deploy --force --verbose

View file

@ -0,0 +1,72 @@
# Arcade MCP Server
<p align="center">
<img src="https://docs.arcade.dev/images/logo/arcade-logo.png" alt="Arcade Logo" width="200"/>
</p>
Arcade MCP (Model Context Protocol) Server enables AI assistants and development tools to interact with your Arcade tools through a standardized protocol. Build, deploy, and integrate MCP servers seamlessly across different AI platforms.
## Quick Links
- **[Quickstart Guide](getting-started/quickstart.md)** - Get up and running in minutes
- **[Walkthrough](examples/README.md)** - Learn by example
- **[API Reference](api/app.md)** - MCPApp API documentation
## Features
- 🚀 **FastAPI-like Interface** - Simple, intuitive API with `MCPApp`
- 🔧 **Tool Discovery** - Automatic discovery of tools in your project
- 🔌 **Multiple Transports** - Support for stdio and HTTP/SSE
- 🤖 **Multi-Client Support** - Works with Claude, Cursor, and more
- 📦 **Package Integration** - Load installed Arcade packages
- 🔐 **Built-in Security** - Environment-based configuration and secrets
- 🔄 **Hot Reload** - Development mode with automatic reloading
- 📊 **Production Ready** - Deploy with Docker, systemd, PM2, or cloud platforms
## Getting Started
### Installation
```bash
pip install arcade-mcp-server
```
### Create Your First Server
```python
from arcade_mcp_server import MCPApp
from typing import Annotated
app = MCPApp(name="my-tools", version="1.0.0")
@app.tool
def greet(name: Annotated[str, "Name to greet"]) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
if __name__ == "__main__":
app.run()
```
### Run Your Server
```bash
# For development
python my_tools.py
# For Claude Desktop
python -m arcade_mcp_server stdio
# For HTTP clients
python -m arcade_mcp_server --host 0.0.0.0 --port 8080
```
## Community
- [GitHub Repository](https://github.com/ArcadeAI/arcade-mcp)
- [Discord Community](https://discord.gg/arcade-mcp)
- [Documentation](https://docs.arcade.dev)
## License
Arcade MCP Server is open source software licensed under the MIT license.

View file

@ -0,0 +1,44 @@
"""
MCP (Model Context Protocol) support for Arcade.
This package provides:
- MCP server implementation for serving Arcade tools
- Multiple transport options (stdio, HTTP/SSE)
- Integration with Arcade workers with factory and runner functions
- Context system for tool execution with MCP methods
A FastAPI-like interface for building MCP servers.
- Add tools with decorators or explicitly
- Run the server with a single function call
- Supports HTTP transport only
`arcade_mcp` for running stdio directly from the command line.
- auto discovery of tools and construction of the server
- supports stdio transport only
- run with uv or `python -m arcade_mcp`
"""
from arcade_tdk import tool
from arcade_mcp_server.context import Context
from arcade_mcp_server.mcp_app import MCPApp
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
from arcade_mcp_server.worker import create_arcade_mcp, run_arcade_mcp
__all__ = [
"Context",
# FastAPI-like interface
"MCPApp",
# MCP Server implementation
"MCPServer",
"MCPSettings",
# Integrated Factory and Runner
"create_arcade_mcp",
"run_arcade_mcp",
# Re-exported TDK functionality
"tool",
]
# Package metadata
__version__ = "0.1.0"

View file

@ -0,0 +1,353 @@
"""
Arcade MCP Server Runner
Provides a unified interface for running MCP servers with either:
- stdio transport for direct client connections
- HTTP/SSE transport with FastAPI for web-based connections
Usage:
# Run with stdio transport
python -m arcade_mcp_server stdio
# Run with HTTP transport (default)
python -m arcade_mcp_server
# Run with specific toolkit
python -m arcade_mcp_server --toolkit my_toolkit
# Run in development mode with hot reload
python -m arcade_mcp_server --reload --debug
"""
import asyncio
import logging
import os
import sys
from pathlib import Path
from typing import Any
from arcade_core.catalog import ToolCatalog
from arcade_core.discovery import discover_tools
from arcade_core.toolkit import ToolkitLoadError
from dotenv import load_dotenv
from loguru import logger
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
# Logging setup with Loguru
class LoguruInterceptHandler(logging.Handler):
"""Intercept standard logging and route to Loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = str(record.levelno)
logger.opt(exception=record.exc_info).log(level, record.getMessage())
def setup_logging(level: str = "INFO", stdio_mode: bool = False) -> None:
"""Configure logging with Loguru."""
# Remove existing handlers
logger.remove()
# Configure output destination
sink = sys.stderr if stdio_mode else sys.stdout
# Add handler with appropriate format
if level == "DEBUG":
format_str = "<level>{level: <8}</level> | <green>{time:HH:mm:ss}</green> | <cyan>{name}:{line}</cyan> | <level>{message}</level>"
else:
format_str = (
"<level>{level: <8}</level> | <green>{time:HH:mm:ss}</green> | <level>{message}</level>"
)
logger.add(
sink,
format=format_str,
level=level,
colorize=True,
diagnose=(level == "DEBUG"),
)
# Intercept standard logging
logging.basicConfig(handlers=[LoguruInterceptHandler()], level=0, force=True)
def initialize_tool_catalog(
tool_package: str | None = None,
show_packages: bool = False,
discover_installed: bool = False,
server_name: str | None = None,
server_version: str | None = None,
) -> ToolCatalog:
"""
Discover and load tools from various sources.
Returns a ToolCatalog or exits with a friendly error if nothing found.
"""
try:
catalog = discover_tools(
tool_package=tool_package,
show_packages=show_packages,
discover_installed=discover_installed,
server_name=server_name,
server_version=server_version,
)
except ToolkitLoadError as exc:
logger.error(str(exc))
sys.exit(1)
total_tools = len(catalog)
if total_tools == 0:
logger.error("No tools found. Create Python files with @tool decorated functions.")
sys.exit(1)
logger.info(f"Total tools loaded: {total_tools}")
return catalog
async def run_stdio_server(
catalog: ToolCatalog,
debug: bool = False,
env_file: str | None = None,
**kwargs: Any,
) -> None:
"""Run MCP server with stdio transport."""
from arcade_mcp_server.transports.stdio import StdioTransport
# Load settings
# Ensure env from provided .env is loaded for stdio runs as well
if env_file:
load_dotenv(env_file)
logger.debug(f"Loaded environment variables from --env-file={env_file}")
settings = MCPSettings.from_env()
if debug:
settings.debug = True
settings.middleware.enable_logging = True
settings.middleware.log_level = "DEBUG"
# Debug log settings and env var names (without values)
try:
tool_env_keys = sorted(settings.tool_secrets().keys())
logger.debug(
f"Arcade settings: \n\
ARCADE_ENVIRONMENT={settings.arcade.environment} \n\
ARCADE_API_URL={settings.arcade.api_url}, \n\
ARCADE_USER_ID={settings.arcade.user_id}, \n\
api_key_present - {bool(settings.arcade.api_key)}"
)
logger.debug(f"Tool environment variable names available to tools: {tool_env_keys}")
except Exception as e:
logger.debug(f"Unable to log settings/tool env keys: {e}")
# Create server
server = MCPServer(
catalog=catalog,
settings=settings,
**kwargs,
)
# Create transport
transport = StdioTransport()
try:
# Start server and transport
await server.start()
await transport.start()
# Run connection
async with transport.connect_session() as session:
await server.run_connection(
session.read_stream,
session.write_stream,
session.init_options,
)
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.exception(f"Server error: {e}")
raise
finally:
# Stop transport and server
try:
await transport.stop()
finally:
await server.stop()
def main() -> None:
"""Main entry point for arcade_mcp_server module."""
import argparse
parser = argparse.ArgumentParser(
description="Run Arcade MCP Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Auto-discover tools from current directory
python -m arcade_mcp_server
# Run with stdio transport for Claude Desktop
python -m arcade_mcp_server stdio
# Load specific arcade package
python -m arcade_mcp_server --tool-package github
python -m arcade_mcp_server -p slack
# Discover all installed arcade packages
python -m arcade_mcp_server --discover-installed --show-packages
# Development mode with hot reload
python -m arcade_mcp_server --debug --reload
# Run from a different directory
python -m arcade_mcp_server --cwd /path/to/project
python -m arcade_mcp_server --cwd ~/my-tools stdio
Auto-discovery looks for Python files with @tool decorated functions in:
- Current directory (*.py)
- tools/ subdirectory
- arcade_tools/ subdirectory
""",
)
# Transport selection (positional for backwards compatibility)
parser.add_argument(
"transport",
nargs="?",
default="http",
choices=["stdio", "http", "streamable-http"],
help="Transport type (default: http)",
)
# Optional arguments
parser.add_argument(
"--host",
default="127.0.0.1",
help="Host to bind to (HTTP mode only)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind to (HTTP mode only)",
)
parser.add_argument(
"--tool-package",
"--package",
"-p",
dest="tool_package",
help="Specific tool package to load (e.g., 'github' for arcade-github)",
)
parser.add_argument(
"--discover-installed",
"--all",
action="store_true",
help="Discover all installed arcade tool packages",
)
parser.add_argument(
"--show-packages",
action="store_true",
help="Show loaded packages during discovery",
)
parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload on code changes (HTTP mode only)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug mode with verbose logging",
)
parser.add_argument(
"--env-file",
help="Path to environment file",
)
parser.add_argument(
"--name",
help="Server name",
)
parser.add_argument(
"--version",
help="Server version",
)
parser.add_argument(
"--cwd",
help="Directory to change to before running (for tool discovery)",
)
args = parser.parse_args()
# Change working directory if specified
if args.cwd:
cwd_path = Path(args.cwd).resolve()
if not cwd_path.exists():
print(f"Error: Directory does not exist: {args.cwd}", file=sys.stderr)
sys.exit(1)
if not cwd_path.is_dir():
print(f"Error: Path is not a directory: {args.cwd}", file=sys.stderr)
sys.exit(1)
os.chdir(cwd_path)
# Update logging to show the new directory
# Load environment variables
if args.env_file:
load_dotenv(args.env_file)
# Setup logging
log_level = "DEBUG" if args.debug else "INFO"
setup_logging(level=log_level, stdio_mode=(args.transport == "stdio"))
# Build kwargs for server
server_kwargs = {}
if args.name:
server_kwargs["name"] = args.name
if args.version:
server_kwargs["version"] = args.version
# Discover tools
catalog = initialize_tool_catalog(
tool_package=args.tool_package,
show_packages=args.show_packages,
discover_installed=args.discover_installed,
server_name=server_kwargs.get("name"),
server_version=server_kwargs.get("version"),
)
# Run appropriate server
try:
if args.transport == "stdio":
logger.info("Starting MCP server with stdio transport")
asyncio.run(
run_stdio_server(catalog, debug=args.debug, env_file=args.env_file, **server_kwargs)
)
else:
logger.info(f"Starting MCP server with HTTP transport on {args.host}:{args.port}")
from arcade_mcp_server.worker import run_arcade_mcp
run_arcade_mcp(
catalog=catalog,
host=args.host,
port=args.port,
reload=args.reload,
debug=args.debug,
tool_package=args.tool_package,
discover_installed=args.discover_installed,
show_packages=args.show_packages,
**server_kwargs,
)
except (KeyboardInterrupt, asyncio.CancelledError):
logger.info("Server stopped")
sys.exit(0)
except Exception as e:
logger.error(f"Server error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,697 @@
"""
MCP Context System
Provides the primary Context class for MCP tool development. This module contains
the Context class that tools should use for both runtime capabilities and
tool-specific data access.
The Context class combines:
- Runtime capabilities: logging, resources, prompts, sampling, UI, notifications
- Tool-specific data: secrets, user_id, authorization, metadata
- Session management: request/session IDs and MCP protocol handling
Key responsibilities:
- Manage per-request state and the current model context using a ContextVar
- Expose namespaced runtime capabilities (log, resources, etc.)
- Provide access to tool-specific data (secrets, user_id, etc.)
- Delegate to the underlying MCP session and server managers
- Handle MCP protocol communication and lifecycle management
Note: Context instances are automatically created and managed by the MCP server.
Tools receive a populated context instance as a parameter and should not
create Context instances directly.
"""
from __future__ import annotations
import asyncio
import logging
import weakref
from builtins import list as builtins_list
from contextvars import ContextVar, Token
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 (
ClientCapabilities,
ElicitResult,
LoggingLevel,
ModelHint,
ModelPreferences,
ResourceContents,
Root,
SamplingMessage,
TextContent,
)
# Context variable for current model context
_current_model_context: ContextVar[Context | None] = ContextVar("model_context", default=None)
_flush_lock = asyncio.Lock()
class _ContextComponent:
def __init__(self, ctx: Context) -> None:
self._ctx = ctx
@property
def server(self) -> Any:
return self._ctx.server
def _require_session(self) -> Any:
session = self._ctx._session
if session is None:
raise ValueError("Session not available")
return session
class Context(ToolContext):
"""Primary context interface for MCP tools.
This class provides both runtime capabilities (logging, resources, prompts, etc.)
and tool-specific data (secrets, user_id, authorization) in a single interface.
Tools should annotate their context parameter with this class.
Runtime Capabilities:
- log: Logging interface (context.log.info(), context.log.error(), etc.)
- progress: Progress reporting for long-running operations
- resources: Access to MCP resources (files, data sources, etc.)
- tools: Call other tools programmatically
- prompts: Access to MCP prompts and templates
- sampling: Create messages using the client's model
- ui: User interaction (elicit input from user)
- notifications: Send notifications to the client
Tool-Specific Data (inherited from ToolContext):
- user_id: The user ID for this tool execution
- secrets: List of secrets available to this tool
- authorization: Authorization context if required
- metadata: Additional metadata for the tool execution
Example:
```python
from arcade_mcp_server import Context, tool
@tool
async def my_tool(context: Context) -> str:
'''Example tool'''
# Runtime capabilities
await context.log.info("Processing request")
return "result"
```
Note: Instances are automatically created and managed by the MCP server.
Tools receive a populated context instance as a parameter.
"""
# Mark as implementing the protocol
__protocols__ = (ModelContextProtocol,) if ModelContextProtocol is not object else ()
def __init__(
self,
server: Any,
session: Any | None = None,
request_id: str | None = None,
):
"""Initialize context with server reference."""
super().__init__()
self._server: weakref.ref[Any] = weakref.ref(server)
self._session: Any | None = session
self._tokens: list[Token] = []
self._notification_queue: set[str] = set()
self._request_id: str | None = request_id
# Namespaced adapters
self._log = Logs(self)
self._progress = Progress(self)
self._resources = Resources(self)
self._tools = Tools(self)
self._prompts = Prompts(self)
self._sampling = Sampling(self)
self._ui = UI(self)
self._notifications = Notifications(self)
@property
def server(self) -> Any:
"""Get the server instance."""
server = self._server()
if server is None:
raise RuntimeError("Server instance is no longer available")
return server
def set_session(self, session: Any) -> None:
"""Set the session for this context."""
self._session = session
def set_request_id(self, request_id: str) -> None:
"""Set the request ID for this context."""
self._request_id = request_id
def set_tool_context(
self,
toolContext: ToolContext,
) -> None:
"""Populate the tool context fields for this model context."""
self.authorization = toolContext.authorization
self.secrets = toolContext.secrets
self.metadata = toolContext.metadata
self.user_id = toolContext.user_id
async def __aenter__(self) -> Context:
"""Enter the context manager and set as current model context."""
# Set this as current model context
token = _current_model_context.set(self)
self._tokens.append(token)
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Exit the context manager and clear current model context."""
# Flush any pending notifications
await self._flush_notifications()
# Reset context
if self._tokens:
token = self._tokens.pop()
_current_model_context.reset(token)
# ============ ModelContext protocol properties ============
@property
def log(self) -> Logs:
"""Logging interface for the tool.
Provides methods for different log levels:
- log.debug(message): Debug-level logging
- log.info(message): Info-level logging
- log.warning(message): Warning-level logging
- log.error(message): Error-level logging
- log.log(level, message): Log at a specific level
Example:
```python
await context.log.info("Processing started")
await context.log.error("Something went wrong")
```
"""
return self._log
@property
def progress(self) -> Progress:
"""Progress reporting for long-running operations.
Use this to report progress back to the client during lengthy operations.
Example:
```python
await context.progress.report(0.5, total=1.0, message="Halfway done")
```
"""
return self._progress
@property
def resources(self) -> Resources:
"""Interface for accessing MCP resources"""
return self._resources
@property
def tools(self) -> Tools:
"""Interface for calling other tools programmatically.
Allows tools to call other tools within the same session.
Example:
```python
result = await context.tools.call_raw("other_tool", {"param": "value"})
```
"""
return self._tools
@property
def prompts(self) -> Prompts:
"""Interface for accessing MCP prompts and templates"""
return self._prompts
@property
def sampling(self) -> Sampling:
"""Create messages using the client's model.
Allows tools to generate text using the connected model.
Example:
```python
response = await context.sampling.create_message(
"Summarize this text: " + text,
temperature=0.7
)
```
"""
return self._sampling
@property
def ui(self) -> UI:
"""User interaction (elicitation) capabilities.
Provides methods for interacting with the user, such as eliciting input.
Example:
```python
result = await context.ui.elicit(
"Please provide your name",
schema={"type": "object", "properties": {"name": {"type": "string"}}}
)
```
"""
return self._ui
@property
def notifications(self) -> Notifications:
"""
Interface for sending notifications to the client
such as tool list changes.
Example:
```python
await context.notifications.tools.list_changed()
```
"""
return self._notifications
@property
def request_id(self) -> str | None:
"""Get the current request ID.
Returns:
The unique identifier for this MCP request, or None if not available.
"""
return self._request_id
@property
def session_id(self) -> str | None:
"""Get the current session ID.
Returns:
The unique identifier for this MCP session, or None if not available.
"""
if self._session is None:
return None
return getattr(self._session, "session_id", None)
# Private helpers
def _check_client_capability(self, capability: ClientCapabilities) -> bool:
"""Check if client has a capability."""
if self._session is None:
return False
return cast(bool, self._session.check_client_capability(capability))
def _parse_model_preferences(
self, prefs: ModelPreferences | str | list[str] | None
) -> ModelPreferences | None:
"""Parse model preferences into standard format."""
if prefs is None:
return None
elif isinstance(prefs, ModelPreferences):
return prefs
elif isinstance(prefs, str):
return ModelPreferences(hints=[ModelHint(name=prefs)])
elif isinstance(prefs, list):
return ModelPreferences(hints=[ModelHint(name=h) for h in prefs])
else:
raise ValueError(f"Invalid model preferences type: {type(prefs)}")
def _try_flush_notifications(self) -> None:
"""Try to flush notifications if in async context."""
try:
loop = asyncio.get_running_loop()
if loop and not loop.is_running():
return
flush_task = asyncio.create_task(self._flush_notifications())
flush_task.add_done_callback(lambda _: self._notification_queue.clear())
except RuntimeError:
# No event loop
pass
async def _flush_notifications(self) -> None:
"""Send all queued notifications."""
async with _flush_lock:
if not self._notification_queue or self._session is None:
return
nm = getattr(self.server, "notification_manager", None)
if nm is None:
return
try:
client_ids = []
if (
self._session
and hasattr(self._session, "session_id")
and self._session.session_id
):
client_ids = [self._session.session_id]
if "notifications/tools/list_changed" in self._notification_queue:
await nm.notify_tool_list_changed(client_ids)
if "notifications/resources/list_changed" in self._notification_queue:
await nm.notify_resource_list_changed(client_ids)
if "notifications/prompts/list_changed" in self._notification_queue:
pass
self._notification_queue.clear()
except Exception:
# Don't let notification failures break the request
logging.debug("Failed to send notifications", exc_info=True)
# =====================
# Namespaced adapters
# =====================
# These thin, per-domain facades (log, progress, resources, tools, prompts,
# sampling, ui, notifications) expose a stable, developer-friendly API on
# Context (e.g., context.log.info(...), context.resources.list()).
#
# They delegate all work to the active MCP session and server managers, keeping
# transport and server-specific details encapsulated in one place.
# This design:
# - avoids leaking MCP internals into the developer surface
# - preserves a cohesive, testable Context API with clear async boundaries
# - allows runtime implementations to evolve without breaking tool code
#
# In short: adapters provide the ergonomics tools rely on, while the underlying
# implementation remains decoupled and replaceable.
class Logs(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def log(
self,
level: str,
message: str,
logger_name: str | None = None,
extra: dict[str, Any] | None = None,
) -> None:
session = self._ctx._session
if session is None:
return
level_typed = cast(LoggingLevel, level)
data = {"msg": message, "extra": extra}
await session.send_log_message(
level=level_typed,
data=data,
logger=logger_name,
)
async def __call__(
self,
level: str,
message: str,
logger_name: str | None = None,
extra: dict[str, Any] | None = None,
) -> None: # compatibility shim
await self.log(level, message, logger_name=logger_name, extra=extra)
async def debug(self, message: str, **kwargs: Any) -> None:
await self.log("debug", message, **kwargs)
async def info(self, message: str, **kwargs: Any) -> None:
await self.log("info", message, **kwargs)
async def warning(self, message: str, **kwargs: Any) -> None:
await self.log("warning", message, **kwargs)
async def error(self, message: str, **kwargs: Any) -> None:
await self.log("error", message, **kwargs)
class Progress(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def report(
self, progress: float, total: float | None = None, message: str | None = None
) -> None:
session = self._ctx._session
if session is None:
return
progress_token = None
if hasattr(session, "_request_meta"):
progress_token = getattr(session._request_meta, "progressToken", None)
if progress_token is None:
return
await session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
message=message,
)
class Resources(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def read(self, uri: str) -> list[ResourceContents]:
if self._ctx.server is None:
raise ValueError("Context is not available outside of a request")
result = await self._ctx.server._mcp_read_resource(uri)
return cast(list[ResourceContents], result)
async def get(self, uri: str) -> ResourceContents:
contents = await self.read(uri)
if not contents:
raise ValueError(f"Resource not found: {uri}")
return contents[0]
async def list_roots(self) -> list[Root]:
if self._ctx._session is None:
return []
result = await self._ctx._session.list_roots()
return result.roots if hasattr(result, "roots") else []
async def list(self) -> list[Root]:
# Convert Resource objects to Root objects
resources = await self._ctx.server._resource_manager.list_resources()
# Resources have uri and name which map to Root
return [Root(uri=r.uri, name=r.name) for r in resources]
async def list_templates(self) -> builtins_list[Any]:
templates = await self._ctx.server._resource_manager.list_resource_templates()
return cast(builtins_list[Any], templates)
class Tools(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def list(self) -> list[Any]:
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=tool_context,
**params,
)
return cast(ToolCallOutput, result)
class Prompts(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def list(self) -> list[Any]:
prompts = await self._ctx.server._prompt_manager.list_prompts()
return cast(list[Any], prompts)
async def get(self, name: str, arguments: dict[str, str] | None = None) -> Any:
return await self._ctx.server._prompt_manager.get_prompt(name, arguments)
class Sampling(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def create_message(
self,
messages: str | list[str | SamplingMessage],
system_prompt: str | None = None,
include_context: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
) -> Any:
if self._ctx._session is None:
raise ValueError("Session not available")
# Convert messages to proper format
if isinstance(messages, str):
sampling_messages = [
SamplingMessage(content=TextContent(text=messages, type="text"), role="user")
]
elif isinstance(messages, list):
sampling_messages = []
for m in messages:
if isinstance(m, str):
sampling_messages.append(
SamplingMessage(content=TextContent(text=m, type="text"), role="user")
)
else:
sampling_messages.append(m)
else:
sampling_messages = messages
# Parse model preferences
parsed_prefs = self._ctx._parse_model_preferences(model_preferences)
# Check client capabilities
if not self._ctx._check_client_capability(ClientCapabilities(sampling={})):
raise ValueError("Client does not support sampling")
result = await self._ctx._session.create_message(
messages=sampling_messages,
system_prompt=system_prompt,
include_context=include_context,
temperature=temperature,
max_tokens=max_tokens or 512,
model_preferences=parsed_prefs,
)
return result.content if hasattr(result, "content") else result
class UI(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
def _validate_elicitation_schema(self, schema: dict[str, Any]) -> None:
"""Validate that the schema conforms to MCP elicitation restrictions."""
if not isinstance(schema, dict):
raise TypeError("Schema must be a dictionary")
if schema.get("type") != "object":
raise ValueError("Schema must have type 'object'")
properties = schema.get("properties", {})
if not isinstance(properties, dict):
raise TypeError("Schema properties must be a dictionary")
# Validate each property
for prop_name, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
raise TypeError(f"Property '{prop_name}' schema must be a dictionary")
prop_type = prop_schema.get("type")
if prop_type not in ["string", "number", "integer", "boolean"]:
raise ValueError(
f"Property '{prop_name}' has unsupported type '{prop_type}'. Only primitive types are allowed."
)
# Validate string formats
if prop_type == "string" and "format" in prop_schema:
allowed_formats = ["email", "uri", "date", "date-time"]
if prop_schema["format"] not in allowed_formats:
raise ValueError(
f"Property '{prop_name}' has unsupported format '{prop_schema['format']}'. Allowed: {allowed_formats}"
)
async def elicit(
self, message: str, schema: dict[str, Any] | None = None, timeout: float = 300.0
) -> ElicitResult:
if self._ctx._session is None:
raise ValueError("Session not available")
if schema is None:
schema = {"type": "object", "properties": {}}
# Validate schema conforms to MCP restrictions
self._validate_elicitation_schema(schema)
result = await self._ctx._session.elicit(
message=message,
requested_schema=schema,
timeout=timeout,
)
return cast(ElicitResult, result)
class _NotificationsTools(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def list_changed(self) -> None:
self._ctx._notification_queue.add("notifications/tools/list_changed")
self._ctx._try_flush_notifications()
class _NotificationsResources(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def list_changed(self) -> None:
self._ctx._notification_queue.add("notifications/resources/list_changed")
self._ctx._try_flush_notifications()
class _NotificationsPrompts(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
async def list_changed(self) -> None:
self._ctx._notification_queue.add("notifications/prompts/list_changed")
self._ctx._try_flush_notifications()
class Notifications(_ContextComponent):
def __init__(self, ctx: Context) -> None:
super().__init__(ctx)
self._tools = _NotificationsTools(ctx)
self._resources = _NotificationsResources(ctx)
self._prompts = _NotificationsPrompts(ctx)
@property
def tools(self) -> _NotificationsTools:
return self._tools
@property
def resources(self) -> _NotificationsResources:
return self._resources
@property
def prompts(self) -> _NotificationsPrompts:
return self._prompts
def get_current_model_context() -> Context | None:
"""Get the current model context if available."""
return _current_model_context.get()
def set_current_model_context(context: Context | None, token: Token | None = None) -> Token:
"""Set the current model context and return a token to reset it."""
if token is not None:
_current_model_context.reset(token)
return token
return _current_model_context.set(context)

View file

@ -0,0 +1,370 @@
import base64
import json
import logging
from enum import Enum
from typing import Any, get_args, get_origin
from arcade_core.catalog import MaterializedTool
from arcade_core.schema import ToolDefinition
from arcade_mcp_server.types import MCPContent, MCPTool, TextContent, ToolAnnotations
logger = logging.getLogger("arcade.mcp")
def create_mcp_tool(tool: MaterializedTool) -> MCPTool | None:
"""
Create an MCP-compatible tool definition from an Arcade tool.
Args:
tool: An Arcade tool object
Returns:
An MCP tool definition or None if the tool cannot be converted
"""
try:
# Get the tool name from the definition
tool_name = getattr(tool.definition, "name", "unknown")
fully_qualified_name = getattr(tool.definition, "fully_qualified_name", None)
# Use fully qualified name for MCP tool name (replacing dots with underscores)
name = fully_qualified_name.replace(".", "_") if fully_qualified_name else tool_name
description = getattr(tool.definition, "description", "No description available")
# Check for deprecation
deprecation_msg = getattr(tool.definition, "deprecation_message", None)
if deprecation_msg:
description = f"[DEPRECATED: {deprecation_msg}] {description}"
# Build input schema using authoritative ToolDefinition when available
try:
if getattr(tool.definition, "input", None):
input_schema = build_input_schema_from_definition(tool.definition)
else:
# Fallback to input_model if definition input is missing
input_schema = _build_input_schema_from_model(tool)
except Exception:
logger.exception("Error while constructing input schema; proceeding with empty schema")
input_schema = {"type": "object", "properties": {}, "additionalProperties": False}
# Create output schema if available
output_schema = None
try:
if hasattr(tool.definition, "output") and tool.definition.output:
output_def = tool.definition.output
if getattr(output_def, "value_schema", None):
output_schema = _build_value_schema_json(output_def.value_schema)
except Exception:
logger.exception("Error while constructing output schema; omitting output schema")
requirements = tool.definition.requirements
# Build annotations using model for stricter typing
annotations = ToolAnnotations(
readOnlyHint=not (
requirements.authorization or requirements.secrets or requirements.metadata
),
openWorldHint=requirements.authorization is not None,
)
# Instantiate MCPTool model to ensure shape correctness
return MCPTool(
name=name,
title=tool.definition.toolkit.name + "_" + tool_name,
description=str(description),
inputSchema=input_schema,
outputSchema=output_schema if output_schema else None,
annotations=annotations,
)
except Exception:
logger.exception(
f"Error creating MCP tool definition for {getattr(tool, 'name', str(tool))}"
)
try:
# Fallback minimal tool to avoid None in callers
fallback_name = getattr(tool.definition, "fully_qualified_name", "unknown").replace(
".", "_"
)
return MCPTool(
name=fallback_name,
title=fallback_name,
description="",
inputSchema={"type": "object", "properties": {}, "additionalProperties": False},
)
except Exception:
return None
def convert_to_mcp_content(value: Any) -> list[MCPContent]:
"""
Convert a Python value to MCP-compatible content.
"""
if value is None:
return []
if isinstance(value, (str, bool, int, float)):
return [TextContent(type="text", text=str(value))]
if isinstance(value, (dict, list)):
try:
return [TextContent(type="text", text=json.dumps(value, ensure_ascii=False))]
except Exception as exc:
raise ValueError("Failed to serialize value to JSON for MCP content") from exc
if isinstance(value, (bytes, bytearray, memoryview)):
# Encode bytes as base64 text so it can be transmitted safely
b = bytes(value)
encoded = base64.b64encode(b).decode("ascii")
return [TextContent(type="text", text=encoded)]
# Default fallback
return [TextContent(type="text", text=str(value))]
def convert_content_to_structured_content(value: Any) -> dict[str, Any] | None:
"""
Convert a Python value to MCP-compatible structured content (JSON object).
According to the MCP specification, structuredContent should be a JSON object
that represents the structured result of the tool call.
Args:
value: The value to convert to structured content
Returns:
A dictionary representing the structured content, or None if value is None
"""
if value is None:
return None
if isinstance(value, dict):
# Already a dictionary - use as-is
return value
elif isinstance(value, list):
# List - wrap in a result object
return {"result": value}
elif isinstance(value, (str, int, float, bool)):
# Primitive types - wrap in a result object
return {"result": value}
else:
# For other types, convert to string and wrap
return {"result": str(value)}
def _map_type_to_json_schema_type(val_type: str | None) -> str:
"""
Map Arcade value types to JSON schema types.
Args:
val_type: The Arcade value type as a string.
Returns:
The corresponding JSON schema type as a string.
"""
if val_type is None:
return "string"
mapping: dict[str, str] = {
"string": "string",
"integer": "integer",
"number": "number",
"boolean": "boolean",
"json": "object",
"array": "array",
}
return mapping.get(val_type, "string")
def build_input_schema_from_definition(definition: ToolDefinition) -> dict[str, Any]:
"""Build a JSON schema object for tool inputs from a ToolDefinition.
Returns a dict with keys: type, properties, and optional required.
"""
properties: dict[str, Any] = {}
required: list[str] = []
if getattr(definition, "input", None) and getattr(definition.input, "parameters", None):
for param in definition.input.parameters:
val_schema = getattr(param, "value_schema", None)
schema: dict[str, Any] = {
"type": _map_type_to_json_schema_type(getattr(val_schema, "val_type", None)),
}
if getattr(param, "description", None):
schema["description"] = param.description
if val_schema and getattr(val_schema, "enum", None):
schema["enum"] = list(val_schema.enum)
if (
val_schema
and val_schema.val_type == "array"
and getattr(val_schema, "inner_val_type", None)
):
schema["items"] = {"type": _map_type_to_json_schema_type(val_schema.inner_val_type)}
if (
val_schema
and val_schema.val_type == "json"
and getattr(val_schema, "properties", None)
):
schema["type"] = "object"
schema["properties"] = {}
for prop_name, prop_schema in val_schema.properties.items():
schema["properties"][prop_name] = {
"type": _map_type_to_json_schema_type(
getattr(prop_schema, "val_type", None)
),
}
if getattr(prop_schema, "description", None):
schema["properties"][prop_name]["description"] = prop_schema.description
properties[param.name] = schema
if getattr(param, "required", False):
required.append(param.name)
input_schema: dict[str, Any] = {
"type": "object",
"properties": properties,
"additionalProperties": False,
}
if required:
input_schema["required"] = required
return input_schema
def _build_input_schema_from_model(tool: MaterializedTool) -> dict[str, Any]:
"""Build input schema from a tool's input_model as a fallback."""
properties: dict[str, Any] = {}
required: list[str] = []
context_param_name = None
tool_input = getattr(tool.definition, "input", None)
if tool_input is not None:
context_param_name = getattr(tool_input, "tool_context_parameter_name", None)
if (
hasattr(tool, "input_model")
and tool.input_model is not None
and hasattr(tool.input_model, "model_fields")
):
for field_name, field in tool.input_model.model_fields.items():
if field_name == context_param_name:
continue
field_type = getattr(field, "annotation", None)
field_type_name = "string" # default
if field_type is int:
field_type_name = "integer"
elif field_type is float:
field_type_name = "number"
elif field_type is bool:
field_type_name = "boolean"
elif field_type is list or (getattr(field_type, "__origin__", None) is list):
field_type_name = "array"
elif field_type is dict or (getattr(field_type, "__origin__", None) is dict):
field_type_name = "object"
field_description = getattr(field, "description", None) or f"Parameter: {field_name}"
param_def: dict[str, Any] = {
"type": field_type_name,
"description": field_description,
}
# Enum support: Enum classes or typing.Annotated[...] with Enum
enum_type = None
ann = getattr(field, "annotation", None)
if ann is not None:
origin = get_origin(ann)
args = get_args(ann)
# typing.Annotated[Enum, ...]
if origin is not None and args:
for arg in args:
if isinstance(arg, type) and issubclass(arg, Enum):
enum_type = arg
break
elif isinstance(ann, type) and issubclass(ann, Enum):
enum_type = ann
if enum_type is not None:
param_def["enum"] = [e.value for e in enum_type]
# Literal[...] support for enum-like constraints
if ann is not None and get_origin(ann) is None:
pass # no-op, handled above
elif ann is not None and get_origin(ann) is Any:
pass
else:
if get_origin(ann) is None:
...
# Attempt to infer inner list item types for list[T]
if field_type_name == "array":
inner = None
if get_origin(field_type) is list and get_args(field_type):
inner = get_args(field_type)[0]
if inner is int:
param_def["items"] = {"type": "integer"}
elif inner is float:
param_def["items"] = {"type": "number"}
elif inner is bool:
param_def["items"] = {"type": "boolean"}
elif inner is str:
param_def["items"] = {"type": "string"}
properties[field_name] = param_def
# Required detection with multiple strategies
is_required_attr = getattr(field, "is_required", None)
try:
if callable(is_required_attr):
if is_required_attr():
required.append(field_name)
elif isinstance(is_required_attr, bool) and is_required_attr:
required.append(field_name)
else:
has_default = getattr(field, "default", None) is not None
has_factory = getattr(field, "default_factory", None) is not None
if not (has_default or has_factory):
required.append(field_name)
except Exception:
logger.debug(
f"Could not determine if field {field_name} is required, assuming optional"
)
input_schema: dict[str, Any] = {
"type": "object",
"properties": properties,
"additionalProperties": False,
}
if required:
input_schema["required"] = required
return input_schema
def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
"""Map a ValueSchema to a JSON schema fragment for outputSchema."""
schema: dict[str, Any] = {
"type": _map_type_to_json_schema_type(getattr(value_schema, "val_type", None)),
}
if getattr(value_schema, "enum", None):
schema["enum"] = list(value_schema.enum)
if getattr(value_schema, "val_type", None) == "array" and getattr(
value_schema, "inner_val_type", None
):
schema["items"] = {"type": _map_type_to_json_schema_type(value_schema.inner_val_type)}
if getattr(value_schema, "val_type", None) == "json" and getattr(
value_schema, "properties", None
):
schema["type"] = "object"
schema["properties"] = {}
for prop_name, prop_schema in value_schema.properties.items():
schema["properties"][prop_name] = {
"type": _map_type_to_json_schema_type(getattr(prop_schema, "val_type", None))
}
if getattr(prop_schema, "description", None):
schema["properties"][prop_name]["description"] = prop_schema.description
return schema

View file

@ -0,0 +1,93 @@
"""
MCP Exception Hierarchy
Provides domain-specific exceptions for better error handling and debugging.
"""
from arcade_core.errors import ToolRuntimeError # Re-export for convenience
__all__ = [
# Re-exports
"ToolRuntimeError",
# Base exceptions
"MCPError",
"MCPRuntimeError",
# Server exceptions
"ServerError",
"SessionError",
"RequestError",
"ResponseError",
"ServerRequestError",
"LifespanError",
# Context exceptions
"MCPContextError",
"NotFoundError",
"AuthorizationError",
"PromptError",
"ResourceError",
"TransportError",
"ProtocolError",
]
class MCPError(Exception):
"""Base error for all MCP-related exceptions."""
class MCPRuntimeError(MCPError):
"""Runtime error for all MCP-related exceptions."""
class ServerError(MCPRuntimeError):
"""Error in server operations."""
class SessionError(ServerError):
"""Error in session management"""
class RequestError(ServerError):
"""Error in request processing from client to server"""
class ResponseError(ServerError):
"""Error in request processing from server -> client"""
class ServerRequestError(RequestError):
"""Error in sending request from server -> client initiated by the server"""
class LifespanError(ServerError):
"""Error in lifespan management."""
class MCPContextError(MCPError):
"""Error in context management."""
class NotFoundError(MCPContextError):
"""Requested entity not found."""
class AuthorizationError(MCPContextError):
"""Authorization failure."""
class PromptError(MCPContextError):
"""Error in prompt management."""
class ResourceError(MCPContextError):
"""Error in resource management."""
# Transport and Protocol Errors
class TransportError(MCPRuntimeError):
"""Error in transport layer (stdio, HTTP, etc)."""
class ProtocolError(MCPRuntimeError):
"""Error in MCP protocol handling."""

View file

@ -0,0 +1 @@
"""FastAPI integration for MCP server."""

View file

@ -0,0 +1,335 @@
"""
FastAPI OpenAPI Documentation Routes
This module provides FastAPI route definitions solely for generating OpenAPI/Swagger
documentation. These routes describe the HTTP endpoints and their request/response
schemas but do not contain actual implementation logic.
The routes documented here are:
- POST /mcp - Send JSON-RPC messages
- GET /mcp - Establish Server-Sent Events (SSE) stream
- DELETE /mcp - Terminate active session
Note: These are documentation-only routes. The actual protocol implementation
is handled separately through the underlying transport layer.
"""
from typing import Any, Optional
from fastapi import APIRouter, Header, HTTPException, Request, status
from pydantic import BaseModel, Field
from arcade_mcp_server.transports.http_streamable import MCP_SESSION_ID_HEADER
from arcade_mcp_server.types import JSONRPC_VERSION, LATEST_PROTOCOL_VERSION
# Pydantic models for OpenAPI documentation
class MCPRequest(BaseModel):
"""JSON-RPC request message for MCP protocol."""
jsonrpc: str = Field(default=JSONRPC_VERSION, description="JSON-RPC version")
method: str = Field(..., description="Method name to invoke")
params: Optional[dict[str, Any]] = Field(None, description="Method parameters")
id: Optional[str | int] = Field(None, description="Request ID for correlating responses")
model_config = {
"json_schema_extra": {
"examples": [
{
"jsonrpc": JSONRPC_VERSION,
"method": "initialize",
"params": {
"protocolVersion": LATEST_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "example-client", "version": "1.0.0"},
},
"id": 1,
}
]
}
}
class MCPResponse(BaseModel):
"""JSON-RPC response message for MCP protocol."""
jsonrpc: str = Field(default=JSONRPC_VERSION, description="JSON-RPC version")
result: Optional[dict[str, Any]] = Field(None, description="Successful response data")
error: Optional[dict[str, Any]] = Field(None, description="Error information if request failed")
id: str | int = Field(..., description="Request ID this response corresponds to")
model_config = {
"json_schema_extra": {
"examples": [
{
"jsonrpc": JSONRPC_VERSION,
"result": {
"protocolVersion": LATEST_PROTOCOL_VERSION,
"capabilities": {},
"serverInfo": {"name": "arcade-server", "version": "1.0.0"},
},
"id": 1,
}
]
}
}
class MCPError(BaseModel):
"""Error response for MCP protocol."""
code: int = Field(..., description="Error code")
message: str = Field(..., description="Human-readable error message")
data: Optional[Any] = Field(None, description="Additional error data")
def get_openapi_routes() -> list[dict]:
"""Get OpenAPI route definitions for MCP endpoints."""
return [
{
"path": "/mcp/",
"post": {
"tags": ["MCP Protocol"],
"summary": "Send MCP message",
"description": "Send a JSON-RPC message to the MCP server. This endpoint handles:\n"
"- Method requests (with id) - returns a JSON response\n"
"- Notifications (without id) - returns 202 Accepted\n\n"
"For SSE mode, set Accept: text/event-stream header.\n"
"For JSON mode, set Accept: application/json header.",
"operationId": "send_mcp_message",
"parameters": [
{
"name": "accept",
"in": "header",
"required": False,
"schema": {"type": "string"},
},
{
"name": "content-type",
"in": "header",
"required": False,
"schema": {"type": "string"},
},
{
"name": MCP_SESSION_ID_HEADER,
"in": "header",
"required": False,
"schema": {"type": "string"},
},
],
"requestBody": {
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/MCPRequest"}}
},
"required": True,
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/MCPResponse"}
}
},
},
"202": {"description": "Notification accepted (no response expected)"},
"400": {"description": "Bad Request - Invalid JSON or missing required fields"},
"404": {"description": "Not Found - Invalid or expired session ID"},
"406": {
"description": "Not Acceptable - Client must accept required content types"
},
"415": {
"description": "Unsupported Media Type - Content-Type must be application/json"
},
"500": {"description": "Internal Server Error"},
},
},
"get": {
"tags": ["MCP Protocol"],
"summary": "Establish SSE stream",
"description": "Establish a Server-Sent Events (SSE) stream for receiving server-initiated messages.\n\n"
"Only one SSE stream is allowed per session. The stream will remain open until:\n"
"- The client closes the connection\n"
"- The session is terminated\n"
"- An error occurs\n\n"
"Requires Accept: text/event-stream header.",
"operationId": "establish_sse_stream",
"parameters": [
{
"name": "accept",
"in": "header",
"required": False,
"schema": {"type": "string"},
},
{
"name": MCP_SESSION_ID_HEADER,
"in": "header",
"required": False,
"schema": {"type": "string"},
},
{
"name": "Last-Event-ID",
"in": "header",
"required": False,
"schema": {"type": "string"},
},
],
"responses": {
"200": {
"description": "SSE stream established",
"content": {
"text/event-stream": {"example": 'data: {"jsonrpc":"2.0",...}\\n\\n'}
},
},
"409": {"description": "Conflict - Only one SSE stream allowed per session"},
"400": {"description": "Bad Request - Invalid JSON or missing required fields"},
"404": {"description": "Not Found - Invalid or expired session ID"},
"406": {
"description": "Not Acceptable - Client must accept required content types"
},
"500": {"description": "Internal Server Error"},
},
},
"delete": {
"tags": ["MCP Protocol"],
"summary": "Terminate session",
"description": "Terminate the current MCP session. This will:\n"
"- Close all active streams\n"
"- Clean up session resources\n"
"- Return 200 OK on successful termination\n\n"
"Only available in stateful mode (when session IDs are used).",
"operationId": "terminate_mcp_session",
"parameters": [
{
"name": MCP_SESSION_ID_HEADER,
"in": "header",
"required": False,
"schema": {"type": "string"},
}
],
"responses": {
"200": {"description": "Session terminated successfully"},
"405": {
"description": "Method Not Allowed - Session termination not supported in stateless mode"
},
"400": {"description": "Bad Request - Invalid JSON or missing required fields"},
"404": {"description": "Not Found - Invalid or expired session ID"},
"500": {"description": "Internal Server Error"},
},
},
}
]
def create_mcp_router() -> APIRouter:
"""Create FastAPI router with MCP endpoint documentation."""
router = APIRouter(
prefix="",
tags=["MCP Protocol"],
responses={
400: {"description": "Bad Request - Invalid JSON or missing required fields"},
404: {"description": "Not Found - Invalid or expired session ID"},
406: {"description": "Not Acceptable - Client must accept required content types"},
415: {"description": "Unsupported Media Type - Content-Type must be application/json"},
500: {"description": "Internal Server Error"},
},
)
@router.post(
"/",
response_model=MCPResponse,
summary="Send MCP message",
description="""
Send a JSON-RPC message to the MCP server. This endpoint handles:
- Method requests (with id) - returns a JSON response
- Notifications (without id) - returns 202 Accepted
For SSE mode, set Accept: text/event-stream header.
For JSON mode, set Accept: application/json header.
""",
responses={
200: {"description": "Successful response", "model": MCPResponse},
202: {"description": "Notification accepted (no response expected)"},
},
)
async def send_message(
request: Request,
body: MCPRequest,
accept: str = Header(None),
content_type: str = Header(None),
mcp_session_id: Optional[str] = Header(None, alias=MCP_SESSION_ID_HEADER),
) -> None:
"""
Documentation-only endpoint definition.
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Documentation endpoint only",
)
@router.get(
"/",
summary="Establish SSE stream",
description="""
Establish a Server-Sent Events (SSE) stream for receiving server-initiated messages.
Only one SSE stream is allowed per session. The stream will remain open until:
- The client closes the connection
- The session is terminated
- An error occurs
Requires Accept: text/event-stream header.
""",
responses={
200: {
"description": "SSE stream established",
"content": {"text/event-stream": {"example": 'data: {"jsonrpc":"2.0",...}\\n\\n'}},
},
409: {"description": "Conflict - Only one SSE stream allowed per session"},
},
)
async def establish_sse(
request: Request,
accept: str = Header(None),
mcp_session_id: Optional[str] = Header(None, alias=MCP_SESSION_ID_HEADER),
last_event_id: Optional[str] = Header(None, alias="Last-Event-ID"),
) -> None:
"""
Documentation-only endpoint definition.
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Documentation endpoint only",
)
@router.delete(
"/",
summary="Terminate session",
description="""
Terminate the current MCP session. This will:
- Close all active streams
- Clean up session resources
- Return 200 OK on successful termination
Only available in stateful mode (when session IDs are used).
""",
responses={
200: {"description": "Session terminated successfully"},
405: {
"description": "Method Not Allowed - Session termination not supported in stateless mode"
},
},
)
async def terminate_session(
request: Request,
mcp_session_id: Optional[str] = Header(None, alias=MCP_SESSION_ID_HEADER),
) -> None:
"""
Documentation-only endpoint definition.
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Documentation endpoint only",
)
return router

View file

@ -0,0 +1,161 @@
"""Lifespan management for MCP server.
Provides a clean interface for managing server lifecycle with proper
resource initialization and cleanup.
"""
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Callable
from arcade_mcp_server.exceptions import LifespanError
logger = logging.getLogger("arcade.mcp")
LifespanResult = dict[str, Any]
@asynccontextmanager
async def default_lifespan(server: Any) -> AsyncIterator[LifespanResult]:
"""Default lifespan that does basic startup/shutdown logging."""
logger.info(f"Starting MCP server: {getattr(server, 'name', 'unknown')}")
# Startup
try:
yield {}
finally:
# Shutdown
logger.info(f"Stopping MCP server: {getattr(server, 'name', 'unknown')}")
class LifespanManager:
"""Manages server lifecycle with proper resource management.
This class wraps a lifespan context manager and provides a clean
interface for server startup and shutdown operations.
"""
def __init__(
self,
server: Any,
lifespan: Callable[[Any], AbstractAsyncContextManager[LifespanResult]] | None = None,
):
"""Initialize lifespan manager.
Args:
server: The server instance
lifespan: Optional custom lifespan function
"""
self.server = server
self.lifespan = lifespan or default_lifespan
self._stack: Any | None = None
self._context: LifespanResult | None = None
self._started = False
async def startup(self) -> LifespanResult:
"""Run startup phase of lifespan."""
if self._started:
raise LifespanError("Lifespan already started")
self._started = True
self._stack = asyncio.create_task(self._run_lifespan())
# Wait for startup to complete
while self._context is None and not self._stack.done():
await asyncio.sleep(0.01)
if self._stack.done() and self._context is None:
# Lifespan failed during startup
try:
await self._stack
except Exception as e:
raise LifespanError(f"Lifespan startup failed: {e}") from e
if self._context is None:
raise LifespanError("Lifespan startup failed")
return self._context
async def shutdown(self) -> None:
"""Run shutdown phase of lifespan."""
if not self._started:
return
self._started = False
if self._stack and not self._stack.done():
# Trigger shutdown by cancelling the lifespan task
self._stack.cancel()
try:
await self._stack
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Error during lifespan shutdown")
self._context = None
self._stack = None
async def _run_lifespan(self) -> None:
"""Run the lifespan context manager."""
try:
async with self.lifespan(self.server) as context:
self._context = context
# Keep running until cancelled
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
# Normal shutdown
self._context = None
raise
except Exception:
# Abnormal shutdown
self._context = None
logger.exception("Error in lifespan")
raise
async def __aenter__(self) -> LifespanResult:
"""Async context manager entry."""
return await self.startup()
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Async context manager exit."""
await self.shutdown()
def compose_lifespans(
*lifespans: Callable[[Any], AbstractAsyncContextManager[LifespanResult]],
) -> Callable[[Any], AbstractAsyncContextManager[LifespanResult]]:
"""Compose multiple lifespan functions into one.
Each lifespan's context is merged into a single dict.
Lifespans are started in order and stopped in reverse order.
"""
@asynccontextmanager
async def composed(server: Any) -> AsyncIterator[LifespanResult]:
contexts: list[tuple[AbstractAsyncContextManager[LifespanResult], LifespanResult]] = []
merged: LifespanResult = {}
# Start lifespans in order (sequential for compatibility)
for lifespan in lifespans:
ctx_mgr = lifespan(server)
context = await ctx_mgr.__aenter__()
contexts.append((ctx_mgr, context))
# Merge context if it's a dict
merged.update(context)
try:
yield merged
finally:
# Stop lifespans in reverse order
for ctx_mgr, _ in reversed(contexts):
try:
await ctx_mgr.__aexit__(None, None, None)
except Exception:
logger.exception("Error stopping lifespan")
return composed

View file

@ -0,0 +1,7 @@
"""MCP Component Managers."""
from arcade_mcp_server.managers.prompt import PromptManager
from arcade_mcp_server.managers.resource import ResourceManager
from arcade_mcp_server.managers.tool import ToolManager
__all__ = ["PromptManager", "ResourceManager", "ToolManager"]

View file

@ -0,0 +1,146 @@
"""
Base Async Managers
Provides async-safe registries with RW locking, versioning, and subscriptions.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterable
from types import TracebackType
from typing import Any, Generic, TypeVar, cast
K = TypeVar("K")
V = TypeVar("V")
class AsyncRWLock:
"""Simple async RW lock allowing concurrent readers and exclusive writers."""
def __init__(self) -> None:
self._reader_count = 0
self._reader_lock = asyncio.Lock()
self._gate = asyncio.Lock()
async def read(self) -> Any:
class _ReadCtx:
async def __aenter__(_self) -> None:
async with self._reader_lock:
self._reader_count += 1
if self._reader_count == 1:
await self._gate.acquire()
async def __aexit__(
_self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
async with self._reader_lock:
self._reader_count -= 1
if self._reader_count == 0:
self._gate.release()
return _ReadCtx()
async def write(self) -> Any:
class _WriteCtx:
async def __aenter__(_self) -> None:
await self._gate.acquire()
async def __aexit__(
_self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self._gate.release()
return _WriteCtx()
class AsyncRegistry(Generic[K, V]):
"""Async-safe registry with deterministic listing and change notifications."""
def __init__(self, component: str) -> None:
self.component = component
self._items: dict[K, V] = {}
self._lock = AsyncRWLock()
self._version = 0
self._subscribers: list[Callable[[str, K | None, V | None, V | None, int], None]] = []
def subscribe(self, fn: Callable[[str, K | None, V | None, V | None, int], None]) -> None:
self._subscribers.append(fn)
async def get(self, key: K) -> V:
async with await self._lock.read():
if key not in self._items:
raise KeyError(f"{self.component.title()} '{key}' not found")
return self._items[key]
async def keys(self) -> list[K]:
async with await self._lock.read():
return sorted(self._items.keys(), key=lambda k: str(k))
async def list(self) -> list[V]:
async with await self._lock.read():
return [self._items[k] for k in sorted(self._items.keys(), key=lambda k: str(k))]
async def upsert(self, key: K, value: V) -> None:
async with await self._lock.write():
old = self._items.get(key)
self._items[key] = value
self._version += 1
version = self._version
for fn in self._subscribers:
fn("upsert", key, old, value, version)
async def remove(self, key: K) -> V:
async with await self._lock.write():
if key not in self._items:
raise KeyError(f"{self.component.title()} '{key}' not found")
old = self._items.pop(key)
self._version += 1
version = self._version
for fn in self._subscribers:
fn("remove", key, old, None, version)
return old
async def bulk_load(self, items: Iterable[tuple[K, V]]) -> None:
async with await self._lock.write():
for k, v in items:
self._items[k] = v
self._version += 1
version = self._version
for fn in self._subscribers:
fn("bulk_load", cast(K, None), None, None, version)
@property
def version(self) -> int:
return self._version
class ComponentManager(Generic[K, V]):
"""Base component manager with lifecycle and async registry."""
def __init__(self, component: str) -> None:
self.registry: AsyncRegistry[K, V] = AsyncRegistry(component)
self._started = False
async def start(self) -> None:
if self._started:
return
self._started = True
async def stop(self) -> None:
if not self._started:
return
self._started = False
def subscribe(self, fn: Callable[[str, K | None, V | None, V | None, int], None]) -> None:
self.registry.subscribe(fn)
@property
def version(self) -> int:
return self.registry.version

View file

@ -0,0 +1,122 @@
"""
Prompt Manager
Async-safe prompts with registry-based storage and deterministic listing.
"""
from __future__ import annotations
import logging
from typing import Callable
from arcade_mcp_server.exceptions import NotFoundError, PromptError
from arcade_mcp_server.managers.base import ComponentManager
from arcade_mcp_server.types import GetPromptResult, Prompt, PromptMessage
logger = logging.getLogger("arcade.mcp.managers.prompt")
class PromptHandler:
"""Handler for generating prompt messages."""
def __init__(
self,
prompt: Prompt,
handler: Callable[[dict[str, str]], list[PromptMessage]] | None = None,
) -> None:
self.prompt = prompt
self.handler = handler or self._default_handler
def __eq__(self, other: object) -> bool: # pragma: no cover - simple comparison
if not isinstance(other, PromptHandler):
return False
return self.prompt == other.prompt and self.handler == other.handler
def _default_handler(self, arguments: dict[str, str]) -> list[PromptMessage]:
return [
PromptMessage(
role="user",
content={
"type": "text",
"text": self.prompt.description or f"Prompt: {self.prompt.name}",
},
)
]
async def get_messages(self, arguments: dict[str, str] | None = None) -> list[PromptMessage]:
args = arguments or {}
# Validate required arguments
if self.prompt.arguments:
for arg in self.prompt.arguments:
if arg.required and arg.name not in args:
raise PromptError(f"Required argument '{arg.name}' not provided")
result = self.handler(args)
if hasattr(result, "__await__"):
result = await result
return result
class PromptManager(ComponentManager[str, PromptHandler]):
"""
Manages prompts for the MCP server.
"""
def __init__(self) -> None:
super().__init__("prompt")
async def list_prompts(self) -> list[Prompt]:
handlers = await self.registry.list()
return [h.prompt for h in handlers]
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
) -> GetPromptResult:
try:
handler = await self.registry.get(name)
except KeyError:
raise NotFoundError(f"Prompt '{name}' not found")
try:
messages = await handler.get_messages(arguments)
return GetPromptResult(
description=handler.prompt.description,
messages=messages,
)
except Exception as e:
if isinstance(e, PromptError):
raise
raise PromptError(f"Error generating prompt: {e}") from e
async def add_prompt(
self,
prompt: Prompt,
handler: Callable[[dict[str, str]], list[PromptMessage]] | None = None,
) -> None:
prompt_handler = PromptHandler(prompt, handler)
await self.registry.upsert(prompt.name, prompt_handler)
async def remove_prompt(self, name: str) -> Prompt:
try:
handler = await self.registry.remove(name)
except KeyError:
raise NotFoundError(f"Prompt '{name}' not found")
return handler.prompt
async def update_prompt(
self,
name: str,
prompt: Prompt,
handler: Callable[[dict[str, str]], list[PromptMessage]] | None = None,
) -> Prompt:
# Ensure exists
try:
_ = await self.registry.get(name)
except KeyError:
raise NotFoundError(f"Prompt '{name}' not found")
prompt_handler = PromptHandler(prompt, handler)
await self.registry.upsert(prompt.name, prompt_handler)
return prompt

View file

@ -0,0 +1,102 @@
"""
Resource Manager
Async-safe resources with registry-based storage and deterministic listing.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from arcade_mcp_server.exceptions import NotFoundError
from arcade_mcp_server.managers.base import ComponentManager
from arcade_mcp_server.types import (
BlobResourceContents,
Resource,
ResourceContents,
ResourceTemplate,
TextResourceContents,
)
logger = logging.getLogger("arcade.mcp.managers.resource")
class ResourceManager(ComponentManager[str, Resource]):
"""
Manages resources for the MCP server.
"""
def __init__(
self,
) -> None:
super().__init__("resource")
self._templates: dict[str, ResourceTemplate] = {}
self._resource_handlers: dict[str, Callable[[str], Any]] = {}
async def list_resources(self) -> list[Resource]:
return await self.registry.list()
async def list_resource_templates(self) -> list[ResourceTemplate]:
return [self._templates[k] for k in sorted(self._templates.keys())]
async def read_resource(self, uri: str) -> list[ResourceContents]:
handler = self._resource_handlers.get(uri)
if handler:
result = handler(uri)
if hasattr(result, "__await__"):
result = await result
if isinstance(result, str):
return [TextResourceContents(uri=uri, text=result)]
elif isinstance(result, dict):
if "text" in result:
return [TextResourceContents(uri=uri, text=result["text"])]
if "blob" in result:
return [BlobResourceContents(uri=uri, blob=result["blob"])]
return [ResourceContents(uri=uri)]
elif isinstance(result, list):
return result
else:
return [TextResourceContents(uri=uri, text=str(result))]
try:
_ = await self.registry.get(uri)
except KeyError as _e:
raise NotFoundError(f"Resource '{uri}' not found")
return [TextResourceContents(uri=uri, text="")] # static placeholder
async def add_resource(
self, resource: Resource, handler: Callable[[str], Any] | None = None
) -> None:
await self.registry.upsert(resource.uri, resource)
if handler:
self._resource_handlers[resource.uri] = handler
async def remove_resource(self, uri: str) -> Resource:
try:
removed = await self.registry.remove(uri)
except KeyError as _e:
raise NotFoundError(f"Resource '{uri}' not found")
self._resource_handlers.pop(uri, None)
return removed
async def update_resource(
self, uri: str, resource: Resource, handler: Callable[[str], Any] | None = None
) -> Resource:
try:
await self.registry.remove(uri)
except KeyError:
raise NotFoundError(f"Resource '{uri}' not found")
await self.registry.upsert(resource.uri, resource)
if handler:
self._resource_handlers[resource.uri] = handler
return resource
async def add_template(self, template: ResourceTemplate) -> None:
self._templates[template.uriTemplate] = template
async def remove_template(self, uri_template: str) -> ResourceTemplate:
if uri_template not in self._templates:
raise NotFoundError(f"Resource template '{uri_template}' not found")
return self._templates.pop(uri_template)

View file

@ -0,0 +1,94 @@
"""
Tool Manager
Async-safe tool management with pre-converted MCPTool DTOs and executable materials.
"""
from __future__ import annotations
from typing import TypedDict
from arcade_core.catalog import MaterializedTool, ToolCatalog
from arcade_mcp_server.convert import build_input_schema_from_definition
from arcade_mcp_server.exceptions import NotFoundError
from arcade_mcp_server.managers.base import ComponentManager
from arcade_mcp_server.types import MCPTool
class ManagedTool(TypedDict):
dto: MCPTool
materialized: MaterializedTool
Key = str # fully qualified tool name
class ToolManager(ComponentManager[Key, ManagedTool]):
"""Tool manager storing both DTO and materialized artifacts."""
def __init__(self) -> None:
super().__init__("tool")
self._sanitized_to_key: dict[str, str] = {}
@staticmethod
def _sanitize_name(name: str) -> str:
return name.replace(".", "_")
def _to_dto(self, tool: MaterializedTool) -> MCPTool:
return MCPTool(
name=self._sanitize_name(tool.definition.fully_qualified_name),
title=f"{tool.definition.toolkit.name}_{tool.definition.name}",
description=tool.definition.description,
inputSchema=build_input_schema_from_definition(tool.definition),
)
async def load_from_catalog(self, catalog: ToolCatalog) -> None:
pairs: list[tuple[Key, ManagedTool]] = []
for t in catalog:
fq = t.definition.fully_qualified_name
pairs.append((fq, {"dto": self._to_dto(t), "materialized": t}))
self._sanitized_to_key[self._sanitize_name(fq)] = fq
await self.registry.bulk_load(pairs)
async def list_tools(self) -> list[MCPTool]:
records = await self.registry.list()
return [r["dto"] for r in records]
async def get_tool(self, name: str) -> MaterializedTool:
# Try exact key first (dotted FQN)
try:
rec = await self.registry.get(name)
return rec["materialized"]
except KeyError:
# Fallback: resolve sanitized name
key = self._sanitized_to_key.get(name)
if key is None:
raise NotFoundError(f"Tool {name} not found")
rec = await self.registry.get(key)
return rec["materialized"]
async def add_tool(self, tool: MaterializedTool) -> None:
key = tool.definition.fully_qualified_name
await self.registry.upsert(key, {"dto": self._to_dto(tool), "materialized": tool})
self._sanitized_to_key[self._sanitize_name(key)] = key
async def update_tool(self, tool: MaterializedTool) -> None:
key = tool.definition.fully_qualified_name
await self.registry.upsert(key, {"dto": self._to_dto(tool), "materialized": tool})
self._sanitized_to_key[self._sanitize_name(key)] = key
async def remove_tool(self, name: str) -> MaterializedTool:
# Accept either exact or sanitized name
key = name
if key not in (await self.registry.keys()):
key = self._sanitized_to_key.get(name, name)
try:
rec = await self.registry.remove(key)
except KeyError as _e:
raise NotFoundError(f"Tool {name} not found")
# Clean mapping if present
sanitized = self._sanitize_name(key)
if sanitized in self._sanitized_to_key:
del self._sanitized_to_key[sanitized]
return rec["materialized"]

View file

@ -0,0 +1,316 @@
"""
MCPApp - A FastAPI-like interface for MCP servers.
Provides a clean, minimal API for building MCP servers with lazy initialization.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Callable, Literal, ParamSpec, TypeVar
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
from arcade_tdk.auth import ToolAuthorization
from arcade_tdk.error_adapters import ErrorAdapter
from arcade_tdk.tool import tool as tool_decorator
from dotenv import load_dotenv
from loguru import logger
from arcade_mcp_server.exceptions import ServerError
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
from arcade_mcp_server.worker import run_arcade_mcp
P = ParamSpec("P")
T = TypeVar("T")
TransportType = Literal["http", "stdio"]
class MCPApp:
"""
A FastAPI-like interface for building MCP servers.
The app collects tools and configuration, then lazily creates the server
and transport when run() is called.
Example:
```python
from arcade_mcp_server import MCPApp
app = MCPApp(name="my_server", version="1.0.0")
@app.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
# Runtime CRUD once you have a server bound to the app:
# app.server = mcp_server
# await app.tools.add(materialized_tool)
# await app.prompts.add(prompt, handler)
# await app.resources.add(resource)
app.run(host="127.0.0.1", port=7777)
```
"""
def __init__(
self,
name: str = "ArcadeMCP",
version: str = "1.0.0dev",
title: str | None = None,
instructions: str | None = None,
log_level: str = "INFO",
transport: TransportType = "http",
host: str = "127.0.0.1",
port: int = 7777,
reload: bool = False,
**kwargs: Any,
):
"""
Initialize the MCP app.
Args:
name: Server name
version: Server version
title: Server title for display
instructions: Server instructions
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
transport: Transport type ("http")
host: Host for transport
port: Port for transport
reload: Enable auto-reload for development
**kwargs: Additional server configuration
"""
self.name = name
self.version = version
self.title = title or name
self.instructions = instructions
self.log_level = log_level
self.server_kwargs = kwargs
self.transport = transport
self.host = host
self.port = port
self.reload = reload
# Tool collection (build-time)
self._catalog = ToolCatalog()
self._toolkit_name = name
# Public handle to the MCPServer (set by caller for runtime ops)
self.server: MCPServer | None = None
self._load_env()
self._setup_logging()
# Properties (exposed below initializer)
@property
def tools(self) -> _ToolsAPI:
"""Runtime and build-time tools API: add/update/remove/list."""
return _ToolsAPI(self)
@property
def prompts(self) -> _PromptsAPI:
"""Runtime prompts API: add/remove/list."""
return _PromptsAPI(self)
@property
def resources(self) -> _ResourcesAPI:
"""Runtime resources API: add/remove/list."""
return _ResourcesAPI(self)
def _load_env(self) -> None:
"""Load .env file from the current directory."""
env_path = Path.cwd() / ".env"
if env_path.exists():
load_dotenv(env_path, override=False)
logger.info(f"Loaded environment from {env_path}")
def _setup_logging(self) -> None:
logger.remove()
if self.log_level == "DEBUG":
format_str = "<level>{level: <8}</level> | <green>{time:HH:mm:ss}</green> | <cyan>{name}:{line}</cyan> | <level>{message}</level>"
else:
format_str = "<level>{level: <8}</level> | <green>{time:HH:mm:ss}</green> | <level>{message}</level>"
logger.add(
sys.stdout,
format=format_str,
level=self.log_level,
colorize=True,
diagnose=(self.log_level == "DEBUG"),
)
def add_tool(
self,
func: Callable[P, T],
desc: str | None = None,
name: str | None = None,
requires_auth: ToolAuthorization | None = None,
requires_secrets: list[str] | None = None,
requires_metadata: list[str] | None = None,
adapters: list[ErrorAdapter] | None = None,
) -> Callable[P, T]:
"""Add a tool for build-time materialization (pre-server)."""
if not hasattr(func, "__tool_name__"):
func = tool_decorator(
func,
desc=desc,
name=name,
requires_auth=requires_auth,
requires_secrets=requires_secrets,
requires_metadata=requires_metadata,
adapters=adapters,
)
try:
self._catalog.add_tool(func, self._toolkit_name)
except ToolDefinitionError as e:
raise e.with_context(func.__name__) from e
logger.debug(f"Added tool: {func.__name__}")
return func
def tool(
self,
func: Callable[P, T] | None = None,
desc: str | None = None,
name: str | None = None,
requires_auth: ToolAuthorization | None = None,
requires_secrets: list[str] | None = None,
requires_metadata: list[str] | None = None,
adapters: list[ErrorAdapter] | None = None,
) -> Callable[[Callable[P, T]], Callable[P, T]] | Callable[P, T]:
"""Decorator for adding tools with optional parameters."""
def decorator(f: Callable[P, T]) -> Callable[P, T]:
return self.add_tool(
f,
desc=desc,
name=name,
requires_auth=requires_auth,
requires_secrets=requires_secrets,
requires_metadata=requires_metadata,
adapters=adapters,
)
if func is not None:
return decorator(func)
return decorator
def run(
self,
host: str = "127.0.0.1",
port: int = 7777,
reload: bool = False,
transport: TransportType = "http",
**kwargs: Any,
) -> None:
if len(self._catalog) == 0:
logger.error("No tools added to the server. Use @app.tool decorator or app.add_tool().")
sys.exit(1)
logger.info(f"Starting {self.name} v{self.version} with {len(self._catalog)} tools")
if transport in ["http", "streamable-http", "streamable"]:
run_arcade_mcp(
catalog=self._catalog,
host=host,
port=port,
reload=reload,
**self.server_kwargs,
)
elif transport == "stdio":
import asyncio
from arcade_mcp_server.__main__ import run_stdio_server
asyncio.run(
run_stdio_server(
catalog=self._catalog,
host=host,
port=port,
reload=reload,
**self.server_kwargs,
)
)
else:
raise ServerError(f"Invalid transport: {transport}")
class _ToolsAPI:
"""Unified tools API for MCPApp (build-time and runtime)."""
def __init__(self, app: MCPApp) -> None:
self._app = app
async def add(self, tool: MaterializedTool) -> None:
"""Add or update a tool at runtime if server is bound; otherwise queue via app.add_tool decorator."""
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime tools API.")
await self._app.server.tools.add_tool(tool)
async def update(self, tool: MaterializedTool) -> None:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime tools API.")
await self._app.server.tools.update_tool(tool)
async def remove(self, name: str) -> MaterializedTool:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime tools API.")
return await self._app.server.tools.remove_tool(name)
async def list(self) -> list[Any]:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime tools API.")
return await self._app.server.tools.list_tools()
class _PromptsAPI:
"""Unified prompts API for MCPApp (runtime)."""
def __init__(self, app: MCPApp) -> None:
self._app = app
async def add(
self, prompt: Prompt, handler: Callable[[dict[str, str]], list[PromptMessage]] | None = None
) -> None:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime prompts API.")
await self._app.server.prompts.add_prompt(prompt, handler)
async def remove(self, name: str) -> Prompt:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime prompts API.")
return await self._app.server.prompts.remove_prompt(name)
async def list(self) -> list[Prompt]:
if self._app.server is None:
raise ServerError("No server bound to app. Set app.server to use runtime prompts API.")
return await self._app.server.prompts.list_prompts()
class _ResourcesAPI:
"""Unified resources API for MCPApp (runtime)."""
def __init__(self, app: MCPApp) -> None:
self._app = app
async def add(self, resource: Resource, handler: Callable[[str], Any] | None = None) -> None:
if self._app.server is None:
raise ServerError(
"No server bound to app. Set app.server to use runtime resources API."
)
await self._app.server.resources.add_resource(resource, handler)
async def remove(self, uri: str) -> Resource:
if self._app.server is None:
raise ServerError(
"No server bound to app. Set app.server to use runtime resources API."
)
return await self._app.server.resources.remove_resource(uri)
async def list(self) -> list[Resource]:
if self._app.server is None:
raise ServerError(
"No server bound to app. Set app.server to use runtime resources API."
)
return await self._app.server.resources.list_resources()

View file

@ -0,0 +1,17 @@
"""MCP Middleware System"""
from arcade_mcp_server.middleware.base import (
CallNext,
Middleware,
MiddlewareContext,
)
from arcade_mcp_server.middleware.error_handling import ErrorHandlingMiddleware
from arcade_mcp_server.middleware.logging import LoggingMiddleware
__all__ = [
"CallNext",
"ErrorHandlingMiddleware",
"LoggingMiddleware",
"Middleware",
"MiddlewareContext",
]

View file

@ -0,0 +1,241 @@
"""Base middleware classes for MCP server."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from functools import partial
from typing import Any, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable
from arcade_mcp_server.types import (
CallToolParams,
CallToolResult,
GetPromptParams,
GetPromptResult,
JSONRPCMessage,
ListPromptsRequest,
ListResourcesRequest,
ListResourceTemplatesRequest,
ListToolsRequest,
MCPTool,
Prompt,
ReadResourceParams,
ReadResourceResult,
Resource,
ResourceTemplate,
)
T = TypeVar("T")
R = TypeVar("R", covariant=True)
@runtime_checkable
class CallNext(Protocol[T, R]):
"""Protocol for the next handler in the middleware chain."""
def __call__(self, context: "MiddlewareContext[T]") -> Awaitable[R]: ...
@dataclass(kw_only=True)
class MiddlewareContext(Generic[T]):
"""Context passed through the middleware chain.
Contains the message being processed and metadata about the request.
"""
# The message being processed
message: T
# The MCP context (optional, set when in request context)
mcp_context: Any | None = None
# Metadata
source: Literal["client", "server"] = "client"
type: Literal["request", "notification"] = "request"
method: str | None = None
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Request-specific metadata
request_id: str | None = None
session_id: str | None = None
# Additional metadata that can be added by middleware
metadata: dict[str, Any] = field(default_factory=dict)
def copy(self, **kwargs: Any) -> "MiddlewareContext[T]":
"""Create a copy with updated fields."""
return replace(self, **kwargs)
class Middleware:
"""Base class for MCP middleware with typed handlers for each method.
Middleware can intercept and modify requests and responses at various
stages of processing. Each handler receives the context and a call_next
function to invoke the next handler in the chain.
"""
async def __call__(
self,
context: MiddlewareContext[T],
call_next: CallNext[T, Any],
) -> Any:
"""Main entry point that orchestrates the middleware chain."""
# Build handler chain based on message type
handler = await self._build_handler_chain(context, call_next)
return await handler(context)
async def _build_handler_chain(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> CallNext[Any, Any]:
"""Build the handler chain for the specific message type."""
handler = call_next
# Method-specific handlers
if context.method:
match context.method:
case "tools/call":
handler = partial(self.on_call_tool, call_next=handler)
case "tools/list":
handler = partial(self.on_list_tools, call_next=handler)
case "resources/read":
handler = partial(self.on_read_resource, call_next=handler)
case "resources/list":
handler = partial(self.on_list_resources, call_next=handler)
case "resources/templates/list":
handler = partial(self.on_list_resource_templates, call_next=handler)
case "prompts/get":
handler = partial(self.on_get_prompt, call_next=handler)
case "prompts/list":
handler = partial(self.on_list_prompts, call_next=handler)
# Type-specific handlers
match context.type:
case "request":
handler = partial(self.on_request, call_next=handler)
case "notification":
handler = partial(self.on_notification, call_next=handler)
# Generic message handler (always runs)
handler = partial(self.on_message, call_next=handler)
return handler
# Generic handlers
async def on_message(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Handle any message. Override to add generic processing."""
return await call_next(context)
async def on_request(
self,
context: MiddlewareContext[JSONRPCMessage],
call_next: CallNext[JSONRPCMessage, Any],
) -> Any:
"""Handle request messages. Override to add request processing."""
return await call_next(context)
async def on_notification(
self,
context: MiddlewareContext[JSONRPCMessage],
call_next: CallNext[JSONRPCMessage, Any],
) -> Any:
"""Handle notification messages. Override to add notification processing."""
return await call_next(context)
# Tool handlers
async def on_call_tool(
self,
context: MiddlewareContext[CallToolParams],
call_next: CallNext[CallToolParams, CallToolResult],
) -> CallToolResult:
"""Handle tool calls. Override to add tool-specific processing."""
return await call_next(context)
async def on_list_tools(
self,
context: MiddlewareContext[ListToolsRequest],
call_next: CallNext[ListToolsRequest, list[MCPTool]],
) -> list[MCPTool]:
"""Handle tool listing. Override to filter or modify tool list."""
return await call_next(context)
# Resource handlers
async def on_read_resource(
self,
context: MiddlewareContext[ReadResourceParams],
call_next: CallNext[ReadResourceParams, ReadResourceResult],
) -> ReadResourceResult:
"""Handle resource reading. Override to add resource processing."""
return await call_next(context)
async def on_list_resources(
self,
context: MiddlewareContext[ListResourcesRequest],
call_next: CallNext[ListResourcesRequest, list[Resource]],
) -> list[Resource]:
"""Handle resource listing. Override to filter or modify resource list."""
return await call_next(context)
async def on_list_resource_templates(
self,
context: MiddlewareContext[ListResourceTemplatesRequest],
call_next: CallNext[ListResourceTemplatesRequest, list[ResourceTemplate]],
) -> list[ResourceTemplate]:
"""Handle resource template listing. Override to filter or modify template list."""
return await call_next(context)
# Prompt handlers
async def on_get_prompt(
self,
context: MiddlewareContext[GetPromptParams],
call_next: CallNext[GetPromptParams, GetPromptResult],
) -> GetPromptResult:
"""Handle prompt retrieval. Override to add prompt processing."""
return await call_next(context)
async def on_list_prompts(
self,
context: MiddlewareContext[ListPromptsRequest],
call_next: CallNext[ListPromptsRequest, list[Prompt]],
) -> list[Prompt]:
"""Handle prompt listing. Override to filter or modify prompt list."""
return await call_next(context)
def compose_middleware(
*middleware: Middleware,
) -> Callable[[MiddlewareContext[T], CallNext[T, R]], Awaitable[R]]:
"""Compose multiple middleware into a single handler.
The middleware are applied in reverse order, so the first middleware
in the list is the outermost (runs first on request, last on response).
"""
async def composed(
context: MiddlewareContext[T],
call_next: CallNext[T, R],
) -> R:
# Build the chain in reverse order into a CallNext[T, R]
current: CallNext[T, R] = call_next
for mw in reversed(middleware):
async def wrapper(
ctx: MiddlewareContext[T],
next_handler: CallNext[T, R] = current,
m: Middleware = mw,
) -> R:
result = await m(ctx, next_handler)
return cast(R, result)
# wrapper conforms to CallNext[T, R]
current = wrapper # type: ignore[assignment]
return await current(context)
return composed

View file

@ -0,0 +1,107 @@
"""Error handling middleware for MCP server."""
import logging
from typing import Any
from arcade_mcp_server.convert import convert_content_to_structured_content, convert_to_mcp_content
from arcade_mcp_server.middleware.base import CallNext, Middleware, MiddlewareContext
from arcade_mcp_server.types import CallToolResult, JSONRPCError
logger = logging.getLogger("arcade.mcp")
class ErrorHandlingMiddleware(Middleware):
"""Middleware that handles errors and converts them to appropriate responses."""
def __init__(self, mask_error_details: bool = True):
"""Initialize error handling middleware.
Args:
mask_error_details: Whether to mask error details in responses
"""
self.mask_error_details = mask_error_details
async def on_message(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Wrap all messages with error handling."""
try:
return await call_next(context)
except Exception as e:
return self._handle_error(context, e)
async def on_call_tool(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Handle tool call errors specially."""
try:
return await call_next(context)
except Exception as e:
# For tool calls, return error as CallToolResult
error_message = self._get_error_message(e)
logger.exception(f"Error calling tool: {error_message}")
content = convert_to_mcp_content(error_message)
structured_content = convert_content_to_structured_content({"error": error_message})
return CallToolResult(
content=content,
structuredContent=structured_content,
isError=True,
)
def _handle_error(self, context: MiddlewareContext[Any], error: Exception) -> Any:
"""Convert exception to appropriate error response."""
error_message = self._get_error_message(error)
# Log the full error
logger.exception(f"Error processing {context.method}: {error}")
# Get request ID if available
request_id = context.request_id
if not request_id and hasattr(context.message, "id"):
request_id = str(getattr(context.message, "id", "unknown"))
# Return JSON-RPC error
return JSONRPCError(
id=request_id or "unknown",
error={
"code": self._get_error_code(error),
"message": error_message,
},
)
def _get_error_message(self, error: Exception) -> str:
"""Get appropriate error message based on configuration."""
if self.mask_error_details:
# Return generic message for security
error_type = type(error).__name__
if error_type in ["ValueError", "TypeError", "KeyError"]:
return "Invalid request parameters"
elif error_type in ["NotFoundError", "FileNotFoundError"]:
return "Resource not found"
elif error_type in ["PermissionError", "AuthorizationError"]:
return "Permission denied"
else:
return "Internal server error"
else:
# Return actual error message for debugging
return str(error)
def _get_error_code(self, error: Exception) -> int:
"""Get JSON-RPC error code for exception."""
error_type = type(error).__name__
# Map common errors to JSON-RPC codes
if error_type in ["ValueError", "TypeError", "KeyError"]:
return -32602 # Invalid params
elif error_type in ["NotFoundError", "FileNotFoundError"]:
return -32601 # Method not found
elif error_type in ["PermissionError", "AuthorizationError"]:
return -32603 # Internal error (used for auth)
else:
return -32603 # Generic internal error

View file

@ -0,0 +1,121 @@
"""Logging middleware for MCP server."""
import logging
import time
from typing import Any
from arcade_mcp_server.middleware.base import CallNext, Middleware, MiddlewareContext
logger = logging.getLogger("arcade.mcp")
class LoggingMiddleware(Middleware):
"""Middleware that logs all MCP messages and timing information."""
def __init__(self, log_level: str = "INFO"):
"""Initialize logging middleware.
Args:
log_level: The log level to use for message logging
"""
self.log_level = getattr(logging, log_level.upper(), logging.INFO)
async def on_message(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Log all messages with timing information."""
start_time = time.time()
# Log the incoming message
self._log_request(context)
try:
# Process the message
result = await call_next(context)
except Exception as e:
# Log error
elapsed = time.time() - start_time
self._log_error(context, e, elapsed)
raise
else:
# Log success
elapsed = time.time() - start_time
self._log_response(context, result, elapsed)
return result
def _log_request(self, context: MiddlewareContext[Any]) -> None:
"""Log incoming request."""
if not logger.isEnabledFor(self.log_level):
return
method = context.method or "unknown"
msg_type = context.type
# Build log message
parts = [f"[{msg_type.upper()}]", f"method={method}"]
if context.request_id:
parts.append(f"request_id={context.request_id}")
if context.session_id:
parts.append(f"session_id={context.session_id}")
# Log message details based on method
if hasattr(context.message, "params"):
params = getattr(context.message, "params", None)
if params:
if hasattr(params, "name"):
parts.append(f"name={params.name}")
elif hasattr(params, "uri"):
parts.append(f"uri={params.uri}")
logger.log(self.log_level, " ".join(parts))
def _log_response(
self,
context: MiddlewareContext[Any],
result: Any,
elapsed: float,
) -> None:
"""Log response with timing."""
if not logger.isEnabledFor(self.log_level):
return
method = context.method or "unknown"
elapsed_ms = int(elapsed * 1000)
# Build log message
parts = ["[RESPONSE]", f"method={method}", f"elapsed={elapsed_ms}ms"]
if context.request_id:
parts.append(f"request_id={context.request_id}")
# Add result info based on type
if isinstance(result, list):
parts.append(f"count={len(result)}")
elif hasattr(result, "content"):
content = getattr(result, "content", [])
if isinstance(content, list):
parts.append(f"content_blocks={len(content)}")
logger.log(self.log_level, " ".join(parts))
def _log_error(
self,
context: MiddlewareContext[Any],
error: Exception,
elapsed: float,
) -> None:
"""Log error with timing."""
method = context.method or "unknown"
elapsed_ms = int(elapsed * 1000)
parts = ["[ERROR]", f"method={method}", f"elapsed={elapsed_ms}ms"]
if context.request_id:
parts.append(f"request_id={context.request_id}")
parts.append(f"error={type(error).__name__}: {error!s}")
logger.error(" ".join(parts))

View file

@ -0,0 +1,898 @@
"""
MCP Server Implementation
Provides request handling, middleware orchestration, and manager-backed
operations for tools, resources, prompts, sampling, logging, and roots.
Key notes:
- For every incoming request, a new MCP ModelContext is created and set as
current via a ContextVar for the request lifetime
- Tool invocations receive a ToolContext (wrapped by TDK as needed) and are
executed via ToolExecutor
- Managers (tool, resource, prompt) back the namespaced operations
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Callable, cast
from arcade_core.catalog import MaterializedTool, ToolCatalog
from arcade_core.executor import ToolExecutor
from arcade_core.schema import ToolAuthRequirement as CoreToolAuthRequirement
from arcade_core.schema import ToolContext
from arcadepy import ArcadeError, AsyncArcade
from arcadepy.types.auth_authorize_params import AuthRequirement, AuthRequirementOauth2
from arcade_mcp_server.context import Context, get_current_model_context, set_current_model_context
from arcade_mcp_server.convert import convert_content_to_structured_content, convert_to_mcp_content
from arcade_mcp_server.exceptions import NotFoundError, ToolRuntimeError
from arcade_mcp_server.lifespan import LifespanManager
from arcade_mcp_server.managers import PromptManager, ResourceManager, ToolManager
from arcade_mcp_server.middleware import (
CallNext,
ErrorHandlingMiddleware,
LoggingMiddleware,
Middleware,
MiddlewareContext,
)
from arcade_mcp_server.session import InitializationState, NotificationManager, ServerSession
from arcade_mcp_server.settings import MCPSettings
from arcade_mcp_server.types import (
LATEST_PROTOCOL_VERSION,
BlobResourceContents,
CallToolRequest,
CallToolResult,
CompleteRequest,
CreateMessageRequest,
ElicitRequest,
GetPromptRequest,
GetPromptResult,
Implementation,
InitializeRequest,
InitializeResult,
JSONRPCError,
JSONRPCResponse,
ListPromptsRequest,
ListPromptsResult,
ListResourcesRequest,
ListResourcesResult,
ListResourceTemplatesRequest,
ListResourceTemplatesResult,
ListRootsRequest,
ListToolsRequest,
ListToolsResult,
MCPMessage,
PingRequest,
ReadResourceRequest,
ReadResourceResult,
ServerCapabilities,
SetLevelRequest,
SubscribeRequest,
TextResourceContents,
UnsubscribeRequest,
)
logger = logging.getLogger("arcade.mcp")
class MCPServer:
"""
MCP Server with middleware and context support.
This server provides:
- Middleware chain for extensible request processing
- Context injection for tools
- Component managers for tools, resources, and prompts
- Bidirectional communication support to MCP clients
"""
# Public manager properties near top
@property
def tools(self) -> ToolManager:
"""Access the ToolManager for runtime tool operations."""
return self._tool_manager
@property
def resources(self) -> ResourceManager:
"""Access the ResourceManager for runtime resource operations."""
return self._resource_manager
@property
def prompts(self) -> PromptManager:
"""Access the PromptManager for runtime prompt operations."""
return self._prompt_manager
def __init__(
self,
catalog: ToolCatalog,
*,
name: str = "ArcadeMCP",
version: str = "0.1.0",
title: str | None = None,
instructions: str | None = None,
settings: MCPSettings | None = None,
middleware: list[Middleware] | None = None,
lifespan: Callable[[Any], Any] | None = None,
auth_disabled: bool = False,
arcade_api_key: str | None = None,
arcade_api_url: str | None = None,
):
"""
Initialize MCP server.
Args:
catalog: Tool catalog
name: Server name
version: Server version
title: Server title for display
instructions: Server instructions
settings: MCP settings (uses env if not provided)
middleware: List of middleware to apply
lifespan: Lifespan manager function
auth_disabled: Disable authentication
arcade_api_key: Arcade API key (overrides settings)
arcade_api_url: Arcade API URL (overrides settings)
"""
self.name = name or self.__class__.__name__
self._started = False
self._lock = asyncio.Lock()
# Server identity
self.version = version
self.title = title or name
self.instructions = instructions or self._default_instructions()
# Settings
self.settings = settings or MCPSettings.from_env()
self.auth_disabled = auth_disabled or self.settings.arcade.auth_disabled
# Initialize Arcade client
# Fallback to API key in ~/.arcade/credentials.yaml if not provided
self._init_arcade_client(
arcade_api_key or self.settings.arcade.api_key,
arcade_api_url or self.settings.arcade.api_url,
)
# Component managers (passive)
self._tool_manager = ToolManager()
self._resource_manager = ResourceManager()
self._prompt_manager = PromptManager()
# Centralized notifications
self.notification_manager = NotificationManager(self)
# Subscribe to changes -> broadcast
self._tool_manager.subscribe(
lambda *_: asyncio.get_event_loop().create_task( # type: ignore[arg-type]
self.notification_manager.notify_tool_list_changed()
)
)
self._resource_manager.subscribe(
lambda *_: asyncio.get_event_loop().create_task( # type: ignore[arg-type]
self.notification_manager.notify_resource_list_changed()
)
)
self._prompt_manager.subscribe(
lambda *_: asyncio.get_event_loop().create_task( # type: ignore[arg-type]
self.notification_manager.notify_prompt_list_changed()
)
)
# Defer loading tools from catalog to server start to ensure readiness
self._initial_catalog = catalog
# Middleware chain
self.middleware: list[Middleware] = []
self._init_middleware(middleware)
# Lifespan management
self.lifespan_manager = LifespanManager(self, lifespan)
# Session management
self._sessions: dict[str, ServerSession] = {}
self._sessions_lock = asyncio.Lock()
# Handler registration
self._handlers = self._register_handlers()
def _init_arcade_client(self, api_key: str | None, api_url: str | None) -> None:
"""Initialize Arcade client for runtime authorization."""
self.arcade: AsyncArcade | None = None
if not api_url:
api_url = os.environ.get("ARCADE_API_URL", "https://api.arcade.dev")
final_api_key = api_key
# If no API key provided, try to load from credentials file
if not final_api_key:
try:
from arcade_core.config import get_config
config = get_config()
final_api_key = config.api.key
if final_api_key:
logger.info("Loaded Arcade API key from ~/.arcade/credentials.yaml")
except Exception as e:
logger.debug(f"Could not load credentials from file: {e}")
if final_api_key:
logger.info(f"Using Arcade client with API URL: {api_url}")
self.arcade = AsyncArcade(api_key=final_api_key, base_url=api_url)
else:
logger.warning(
"Arcade API key not configured. Tools requiring auth will return a login instruction."
)
def _init_middleware(self, custom_middleware: list[Middleware] | None) -> None:
"""Initialize middleware chain."""
# Always add error handling first (innermost)
self.middleware.append(
ErrorHandlingMiddleware(mask_error_details=self.settings.middleware.mask_error_details)
)
# Add logging if enabled
if self.settings.middleware.enable_logging:
self.middleware.append(LoggingMiddleware(log_level=self.settings.middleware.log_level))
# Add custom middleware
if custom_middleware:
self.middleware.extend(custom_middleware)
def _register_handlers(self) -> dict[str, Callable]:
"""Register method handlers."""
return {
"ping": self._handle_ping,
"initialize": self._handle_initialize,
"tools/list": self._handle_list_tools,
"tools/call": self._handle_call_tool,
"resources/list": self._handle_list_resources,
"resources/templates/list": self._handle_list_resource_templates,
"resources/read": self._handle_read_resource,
"prompts/list": self._handle_list_prompts,
"prompts/get": self._handle_get_prompt,
"logging/setLevel": self._handle_set_log_level,
}
def _default_instructions(self) -> str:
"""Get default server instructions."""
return (
"The Arcade MCP Server provides access to tools defined in Arcade toolkits. "
"Use 'tools/list' to see available tools and 'tools/call' to execute them."
)
async def _start(self) -> None:
"""Start server components (called by MCPComponent.start)."""
await self._tool_manager.start()
# Load initial catalog now that manager is started
try:
await self._tool_manager.load_from_catalog(self._initial_catalog)
except Exception:
logger.exception("Failed to load tools from initial catalog")
await self._resource_manager.start()
await self._prompt_manager.start()
await self.lifespan_manager.startup()
async def _stop(self) -> None:
"""Stop server components (called by MCPComponent.stop)."""
# Stop all sessions
async with self._sessions_lock:
sessions = list(self._sessions.values())
for _session in sessions:
# Sessions should handle their own cleanup
pass
await self._prompt_manager.stop()
await self._resource_manager.stop()
await self._tool_manager.stop()
# Stop lifespan
await self.lifespan_manager.shutdown()
async def start(self) -> None:
async with self._lock:
if self._started:
logger.debug(f"{self.name} already started")
return
logger.info(f"Starting {self.name}")
try:
await self._start()
self._started = True
logger.info(f"{self.name} started successfully")
except Exception:
logger.exception(f"Failed to start {self.name}")
raise
async def stop(self) -> None:
async with self._lock:
if not self._started:
logger.debug(f"{self.name} not started")
return
logger.info(f"Stopping {self.name}")
try:
await self._stop()
self._started = False
logger.info(f"{self.name} stopped successfully")
except Exception:
logger.exception(f"Failed to stop {self.name}")
# best-effort on stop
async def run_connection(
self,
read_stream: Any,
write_stream: Any,
init_options: Any = None,
) -> None:
"""
Run a single MCP connection.
Args:
read_stream: Stream for reading messages
write_stream: Stream for writing messages
init_options: Connection initialization options
"""
# Create session
session = ServerSession(
server=self,
read_stream=read_stream,
write_stream=write_stream,
init_options=init_options,
)
# Register session
async with self._sessions_lock:
self._sessions[session.session_id] = session
try:
logger.info(f"Starting session {session.session_id}")
await session.run()
except Exception:
logger.exception("Session error")
raise
finally:
# Unregister session
async with self._sessions_lock:
self._sessions.pop(session.session_id, None)
logger.info(f"Session {session.session_id} ended")
async def handle_message(
self,
message: Any,
session: ServerSession | None = None,
) -> MCPMessage | None:
"""
Handle an incoming message.
Args:
message: Message to handle
session: Server session
Returns:
Response message or None
"""
# Validate message
if (
not isinstance(message, dict)
or not message.get("method")
or not isinstance(message["method"], str)
):
return JSONRPCError(
id="null",
error={"code": -32600, "message": "Invalid request"},
)
method = message["method"]
msg_id = message.get("id")
# Handle notifications (no response needed)
if method and method.startswith("notifications/"):
if method == "notifications/initialized" and session:
session.mark_initialized()
return None
# Check if this is a response to a server-initiated request
if "id" in message and "method" not in message:
# This is handled in the session's message processing
return None
# Check initialization state
if (
session
and session.initialization_state != InitializationState.INITIALIZED
and method not in ["initialize", "ping"]
):
return JSONRPCError(
id=str(msg_id or "null"),
error={
"code": -32600,
"message": "Request not allowed before initialization",
},
)
# Find handler
handler = self._handlers.get(method)
if not handler:
return JSONRPCError(
id=str(msg_id or "null"),
error={"code": -32601, "message": f"Method not found: {method}"},
)
# Create context and apply middleware
try:
# Create request context
context = (
await session.create_request_context()
if session
else Context(self, request_id=str(msg_id) if msg_id else None)
)
# Set as current model context
token = set_current_model_context(context)
try:
# Create middleware context
middleware_context = MiddlewareContext(
message=message,
mcp_context=context,
source="client",
type="request",
method=method,
request_id=str(msg_id) if msg_id else None,
session_id=session.session_id if session else None,
)
# Parse message based on method
parsed_message = self._parse_message(message, method or "")
# Apply middleware chain
async def final_handler(_: MiddlewareContext[Any]) -> Any:
return await handler(parsed_message, session=session)
result = await self._apply_middleware(middleware_context, final_handler)
from typing import cast
return cast(MCPMessage | None, result)
finally:
# Clean up context
set_current_model_context(None, token)
if session:
await session.cleanup_request_context(context)
except Exception:
logger.exception("Error handling message")
return JSONRPCError(
id=str(msg_id or "null"),
error={"code": -32603, "message": "Internal error"},
)
def _parse_message(self, message: dict[str, Any], method: str) -> Any:
"""Parse raw message dict into typed message based on method."""
message_types = {
"ping": PingRequest,
"initialize": InitializeRequest,
"tools/list": ListToolsRequest,
"tools/call": CallToolRequest,
"resources/list": ListResourcesRequest,
"resources/read": ReadResourceRequest,
"resources/subscribe": SubscribeRequest,
"resources/unsubscribe": UnsubscribeRequest,
"resources/templates/list": ListResourceTemplatesRequest,
"prompts/list": ListPromptsRequest,
"prompts/get": GetPromptRequest,
"logging/setLevel": SetLevelRequest,
"sampling/createMessage": CreateMessageRequest,
"completion/complete": CompleteRequest,
"roots/list": ListRootsRequest,
"elicitation/create": ElicitRequest,
}
message_type = message_types.get(method)
if message_type is not None:
# Use constructor for compatibility across Pydantic versions
return message_type(**message)
return message
async def _apply_middleware(
self,
context: MiddlewareContext[Any],
final_handler: Callable[[MiddlewareContext[Any]], Any] | CallNext[Any, Any],
) -> Any:
"""Apply middleware chain to a request."""
# Build chain from outside in
async def chain_fn(ctx: MiddlewareContext[Any]) -> Any:
return await final_handler(ctx)
chain: CallNext[Any, Any] = cast(CallNext[Any, Any], chain_fn)
for middleware in reversed(self.middleware):
async def make_handler(
ctx: MiddlewareContext[Any],
next_handler: CallNext[Any, Any] = chain,
mw: Middleware = middleware,
) -> Any:
return await mw(ctx, next_handler)
chain = make_handler # type: ignore[assignment]
# Execute chain
return await chain(context)
# Handler methods
async def _handle_ping(
self,
message: PingRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[Any]:
"""Handle ping request."""
return JSONRPCResponse(id=message.id, result={})
async def _handle_initialize(
self,
message: InitializeRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[InitializeResult]:
"""Handle initialize request."""
if session:
session.set_client_params(message.params)
result = InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(
tools={"listChanged": True},
logging={},
prompts={"listChanged": True},
resources={"subscribe": True, "listChanged": True},
),
serverInfo=Implementation(
name=self.name,
version=self.version,
title=self.title,
),
instructions=self.instructions,
)
return JSONRPCResponse(id=message.id, result=result)
async def _handle_list_tools(
self,
message: ListToolsRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[ListToolsResult] | JSONRPCError:
"""Handle list tools request."""
try:
tools = await self._tool_manager.list_tools()
return JSONRPCResponse(id=message.id, result=ListToolsResult(tools=tools))
except Exception:
logger.exception("Error listing tools")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error listing tools"},
)
async def _create_tool_context(
self, tool: MaterializedTool, session: ServerSession | None = None
) -> ToolContext:
"""Create a tool context from a tool definition and session"""
tool_context = ToolContext()
# secrets
if tool.definition.requirements and tool.definition.requirements.secrets:
for secret in tool.definition.requirements.secrets:
if secret.key in self.settings.tool_secrets():
tool_context.set_secret(secret.key, self.settings.tool_secrets()[secret.key])
elif secret.key in os.environ:
tool_context.set_secret(secret.key, os.environ[secret.key])
# user_id selection
env = (self.settings.arcade.environment or "").lower()
user_id = self.settings.arcade.user_id
# If no user_id from env, try config file (like we do for API key)
if not user_id:
try:
from arcade_core.config import get_config
config = get_config()
if config.user and config.user.email:
user_id = config.user.email
logger.debug(f"Context user_id set from config file: {user_id}")
except Exception:
logger.debug("Could not load user_id from config file")
if user_id:
tool_context.user_id = user_id
logger.debug(f"Context user_id set: {user_id}")
elif env in ("development", "dev", "local"):
tool_context.user_id = session.session_id if session else None
logger.debug(f"Context user_id set from session (dev env={env})")
else:
tool_context.user_id = session.session_id if session else None
logger.debug("Context user_id set from session (non-dev env)")
return tool_context
async def _handle_call_tool(
self,
message: CallToolRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[CallToolResult] | JSONRPCError:
"""Handle tool call request."""
tool_name = message.params.name
input_params = message.params.arguments or {}
try:
# Get tool
tool = await self._tool_manager.get_tool(tool_name)
# Create tool context
tool_context = await self._create_tool_context(tool, session)
# Attach tool_context to current model context for this request
mctx = get_current_model_context()
if mctx is not None:
mctx.set_tool_context(tool_context)
# Handle authorization if required
if tool.definition.requirements and tool.definition.requirements.authorization:
auth_result = await self._check_authorization(tool, tool_context.user_id)
if auth_result.status != "completed":
tool_response = {
"message": "The tool was not executed because it requires authorization. This is not an error, but the end user must click the link to complete the OAuth2 flow before the tool can be executed.",
"llm_instructions": f"Please show the following link to the end user formatted as markdown: {auth_result.url} \nInform the end user that the tool requires their authorization to be completed before the tool can be executed.",
"authorization_url": auth_result.url,
}
content = convert_to_mcp_content(tool_response)
structured_content = convert_content_to_structured_content(tool_response)
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
isError=False,
),
)
# Execute tool
result = await ToolExecutor.run(
func=tool.tool,
definition=tool.definition,
input_model=tool.input_model,
output_model=tool.output_model,
context=tool_context,
**input_params,
)
# Convert result
if result.value is not None:
content = convert_to_mcp_content(result.value)
# structuredContent should be the raw result value as a JSON object
structured_content = convert_content_to_structured_content(result.value)
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
isError=False,
),
)
else:
error = result.error or "Error calling tool"
content = convert_to_mcp_content(str(error))
# structuredContent should be the error as a JSON object
structured_content = convert_content_to_structured_content({"error": str(error)})
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
isError=True,
),
)
except NotFoundError:
# Match test expectation: return a normal response with isError=True
error_message = f"Unknown tool: {tool_name}"
content = convert_to_mcp_content(error_message)
# structuredContent should be the error as a JSON object
structured_content = convert_content_to_structured_content({"error": error_message})
return JSONRPCResponse(
id=message.id,
result=CallToolResult(
content=content,
structuredContent=structured_content,
isError=True,
),
)
except Exception:
logger.exception("Error calling tool")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error calling tool"},
)
async def _check_authorization(
self,
tool: MaterializedTool,
user_id: str | None = None,
) -> Any:
"""Check tool authorization."""
if not self.arcade:
raise ToolRuntimeError(
"Authorization required but Arcade API Key is not configured. "
"Set ARCADE_API_KEY as environment variable or run 'arcade login'."
)
req = tool.definition.requirements.authorization
provider_id = str(getattr(req, "provider_id", ""))
provider_type = str(getattr(req, "provider_type", ""))
# TypedDict requires concrete type; supply empty scopes if absent when oauth2 provider
oauth2_req = (
AuthRequirementOauth2(
scopes=(req.oauth2.scopes or []) if req.oauth2 is not None else []
)
if isinstance(req, CoreToolAuthRequirement) and provider_type.lower() == "oauth2"
else AuthRequirementOauth2()
)
auth_req = AuthRequirement(
provider_id=provider_id,
provider_type=provider_type,
oauth2=oauth2_req,
)
# Log a warning if user_id is not set
final_user_id = user_id or "anonymous"
if final_user_id == "anonymous":
logger.warning(
"No user_id available for authorization, defaulting to 'anonymous'. "
"Set ARCADE_USER_ID as environment variable or run 'arcade login'."
)
try:
response = await self.arcade.auth.authorize(
auth_requirement=auth_req,
user_id=final_user_id,
)
except ArcadeError as e:
logger.exception("Error authorizing tool")
raise ToolRuntimeError(f"Authorization failed: {e}") from e
else:
return response
async def _handle_list_resources(
self,
message: ListResourcesRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[ListResourcesResult] | JSONRPCError:
"""Handle list resources request."""
try:
resources = await self._resource_manager.list_resources()
return JSONRPCResponse(id=message.id, result=ListResourcesResult(resources=resources))
except Exception:
logger.exception("Error listing resources")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error listing resources"},
)
async def _handle_list_resource_templates(
self,
message: ListResourceTemplatesRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[ListResourceTemplatesResult] | JSONRPCError:
"""Handle list resource templates request."""
try:
templates = await self._resource_manager.list_resource_templates()
return JSONRPCResponse(
id=message.id,
result=ListResourceTemplatesResult(resourceTemplates=templates),
)
except Exception:
logger.exception("Error listing resource templates")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error listing resource templates"},
)
async def _handle_read_resource(
self,
message: ReadResourceRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[ReadResourceResult] | JSONRPCError:
"""Handle read resource request."""
try:
contents = await self._resource_manager.read_resource(message.params.uri)
# Narrow to allowed types for ReadResourceResult
allowed_contents = [
c for c in contents if isinstance(c, (TextResourceContents, BlobResourceContents))
]
return JSONRPCResponse(
id=message.id,
result=ReadResourceResult(contents=allowed_contents),
)
except NotFoundError:
return JSONRPCError(
id=message.id,
error={"code": -32002, "message": f"Resource not found: {message.params.uri}"},
)
except Exception:
logger.exception(f"Error reading resource: {message.params.uri}")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error reading resource"},
)
async def _handle_list_prompts(
self,
message: ListPromptsRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[ListPromptsResult] | JSONRPCError:
"""Handle list prompts request."""
try:
prompts = await self._prompt_manager.list_prompts()
return JSONRPCResponse(id=message.id, result=ListPromptsResult(prompts=prompts))
except Exception:
logger.exception("Error listing prompts")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error listing prompts"},
)
async def _handle_get_prompt(
self,
message: GetPromptRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[GetPromptResult] | JSONRPCError:
"""Handle get prompt request."""
try:
result = await self._prompt_manager.get_prompt(
message.params.name,
message.params.arguments if hasattr(message.params, "arguments") else None,
)
return JSONRPCResponse(id=message.id, result=result)
except NotFoundError:
return JSONRPCError(
id=message.id,
error={"code": -32002, "message": f"Prompt not found: {message.params.name}"},
)
except Exception:
logger.exception(f"Error getting prompt: {message.params.name}")
return JSONRPCError(
id=message.id,
error={"code": -32603, "message": "Internal error getting prompt"},
)
async def _handle_set_log_level(
self,
message: SetLevelRequest,
session: ServerSession | None = None,
) -> JSONRPCResponse[Any] | JSONRPCError:
"""Handle set log level request."""
try:
level_name = str(
message.params.level.value
if hasattr(message.params.level, "value")
else message.params.level
)
logger.setLevel(getattr(logging, level_name.upper(), logging.INFO))
except Exception:
logger.setLevel(logging.INFO)
return JSONRPCResponse(id=message.id, result={})
# Resource support for Context
async def _mcp_read_resource(self, uri: str) -> list[Any]:
"""Read a resource (for Context.read_resource)."""
return await self._resource_manager.read_resource(uri)

View file

@ -0,0 +1,637 @@
"""
MCP Server Session
Manages per-session state and provides session-level operations.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from enum import Enum
from typing import Any
from arcade_mcp_server.context import Context
from arcade_mcp_server.exceptions import RequestError, SessionError
from arcade_mcp_server.types import (
CancelledNotification,
CancelledParams,
ClientCapabilities,
CompleteResult,
CreateMessageResult,
ElicitResult,
InitializeParams,
JSONRPCError,
JSONRPCMessage,
JSONRPCRequest,
ListRootsResult,
LoggingLevel,
LoggingMessageNotification,
LoggingMessageParams,
ProgressNotification,
ProgressNotificationParams,
PromptListChangedNotification,
ResourceListChangedNotification,
ToolListChangedNotification,
)
logger = logging.getLogger(__name__)
class InitializationState(Enum):
"""Session initialization states."""
NOT_INITIALIZED = 1
INITIALIZING = 2
INITIALIZED = 3
class RequestManager:
"""
Manages server-initiated requests to the client.
Handles request/response correlation for bidirectional communication.
"""
def __init__(self, write_stream: Any):
"""Initialize request manager."""
self._write_stream = write_stream
self._pending_requests: dict[str, asyncio.Future[Any]] = {}
self._lock = asyncio.Lock()
self._closed = asyncio.Event()
def is_closed(self) -> bool:
"""Return True if the manager has been closed/cancelled."""
return self._closed.is_set()
async def send_request(
self,
method: str,
params: dict[str, Any] | None = None,
timeout: float = 400.0,
) -> Any:
"""
Send a request to the client and wait for response.
Args:
method: Request method
params: Request parameters
timeout: Request timeout in seconds
Returns:
Response result
Raises:
MCPTimeoutError: If request times out
ProtocolError: If response is an error
"""
if self._closed.is_set():
raise SessionError("Session closed")
request_id = str(uuid.uuid4())
# Create request
request = JSONRPCRequest(
id=request_id,
method=method,
params=params or {},
)
# Create future for response
future: asyncio.Future[Any] = asyncio.Future()
async with self._lock:
if self._closed.is_set():
raise SessionError("Session closed")
self._pending_requests[request_id] = future
try:
# Send request
message = request.model_dump_json(exclude_none=True) + "\n"
logger.debug(f"Sending server->client request method={method} id={request_id}")
await self._write_stream.send(message)
# Wait for response
result = await asyncio.wait_for(future, timeout=timeout)
logger.debug(f"Received response for id={request_id} method={method}")
return result
finally:
# Clean up
async with self._lock:
self._pending_requests.pop(request_id, None)
async def handle_response(self, message: dict[str, Any]) -> None:
"""
Handle a response message from the client.
Args:
message: Response message
"""
if self._closed.is_set():
# Drop any late responses after closure
return
request_id = message.get("id")
if not request_id:
logger.debug("Received response without id; ignoring")
return
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")
future.set_exception(RequestError(f"Request failed: {message['error']}"))
else:
logger.debug(f"Correlated response id={request_id} -> completing future")
future.set_result(message.get("result"))
else:
logger.debug(
f"No pending future for response id={request_id}; possibly late or mismatched"
)
async def cancel_all(self, reason: str | None = None) -> None:
"""Cancel all pending requests and notify the client.
Sends a CancelledNotification for each in-flight request and
completes their futures with SessionError so awaiters unblock.
"""
# Mark closed first to prevent new requests
if not self._closed.is_set():
self._closed.set()
# Snapshot current pending ids and futures
async with self._lock:
pending_items = list(self._pending_requests.items())
# Clear the map eagerly to prevent races with late responses
self._pending_requests.clear()
if not pending_items:
return
# Best-effort notify client of cancellations
notifications = []
for request_id, _future in pending_items:
notification = CancelledNotification(
params=CancelledParams(requestId=request_id, reason=reason)
)
notifications.append(notification)
try:
for note in notifications:
message = note.model_dump_json(exclude_none=True) + "\n"
await self._write_stream.send(message)
except Exception:
# Swallow transport errors during shutdown; proceed to cancel futures
logging.debug(
"Failed to send cancellation notifications during shutdown", exc_info=True
)
# Cancel futures so any waiters are released
for _request_id, future in pending_items:
if not future.done():
future.set_exception(SessionError("Session closed"))
class NotificationManager:
"""Broadcasts server-initiated listChanged notifications to sessions."""
def __init__(self, server: Any):
self._server = server
async def _broadcast(
self, notification: JSONRPCMessage, session_ids: list[str] | None = None
) -> None:
# Do not broadcast before server is started
if not getattr(self._server, "_started", False):
return
async with self._server._sessions_lock:
if session_ids is None:
sessions = list(self._server._sessions.values())
else:
sessions = [
self._server._sessions.get(sid)
for sid in session_ids
if sid in self._server._sessions
]
for s in sessions:
if s is None:
continue
try:
await s.send_notification(notification)
except Exception:
logger.debug("Failed to notify a session", exc_info=True)
async def notify_tool_list_changed(self, session_ids: list[str] | None = None) -> None:
await self._broadcast(ToolListChangedNotification(), session_ids)
async def notify_resource_list_changed(self, session_ids: list[str] | None = None) -> None:
await self._broadcast(ResourceListChangedNotification(), session_ids)
async def notify_prompt_list_changed(self, session_ids: list[str] | None = None) -> None:
await self._broadcast(PromptListChangedNotification(), session_ids)
class ServerSession:
"""
MCP server session handling a single client connection.
Manages:
- Session state and lifecycle
- Client capabilities
- Request/response handling
- Notification sending
"""
def __init__(
self,
server: Any,
session_id: str | None = None,
read_stream: Any | None = None,
write_stream: Any | None = None,
init_options: Any | None = None,
stateless: bool = False,
):
"""
Initialize server session.
Args:
server: Parent server instance
session_id: Session identifier (generated if not provided)
read_stream: Stream for reading messages
write_stream: Stream for writing messages
init_options: Initialization options
stateless: Whether session is stateless
"""
self.server = server
self.session_id = session_id or str(uuid.uuid4())
self.read_stream = read_stream
self.write_stream = write_stream
self.init_options = init_options or {}
self.stateless = stateless
# Session state
self.initialization_state = InitializationState.NOT_INITIALIZED
self.client_params: InitializeParams | None = None
self._session_data: dict[str, Any] = {}
# Request management
self._request_manager = RequestManager(write_stream) if write_stream else None
# Context for current request
self._current_context: Context | None = None
def set_client_params(self, params: InitializeParams) -> None:
"""Set client initialization parameters."""
self.client_params = params
self.initialization_state = InitializationState.INITIALIZING
def mark_initialized(self) -> None:
"""Mark session as initialized."""
self.initialization_state = InitializationState.INITIALIZED
def check_client_capability(self, capability: ClientCapabilities) -> bool:
"""
Check if client has a specific capability.
Args:
capability: Capability to check
Returns:
True if client has capability
"""
if not self.client_params or not self.client_params.capabilities:
return False
client_caps = self.client_params.capabilities
# Check specific capabilities
# Use hasattr to check for attributes that might be in extra fields
if (
hasattr(capability, "tools")
and capability.tools
and not (hasattr(client_caps, "tools") and client_caps.tools)
):
return False
if (
hasattr(capability, "resources")
and capability.resources
and not (hasattr(client_caps, "resources") and client_caps.resources)
):
return False
if (
hasattr(capability, "prompts")
and capability.prompts
and not (hasattr(client_caps, "prompts") and client_caps.prompts)
):
return False
return not (
hasattr(capability, "logging")
and capability.logging
and not (hasattr(client_caps, "logging") and client_caps.logging)
)
async def run(self) -> None:
"""
Run the session message loop.
Reads messages from the stream and processes them.
"""
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 def _process_message(self, message: str) -> None:
"""Process a single message."""
try:
# Parse message
data = json.loads(message)
# Check if it's a response to our request
if "id" in data and "method" not in data:
if self._request_manager:
logger.debug(
f"Session received response message id={data.get('id')} -> routing to RequestManager"
)
await self._request_manager.handle_response(data)
return
# Otherwise, process as incoming request
response = await self.server.handle_message(data, self)
# Send response if any
if response and self.write_stream:
if hasattr(response, "model_dump_json"):
response_data = response.model_dump_json(exclude_none=True)
else:
response_data = json.dumps(response)
if not response_data.endswith("\n"):
response_data += "\n"
await self.write_stream.send(response_data)
except json.JSONDecodeError:
await self._send_error_response(
None,
-32700,
"Parse error",
)
except Exception as e:
await self._send_error_response(
None,
-32603,
f"Internal error: {e!s}",
)
async def _send_error_response(
self,
request_id: Any,
code: int,
message: str,
) -> None:
"""Send an error response."""
if not self.write_stream:
return
error_response = JSONRPCError(
id=str(request_id) if request_id else "null",
error={"code": code, "message": message},
)
response_data = error_response.model_dump_json() + "\n"
await self.write_stream.send(response_data)
async def _cleanup_pending_requests(self) -> None:
"""Clean up any pending requests."""
if self._request_manager:
# Cancel all pending futures and notify client
await self._request_manager.cancel_all(reason="Session closed")
# Notification methods
async def send_notification(self, notification: JSONRPCMessage) -> None:
"""Send a notification to the client."""
if not self.write_stream:
return
message = notification.model_dump_json(exclude_none=True) + "\n"
await self.write_stream.send(message)
async def send_progress_notification(
self,
progress_token: str | int,
progress: float,
total: float | None = None,
message: str | None = None,
) -> None:
"""Send a progress notification."""
notification = ProgressNotification(
params=ProgressNotificationParams(
progressToken=progress_token,
progress=progress,
total=total,
message=message,
)
)
await self.send_notification(notification)
async def send_log_message(
self,
level: LoggingLevel,
data: Any,
logger: str | None = None,
) -> None:
"""Send a log message notification."""
notification = LoggingMessageNotification(
params=LoggingMessageParams(
level=level,
data=data,
logger=logger,
)
)
await self.send_notification(notification)
async def send_tool_list_changed(self) -> None:
"""Send tool list changed notification."""
await self.send_notification(ToolListChangedNotification())
async def send_resource_list_changed(self) -> None:
"""Send resource list changed notification."""
await self.send_notification(ResourceListChangedNotification())
async def send_prompt_list_changed(self) -> None:
"""Send prompt list changed notification."""
await self.send_notification(PromptListChangedNotification())
# Server-initiated requests
async def create_message(
self,
messages: list[dict[str, Any]],
max_tokens: int,
system_prompt: str | None = None,
include_context: str | None = None,
temperature: float | None = None,
model_preferences: dict[str, Any] | None = None,
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
timeout: float = 60.0,
) -> CreateMessageResult:
"""
Send a sampling request to the client.
Args:
messages: Messages to sample
max_tokens: Maximum tokens to generate
system_prompt: System prompt
include_context: Context to include
temperature: Sampling temperature
model_preferences: Model preferences
stop_sequences: Stop sequences
metadata: Request metadata
timeout: Request timeout
Returns:
Sampling result
"""
if not self._request_manager:
raise SessionError("Cannot send requests without request manager")
params = {
"messages": messages,
"maxTokens": max_tokens,
}
# Add optional parameters
if system_prompt is not None:
params["systemPrompt"] = system_prompt
if include_context is not None:
params["includeContext"] = include_context
if temperature is not None:
params["temperature"] = temperature
if model_preferences is not None:
params["modelPreferences"] = model_preferences
if stop_sequences is not None:
params["stopSequences"] = stop_sequences
if metadata is not None:
params["metadata"] = metadata
result = await self._request_manager.send_request(
"sampling/createMessage",
params,
timeout,
)
return CreateMessageResult(**result)
async def list_roots(self, timeout: float = 60.0) -> ListRootsResult:
"""
Request roots list from the client.
Args:
timeout: Request timeout
Returns:
Roots list result
"""
if not self._request_manager:
raise SessionError("Cannot send requests without request manager")
result = await self._request_manager.send_request(
"roots/list",
None,
timeout,
)
return ListRootsResult(**result)
async def complete(
self,
ref: dict[str, Any],
argument: dict[str, Any],
timeout: float = 60.0,
) -> CompleteResult:
"""
Request completion from the client.
Args:
ref: Completion reference
argument: Completion argument
timeout: Request timeout
Returns:
Completion result
"""
if not self._request_manager:
raise SessionError("Cannot send requests without request manager")
result = await self._request_manager.send_request(
"completion/complete",
{"ref": ref, "argument": argument},
timeout,
)
return CompleteResult(**result)
async def elicit(
self,
message: str,
requested_schema: dict[str, Any] | None = None,
timeout: float = 300.0,
) -> ElicitResult:
"""
Send an elicitation request to the client.
Args:
message: Elicitation message to display
requested_schema: JSON schema for the requested response
timeout: Request timeout
Returns:
Elicitation result
"""
if not self._request_manager:
raise SessionError("Cannot send requests without request manager")
params: dict[str, Any] = {
"message": message,
}
# Add schema if provided
if requested_schema is not None:
params["requestedSchema"] = requested_schema
result = await self._request_manager.send_request(
"elicitation/create",
params,
timeout,
)
return ElicitResult(**result)
# Context management
async def create_request_context(self) -> Context:
"""Create a context for the current request."""
context = Context(self.server)
context.set_session(self)
self._current_context = context
return context
async def cleanup_request_context(self, context: Context) -> None:
"""Clean up request context."""
# Flush any pending notifications
await context._flush_notifications()
self._current_context = None

View file

@ -0,0 +1,252 @@
"""
MCP Settings Management
Provides Pydantic-based settings with validation and environment variable support.
"""
import os
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
class NotificationSettings(BaseSettings):
"""Notification-related settings."""
rate_limit_per_minute: int = Field(
default=60,
description="Maximum notifications per minute per client",
ge=1,
le=1000,
)
default_debounce_ms: int = Field(
default=100,
description="Default debounce time in milliseconds",
ge=0,
le=10000,
)
max_queued_notifications: int = Field(
default=1000,
description="Maximum queued notifications per client",
ge=10,
le=10000,
)
model_config = {"env_prefix": "MCP_NOTIFICATION_"}
class TransportSettings(BaseSettings):
"""Transport-related settings."""
session_timeout_seconds: int = Field(
default=300,
description="Session timeout in seconds",
ge=30,
le=3600,
)
cleanup_interval_seconds: int = Field(
default=10,
description="Cleanup interval in seconds",
ge=1,
le=60,
)
max_sessions: int = Field(
default=1000,
description="Maximum concurrent sessions",
ge=1,
le=10000,
)
max_queue_size: int = Field(
default=1000,
description="Maximum queue size per session",
ge=10,
le=10000,
)
model_config = {"env_prefix": "MCP_TRANSPORT_"}
class ServerSettings(BaseSettings):
"""Server-related settings."""
name: str = Field(
default="ArcadeMCP",
description="Server name",
)
version: str = Field(
default="0.1.0dev",
description="Server version",
)
title: str | None = Field(
default="Arcade MCP",
description="Server title for display",
)
instructions: str | None = Field(
default=(
"ArcadeMCP provides access to a wide range of tools and toolkits."
"Use 'tools/list' to see available tools and 'tools/call' to execute them."
),
description="Server instructions for clients",
)
model_config = {"env_prefix": "MCP_SERVER_"}
class MiddlewareSettings(BaseSettings):
"""Middleware-related settings."""
enable_logging: bool = Field(
default=True,
description="Enable logging middleware",
)
log_level: str = Field(
default="INFO",
description="Log level",
)
enable_error_handling: bool = Field(
default=True,
description="Enable error handling middleware",
)
mask_error_details: bool = Field(
default=False,
description="Mask error details in production",
)
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate log level."""
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
v = v.upper()
if v not in valid_levels:
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
return v
model_config = {"env_prefix": "MCP_MIDDLEWARE_"}
class ArcadeSettings(BaseSettings):
"""Arcade-specific settings."""
api_key: str | None = Field(
default=None,
description="Arcade API key",
)
api_url: str = Field(
default="https://api.arcade.dev",
description="Arcade API URL",
)
auth_disabled: bool = Field(
default=False,
description="Disable authentication",
)
server_secret: str | None = Field(
default="dev",
description="Server secret",
validation_alias="ARCADE_WORKER_SECRET",
)
environment: str = Field(
default="dev",
description="Environment (dev or prod.)",
)
user_id: str | None = Field(
default=None,
description="User ID for Arcade environment",
)
model_config = {"env_prefix": "ARCADE_"}
class ToolEnvironmentSettings(BaseSettings):
"""Tool environment settings.
Every environment variable that is not prefixed
with one of the prefixes for the other settings
will be added to the tool environment as an
available tool secret in the ToolContext
"""
tool_environment: dict[str, Any] = Field(
default_factory=dict,
description="Tool environment",
)
def model_post_init(self, __context: Any) -> None:
"""Populate tool_environment from process env if not provided."""
if not self.tool_environment:
excluded_prefixes = ("MCP_", "_")
self.tool_environment = {
key: value
for key, value in os.environ.items()
if not any(key.startswith(prefix) for prefix in excluded_prefixes)
}
model_config = {
"env_prefix": "",
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
"extra": "allow",
}
class MCPSettings(BaseSettings):
"""Main MCP settings container."""
# Sub-settings
notification: NotificationSettings = Field(
default_factory=NotificationSettings,
description="Notification settings",
)
transport: TransportSettings = Field(
default_factory=TransportSettings,
description="Transport settings",
)
server: ServerSettings = Field(
default_factory=ServerSettings,
description="Server settings",
)
middleware: MiddlewareSettings = Field(
default_factory=MiddlewareSettings,
description="Middleware settings",
)
arcade: ArcadeSettings = Field(
default_factory=ArcadeSettings,
description="Arcade integration settings",
)
tool_environment: ToolEnvironmentSettings = Field(
default_factory=ToolEnvironmentSettings,
description="Tool environment settings",
)
# Global settings
debug: bool = Field(
default=False,
description="Enable debug mode",
)
model_config = {
"env_prefix": "MCP_",
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
"extra": "allow",
}
@classmethod
def from_env(cls) -> "MCPSettings":
"""Create settings from environment variables."""
return cls()
def tool_secrets(self) -> dict[str, Any]:
"""Get tool secrets."""
return self.tool_environment.tool_environment
def to_dict(self) -> dict[str, Any]:
"""Convert settings to dictionary."""
return self.model_dump(exclude_unset=True)
# Global settings instance
settings = MCPSettings.from_env()

View file

@ -0,0 +1,12 @@
"""MCP Transport implementations."""
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
from arcade_mcp_server.transports.http_streamable import EventStore, HTTPStreamableTransport
from arcade_mcp_server.transports.stdio import StdioTransport
__all__ = [
"EventStore",
"HTTPSessionManager",
"HTTPStreamableTransport",
"StdioTransport",
]

View file

@ -0,0 +1,262 @@
"""HTTP Session Manager for MCP servers.
Manages HTTP streaming sessions with optional resumability via event store.
"""
import contextlib
import logging
from collections.abc import AsyncIterator
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
import anyio
from anyio.abc import TaskStatus
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.session import ServerSession
from arcade_mcp_server.transports.http_streamable import (
MCP_SESSION_ID_HEADER,
EventStore,
HTTPStreamableTransport,
)
logger = logging.getLogger(__name__)
class HTTPSessionManager:
"""Manages HTTP streaming sessions with optional resumability.
This class abstracts session management, event storage, and request handling
for HTTP streaming transports. It handles:
1. Session tracking for clients
2. Resumability via optional event store
3. Connection management and lifecycle
4. Request handling and transport setup
Important: Only one HTTPSessionManager instance should be created per application.
The instance cannot be reused after its run() context has completed.
"""
def __init__(
self,
server: MCPServer,
event_store: Optional[EventStore] = None,
json_response: bool = False,
stateless: bool = False,
):
"""Initialize HTTP session manager.
Args:
server: The MCP server instance
event_store: Optional event store for resumability
json_response: Whether to use JSON responses instead of SSE
stateless: If True, creates fresh transport for each request
"""
self.server = server
self.event_store = event_store
self.json_response = json_response
self.stateless = stateless
# Session tracking (only used if not stateless)
self._session_creation_lock = anyio.Lock()
self._server_instances: dict[str, HTTPStreamableTransport] = {}
# Task group will be set during lifespan
self._task_group: Optional[anyio.abc.TaskGroup] = None
# Thread-safe tracking of run() calls
self._run_lock = anyio.Lock()
self._has_started = False
@contextlib.asynccontextmanager
async def run(self) -> AsyncIterator[None]:
"""Run the session manager with lifecycle management.
This creates and manages the task group for all session operations.
Important: This method can only be called once per instance.
Create a new instance if you need to restart.
"""
async with self._run_lock:
if self._has_started:
raise RuntimeError(
"HTTPSessionManager.run() can only be called once per instance. "
"Create a new instance if you need to run again."
)
self._has_started = True
async with anyio.create_task_group() as tg:
self._task_group = tg
logger.info("HTTP session manager started")
try:
yield
finally:
logger.info("HTTP session manager shutting down")
tg.cancel_scope.cancel()
self._task_group = None
self._server_instances.clear()
async def handle_request(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Process ASGI request with proper session handling.
Args:
scope: ASGI scope
receive: ASGI receive function
send: ASGI send function
"""
if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().")
if self.stateless:
await self._handle_stateless_request(scope, receive, send)
else:
await self._handle_stateful_request(scope, receive, send)
async def _handle_stateless_request(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Process request in stateless mode - new transport per request."""
logger.debug("Stateless mode: Creating new transport for this request")
# Create transport without session ID in stateless mode
http_transport = HTTPStreamableTransport(
mcp_session_id=None,
is_json_response_enabled=self.json_response,
event_store=None, # No event store in stateless mode
)
# Start server in a new task
async def run_stateless_server(
*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
) -> None:
async with http_transport.connect() as streams:
read_stream, write_stream = streams
task_status.started()
try:
# Create a new session for this request
session = ServerSession(
server=self.server,
read_stream=read_stream,
write_stream=write_stream,
)
# Set the session on the transport
http_transport.session = session
# Run the session (start + loop until closed)
await session.run()
# Brief yield to allow cleanup
await anyio.sleep(0)
except Exception:
logger.exception("Stateless session crashed")
if self._task_group is None:
raise RuntimeError("Task group not initialized")
await self._task_group.start(run_stateless_server)
# Handle the HTTP request
await http_transport.handle_request(scope, receive, send)
# Terminate the transport
await http_transport.terminate()
async def _handle_stateful_request(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Process request in stateful mode - maintain session state."""
request = Request(scope, receive)
request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER)
# Existing session case
if request_mcp_session_id and request_mcp_session_id in self._server_instances:
transport = self._server_instances[request_mcp_session_id]
logger.debug("Session already exists, handling request directly")
await transport.handle_request(scope, receive, send)
return
if request_mcp_session_id is None:
# New session case
logger.debug("Creating new transport")
async with self._session_creation_lock:
new_session_id = uuid4().hex
http_transport = HTTPStreamableTransport(
mcp_session_id=new_session_id,
is_json_response_enabled=self.json_response,
event_store=self.event_store,
)
if http_transport.mcp_session_id is None:
raise RuntimeError("MCP session ID not set")
self._server_instances[http_transport.mcp_session_id] = http_transport
logger.info(f"Created new transport with session ID: {new_session_id}")
# Define the server runner
async def run_server(
*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
) -> None:
async with http_transport.connect() as streams:
read_stream, write_stream = streams
task_status.started()
try:
# Create a session for this connection
session = ServerSession(
server=self.server,
read_stream=read_stream,
write_stream=write_stream,
)
# Set the session on the transport
http_transport.session = session
# Run the session (start + loop until closed)
await session.run()
# Brief yield to allow cleanup
await anyio.sleep(0)
except Exception as e:
logger.error(
f"Session {http_transport.mcp_session_id} crashed: {e}",
exc_info=True,
)
finally:
# Clean up on crash
if (
http_transport.mcp_session_id
and http_transport.mcp_session_id in self._server_instances
and not http_transport.is_terminated
):
logger.info(
f"Cleaning up crashed session {http_transport.mcp_session_id}"
)
del self._server_instances[http_transport.mcp_session_id]
if self._task_group is None:
raise RuntimeError("Task group not initialized")
await self._task_group.start(run_server)
# Handle the HTTP request
await http_transport.handle_request(scope, receive, send)
else:
# Invalid session ID
response = Response(
"Bad Request: No valid session ID provided",
status_code=HTTPStatus.BAD_REQUEST,
)
await response(scope, receive, send)

View file

@ -0,0 +1,834 @@
"""HTTP Streamable Transport for MCP servers.
This module implements HTTP transport with Server-Sent Events (SSE) streaming support,
following the patterns from the sample library.
Design overview
- The transport provides a duplex, in-process message channel between the HTTP layer
and the MCP session using anyio memory streams:
- read side (transport -> session):
- `_read_stream_writer` (SendStream[SessionMessage | Exception])
- `_read_stream` (ReceiveStream[SessionMessage | Exception])
- write side (session -> transport):
- `_write_stream` (SendStream[SessionMessage])
- `_write_stream_reader` (ReceiveStream[SessionMessage])
- The transport writes inbound client messages (parsed from HTTP requests) to
`_read_stream_writer`; the session consumes them from `_read_stream`.
- The session writes outbound server messages to `_write_stream`; the transport's
`message_router` task consumes them from `_write_stream_reader` and fans them out
to the correct per-request stream maintained in `_request_streams[request_id]`.
- Response modes:
- JSON response mode: a single HTTP JSON response is returned by awaiting the
first terminal message (JSONRPCResponse or JSONRPCError) for the request.
- SSE response mode: a long-lived stream of events is sent as SSE; the stream
is closed when a terminal message is observed for the request.
- A standalone GET SSE stream uses the special key `GET_STREAM_KEY` to deliver
server-initiated events without a preceding POST.
- Optional resumability can be enabled by providing an `EventStore` implementation.
"""
import json
import logging
import re
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from http import HTTPStatus
from typing import cast
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from pydantic import BaseModel, TypeAdapter
from sse_starlette import EventSourceResponse
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from arcade_mcp_server.session import ServerSession
from arcade_mcp_server.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
PARSE_ERROR,
ErrorData,
JSONRPCError,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
MCPMessage,
RequestId,
SessionMessage,
)
logger = logging.getLogger(__name__)
# Header names
MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
LAST_EVENT_ID_HEADER = "Last-Event-ID"
# Content types
CONTENT_TYPE_JSON = "application/json"
CONTENT_TYPE_SSE = "text/event-stream"
# Special key for the standalone GET stream
GET_STREAM_KEY = "_GET_stream"
# Session ID validation pattern (visible ASCII characters)
SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$")
# Type aliases
StreamId = str
EventId = str
@dataclass
class EventMessage:
"""A JSONRPCMessage with an optional event ID for stream resumability."""
message: MCPMessage
event_id: str | None = None
EventCallback = Callable[[EventMessage], Awaitable[None]]
class EventStore:
"""Interface for resumability support via event storage."""
async def store_event(self, stream_id: StreamId, message: MCPMessage) -> EventId:
"""Store an event for later retrieval."""
raise NotImplementedError
async def replay_events_after(
self,
last_event_id: EventId,
send_callback: EventCallback,
) -> StreamId | None:
"""Replay events after the specified event ID."""
raise NotImplementedError
class HTTPStreamableTransport:
"""HTTP transport with SSE streaming support for MCP.
Responsibilities
- Parse HTTP requests into JSON-RPC messages and enqueue them on the
transportsession read stream (via `_read_stream_writer`).
- Consume sessiontransport messages from `_write_stream_reader` in a
background `message_router`, routing them to per-request streams in
`_request_streams` keyed by the JSON-RPC request id (or `GET_STREAM_KEY`
for the standalone GET SSE stream).
- Serve responses back to the HTTP client:
- JSON response mode: wait for the first terminal response and return a
single `application/json` body.
- SSE mode: stream each outbound `SessionMessage` as an SSE event with
appropriate headers and close on terminal response.
Streams created in `connect()`
- `_read_stream_writer` / `_read_stream`: transportsession channel for inbound
client messages.
- `_write_stream` / `_write_stream_reader`: sessiontransport channel for outbound
server messages, consumed by the `message_router`.
These in-memory channels provide backpressure and decouple HTTP from the session
loop while keeping the implementation fully async.
"""
def __init__(
self,
mcp_session_id: str | None,
session: ServerSession | None = None,
is_json_response_enabled: bool = False,
event_store: EventStore | None = None,
):
"""Initialize HTTP streamable transport.
Args:
mcp_session_id: Session identifier (must be visible ASCII)
session: Server session for handling requests
is_json_response_enabled: If True, return JSON responses instead of SSE
event_store: Optional event store for resumability
"""
if mcp_session_id and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
raise ValueError("Session ID must only contain visible ASCII characters")
self.mcp_session_id = mcp_session_id
self.session = session
self.is_json_response_enabled = is_json_response_enabled
self._event_store = event_store
self._request_streams: dict[
RequestId,
tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]],
] = {}
self._terminated = False
# Streams for connection
self._read_stream_writer: MemoryObjectSendStream[str | Exception] | None = None
self._read_stream: MemoryObjectReceiveStream[str | Exception] | None = None
self._write_stream: MemoryObjectSendStream[str | SessionMessage] | None = None
self._write_stream_reader: MemoryObjectReceiveStream[str | SessionMessage] | None = None
def _parse_mcp_message(self, obj: str | dict[str, object] | MCPMessage) -> MCPMessage:
"""Parse incoming data into a typed MCPMessage.
Accepts a raw JSON string, already-parsed dict, or an existing MCPMessage.
"""
if isinstance(obj, BaseModel):
# Already a pydantic model; trust caller and cast to MCPMessage
return cast(MCPMessage, obj)
parsed: dict[str, object]
if isinstance(obj, str):
try:
maybe = json.loads(obj)
except Exception as exc: # parse error - treat as invalid request
raise ValueError(f"Invalid JSON: {exc}")
if not isinstance(maybe, dict):
raise TypeError("JSON must be an object")
parsed = maybe
elif isinstance(obj, dict):
parsed = obj
else:
raise TypeError("Unsupported message type")
try:
return TypeAdapter(MCPMessage).validate_python(parsed)
except Exception:
# Fallback: treat as error
return JSONRPCError(
id=str(parsed.get("id", "null")),
error={"code": -32600, "message": "Invalid message"},
)
@property
def is_terminated(self) -> bool:
"""Check if transport has been terminated."""
return self._terminated
def _create_error_response(
self,
error_message: str,
status_code: HTTPStatus,
error_code: int = INVALID_REQUEST,
headers: dict[str, str] | None = None,
) -> Response:
"""Create an error response."""
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
if headers:
response_headers.update(headers)
if self.mcp_session_id:
response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
error_response = JSONRPCError(
jsonrpc="2.0",
id="server-error",
error=ErrorData(code=error_code, message=error_message).model_dump(exclude_none=True),
)
return Response(
error_response.model_dump_json(by_alias=True, exclude_none=True),
status_code=status_code,
headers=response_headers,
)
def _create_json_response(
self,
response_message: JSONRPCMessage | None,
status_code: HTTPStatus = HTTPStatus.OK,
headers: dict[str, str] | None = None,
) -> Response:
"""Create a JSON response."""
response_headers = {"Content-Type": CONTENT_TYPE_JSON}
if headers:
response_headers.update(headers)
if self.mcp_session_id:
response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
return Response(
response_message.model_dump_json(by_alias=True, exclude_none=True)
if response_message
else None,
status_code=status_code,
headers=response_headers,
)
def _get_session_id(self, request: Request) -> str | None:
"""Extract session ID from request headers."""
return request.headers.get(MCP_SESSION_ID_HEADER)
def _create_event_data(self, event_message: EventMessage) -> dict[str, str]:
"""Create event data dictionary from EventMessage."""
event_data = {
"event": "message",
"data": event_message.message.model_dump_json(by_alias=True, exclude_none=True),
}
if event_message.event_id:
event_data["id"] = event_message.event_id
return event_data
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
"""Clean up memory streams for a request."""
if request_id in self._request_streams:
try:
await self._request_streams[request_id][0].aclose()
await self._request_streams[request_id][1].aclose()
except Exception:
logger.debug("Error closing memory streams - may already be closed")
finally:
self._request_streams.pop(request_id, None)
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Handle incoming HTTP requests."""
request = Request(scope, receive)
if self._terminated:
response = self._create_error_response(
"Not Found: Session has been terminated",
HTTPStatus.NOT_FOUND,
)
await response(scope, receive, send)
return
if request.method == "POST":
await self._handle_post_request(scope, request, receive, send)
elif request.method == "GET":
await self._handle_get_request(request, send)
elif request.method == "DELETE":
await self._handle_delete_request(request, send)
else:
await self._handle_unsupported_request(request, send)
def _check_accept_headers(self, request: Request) -> tuple[bool, bool]:
"""Check if request accepts required media types."""
accept_header = request.headers.get("accept", "")
accept_types = [media_type.strip() for media_type in accept_header.split(",")]
has_json = any(media_type.startswith(CONTENT_TYPE_JSON) for media_type in accept_types)
has_sse = any(media_type.startswith(CONTENT_TYPE_SSE) for media_type in accept_types)
return has_json, has_sse
def _check_content_type(self, request: Request) -> bool:
"""Check if request has correct Content-Type."""
content_type = request.headers.get("content-type", "")
content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")]
return any(part == CONTENT_TYPE_JSON for part in content_type_parts)
async def _handle_post_request(
self, scope: Scope, request: Request, receive: Receive, send: Send
) -> None:
"""Handle POST requests containing JSON-RPC messages."""
writer = self._read_stream_writer
if writer is None:
raise ValueError("No read stream writer available. Ensure connect() is called first.")
try:
# Check Accept headers
has_json, has_sse = self._check_accept_headers(request)
if self.is_json_response_enabled:
if not has_json:
response = self._create_error_response(
"Not Acceptable: Client must accept application/json",
HTTPStatus.NOT_ACCEPTABLE,
)
await response(scope, receive, send)
return
else:
if not has_sse:
response = self._create_error_response(
"Not Acceptable: Client must accept text/event-stream",
HTTPStatus.NOT_ACCEPTABLE,
)
await response(scope, receive, send)
return
# Validate Content-Type for POST payloads only when JSON mode
if self.is_json_response_enabled and not self._check_content_type(request):
response = self._create_error_response(
"Unsupported Media Type: Content-Type must be application/json",
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
)
await response(scope, receive, send)
return
# Parse the body
body = await request.body()
body_str = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body)
try:
raw_message = json.loads(body)
except json.JSONDecodeError as e:
response = self._create_error_response(
f"Parse error: {e!s}", HTTPStatus.BAD_REQUEST, PARSE_ERROR
)
await response(scope, receive, send)
return
# Accept either well-typed messages or raw dicts
message_dict = raw_message if isinstance(raw_message, dict) else {}
try:
message = self._parse_mcp_message(message_dict or body_str)
except Exception as exc:
response = self._create_error_response(
f"Invalid request: {exc}",
HTTPStatus.BAD_REQUEST,
INVALID_REQUEST,
)
await response(scope, receive, send)
return
# Check if this is an initialization request
# Determine initialization by dict method when validation fallback used
is_initialization_request = (
isinstance(message, JSONRPCRequest) and message.method == "initialize"
)
if is_initialization_request:
if self.mcp_session_id:
request_session_id = self._get_session_id(request)
if request_session_id and request_session_id != self.mcp_session_id:
response = self._create_error_response(
"Not Found: Invalid or expired session ID",
HTTPStatus.NOT_FOUND,
)
await response(scope, receive, send)
return
elif not await self._validate_request_headers(request, send):
return
# For notifications and responses, return 202 Accepted
if not isinstance(message, JSONRPCRequest):
response = self._create_json_response(None, HTTPStatus.ACCEPTED)
await response(scope, receive, send)
# Process the message
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
return
# Handle requests
request_id = str(message.id)
self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0)
request_stream_reader = self._request_streams[request_id][1]
if self.is_json_response_enabled:
# JSON response mode
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
try:
response_message = None
async for event_message in request_stream_reader:
if isinstance(event_message.message, (JSONRPCResponse, JSONRPCError)):
response_message = event_message.message
break
if response_message:
response = self._create_json_response(response_message)
await response(scope, receive, send)
else:
logger.error("No response received before stream closed")
response = self._create_error_response(
"Error processing request: No response received",
HTTPStatus.INTERNAL_SERVER_ERROR,
)
await response(scope, receive, send)
except Exception:
logger.exception("Error processing JSON response")
response = self._create_error_response(
"Error processing request",
HTTPStatus.INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
)
await response(scope, receive, send)
finally:
await self._clean_up_memory_streams(request_id)
else:
# SSE response mode
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[
dict[str, str]
](0)
async def sse_writer() -> None:
try:
async with sse_stream_writer, request_stream_reader:
async for event_message in request_stream_reader:
event_data = self._create_event_data(event_message)
await sse_stream_writer.send(event_data)
if isinstance(
event_message.message, (JSONRPCResponse, JSONRPCError)
):
break
except Exception:
logger.exception("Error in SSE writer")
finally:
logger.debug("Closing SSE writer")
await self._clean_up_memory_streams(request_id)
headers = {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Content-Type": CONTENT_TYPE_SSE,
**({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}),
}
response = EventSourceResponse(
content=sse_stream_reader,
data_sender_callable=sse_writer,
headers=headers,
)
try:
async with anyio.create_task_group() as tg:
tg.start_soon(response, scope, receive, send)
await writer.send(body_str if body_str.endswith("\n") else body_str + "\n")
except Exception:
logger.exception("SSE response error")
await sse_stream_writer.aclose()
await sse_stream_reader.aclose()
await self._clean_up_memory_streams(request_id)
except Exception as err:
logger.exception("Error handling POST request")
response = self._create_error_response(
f"Error handling POST request: {err}",
HTTPStatus.INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
)
await response(scope, receive, send)
if writer:
await writer.send(Exception(err))
async def _handle_get_request(self, request: Request, send: Send) -> None:
"""Handle GET request to establish SSE."""
writer = self._read_stream_writer
if writer is None:
raise ValueError("No read stream writer available. Ensure connect() is called first.")
# Validate Accept header
_, has_sse = self._check_accept_headers(request)
if not has_sse:
error_response = self._create_error_response(
"Not Acceptable: Client must accept text/event-stream",
HTTPStatus.NOT_ACCEPTABLE,
)
await error_response(request.scope, request.receive, send)
return
if not await self._validate_request_headers(request, send):
return
# Handle resumability
if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER):
await self._replay_events(last_event_id, request, send)
return
headers = {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Content-Type": CONTENT_TYPE_SSE,
}
if self.mcp_session_id:
headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
# Check if we already have an active GET stream
if GET_STREAM_KEY in self._request_streams:
error_response = self._create_error_response(
"Conflict: Only one SSE stream is allowed per session",
HTTPStatus.CONFLICT,
)
await error_response(request.scope, request.receive, send)
return
# Create SSE stream
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0)
async def standalone_sse_writer() -> None:
try:
self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[
EventMessage
](0)
standalone_stream_reader = self._request_streams[GET_STREAM_KEY][1]
async with sse_stream_writer, standalone_stream_reader:
async for event_message in standalone_stream_reader:
event_data = self._create_event_data(event_message)
await sse_stream_writer.send(event_data)
except Exception:
logger.exception("Error in standalone SSE writer")
finally:
logger.debug("Closing standalone SSE writer")
await self._clean_up_memory_streams(GET_STREAM_KEY)
sse_response: EventSourceResponse = EventSourceResponse(
content=sse_stream_reader,
data_sender_callable=standalone_sse_writer,
headers=headers,
)
try:
await sse_response(request.scope, request.receive, send)
except Exception:
logger.exception("Error in standalone SSE response")
await sse_stream_writer.aclose()
await sse_stream_reader.aclose()
await self._clean_up_memory_streams(GET_STREAM_KEY)
async def _handle_delete_request(self, request: Request, send: Send) -> None:
"""Handle DELETE requests for session termination."""
if not self.mcp_session_id:
response = self._create_error_response(
"Method Not Allowed: Session termination not supported",
HTTPStatus.METHOD_NOT_ALLOWED,
)
await response(request.scope, request.receive, send)
return
if not await self._validate_request_headers(request, send):
return
await self.terminate()
response = self._create_json_response(None, HTTPStatus.OK)
await response(request.scope, request.receive, send)
async def terminate(self) -> None:
"""Terminate the current session."""
self._terminated = True
logger.info(f"Terminating session: {self.mcp_session_id}")
# Close all request streams
request_stream_keys = list(self._request_streams.keys())
for key in request_stream_keys:
await self._clean_up_memory_streams(key)
self._request_streams.clear()
try:
if self._read_stream_writer:
await self._read_stream_writer.aclose()
if self._read_stream:
await self._read_stream.aclose()
if self._write_stream_reader:
await self._write_stream_reader.aclose()
if self._write_stream:
await self._write_stream.aclose()
except Exception as e:
logger.debug(f"Error closing streams: {e}")
async def _handle_unsupported_request(self, request: Request, send: Send) -> None:
"""Handle unsupported HTTP methods."""
headers = {
"Content-Type": CONTENT_TYPE_JSON,
"Allow": "GET, POST, DELETE",
}
if self.mcp_session_id:
headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
response = self._create_error_response(
"Method Not Allowed",
HTTPStatus.METHOD_NOT_ALLOWED,
headers=headers,
)
await response(request.scope, request.receive, send)
async def _validate_request_headers(self, request: Request, send: Send) -> bool:
"""Validate request headers."""
return await self._validate_session(request, send)
async def _validate_session(self, request: Request, send: Send) -> bool:
"""Validate session ID in request."""
if not self.mcp_session_id:
return True
request_session_id = self._get_session_id(request)
if not request_session_id:
response = self._create_error_response(
"Bad Request: Missing session ID",
HTTPStatus.BAD_REQUEST,
)
await response(request.scope, request.receive, send)
return False
if request_session_id != self.mcp_session_id:
response = self._create_error_response(
"Not Found: Invalid or expired session ID",
HTTPStatus.NOT_FOUND,
)
await response(request.scope, request.receive, send)
return False
return True
async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None:
"""Replay events after the specified event ID."""
event_store = self._event_store
if not event_store:
return
try:
headers = {
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Content-Type": CONTENT_TYPE_SSE,
}
if self.mcp_session_id:
headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[
dict[str, str]
](0)
async def replay_sender() -> None:
try:
async with sse_stream_writer:
async def send_event(event_message: EventMessage) -> None:
event_data = self._create_event_data(event_message)
await sse_stream_writer.send(event_data)
stream_id = await event_store.replay_events_after(last_event_id, send_event)
if stream_id and stream_id not in self._request_streams:
self._request_streams[stream_id] = anyio.create_memory_object_stream[
EventMessage
](0)
msg_reader = self._request_streams[stream_id][1]
async with msg_reader:
async for event_message in msg_reader:
event_data = self._create_event_data(event_message)
await sse_stream_writer.send(event_data)
except Exception:
logger.exception("Error in replay sender")
sse_response: EventSourceResponse = EventSourceResponse(
content=sse_stream_reader,
data_sender_callable=replay_sender,
headers=headers,
)
try:
await sse_response(request.scope, request.receive, send)
except Exception:
logger.exception("Error in replay response")
finally:
await sse_stream_writer.aclose()
await sse_stream_reader.aclose()
except Exception:
logger.exception("Error replaying events")
error_response = self._create_error_response(
"Error replaying events",
HTTPStatus.INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
)
await error_response(request.scope, request.receive, send)
@asynccontextmanager
async def connect(
self,
) -> AsyncIterator[
tuple[
MemoryObjectReceiveStream[str | Exception],
MemoryObjectSendStream[str | SessionMessage],
]
]:
"""Context manager providing read and write streams for connection.
Creates the in-memory channels used by the transport and starts the
`message_router` task responsible for routing outbound messages from
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)
write_stream, write_stream_reader = anyio.create_memory_object_stream[str | SessionMessage](
0
)
# Store the streams
self._read_stream_writer = read_stream_writer
self._read_stream = read_stream
self._write_stream_reader = write_stream_reader
self._write_stream = write_stream
# Start message router
async with anyio.create_task_group() as tg:
async def message_router() -> None:
try:
async for session_message in write_stream_reader:
# Accept either a SessionMessage wrapper or a raw JSON string
try:
if isinstance(session_message, SessionMessage):
message = session_message.message
elif isinstance(session_message, str):
message = self._parse_mcp_message(session_message)
elif isinstance(session_message, BaseModel):
message = cast(JSONRPCMessage, session_message)
else:
logger.error(
f"Unsupported outbound message type: {type(session_message)}"
)
continue
except Exception:
logger.exception("Failed to parse outbound message from session")
continue
target_request_id = None
# Check if this is a response
if isinstance(message, (JSONRPCResponse, JSONRPCError)):
target_request_id = str(message.id)
request_stream_id = (
target_request_id if target_request_id else GET_STREAM_KEY
)
# Store event if we have an event store
event_id = None
if self._event_store:
event_id = await self._event_store.store_event(
request_stream_id,
message, # type: ignore[arg-type]
)
logger.debug(f"Stored {event_id} from {request_stream_id}")
if request_stream_id in self._request_streams:
try:
await self._request_streams[request_stream_id][0].send(
EventMessage(message, event_id) # type: ignore[arg-type]
)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
self._request_streams.pop(request_stream_id, None)
except Exception:
logger.exception("Error in message router")
tg.start_soon(message_router)
try:
yield read_stream, write_stream
finally:
for stream_id in list(self._request_streams.keys()):
await self._clean_up_memory_streams(stream_id)
self._request_streams.clear()
try:
await read_stream_writer.aclose()
await read_stream.aclose()
await write_stream_reader.aclose()
await write_stream.aclose()
except Exception as e:
logger.debug(f"Error closing streams: {e}")

View file

@ -0,0 +1,209 @@
"""
Stdio Transport
Provides stdio (stdin/stdout) transport for MCP communication.
"""
import asyncio
import contextlib
import logging
import queue
import signal
import sys
import threading
import uuid
from collections.abc import AsyncIterator
from typing import Any
from arcade_mcp_server.exceptions import TransportError
from arcade_mcp_server.session import ServerSession
logger = logging.getLogger("arcade.mcp.transports.stdio")
class StdioWriteStream:
"""Write stream implementation for stdio."""
def __init__(self, write_queue: queue.Queue[str | None]):
self.write_queue = write_queue
async def send(self, data: str) -> None:
"""Send data to stdout."""
if not data.endswith("\n"):
data += "\n"
await asyncio.to_thread(self.write_queue.put, data)
class StdioReadStream:
"""Read stream implementation for stdio."""
def __init__(self, read_queue: queue.Queue[str | None]):
self.read_queue = read_queue
self._running = True
def stop(self) -> None:
"""Stop the read stream."""
self._running = False
def __aiter__(self) -> AsyncIterator[str]:
return self
async def __anext__(self) -> str:
if not self._running:
raise StopAsyncIteration
try:
line = await asyncio.to_thread(self.read_queue.get)
except asyncio.CancelledError:
raise StopAsyncIteration
except Exception as e:
logger.exception("Error reading from stdin")
raise TransportError(f"Read error: {e}") from e
if line is None or not self._running:
raise StopAsyncIteration
return line
class StdioTransport:
"""
Stdio transport implementation for stdio communication.
This transport uses stdin/stdout for MCP communication,
suitable for command-line tools and scripts.
"""
def __init__(self, name: str = "stdio"):
"""Initialize stdio transport."""
self.name = name
self.read_queue: queue.Queue[str | None] = queue.Queue()
self.write_queue: queue.Queue[str | None] = queue.Queue()
self.reader_thread: threading.Thread | None = None
self.writer_thread: threading.Thread | None = None
self._shutdown_event = asyncio.Event()
self._running = False
self._sessions: dict[str, ServerSession] = {}
async def start(self) -> None:
"""Start the transport."""
# Component start is handled here directly
# Start I/O threads
self._running = True
self.reader_thread = threading.Thread(
target=self._reader_loop,
daemon=True,
name=f"{self.name}-reader",
)
self.writer_thread = threading.Thread(
target=self._writer_loop,
daemon=True,
name=f"{self.name}-writer",
)
self.reader_thread.start()
self.writer_thread.start()
# Set up signal handlers
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop()))
except NotImplementedError:
# Windows doesn't support POSIX signals
if sys.platform == "win32":
logger.warning("Signal handling not fully supported on Windows")
else:
logger.warning(f"Failed to set up signal handler for {sig}")
async def stop(self) -> None:
"""Stop the transport."""
if not self._running:
return
logger.info("Stopping stdio transport")
self._running = False
# Signal threads to stop
self.read_queue.put(None)
self.write_queue.put(None)
# Wait for threads to finish
if self.reader_thread and self.reader_thread.is_alive():
self.reader_thread.join(timeout=1.0)
if self.writer_thread and self.writer_thread.is_alive():
self.writer_thread.join(timeout=1.0)
# Set shutdown event
self._shutdown_event.set()
def _reader_loop(self) -> None:
"""Reader thread loop."""
try:
for line in sys.stdin:
if not self._running:
break
self.read_queue.put(line.strip())
except Exception:
logger.exception("Error in reader thread")
finally:
self.read_queue.put(None) # Signal EOF
def _writer_loop(self) -> None:
"""Writer thread loop."""
try:
while self._running:
msg = self.write_queue.get()
if msg is None:
break
sys.stdout.write(msg)
sys.stdout.flush()
except Exception:
logger.exception("Error in writer thread")
@contextlib.asynccontextmanager
async def connect_session(self, **options: Any) -> AsyncIterator[ServerSession]:
"""
Create a stdio session.
Since stdio is inherently single-session, this will fail
if a session is already active.
"""
# Check if already have a session
sessions = await self.list_sessions()
if sessions:
raise TransportError("Stdio transport only supports one session")
# Create session
session_id = str(uuid.uuid4())
read_stream = StdioReadStream(self.read_queue)
write_stream = StdioWriteStream(self.write_queue)
session = ServerSession(
server=None, # set by the caller using run_connection; not used here
session_id=session_id,
read_stream=read_stream,
write_stream=write_stream,
init_options=options,
stateless=True,
)
# Register session
await self.register_session(session)
try:
yield session
finally:
# Cleanup
read_stream.stop()
await self.unregister_session(session_id)
async def wait_for_shutdown(self) -> None:
"""Wait for the transport to shut down."""
await self._shutdown_event.wait()
# Minimal session registry to support connect_session lifecycle
async def list_sessions(self) -> list[str]:
return list(self._sessions.keys())
async def register_session(self, session: ServerSession) -> None:
self._sessions[session.session_id] = session
async def unregister_session(self, session_id: str) -> None:
self._sessions.pop(session_id, None)

View file

@ -0,0 +1,666 @@
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Any, Generic, Literal, TypeAlias, TypeVar
from pydantic import BaseModel, ConfigDict, Field
# -----------------------------------------------------------------------------
# JSON-RPC constants
# -----------------------------------------------------------------------------
JSONRPC_VERSION: Literal["2.0"] = "2.0"
LATEST_PROTOCOL_VERSION: str = "2025-06-18"
# -----------------------------------------------------------------------------
# Basic types
# -----------------------------------------------------------------------------
ProgressToken = str | int
Cursor = str
RequestId = str | int
AnyFunction: TypeAlias = Callable[..., Any]
# -----------------------------------------------------------------------------
# Base JSON-RPC shapes
# -----------------------------------------------------------------------------
class Request(BaseModel):
method: str
params: Any = None
model_config = ConfigDict(extra="allow", populate_by_name=True)
class Notification(BaseModel):
method: str
params: Any = None
model_config = ConfigDict(extra="allow", populate_by_name=True)
class Result(BaseModel):
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class JSONRPCMessage(BaseModel):
jsonrpc: Literal["2.0"] = Field(default=JSONRPC_VERSION, frozen=True)
model_config = ConfigDict(extra="allow")
class JSONRPCRequest(JSONRPCMessage, Request):
id: RequestId
T = TypeVar("T", bound=Result)
class JSONRPCResponse(JSONRPCMessage, Generic[T]):
id: RequestId
result: T | dict[str, Any]
# Standard JSON-RPC error codes
PARSE_ERROR = -32700
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
class ErrorData(BaseModel):
code: int
message: str
data: Any | None = None
class JSONRPCError(JSONRPCMessage):
id: RequestId
error: dict[str, Any]
# -----------------------------------------------------------------------------
# Transport types
# -----------------------------------------------------------------------------
@dataclass
class SessionMessage:
"""Wrapper for messages in transport sessions."""
message: JSONRPCMessage
# -----------------------------------------------------------------------------
# Initialization
# -----------------------------------------------------------------------------
class BaseMetadata(BaseModel):
name: str
title: str | None = None
model_config = ConfigDict(extra="allow")
class Implementation(BaseMetadata):
version: str
class ClientCapabilities(BaseModel):
experimental: dict[str, object] | None = None
roots: dict[str, Any] | None = None
sampling: dict[str, Any] | None = None
elicitation: dict[str, Any] | None = None
model_config = ConfigDict(extra="allow")
class ServerCapabilities(BaseModel):
experimental: dict[str, object] | None = None
logging: dict[str, Any] | None = None
completions: dict[str, Any] | None = None
prompts: dict[str, Any] | None = None
resources: dict[str, Any] | None = None
tools: dict[str, Any] | None = None
model_config = ConfigDict(extra="allow")
class InitializeParams(BaseModel):
protocolVersion: str
capabilities: ClientCapabilities = Field(default_factory=ClientCapabilities)
clientInfo: Implementation
class InitializeRequest(JSONRPCRequest):
method: Literal["initialize"] = Field(default="initialize", frozen=True)
params: InitializeParams
class InitializeResult(Result):
protocolVersion: str
capabilities: ServerCapabilities
serverInfo: Implementation
instructions: str | None = None
class InitializedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/initialized"] = Field(
default="notifications/initialized", frozen=True
)
# -----------------------------------------------------------------------------
# Ping
# -----------------------------------------------------------------------------
class PingRequest(JSONRPCRequest):
method: Literal["ping"] = Field(default="ping", frozen=True)
# -----------------------------------------------------------------------------
# Progress notifications
# -----------------------------------------------------------------------------
class ProgressNotificationParams(BaseModel):
progressToken: ProgressToken
progress: float
total: float | None = None
message: str | None = None
class ProgressNotification(JSONRPCMessage, Notification):
method: Literal["notifications/progress"] = Field(default="notifications/progress", frozen=True)
params: ProgressNotificationParams
# -----------------------------------------------------------------------------
# Pagination
# -----------------------------------------------------------------------------
class PaginatedRequest(JSONRPCRequest):
params: dict[str, Any] | None = None
class PaginatedResult(Result):
nextCursor: Cursor | None = None
# -----------------------------------------------------------------------------
# Annotations (used across resources, content, etc.)
# -----------------------------------------------------------------------------
Role = Literal["user", "assistant"]
class Annotations(BaseModel):
audience: list[Role] | None = None
priority: float | None = None
lastModified: str | None = None
model_config = ConfigDict(extra="allow")
# -----------------------------------------------------------------------------
# Resources
# -----------------------------------------------------------------------------
class Resource(BaseMetadata):
uri: str
description: str | None = None
mimeType: str | None = None
annotations: Annotations | None = None
size: int | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ListResourcesRequest(PaginatedRequest):
method: Literal["resources/list"] = Field(default="resources/list", frozen=True)
class ListResourcesResult(PaginatedResult):
resources: list[Resource] = Field(default_factory=list)
class ListResourceTemplatesRequest(PaginatedRequest):
method: Literal["resources/templates/list"] = Field(
default="resources/templates/list", frozen=True
)
class ResourceTemplate(BaseMetadata):
uriTemplate: str
description: str | None = None
mimeType: str | None = None
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ListResourceTemplatesResult(PaginatedResult):
resourceTemplates: list[ResourceTemplate] = Field(default_factory=list)
class ReadResourceParams(BaseModel):
uri: str
class ReadResourceRequest(JSONRPCRequest):
method: Literal["resources/read"] = Field(default="resources/read", frozen=True)
params: ReadResourceParams
class ResourceContents(BaseModel):
uri: str
mimeType: str | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class TextResourceContents(ResourceContents):
text: str
class BlobResourceContents(ResourceContents):
blob: str
class ReadResourceResult(Result):
contents: list[TextResourceContents | BlobResourceContents]
class ResourceListChangedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/resources/list_changed"] = Field(
default="notifications/resources/list_changed", frozen=True
)
class ResourceUpdatedNotificationParams(BaseModel):
uri: str
class ResourceUpdatedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/resources/updated"] = Field(
default="notifications/resources/updated", frozen=True
)
params: ResourceUpdatedNotificationParams
class SubscribeParams(BaseModel):
uri: str
class SubscribeRequest(JSONRPCRequest):
method: Literal["resources/subscribe"] = Field(default="resources/subscribe", frozen=True)
params: SubscribeParams
class UnsubscribeParams(BaseModel):
uri: str
class UnsubscribeRequest(JSONRPCRequest):
method: Literal["resources/unsubscribe"] = Field(default="resources/unsubscribe", frozen=True)
params: UnsubscribeParams
# -----------------------------------------------------------------------------
# Prompts
# -----------------------------------------------------------------------------
class PromptArgument(BaseMetadata):
description: str | None = None
required: bool | None = None
class Prompt(BaseMetadata):
description: str | None = None
arguments: list[PromptArgument] | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ListPromptsRequest(PaginatedRequest):
method: Literal["prompts/list"] = Field(default="prompts/list", frozen=True)
class ListPromptsResult(PaginatedResult):
prompts: list[Prompt] = Field(default_factory=list)
class PromptMessage(BaseModel):
role: Role
content: dict[str, Any]
class GetPromptParams(BaseModel):
name: str
arguments: dict[str, str] | None = None
class GetPromptRequest(JSONRPCRequest):
method: Literal["prompts/get"] = Field(default="prompts/get", frozen=True)
params: GetPromptParams
class GetPromptResult(Result):
description: str | None = None
messages: list[PromptMessage]
class PromptListChangedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/prompts/list_changed"] = Field(
default="notifications/prompts/list_changed", frozen=True
)
# -----------------------------------------------------------------------------
# Tools
# -----------------------------------------------------------------------------
class ToolAnnotations(BaseModel):
title: str | None = None
readOnlyHint: bool | None = None
destructiveHint: bool | None = None
idempotentHint: bool | None = None
openWorldHint: bool | None = None
model_config = ConfigDict(extra="allow")
class MCPTool(BaseModel):
name: str
description: str | None = None
inputSchema: dict[str, Any]
outputSchema: dict[str, Any] | None = None
annotations: ToolAnnotations | None = None
title: str | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ListToolsRequest(PaginatedRequest):
method: Literal["tools/list"] = Field(default="tools/list", frozen=True)
class ListToolsResult(PaginatedResult):
tools: list[MCPTool]
class ToolListChangedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/tools/list_changed"] = Field(
default="notifications/tools/list_changed", frozen=True
)
class CallToolParams(BaseModel):
name: str
arguments: dict[str, Any] | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class CallToolRequest(JSONRPCRequest):
method: Literal["tools/call"] = Field(default="tools/call", frozen=True)
params: CallToolParams
class TextContent(BaseModel):
type: Literal["text"]
text: str
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ImageContent(BaseModel):
type: Literal["image"]
data: str
mimeType: str
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class AudioContent(BaseModel):
type: Literal["audio"]
data: str
mimeType: str
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ResourceLink(Resource):
type: Literal["resource_link"] = Field(default="resource_link", frozen=True)
class EmbeddedResource(BaseModel):
type: Literal["resource"] = Field(default="resource", frozen=True)
resource: TextResourceContents | BlobResourceContents
annotations: Annotations | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
MCPContent = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource
class CallToolResult(Result):
"""
A list of content objects that represent the unstructured result of the tool call.
"""
content: list[MCPContent]
"""
An optional JSON object that represents the structured result of the tool call.
"""
structuredContent: dict[str, Any] | None = None
isError: bool | None = None
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
class LoggingLevel(str, Enum):
DEBUG = "debug"
INFO = "info"
NOTICE = "notice"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
ALERT = "alert"
EMERGENCY = "emergency"
class SetLevelParams(BaseModel):
level: LoggingLevel
class SetLevelRequest(JSONRPCRequest):
method: Literal["logging/setLevel"] = Field(default="logging/setLevel", frozen=True)
params: SetLevelParams
class LoggingMessageParams(BaseModel):
level: LoggingLevel
logger: str | None = None
data: Any
class LoggingMessageNotification(JSONRPCMessage, Notification):
method: Literal["notifications/message"] = Field(default="notifications/message", frozen=True)
params: LoggingMessageParams
# -----------------------------------------------------------------------------
# Cancellation (notification-only)
# -----------------------------------------------------------------------------
class CancelledParams(BaseModel):
requestId: RequestId
reason: str | None = None
class CancelledNotification(JSONRPCMessage, Notification):
method: Literal["notifications/cancelled"] = Field(
default="notifications/cancelled", frozen=True
)
params: CancelledParams
# -----------------------------------------------------------------------------
# Sampling (server -> client)
# -----------------------------------------------------------------------------
class SamplingMessage(BaseModel):
role: Role
content: TextContent | ImageContent | AudioContent
class ModelHint(BaseModel):
name: str | None = None
class ModelPreferences(BaseModel):
hints: list[ModelHint] | None = None
costPriority: float | None = None
speedPriority: float | None = None
intelligencePriority: float | None = None
class CreateMessageParams(BaseModel):
messages: list[SamplingMessage]
modelPreferences: ModelPreferences | None = None
systemPrompt: str | None = None
includeContext: Literal["none", "thisServer", "allServers"] | None = None
temperature: float | None = None
maxTokens: int
stopSequences: list[str] | None = None
metadata: dict[str, Any] | None = None
class CreateMessageRequest(JSONRPCRequest):
method: Literal["sampling/createMessage"] = Field(default="sampling/createMessage", frozen=True)
params: CreateMessageParams
class CreateMessageResult(Result, SamplingMessage):
model: str
stopReason: Literal["endTurn", "stopSequence", "maxTokens"] | str | None = None
# -----------------------------------------------------------------------------
# Completion (client -> server)
# -----------------------------------------------------------------------------
class ResourceTemplateReference(BaseModel):
type: Literal["ref/resource"]
uri: str
class PromptReference(BaseMetadata):
type: Literal["ref/prompt"]
class CompletionArgument(BaseModel):
name: str
value: str
class CompletionContext(BaseModel):
arguments: dict[str, str] | None = None
class CompleteParams(BaseModel):
ref: ResourceTemplateReference | PromptReference
argument: CompletionArgument
context: CompletionContext | None = None
class CompleteRequest(JSONRPCRequest):
method: Literal["completion/complete"] = Field(default="completion/complete", frozen=True)
params: CompleteParams
class Completion(BaseModel):
values: list[str]
total: int | None = None
hasMore: bool | None = None
class CompleteResult(Result):
completion: Completion
# -----------------------------------------------------------------------------
# Roots
# -----------------------------------------------------------------------------
class ListRootsRequest(JSONRPCRequest):
method: Literal["roots/list"] = Field(default="roots/list", frozen=True)
class Root(BaseModel):
uri: str
name: str | None = None
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
class ListRootsResult(Result):
roots: list[Root]
class RootsListChangedNotification(JSONRPCMessage, Notification):
method: Literal["notifications/roots/list_changed"] = Field(
default="notifications/roots/list_changed", frozen=True
)
# -----------------------------------------------------------------------------
# Elicitation (server -> client)
# -----------------------------------------------------------------------------
ElicitRequestedSchema = dict[str, Any]
class ElicitParams(BaseModel):
message: str
requestedSchema: ElicitRequestedSchema
class ElicitRequest(JSONRPCRequest):
method: Literal["elicitation/create"] = Field(default="elicitation/create", frozen=True)
params: ElicitParams
class ElicitResult(Result):
action: Literal["accept", "decline", "cancel"]
content: dict[str, str | int | float | bool | None] | None = None
# -----------------------------------------------------------------------------
# Union for middleware typing and convenience
# -----------------------------------------------------------------------------
MCPMessage = (
JSONRPCRequest
| JSONRPCResponse[Any]
| JSONRPCError
| CancelledNotification
| ProgressNotification
| LoggingMessageNotification
)

View file

@ -0,0 +1,291 @@
"""
Arcade MCP Server (Integrated Worker + MCP HTTP)
Creates a FastAPI application that exposes both Arcade Worker endpoints and
MCP Server endpoints over HTTP/SSE. MCP is always enabled in this integrated mode.
"""
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import uvicorn
from arcade_core.catalog import ToolCatalog
from arcade_serve.fastapi.worker import FastAPIWorker
from fastapi import FastAPI
from loguru import logger
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings
from arcade_mcp_server.transports.http_session_manager import HTTPSessionManager
@asynccontextmanager
async def create_lifespan(
catalog: ToolCatalog,
mcp_settings: MCPSettings | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
"""
Create lifespan context for the MCP server components.
Yields a dict with `mcp_server`, and `session_manager`.
"""
if mcp_settings is None:
mcp_settings = MCPSettings.from_env()
try:
tool_env_keys = sorted(mcp_settings.tool_secrets().keys())
logger.debug(
f"Arcade settings: \n\
ARCADE_ENVIRONMENT={mcp_settings.arcade.environment} \n\
ARCADE_API_URL={mcp_settings.arcade.api_url}, \n\
ARCADE_USER_ID={mcp_settings.arcade.user_id}, \n\
api_key_present - {bool(mcp_settings.arcade.api_key)}"
)
logger.debug(f"Tool environment variable names available to tools: {tool_env_keys}")
except Exception as e:
logger.debug(f"Unable to log settings/tool env keys: {e}")
mcp_server = MCPServer(
catalog,
settings=mcp_settings,
**kwargs,
)
session_manager = HTTPSessionManager(
server=mcp_server,
json_response=True,
)
await mcp_server.start()
async with session_manager.run():
logger.info("MCP server started and ready for connections")
yield {
"mcp_server": mcp_server,
"session_manager": session_manager,
}
await mcp_server.stop()
def create_arcade_mcp(
catalog: ToolCatalog,
mcp_settings: MCPSettings | None = None,
debug: bool = False,
**kwargs: Any,
) -> FastAPI:
"""
Create a FastAPI app exposing Arcade Worker and MCP HTTP endpoints.
MCP is always enabled in this integrated application.
"""
if mcp_settings is None:
mcp_settings = MCPSettings.from_env()
secret = mcp_settings.arcade.server_secret
if secret is None:
secret = "dev" # noqa: S105
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with create_lifespan(catalog, mcp_settings, **kwargs) as components:
app.state.mcp_server = components["mcp_server"]
app.state.session_manager = components["session_manager"]
yield
app = FastAPI(
title=(mcp_settings.server.title or mcp_settings.server.name),
description=(mcp_settings.server.instructions or ""),
version=mcp_settings.server.version,
docs_url="/docs" if not mcp_settings.arcade.auth_disabled else None,
redoc_url="/redoc" if not mcp_settings.arcade.auth_disabled else None,
lifespan=lifespan,
**kwargs,
)
# Worker endpoints
worker = FastAPIWorker(
app=app,
secret=secret,
disable_auth=mcp_settings.arcade.auth_disabled,
)
worker.catalog = catalog
class _MCPASGIProxy:
def __init__(self, parent_app: FastAPI):
self._app = parent_app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
session_manager = getattr(self._app.state, "session_manager", None)
if session_manager is None:
resp = Response("MCP server not initialized", status_code=503)
await resp(scope, receive, send)
return
await session_manager.handle_request(scope, receive, send)
# Mount the actual ASGI proxy to handle all /mcp requests
app.mount("/mcp", _MCPASGIProxy(app), name="mcp-proxy")
# Customize OpenAPI to include MCP documentation
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
# Get the default OpenAPI schema
from fastapi.openapi.utils import get_openapi
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
# Add MCP routes to the schema
from arcade_mcp_server.fastapi.routes import (
MCPError,
MCPRequest,
MCPResponse,
get_openapi_routes,
)
# Add MCP schemas
if "components" not in openapi_schema:
openapi_schema["components"] = {}
if "schemas" not in openapi_schema["components"]:
openapi_schema["components"]["schemas"] = {}
# Add schema definitions
openapi_schema["components"]["schemas"]["MCPRequest"] = MCPRequest.model_json_schema()
openapi_schema["components"]["schemas"]["MCPResponse"] = MCPResponse.model_json_schema()
openapi_schema["components"]["schemas"]["MCPError"] = MCPError.model_json_schema()
# Add MCP paths
if "paths" not in openapi_schema:
openapi_schema["paths"] = {}
for route_def in get_openapi_routes():
path = route_def["path"]
openapi_schema["paths"][path] = {k: v for k, v in route_def.items() if k != "path"}
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi # type: ignore[method-assign]
return app
def create_arcade_mcp_factory() -> FastAPI:
"""
App factory for uvicorn reload support.
This function is called by uvicorn when using reload mode with an import string.
It rediscovers the catalog and reads configuration from environment variables.
"""
import os
from arcade_core.discovery import discover_tools
from arcade_core.toolkit import ToolkitLoadError
# Read configuration from env vars that were set before running the server
debug = os.environ.get("ARCADE_MCP_DEBUG", "false").lower() == "true"
tool_package = os.environ.get("ARCADE_MCP_TOOL_PACKAGE")
discover_installed = os.environ.get("ARCADE_MCP_DISCOVER_INSTALLED", "false").lower() == "true"
show_packages = os.environ.get("ARCADE_MCP_SHOW_PACKAGES", "false").lower() == "true"
server_name = os.environ.get("ARCADE_MCP_SERVER_NAME")
server_version = os.environ.get("ARCADE_MCP_SERVER_VERSION")
# Rediscover tools since there have been changes
try:
catalog = discover_tools(
tool_package=tool_package,
show_packages=show_packages,
discover_installed=discover_installed,
server_name=server_name,
server_version=server_version,
)
except ToolkitLoadError as exc:
logger.error(str(exc))
raise RuntimeError(f"Failed to discover tools: {exc}") from exc
total_tools = len(catalog)
if total_tools == 0:
logger.error("No tools found. Create Python files with @tool decorated functions.")
raise RuntimeError("No tools found")
logger.info(f"Total tools loaded: {total_tools}")
# Build kwargs for server creation
kwargs = {}
if server_name:
kwargs["name"] = server_name
if server_version:
kwargs["version"] = server_version
return create_arcade_mcp(
catalog=catalog,
mcp_settings=None,
debug=debug,
**kwargs,
)
def run_arcade_mcp(
catalog: ToolCatalog,
host: str = "127.0.0.1",
port: int = 7777,
reload: bool = False,
debug: bool = False,
tool_package: str | None = None,
discover_installed: bool = False,
show_packages: bool = False,
**kwargs: Any,
) -> None:
"""
Run the integrated Arcade MCP server with uvicorn.
"""
import os
log_level = "debug" if debug else "info"
if reload:
# Set env vars for the app factory to read later
os.environ["ARCADE_MCP_DEBUG"] = str(debug)
if tool_package:
os.environ["ARCADE_MCP_TOOL_PACKAGE"] = tool_package
os.environ["ARCADE_MCP_DISCOVER_INSTALLED"] = str(discover_installed)
os.environ["ARCADE_MCP_SHOW_PACKAGES"] = str(show_packages)
if kwargs.get("name"):
os.environ["ARCADE_MCP_SERVER_NAME"] = kwargs["name"]
if kwargs.get("version"):
os.environ["ARCADE_MCP_SERVER_VERSION"] = kwargs["version"]
# import string is required for reload mode
app_import_string = "arcade_mcp_server.worker:create_arcade_mcp_factory"
uvicorn.run(
app_import_string,
factory=True,
host=host,
port=port,
log_level=log_level,
reload=reload,
lifespan="on",
)
else:
app = create_arcade_mcp(
catalog=catalog,
debug=debug,
**kwargs,
)
uvicorn.run(
app,
host=host,
port=port,
log_level=log_level,
reload=reload,
lifespan="on",
)

View file

@ -0,0 +1,189 @@
# Transport Modes
MCP servers can communicate with clients through different transport mechanisms. Each transport is optimized for specific use cases and client types.
## stdio Transport
The stdio (standard input/output) transport is used for direct client connections.
### Characteristics
- Communicates via standard input/output streams
- Logs go to stderr to avoid interfering with protocol messages
- Ideal for desktop applications and command-line tools
- Used by Claude Desktop and similar clients
### Usage
```bash
# Run with stdio transport
python -m arcade_mcp_server stdio
# Or with MCPApp
app.run(transport="stdio")
```
### Client Configuration
For Claude Desktop, configure in `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-tools": {
"command": "python",
"args": ["-m", "arcade_mcp_server", "stdio"],
"cwd": "/path/to/your/tools"
}
}
}
```
## HTTP Transport
The HTTP transport provides REST/SSE endpoints for web-based clients.
### Characteristics
- RESTful API with Server-Sent Events (SSE) for streaming
- Supports hot reload for development
- Includes health checks and API documentation
- Can be deployed behind reverse proxies
- Suitable for web applications and services
### Usage
```bash
# Run with HTTP transport (default)
python -m arcade_mcp_server
# With specific host and port
python -m arcade_mcp_server --host 0.0.0.0 --port 8080
# Or with MCPApp
app.run(transport="http", host="0.0.0.0", port=8080)
```
### Endpoints
When running in HTTP mode, the server provides:
- `GET /health` - Health check endpoint
- `GET /mcp` - SSE endpoint for MCP protocol
- `GET /docs` - Swagger UI documentation (debug mode)
- `GET /redoc` - ReDoc documentation (debug mode)
### Development Features
```bash
# Enable hot reload and debug mode
python -m arcade_mcp_server --reload --debug
# This enables:
# - Automatic restart on code changes
# - Detailed error messages
# - API documentation endpoints
# - Verbose logging
```
## Choosing a Transport
### Use stdio when:
- Integrating with desktop applications (Claude Desktop, VS Code)
- Building command-line tools
- You need simple, direct communication
- Running in environments without network access
### Use HTTP when:
- Building web applications
- Deploying to cloud environments
- You need to support multiple concurrent clients
- Integrating with existing web services
- You want API documentation and testing tools
## Transport Configuration
### Environment Variables
Both transports respect common environment variables:
```bash
# Server identification
MCP_SERVER_NAME="My MCP Server"
MCP_SERVER_VERSION="1.0.0"
# Logging
MCP_DEBUG=true
MCP_LOG_LEVEL=DEBUG
# HTTP-specific
MCP_HTTP_HOST=0.0.0.0
MCP_HTTP_PORT=8080
```
### Programmatic Configuration
When using MCPApp:
```python
from arcade_mcp_server import MCPApp
app = MCPApp(
name="my-server",
version="1.0.0",
log_level="DEBUG"
)
# Run with specific transport
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "stdio":
app.run(transport="stdio")
else:
app.run(transport="http", host="0.0.0.0", port=8080)
```
## Security Considerations
### stdio Transport
- Inherits security context of the parent process
- No network exposure
- Suitable for trusted environments
### HTTP Transport
- Exposes network endpoints
- Should use authentication in production
- Consider using HTTPS with reverse proxy
- Implement rate limiting for public deployments
## Advanced Transport Features
### Custom Middleware (HTTP)
Add custom middleware to HTTP transports:
```python
from arcade_mcp_server import MCPApp
app = MCPApp(name="my-server")
# Add custom middleware
@app.middleware("http")
async def add_custom_headers(request, call_next):
response = await call_next(request)
response.headers["X-Custom-Header"] = "value"
return response
```
### Transport Events
Listen to transport lifecycle events:
```python
@app.on_event("startup")
async def startup_handler():
print("Server starting up...")
@app.on_event("shutdown")
async def shutdown_handler():
print("Server shutting down...")
```

View file

@ -0,0 +1,105 @@
# CLI
The `arcade_mcp_server` CLI is a simple tool for running MCP servers.
It is used to discover tools and run the server.
## Command Line Options
```
usage: python -m arcade_mcp_server [-h] [--host HOST] [--port PORT]
[--tool-package PACKAGE] [--discover-installed]
[--show-packages] [--reload] [--debug]
[--env-file ENV_FILE] [--name NAME] [--version VERSION]
[transport]
Run Arcade MCP Server
positional arguments:
transport Transport type: stdio, http, streamable-http (default: http)
optional arguments:
-h, --help show this help message and exit
--host HOST Host to bind to (HTTP mode only, default: 127.0.0.1)
--port PORT Port to bind to (HTTP mode only, default: 8000)
--tool-package PACKAGE, --package PACKAGE, -p PACKAGE
Specific tool package to load (e.g., 'github' for arcade-github)
--discover-installed, --all
Discover all installed arcade tool packages
--show-packages Show loaded packages during discovery
--reload Enable auto-reload on code changes (HTTP mode only)
--debug Enable debug mode with verbose logging
--env-file ENV_FILE Path to environment file
--name NAME Server name
--version VERSION Server version
```
## Tool Discovery
The CLI discovers tools in three ways:
### 1. Auto-Discovery (Default)
Automatically finds Python files with `@tool` decorated functions in:
- Current directory (`*.py`)
- `tools/` subdirectory
- `arcade_tools/` subdirectory
Example file structure:
```
my_project/
├── hello.py # Contains @tool functions
├── tools/
│ └── math.py # More @tool functions
└── arcade_tools/
└── utils.py # Even more @tool functions
```
### 2. Package Loading
Load specific arcade packages installed in your environment:
```bash
# Load arcade-github package
python -m arcade_mcp_server --tool-package github
# Load custom package (tries arcade_ prefix first)
python -m arcade_mcp_server -p mycompany_tools
```
### 3. Discover All Installed
Find and load all arcade packages in your Python environment:
```bash
# Load all arcade packages
python -m arcade_mcp_server --discover-installed
# Show what's being loaded
python -m arcade_mcp_server --discover-installed --show-packages
```
### Example Tool File
Create any Python file with `@tool` decorated functions:
```python
from arcade_mcp_server import tool
@tool
def hello(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
@tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
```
Then run:
```bash
python -m arcade_mcp_server # Auto-discovers and loads these tools
```

View file

@ -0,0 +1,47 @@
### MCPApp
A FastAPI-like interface for building MCP servers with lazy initialization.
MCPApp provides a clean, minimal API for building MCP servers programmatically. It handles tool collection, server configuration, and transport setup with a developer-friendly interface.
#### Basic Usage
```python
from arcade_mcp_server import MCPApp
app = MCPApp(name="my_server", version="1.0.0")
@app.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
app.run(host="127.0.0.1", port=7777)
```
#### Class Reference
::: arcade_mcp_server.mcp_app.MCPApp
#### Examples
```python
# --- server.py ---
# Programmatic server creation with a simple tool and HTTP transport
from arcade_mcp_server import MCPApp
app = MCPApp(name="example_server", version="1.0.0")
@app.tool
def echo(text: str) -> str:
return f"Echo: {text}"
if __name__ == "__main__":
# Start an HTTP server (good for local development/testing)
app.run(host="0.0.0.0", port=7777, reload=False, debug=True)
```
```bash
# then run the server
python server.py
```

View file

@ -0,0 +1,36 @@
### Exceptions
Domain-specific error types raised by the MCP server and components.
::: arcade_mcp_server.exceptions
#### Examples
```python
from arcade_mcp_server.exceptions import (
MCPError,
NotFoundError,
DuplicateError,
ValidationError,
ToolError,
)
# Raising a not-found when a resource is missing
async def read_resource_or_fail(uri: str) -> str:
if not await exists(uri):
raise NotFoundError(f"Resource not found: {uri}")
return await read(uri)
# Validating input
def validate_age(age: int) -> None:
if age < 0:
raise ValidationError("age must be non-negative")
# Handling tool execution errors in middleware or handlers
async def call_tool_safely(call):
try:
return await call()
except ToolError as e:
# Convert to an error result or re-raise
raise MCPError(f"Tool failed: {e}")
```

View file

@ -0,0 +1,50 @@
### Middleware
Base interfaces and built-in middleware.
::: arcade_mcp_server.middleware.base.Middleware
::: arcade_mcp_server.middleware.base.MiddlewareContext
::: arcade_mcp_server.middleware.base.compose_middleware
#### Built-ins
::: arcade_mcp_server.middleware.logging.LoggingMiddleware
::: arcade_mcp_server.middleware.error_handling.ErrorHandlingMiddleware
#### Examples
```python
# Implement a custom middleware
from arcade_mcp_server.middleware.base import Middleware, MiddlewareContext
class TimingMiddleware(Middleware):
async def __call__(self, context: MiddlewareContext, call_next):
import time
start = time.perf_counter()
try:
return await call_next(context)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
# Attach timing info to context metadata
context.metadata["elapsed_ms"] = round(elapsed_ms, 2)
```
```python
# Compose middleware and create a server
from arcade_mcp_server.middleware.base import compose_middleware
from arcade_mcp_server.middleware.logging import LoggingMiddleware
from arcade_mcp_server.middleware.error_handling import ErrorHandlingMiddleware
from arcade_mcp_server.server import MCPServer
from arcade_core.catalog import ToolCatalog
middleware = compose_middleware([
ErrorHandlingMiddleware(mask_error_details=False),
LoggingMiddleware(log_level="INFO"),
TimingMiddleware(),
])
server = MCPServer(catalog=ToolCatalog(), middleware=[middleware])
```

View file

@ -0,0 +1,53 @@
# Server
### Low-level Server
Low-level server for hosting Arcade tools over MCP.
::: arcade_mcp_server.server.MCPServer
#### Examples
```python
# Basic server with tool catalog and stdio transport
import asyncio
from arcade_mcp_server.server import MCPServer
from arcade_core.catalog import ToolCatalog
from arcade_mcp_server.transports.stdio import StdioTransport
async def main():
catalog = ToolCatalog()
server = MCPServer(catalog=catalog, name="example", version="1.0.0")
await server._start()
try:
# Run stdio transport loop
transport = StdioTransport()
await transport.run(server)
finally:
await server._stop()
if __name__ == "__main__":
asyncio.run(main())
```
```python
# Handling a single HTTP streamable connection
import asyncio
from arcade_mcp_server.server import MCPServer
from arcade_core.catalog import ToolCatalog
from arcade_mcp_server.transports.http_streamable import HTTPStreamableTransport
async def run_http():
catalog = ToolCatalog()
server = MCPServer(catalog=catalog)
await server._start()
try:
transport = HTTPStreamableTransport(host="0.0.0.0", port=7777)
await transport.run(server)
finally:
await server._stop()
asyncio.run(run_http())
```

View file

@ -0,0 +1,49 @@
### Settings
Global configuration and environment-driven settings.
::: arcade_mcp_server.settings.MCPSettings
#### Sub-settings
::: arcade_mcp_server.settings.ServerSettings
::: arcade_mcp_server.settings.MiddlewareSettings
::: arcade_mcp_server.settings.NotificationSettings
::: arcade_mcp_server.settings.TransportSettings
::: arcade_mcp_server.settings.ArcadeSettings
::: arcade_mcp_server.settings.ToolEnvironmentSettings
#### Examples
```python
from arcade_mcp_server.settings import MCPSettings
settings = MCPSettings(
debug=True,
middleware=MCPSettings.middleware.__class__(
enable_logging=True,
mask_error_details=False,
),
server=MCPSettings.server.__class__(
title="My MCP Server",
instructions="Use responsibly",
),
transport=MCPSettings.transport.__class__(
http_host="0.0.0.0",
http_port=8000,
),
)
```
```python
# Loading from environment
from arcade_mcp_server.settings import MCPSettings
# Values like ARCADE_MCP_DEBUG, ARCADE_MCP_HTTP_PORT, etc. are parsed
settings = MCPSettings()
```

View file

@ -0,0 +1,37 @@
### Types
Core Pydantic models and enums for the MCP protocol shapes.
::: _server.types
#### Examples
```python
# Constructing a JSON-RPC request and response model
from arcade_mcp_server.types import JSONRPCRequest, JSONRPCResponse
req = JSONRPCRequest(id=1, method="ping", params={})
res = JSONRPCResponse(id=req.id, result={})
print(req.model_dump_json())
print(res.model_dump_json())
```
```python
# Building a tools/call request and examining result shape
from arcade_mcp_server.types import CallToolRequest, CallToolResult, TextContent
call = CallToolRequest(
id=2,
method="tools/call",
params={
"name": "Toolkit.tool",
"arguments": {"text": "hello"},
},
)
# Result would typically be produced by the server:
result = CallToolResult(
content=[TextContent(type="text", text="Echo: hello")],
structuredContent={"result": "Echo: hello"},
isError=False
)
```

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more