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:
parent
a270472a09
commit
3424ec8219
177 changed files with 18280 additions and 2508 deletions
12
.github/actions/setup-uv-env/action.yml
vendored
12
.github/actions/setup-uv-env/action.yml
vendored
|
|
@ -32,8 +32,16 @@ runs:
|
|||
working-directory: ${{ inputs.working-directory }}
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install package dependencies
|
||||
if: inputs.is-toolkit == 'true' || inputs.is-contrib == 'true'
|
||||
- name: Install toolkit dependencies
|
||||
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 }}
|
||||
run: |
|
||||
echo "Installing dependencies for ${{ inputs.working-directory }}"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ repos:
|
|||
- id: check-toml
|
||||
exclude: ".*/templates/.*"
|
||||
- id: check-yaml
|
||||
exclude: ".*/templates/.*"
|
||||
exclude: ".*/templates/.*|libs/arcade-mcp-server/mkdocs.yml"
|
||||
- id: end-of-file-fixer
|
||||
exclude: ".*/templates/.*"
|
||||
- id: trailing-whitespace
|
||||
|
|
@ -17,6 +17,6 @@ repos:
|
|||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
exclude: ".*/templates/.*"
|
||||
exclude: "(.*/templates/.*|libs/tests/.*)"
|
||||
- id: ruff-format
|
||||
exclude: ".*/templates/.*"
|
||||
exclude: "(.*/templates/.*|libs/tests/.*)"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ ignore = [
|
|||
|
||||
[lint.per-file-ignores]
|
||||
"**/tests/*" = ["S101"]
|
||||
"libs/**/*.py" = ["C901"]
|
||||
"libs/arcade-mcp-server/docs/**" = ["TRY400"]
|
||||
|
||||
[format]
|
||||
preview = true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Contributing to `arcade-ai`
|
||||
# Contributing to `arcade-mcp`
|
||||
|
||||
Contributions are welcome, and they are greatly appreciated!
|
||||
Every little bit helps, and credit will always be given.
|
||||
|
|
@ -9,7 +9,7 @@ You can contribute in many ways:
|
|||
|
||||
## 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:
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ Arcade could always use more documentation, whether as part of the official docs
|
|||
|
||||
## 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:
|
||||
|
||||
|
|
@ -44,22 +44,22 @@ If you are proposing a new feature:
|
|||
|
||||
# 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.
|
||||
|
||||
1. Fork the `arcade-ai` repo on GitHub.
|
||||
1. Fork the `arcade-mcp` repo on GitHub.
|
||||
|
||||
2. Clone your fork locally:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
cd arcade-ai
|
||||
cd arcade-mcp
|
||||
```
|
||||
|
||||
Create your virtual environment
|
||||
|
|
|
|||
25
Makefile
25
Makefile
|
|
@ -2,7 +2,7 @@
|
|||
.PHONY: install
|
||||
install: ## Install the uv environment and all packages with dependencies
|
||||
@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
|
||||
@echo "✅ All packages and dependencies installed via uv workspace"
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ check: ## Run code quality tools.
|
|||
@echo "🚀 Static type checking: Running mypy on libs"
|
||||
@for lib in libs/arcade*/ ; do \
|
||||
echo "🔍 Type checking $$lib"; \
|
||||
(cd $$lib && uv run mypy . || true); \
|
||||
(cd $$lib && uv run mypy . --exclude tests || true); \
|
||||
done
|
||||
|
||||
.PHONY: check-libs
|
||||
|
|
@ -62,7 +62,7 @@ check-toolkits: ## Run code quality tools for each toolkit that has a Makefile
|
|||
@for dir in toolkits/*/ ; do \
|
||||
if [ -f "$$dir/Makefile" ]; then \
|
||||
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 \
|
||||
echo "🛠️ Skipping toolkit $$dir (no Makefile found)"; \
|
||||
fi; \
|
||||
|
|
@ -71,7 +71,7 @@ check-toolkits: ## Run code quality tools for each toolkit that has a Makefile
|
|||
.PHONY: test
|
||||
test: ## Test the code with 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
|
||||
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 \
|
||||
toolkit_name=$$(basename "$$dir"); \
|
||||
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
|
||||
|
||||
.PHONY: coverage
|
||||
|
|
@ -194,7 +194,7 @@ full-dist: clean-dist ## Build all projects and copy wheels to ./dist
|
|||
(cd libs/$$lib && uv build); \
|
||||
done
|
||||
|
||||
@echo "🛠️ Building arcade-ai package and copying wheel to ./dist"
|
||||
@echo "🛠️ Building arcade-mcp package and copying wheel to ./dist"
|
||||
@uv build
|
||||
@rm -f dist/*.tar.gz
|
||||
|
||||
|
|
@ -224,7 +224,9 @@ clean-dist: ## Clean all built distributions
|
|||
done
|
||||
|
||||
.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
|
||||
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}'
|
||||
|
||||
.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
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -6,15 +6,15 @@
|
|||
>
|
||||
</h3>
|
||||
<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">
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/last-commit/ArcadeAI/arcade-ai" alt="GitHub last commit">
|
||||
<a href="https://github.com/arcadeai/arcade-ai/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/last-commit/ArcadeAI/arcade-mcp" alt="GitHub last commit">
|
||||
<a href="https://github.com/arcadeai/arcade-mcp/actions?query=branch%3Amain">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/arcadeai/arcade-mcp/main.yml?branch=main" alt="GitHub Actions Status">
|
||||
</a>
|
||||
<a href="https://img.shields.io/pypi/pyversions/arcade-ai">
|
||||
<img src="https://img.shields.io/pypi/pyversions/arcade-ai" alt="Python Version">
|
||||
<a href="https://img.shields.io/pypi/pyversions/arcade-mcp">
|
||||
<img src="https://img.shields.io/pypi/pyversions/arcade-mcp" alt="Python Version">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<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;" />
|
||||
</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;" />
|
||||
</a>
|
||||
<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:
|
||||
|
||||
- **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-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-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-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-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-core** - Core platform functionality and schemas | [Source code](https://github.com/ArcadeAI/arcade-mcp/tree/main/libs/arcade-core) | `pip install arcade-core` |
|
||||
- **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-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-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-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` |
|
||||
|
||||

|
||||
|
||||
|
|
@ -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!_
|
||||
|
||||
<a href="https://github.com/ArcadeAI/arcade-ai">
|
||||
<img src="https://img.shields.io/github/stars/ArcadeAI/arcade-ai.svg" alt="GitHub stars">
|
||||
<a href="https://github.com/ArcadeAI/arcade-mcp">
|
||||
<img src="https://img.shields.io/github/stars/ArcadeAI/arcade-mcp.svg" alt="GitHub stars">
|
||||
</a>
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -76,9 +77,9 @@ make install
|
|||
For production use, install individual packages as needed:
|
||||
|
||||
```bash
|
||||
pip install arcade-ai # CLI
|
||||
pip install 'arcade-ai[evals]' # CLI + Evaluation framework
|
||||
pip install 'arcade-ai[all]' # CLI + Serving infra + eval framework + TDK
|
||||
pip install arcade-mcp # CLI
|
||||
pip install 'arcade-mcp[evals]' # CLI + Evaluation framework
|
||||
pip install 'arcade-mcp[all]' # CLI + Serving infra + eval framework + TDK
|
||||
pip install arcade_serve # Serving infrastructure
|
||||
pip install arcade-tdk # Tool Development Kit
|
||||
```
|
||||
|
|
@ -115,5 +116,5 @@ make help
|
|||
## Support and Community
|
||||
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
</h3>
|
||||
<div align="center">
|
||||
<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">
|
||||
</a>
|
||||
<a href="https://pepy.tech/project/crewai-arcade">
|
||||
|
|
@ -34,4 +34,4 @@ pip install crewai-arcade
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ version = "0.1.1"
|
|||
description = "An integration package connecting Arcade and CrewAI"
|
||||
authors = ["Arcade <dev@arcade.dev>"]
|
||||
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"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ graph = create_react_agent(model, tools)
|
|||
# Run the agent with the "user_id" field in the config
|
||||
# IMPORTANT the "user_id" field is required for tools that require user authorization
|
||||
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):
|
||||
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
|
||||
|
||||
|
|
@ -172,4 +172,4 @@ For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade
|
|||
|
||||
## 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).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from .manager import ArcadeToolManager, AsyncToolManager, ToolManager
|
||||
|
||||
__all__ = [
|
||||
"ToolManager",
|
||||
"AsyncToolManager",
|
||||
"ArcadeToolManager", # Deprecated
|
||||
"AsyncToolManager",
|
||||
"ToolManager",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ name = "langchain-arcade"
|
|||
version = "1.4.4"
|
||||
description = "An integration package connecting Arcade and Langchain/LangGraph"
|
||||
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"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ RUN ls -la /app/dist/
|
|||
# Install the worker and CLI package
|
||||
RUN python -m pip install \
|
||||
/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
|
||||
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
|
||||
if [ "$wheel_name" != "arcade_core" ] && \
|
||||
[ "$wheel_name" != "arcade_serve" ] && \
|
||||
[ "$wheel_name" != "arcade_ai" ] && \
|
||||
[ "$wheel_name" != "arcade_mcp" ] && \
|
||||
[ "$wheel_name" != "arcade_mcp_server" ] && \
|
||||
[ "$wheel_name" != "arcade_tdk" ]; then \
|
||||
if ls $wheel_file 1> /dev/null 2>&1; then \
|
||||
echo "Installing $toolkit from $wheel_file"; \
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
VENDOR ?= ArcadeAI
|
||||
PROJECT ?= ArcadeAI
|
||||
SOURCE ?= https://github.com/ArcadeAI/arcade-ai
|
||||
SOURCE ?= https://github.com/ArcadeAI/arcade-mcp
|
||||
LICENSE ?= MIT
|
||||
DESCRIPTION ?= "Arcade Worker for LLM Tool Serving"
|
||||
REPOSITORY ?= arcadeai/worker
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ This guide provides detailed instructions on how to set up and run Arcade using
|
|||
Begin by cloning the Arcade repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ArcadeAI/arcade-ai.git
|
||||
git clone https://github.com/ArcadeAI/arcade-mcp.git
|
||||
```
|
||||
|
||||
### 2. Build package wheels
|
||||
|
||||
From the root of the arcade-ai repository:
|
||||
From the root of the arcade-mcp repository:
|
||||
|
||||
```bash
|
||||
make full-dist
|
||||
|
|
@ -30,7 +30,7 @@ make full-dist
|
|||
Change to the `docker` directory:
|
||||
|
||||
```bash
|
||||
cd arcade-ai/docker
|
||||
cd arcade-mcp/docker
|
||||
```
|
||||
|
||||
Copy the example environment file to `.env`:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
# 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "arcade-ai-sdk",
|
||||
"name": "arcade-mcp-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from openai import OpenAI
|
|||
def call_tool_with_openai(client: OpenAI) -> dict:
|
||||
response = client.chat.completions.create(
|
||||
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
|
||||
user="you@example.com",
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const main = async () => {
|
|||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Star arcadeai/arcade-ai on github",
|
||||
content: "Star arcadeai/arcade-mcp on github",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ graph = create_react_agent(model=bound_model, tools=lc_tools, checkpointer=memor
|
|||
# 6) Provide basic config and a user query.
|
||||
# Note: user_id is required for the tool to be authorized
|
||||
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
|
||||
for chunk in graph.stream(user_input, config, stream_mode="values"):
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ if __name__ == "__main__":
|
|||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Star arcadeai/arcade-ai on github",
|
||||
"content": "Star arcadeai/arcade-mcp on github",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"arcade": {
|
||||
"command": "bash",
|
||||
"args": ["-c", "export ARCADE_API_KEY=arc_xxxx && /path/to/python /path/to/arcade mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -14,7 +14,7 @@ Arcade CLI provides a comprehensive command-line interface for the Arcade platfo
|
|||
|
||||
## Installation
|
||||
```bash
|
||||
pip install arcade-ai
|
||||
pip install arcade-mcp
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
|
|||
236
libs/arcade-cli/arcade_cli/configure.py
Normal file
236
libs/arcade-cli/arcade_cli/configure.py
Normal 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)
|
||||
|
|
@ -10,6 +10,7 @@ from typing import Any
|
|||
|
||||
import toml
|
||||
from arcade_core import Toolkit
|
||||
from arcade_core.catalog import ToolCatalog
|
||||
from arcade_core.toolkit import Validate
|
||||
from arcadepy import Arcade, NotFoundError
|
||||
from httpx import Client, ConnectError, HTTPStatusError, TimeoutException
|
||||
|
|
@ -75,12 +76,78 @@ class Secret(BaseModel):
|
|||
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):
|
||||
"""The configuration for an Arcade worker deployment."""
|
||||
|
||||
id: str
|
||||
"""The unique id for the worker deployment."""
|
||||
|
||||
enabled: bool = True
|
||||
timeout: int = 30
|
||||
retries: int = 3
|
||||
"""Whether the worker is enabled. Defaults to True."""
|
||||
|
||||
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
|
||||
@field_validator("secret", mode="before")
|
||||
|
|
@ -89,21 +156,18 @@ class Config(BaseModel):
|
|||
# If the secret is a string, attempt to parse it as an environment variable or return the secret
|
||||
if isinstance(v, str):
|
||||
secret = get_env_secret(v)
|
||||
# If the secret has been manually set, return it
|
||||
elif isinstance(v, Secret):
|
||||
secret = v
|
||||
else:
|
||||
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() == "" or secret.value == "dev":
|
||||
raise ValueError("Secret must be a non-empty string and not 'dev'")
|
||||
if secret.value.strip() == "":
|
||||
raise ValueError("Secret must be a non-empty string")
|
||||
return secret
|
||||
|
||||
@field_serializer("secret")
|
||||
def serialize_secret(self, secret: Secret) -> str:
|
||||
if secret.pattern:
|
||||
return f"$env:{secret.pattern}"
|
||||
else:
|
||||
return secret.value
|
||||
|
||||
|
||||
|
|
@ -254,7 +318,8 @@ class Worker(BaseModel):
|
|||
)
|
||||
|
||||
# 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
|
||||
byte_stream = io.BytesIO()
|
||||
|
|
@ -287,6 +352,22 @@ class Worker(BaseModel):
|
|||
if 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):
|
||||
toml_path: Path
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ def display_tools_table(tools: list[ToolDefinition]) -> None:
|
|||
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.
|
||||
|
||||
|
|
@ -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("Required", style="yellow")
|
||||
inputs_table.add_column("Description", style="white")
|
||||
|
||||
inputs_table.add_column("Default", style="blue")
|
||||
for param in inputs:
|
||||
# Format the type string properly
|
||||
type_str = _format_type_string(param.value_schema)
|
||||
|
||||
# Add the main parameter row
|
||||
# Since InputParameter does not have a default field, we use "N/A"
|
||||
default_value = "N/A"
|
||||
if param.value_schema.enum:
|
||||
default_value = f"One of {param.value_schema.enum}"
|
||||
inputs_table.add_row(
|
||||
param.name,
|
||||
type_str,
|
||||
param.value_schema.val_type,
|
||||
str(param.required),
|
||||
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_table,
|
||||
title="Input Parameters",
|
||||
|
|
@ -258,7 +241,7 @@ def _add_nested_properties(
|
|||
is_array_item: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Recursively add nested properties to the table.
|
||||
Recursively add nested properties to the output table.
|
||||
|
||||
Args:
|
||||
table: The Rich table to add rows to
|
||||
|
|
@ -270,14 +253,11 @@ def _add_nested_properties(
|
|||
|
||||
# Show array item indicator if needed
|
||||
if is_array_item and indent > 0:
|
||||
# Get column count from the table
|
||||
num_columns = len(table.columns)
|
||||
|
||||
# Create a row with the array indicator in the first column and empty strings for the rest
|
||||
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)
|
||||
table.add_row(
|
||||
f"{indent_prefix[:-2]}[item]",
|
||||
"",
|
||||
"[dim]Each item in array:[/dim]",
|
||||
)
|
||||
|
||||
for prop_name, prop_schema in properties.items():
|
||||
# Format the type string
|
||||
|
|
@ -289,19 +269,11 @@ def _add_nested_properties(
|
|||
if hasattr(prop_schema, "description") and prop_schema.description:
|
||||
description = prop_schema.description
|
||||
|
||||
# Create row data based on number of columns
|
||||
num_columns = len(table.columns)
|
||||
row_data = [f"{indent_prefix}{prop_name}", type_str]
|
||||
|
||||
# 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)
|
||||
table.add_row(
|
||||
f"{indent_prefix}{prop_name}",
|
||||
type_str,
|
||||
f"[dim]{description}[/dim]" if description else "",
|
||||
)
|
||||
|
||||
# Recursively add nested properties if this is a json type with properties
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,52 +1,46 @@
|
|||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from arcadepy import Arcade
|
||||
from arcadepy.types import AuthorizationResponse
|
||||
from openai import OpenAI, OpenAIError
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from tqdm import tqdm
|
||||
|
||||
import arcade_cli.secret as secret
|
||||
import arcade_cli.worker as worker
|
||||
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
|
||||
from arcade_cli.constants import (
|
||||
CREDENTIALS_FILE_PATH,
|
||||
LOCALHOST,
|
||||
PROD_CLOUD_HOST,
|
||||
PROD_ENGINE_HOST,
|
||||
)
|
||||
from arcade_cli.deployment import Deployment
|
||||
from arcade_cli.display import (
|
||||
display_arcade_chat_header,
|
||||
display_eval_results,
|
||||
display_tool_messages,
|
||||
)
|
||||
from arcade_cli.show import show_logic
|
||||
from arcade_cli.toolkit_docs import generate_toolkit_docs
|
||||
from arcade_cli.utils import (
|
||||
OrderCommands,
|
||||
Provider,
|
||||
compute_base_url,
|
||||
compute_login_url,
|
||||
get_eval_files,
|
||||
get_today_context,
|
||||
get_user_input,
|
||||
handle_chat_interaction,
|
||||
handle_tool_authorization,
|
||||
handle_user_command,
|
||||
is_authorization_pending,
|
||||
load_eval_suites,
|
||||
log_engine_health,
|
||||
require_dependency,
|
||||
resolve_provider_api_key,
|
||||
validate_and_get_config,
|
||||
version_callback,
|
||||
)
|
||||
|
|
@ -69,6 +63,13 @@ cli.add_typer(
|
|||
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()
|
||||
|
||||
|
|
@ -179,18 +180,119 @@ def new(
|
|||
),
|
||||
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"),
|
||||
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:
|
||||
"""
|
||||
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:
|
||||
if not full:
|
||||
create_new_toolkit_minimal(directory, toolkit_name)
|
||||
else:
|
||||
create_new_toolkit(directory, toolkit_name)
|
||||
except Exception as e:
|
||||
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(
|
||||
help="Show the installed toolkits or details of a specific tool",
|
||||
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")
|
||||
def evals(
|
||||
directory: str = typer.Argument(".", help="Directory containing evaluation files"),
|
||||
|
|
@ -402,34 +376,19 @@ def evals(
|
|||
"gpt-4o",
|
||||
"--models",
|
||||
"-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(
|
||||
LOCALHOST,
|
||||
"-h",
|
||||
"--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,
|
||||
provider: Provider = typer.Option(
|
||||
Provider.OPENAI,
|
||||
"--provider",
|
||||
"-p",
|
||||
"--port",
|
||||
help="The port of the Arcade Engine.",
|
||||
help="The provider of the models to use for evaluation.",
|
||||
),
|
||||
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.",
|
||||
provider_api_key: str = typer.Option(
|
||||
None,
|
||||
"--provider-api-key",
|
||||
"-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.",
|
||||
),
|
||||
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
|
||||
) -> None:
|
||||
|
|
@ -440,7 +399,7 @@ def evals(
|
|||
require_dependency(
|
||||
package_name="arcade_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
|
||||
# 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",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
if not eval_files:
|
||||
return
|
||||
|
||||
console.print(
|
||||
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)
|
||||
console.print("\nRunning evaluations", style="bold")
|
||||
|
||||
# Use the new function to load eval suites
|
||||
eval_suites = load_eval_suites(eval_files)
|
||||
|
|
@ -500,8 +459,7 @@ def evals(
|
|||
for model in models_list:
|
||||
task = asyncio.create_task(
|
||||
suite_func(
|
||||
config=config,
|
||||
base_url=base_url,
|
||||
provider_api_key=resolved_api_key,
|
||||
model=model,
|
||||
max_concurrency=max_concurrent,
|
||||
)
|
||||
|
|
@ -528,6 +486,7 @@ def evals(
|
|||
@cli.command(
|
||||
help="Start tool server worker with locally installed tools",
|
||||
rich_help_panel="Launch",
|
||||
hidden=True,
|
||||
)
|
||||
def serve(
|
||||
host: str = typer.Option(
|
||||
|
|
@ -565,6 +524,9 @@ def serve(
|
|||
"""
|
||||
Start a local Arcade Worker server.
|
||||
"""
|
||||
console.log(
|
||||
"⚠️ This command is deprecated and will be removed in a future version.", style="yellow"
|
||||
)
|
||||
require_dependency(
|
||||
package_name="arcade_serve",
|
||||
command_name="serve",
|
||||
|
|
@ -590,58 +552,68 @@ def serve(
|
|||
|
||||
|
||||
@cli.command(
|
||||
help="Start a server with locally installed Arcade tools",
|
||||
rich_help_panel="Launch",
|
||||
hidden=True,
|
||||
help="Configure MCP clients to connect to your server", rich_help_panel="Tool Development"
|
||||
)
|
||||
def workerup(
|
||||
host: str = typer.Option(
|
||||
"127.0.0.1",
|
||||
help="Host for the app, from settings by default.",
|
||||
show_default=True,
|
||||
def configure(
|
||||
client: str = typer.Argument(
|
||||
...,
|
||||
help="The MCP client to configure (claude, cursor, vscode)",
|
||||
),
|
||||
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(
|
||||
"8002",
|
||||
"-p",
|
||||
8000,
|
||||
"--port",
|
||||
help="Port for the app, defaults to ",
|
||||
show_default=True,
|
||||
"-p",
|
||||
help="Port for local servers",
|
||||
),
|
||||
disable_auth: bool = typer.Option(
|
||||
False,
|
||||
"--no-auth",
|
||||
help="Disable authentication for the worker. Not recommended for production.",
|
||||
show_default=True,
|
||||
),
|
||||
otel_enable: bool = typer.Option(
|
||||
False, "--otel-enable", help="Send logs to OpenTelemetry", show_default=True
|
||||
path: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--path",
|
||||
"-f",
|
||||
exists=False,
|
||||
help="Optional path to a specific MCP client config file (overrides default path)",
|
||||
),
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
) -> None:
|
||||
"""
|
||||
Starts the worker with host, port, and reload options. Uses
|
||||
Uvicorn as ASGI worker. Parameters allow runtime configuration.
|
||||
"""
|
||||
require_dependency(
|
||||
package_name="arcade_serve",
|
||||
command_name="worker",
|
||||
install_command=r"pip install 'arcade-serve'",
|
||||
)
|
||||
Configure MCP clients to connect to your server.
|
||||
|
||||
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:
|
||||
serve_default_worker(
|
||||
host,
|
||||
port,
|
||||
disable_auth=disable_auth,
|
||||
enable_otel=otel_enable,
|
||||
debug=debug,
|
||||
configure_client(
|
||||
client=client,
|
||||
server_name=server_name,
|
||||
from_local=from_local,
|
||||
from_arcade=from_arcade,
|
||||
port=port,
|
||||
path=path,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
typer.Exit()
|
||||
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")
|
||||
|
|
@ -712,6 +684,30 @@ def deploy(
|
|||
for worker in deployment.worker:
|
||||
console.log(f"Deploying '{worker.config.id}...'", style="dim")
|
||||
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
|
||||
worker.request().execute(cloud_client, engine_client)
|
||||
console.log(
|
||||
|
|
@ -792,7 +788,10 @@ def dashboard(
|
|||
)
|
||||
def docs(
|
||||
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(
|
||||
...,
|
||||
|
|
@ -897,7 +896,10 @@ def docs(
|
|||
)
|
||||
def generate_toolkit_docs_command(
|
||||
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(
|
||||
...,
|
||||
|
|
@ -975,14 +977,14 @@ def main_callback(
|
|||
help="Print version and exit.",
|
||||
),
|
||||
) -> None:
|
||||
excluded_commands = {
|
||||
# Commands that do not require a logged in user
|
||||
public_commands = {
|
||||
login.__name__,
|
||||
logout.__name__,
|
||||
serve.__name__,
|
||||
workerup.__name__,
|
||||
dashboard.__name__,
|
||||
evals.__name__,
|
||||
}
|
||||
if ctx.invoked_subcommand in excluded_commands:
|
||||
if ctx.invoked_subcommand in public_commands:
|
||||
return
|
||||
|
||||
if not check_existing_login(suppress_message=True):
|
||||
|
|
|
|||
|
|
@ -9,25 +9,25 @@ import typer
|
|||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from rich.console import Console
|
||||
|
||||
from arcade_cli.deployment import (
|
||||
create_demo_deployment,
|
||||
)
|
||||
from arcade_cli.templates import get_full_template_directory, get_minimal_template_directory
|
||||
|
||||
console = Console()
|
||||
|
||||
# Retrieve the installed version of arcade-ai
|
||||
# Retrieve the installed version of arcade-mcp
|
||||
try:
|
||||
ARCADE_AI_MIN_VERSION = get_version("arcade-ai")
|
||||
ARCADE_AI_MAX_VERSION = str(int(ARCADE_AI_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
|
||||
ARCADE_MCP_MIN_VERSION = get_version("arcade-mcp")
|
||||
ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to get arcade-ai version: {e}[/red]")
|
||||
ARCADE_AI_MIN_VERSION = "2.0.0" # Default version if unable to fetch
|
||||
ARCADE_AI_MAX_VERSION = "3.0.0"
|
||||
console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]")
|
||||
ARCADE_MCP_MIN_VERSION = "1.0.0rc1" # Default version if unable to fetch
|
||||
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_SERVE_MIN_VERSION = "2.0.0"
|
||||
ARCADE_SERVE_MIN_VERSION = "2.2.0rc1"
|
||||
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:
|
||||
|
|
@ -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
|
||||
# name of the repo, a better detection method is required here
|
||||
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 = (
|
||||
"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)
|
||||
|
||||
|
|
@ -200,13 +200,14 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
|
|||
"arcade_tdk_max_version": ARCADE_TDK_MAX_VERSION,
|
||||
"arcade_serve_min_version": ARCADE_SERVE_MIN_VERSION,
|
||||
"arcade_serve_max_version": ARCADE_SERVE_MAX_VERSION,
|
||||
"arcade_ai_min_version": ARCADE_AI_MIN_VERSION,
|
||||
"arcade_ai_max_version": ARCADE_AI_MAX_VERSION,
|
||||
"arcade_mcp_min_version": ARCADE_MCP_MIN_VERSION,
|
||||
"arcade_mcp_max_version": ARCADE_MCP_MAX_VERSION,
|
||||
"creation_year": datetime.now().year,
|
||||
"is_community_toolkit": is_community_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(
|
||||
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:
|
||||
worker_toml = toolkit_directory / "worker.toml"
|
||||
if not worker_toml.exists():
|
||||
create_demo_deployment(worker_toml, toolkit_name)
|
||||
else:
|
||||
# No longer create worker.toml for MCP servers
|
||||
# The server.py file handles all configuration
|
||||
pass
|
||||
# Disabled pending bug fix
|
||||
# update_deployment_with_local_packages(worker_toml, toolkit_name)
|
||||
|
||||
|
||||
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:
|
||||
console.print(
|
||||
"[red]Toolkit name contains illegal characters. "
|
||||
"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
|
||||
|
|
|
|||
286
libs/arcade-cli/arcade_cli/secret.py
Normal file
286
libs/arcade-cli/arcade_cli/secret.py
Normal 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()
|
||||
|
|
@ -15,8 +15,8 @@ import uvicorn
|
|||
# Watchfiles is used under the hood by Uvicorn's reload feature.
|
||||
# Importing watchfiles here is an explicit acknowledgement that it needs to be installed
|
||||
import watchfiles # noqa: F401
|
||||
from arcade_core.telemetry import OTELHandler
|
||||
from arcade_core.toolkit import Toolkit, get_package_directory
|
||||
from arcade_serve.fastapi.telemetry import OTELHandler
|
||||
from arcade_serve.fastapi.worker import FastAPIWorker
|
||||
from loguru import logger
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
logger.info("Registered toolkits:")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ from rich.console import Console
|
|||
from rich.markup import escape
|
||||
|
||||
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(
|
||||
|
|
@ -25,7 +29,7 @@ def show_logic(
|
|||
console = Console()
|
||||
try:
|
||||
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)]
|
||||
else:
|
||||
tools = get_tools_from_engine(host, port, force_tls, force_no_tls, toolkit)
|
||||
|
|
|
|||
10
libs/arcade-cli/arcade_cli/templates/__init__.py
Normal file
10
libs/arcade-cli/arcade_cli/templates/__init__.py
Normal 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"
|
||||
|
|
@ -24,7 +24,7 @@ email = "{{ toolkit_author_email }}"
|
|||
|
||||
[project.optional-dependencies]
|
||||
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 }}",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
|
|
@ -43,16 +43,16 @@ toolkit_name = "{{ package_name }}"
|
|||
{% if is_community_toolkit -%}
|
||||
# Use local path sources for arcade libs when working locally
|
||||
[tool.uv.sources]
|
||||
arcade-ai = { path = "../../", editable = true }
|
||||
arcade-mcp = { path = "../../", editable = true }
|
||||
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
|
||||
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
|
||||
{% endif -%}
|
||||
{% if is_official_toolkit -%}
|
||||
# Use local path sources for arcade libs when working locally
|
||||
[tool.uv.sources]
|
||||
arcade-ai = { path = "../../../arcade-ai", editable = true }
|
||||
arcade-serve = { path = "../../../arcade-ai/libs/arcade-serve/", editable = true }
|
||||
arcade-tdk = { path = "../../../arcade-ai/libs/arcade-tdk/", editable = true }
|
||||
arcade-mcp = { path = "../../../arcade-mcp", editable = true }
|
||||
arcade-serve = { path = "../../../arcade-mcp/libs/arcade-serve/", editable = true }
|
||||
arcade-tdk = { path = "../../../arcade-mcp/libs/arcade-tdk/", editable = true }
|
||||
{% endif -%}
|
||||
|
||||
[tool.mypy]
|
||||
|
|
@ -0,0 +1 @@
|
|||
MY_SECRET_KEY="Your tools can have secrets injected at runtime!"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -97,10 +97,7 @@ const USER_ID = "{{arcade_user_id}}";
|
|||
const TOOL_NAME = "{tool_fully_qualified_name}";
|
||||
|
||||
// Start the authorization process
|
||||
const authResponse = await client.tools.authorize({{
|
||||
tool_name: TOOL_NAME,
|
||||
user_id: USER_ID
|
||||
}});
|
||||
const authResponse = await client.tools.authorize({{tool_name: TOOL_NAME}});
|
||||
|
||||
if (authResponse.status !== "completed") {{
|
||||
console.log(`Click this link to authorize: ${{authResponse.url}}`);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import importlib.util
|
|||
import ipaddress
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
|
@ -16,6 +17,12 @@ import idna
|
|||
import typer
|
||||
from arcade_core import ToolCatalog, Toolkit
|
||||
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.schema import ToolDefinition
|
||||
from arcadepy import (
|
||||
|
|
@ -65,6 +72,12 @@ class ChatCommand(str, Enum):
|
|||
EXIT = "/exit"
|
||||
|
||||
|
||||
class Provider(str, Enum):
|
||||
"""Supported model providers for evaluations."""
|
||||
|
||||
OPENAI = "openai"
|
||||
|
||||
|
||||
def create_cli_catalog(
|
||||
toolkit: str | None = None,
|
||||
show_toolkits: bool = False,
|
||||
|
|
@ -98,6 +111,59 @@ def create_cli_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(
|
||||
force_tls: bool,
|
||||
force_no_tls: bool,
|
||||
|
|
@ -530,7 +596,30 @@ def get_eval_files(directory: str) -> list[Path]:
|
|||
directory_path = Path(directory).resolve()
|
||||
|
||||
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():
|
||||
eval_files = (
|
||||
[directory_path]
|
||||
|
|
@ -555,21 +644,26 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]:
|
|||
"""
|
||||
Load evaluation suites from the given eval_files by importing the modules
|
||||
and extracting functions decorated with `@tool_eval`.
|
||||
|
||||
Args:
|
||||
eval_files: A list of Paths to evaluation files.
|
||||
|
||||
Returns:
|
||||
A list of callable evaluation suite functions.
|
||||
"""
|
||||
eval_suites = []
|
||||
for eval_file_path in eval_files:
|
||||
module_name = eval_file_path.stem # filename without extension
|
||||
|
||||
# Now we need to load the module from eval_file_path
|
||||
file_path_str = str(eval_file_path)
|
||||
module_name_str = module_name
|
||||
|
||||
# Add the directory containing the eval file to sys.path temporarily
|
||||
# so that the eval file can import other modules in the same directory
|
||||
eval_dir = str(eval_file_path.parent)
|
||||
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:
|
||||
|
|
@ -597,6 +691,12 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]:
|
|||
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
|
||||
finally:
|
||||
# Restore the original sys.path
|
||||
sys.path[:] = original_path
|
||||
|
||||
return eval_suites
|
||||
|
||||
|
|
@ -698,7 +798,7 @@ def version_callback(value: bool) -> None:
|
|||
Prints the version of Arcade and exit.
|
||||
"""
|
||||
if value:
|
||||
version = metadata.version("arcade-ai")
|
||||
version = metadata.version("arcade-mcp")
|
||||
console.print(f"[bold]Arcade CLI[/bold] (version {version})")
|
||||
exit()
|
||||
|
||||
|
|
@ -787,6 +887,45 @@ def load_dotenv(path: str | Path, *, override: bool = False) -> dict[str, str]:
|
|||
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(
|
||||
package_name: str,
|
||||
command_name: str,
|
||||
|
|
@ -798,7 +937,7 @@ def require_dependency(
|
|||
Args:
|
||||
package_name: The name of the package to import (e.g., 'arcade_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:
|
||||
importlib.import_module(package_name.replace("-", "_"))
|
||||
|
|
|
|||
|
|
@ -405,7 +405,9 @@ class ToolCatalog(BaseModel):
|
|||
# Hard requirement: tools must have descriptions
|
||||
tool_description = getattr(tool, "__tool_description__", None)
|
||||
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 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
|
||||
|
||||
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:
|
||||
raise ToolInputSchemaError(
|
||||
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
|
||||
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:
|
||||
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__"):
|
||||
func = func.__wrapped__
|
||||
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
|
||||
# Skip ToolContext parameters
|
||||
if param.annotation is ToolContext:
|
||||
# Skip ToolContext parameters (including subclasses like arcade_mcp_server.Context)
|
||||
ann = param.annotation
|
||||
if isinstance(ann, type) and issubclass(ann, ToolContext):
|
||||
continue
|
||||
|
||||
# TODO make this cleaner
|
||||
|
|
@ -1004,7 +1011,7 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
|
|||
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.
|
||||
"""
|
||||
|
|
@ -1149,9 +1156,13 @@ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[
|
|||
def to_tool_secret_requirements(
|
||||
secrets_requirement: list[str],
|
||||
) -> list[ToolSecretRequirement]:
|
||||
# Iterate through the list, de-dupe case-insensitively, and convert each string to a ToolSecretRequirement
|
||||
unique_secrets = {name.lower(): name.lower() for name in secrets_requirement}.values()
|
||||
return [ToolSecretRequirement(key=name) for name in unique_secrets]
|
||||
# De-dupe case-insensitively but preserve the original casing for env var lookup
|
||||
unique_map: dict[str, str] = {}
|
||||
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(
|
||||
|
|
|
|||
128
libs/arcade-core/arcade_core/context.py
Normal file
128
libs/arcade-core/arcade_core/context.py
Normal 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: ...
|
||||
220
libs/arcade-core/arcade_core/converters/openai.py
Normal file
220
libs/arcade-core/arcade_core/converters/openai.py
Normal 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
|
||||
253
libs/arcade-core/arcade_core/discovery.py
Normal file
253
libs/arcade-core/arcade_core/discovery.py
Normal 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
|
||||
|
|
@ -36,6 +36,18 @@ def get_function_name_if_decorated(
|
|||
and isinstance(decorator.func, ast.Name)
|
||||
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 None
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
|
@ -23,10 +41,10 @@ class ValueSchema(BaseModel):
|
|||
enum: list[str] | None = None
|
||||
"""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."""
|
||||
|
||||
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."""
|
||||
|
||||
description: str | None = None
|
||||
|
|
@ -100,7 +118,7 @@ class ToolAuthRequirement(BaseModel):
|
|||
# or
|
||||
# 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.
|
||||
provider_id: str | None = None
|
||||
"""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(),
|
||||
))
|
||||
|
||||
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."""
|
||||
return (
|
||||
self.name.lower() == other.name.lower()
|
||||
|
|
@ -208,7 +226,7 @@ class FullyQualifiedName:
|
|||
)
|
||||
|
||||
@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."""
|
||||
return FullyQualifiedName(tool_name, toolkit.name, toolkit.version)
|
||||
|
||||
|
|
@ -298,7 +316,16 @@ class ToolMetadataItem(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
|
||||
"""The authorization context for the tool invocation that requires authorization."""
|
||||
|
|
@ -312,16 +339,35 @@ class ToolContext(BaseModel):
|
|||
user_id: str | None = None
|
||||
"""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:
|
||||
"""Retrieve the authorization token, or return an empty string if not available."""
|
||||
return self.authorization.token if self.authorization and self.authorization.token else ""
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
def _get_item(
|
||||
|
|
@ -335,21 +381,14 @@ class ToolContext(BaseModel):
|
|||
f"{item_name.capitalize()} key passed to get_{item_name} cannot be empty."
|
||||
)
|
||||
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()
|
||||
for item in items:
|
||||
if item.key.lower() == normalized_key:
|
||||
return item.value
|
||||
|
||||
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)
|
||||
raise ValueError(f"{item_name.capitalize()} '{key}' not found in context.")
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import types
|
|||
from collections import defaultdict
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
import toml
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from arcade_core.errors import ToolkitLoadError
|
||||
|
|
@ -59,6 +60,71 @@ class Toolkit(BaseModel):
|
|||
"""
|
||||
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
|
||||
def from_package(cls, package: str) -> "Toolkit":
|
||||
"""
|
||||
|
|
@ -232,9 +298,14 @@ class Toolkit(BaseModel):
|
|||
for module_path in modules:
|
||||
relative_path = module_path.relative_to(package_dir)
|
||||
cls.validate_file(module_path)
|
||||
import_path = ".".join(relative_path.with_suffix("").parts)
|
||||
import_path = f"{package_name}.{import_path}"
|
||||
tools[import_path] = get_tools_from_file(str(module_path))
|
||||
# Build import path and avoid duplicating the package prefix if it already exists
|
||||
relative_parts = relative_path.with_suffix("").parts
|
||||
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:
|
||||
raise ToolkitLoadError(f"No tools found in package {package_name}")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import ast
|
|||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable, Iterable
|
||||
from textwrap import dedent
|
||||
from types import UnionType
|
||||
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:
|
||||
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):
|
||||
def __init__(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-core"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0rc1"
|
||||
description = "Arcade Core - Core library for Arcade platform"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
@ -28,9 +28,6 @@ dependencies = [
|
|||
"types-python-dateutil==2.9.0.20241003",
|
||||
"types-pytz==2024.2.0.20241003",
|
||||
"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]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Arcade Evals provides comprehensive evaluation capabilities for Arcade tools:
|
|||
## Installation
|
||||
|
||||
```bash
|
||||
pip install 'arcade-ai[evals]'
|
||||
pip install 'arcade-mcp[evals]'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
|||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
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 openai import AsyncOpenAI
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
|
|
@ -613,14 +613,12 @@ class EvalSuite:
|
|||
Args:
|
||||
client: The AsyncOpenAI client instance.
|
||||
model: The model to evaluate.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the evaluation results.
|
||||
"""
|
||||
results: dict[str, Any] = {"model": model, "rubric": self.rubric, "cases": []}
|
||||
|
||||
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||
tool_names = list(self.catalog.get_tool_names())
|
||||
|
||||
async def sem_task(case: EvalCase) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
|
|
@ -629,12 +627,14 @@ class EvalSuite:
|
|||
messages.extend(case.additional_messages)
|
||||
messages.append({"role": "user", "content": case.user_message})
|
||||
|
||||
tools = get_formatted_tools(self.catalog, tool_format="openai")
|
||||
|
||||
# Get the model response
|
||||
response = await client.chat.completions.create( # type: ignore[call-overload]
|
||||
model=model,
|
||||
messages=messages,
|
||||
tool_choice="auto",
|
||||
tools=(str(name) for name in tool_names),
|
||||
tools=tools,
|
||||
user="eval_user",
|
||||
seed=42,
|
||||
stream=False,
|
||||
|
|
@ -675,6 +675,23 @@ class EvalSuite:
|
|||
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]]]:
|
||||
"""
|
||||
Returns the tool arguments from the chat completion object.
|
||||
|
|
@ -729,8 +746,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
|
|||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
config: Config,
|
||||
base_url: str,
|
||||
provider_api_key: str,
|
||||
model: str,
|
||||
max_concurrency: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
|
|
@ -740,8 +756,7 @@ def tool_eval() -> Callable[[Callable], Callable]:
|
|||
suite.max_concurrent = max_concurrency
|
||||
results = []
|
||||
async with AsyncOpenAI(
|
||||
api_key=config.api.key,
|
||||
base_url=base_url + "/v1",
|
||||
api_key=provider_api_key,
|
||||
) as client:
|
||||
result = await suite.run(client, model)
|
||||
results.append(result)
|
||||
|
|
|
|||
40
libs/arcade-mcp-server/Makefile
Normal file
40
libs/arcade-mcp-server/Makefile
Normal 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
|
||||
72
libs/arcade-mcp-server/README.md
Normal file
72
libs/arcade-mcp-server/README.md
Normal 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.
|
||||
44
libs/arcade-mcp-server/arcade_mcp_server/__init__.py
Normal file
44
libs/arcade-mcp-server/arcade_mcp_server/__init__.py
Normal 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"
|
||||
353
libs/arcade-mcp-server/arcade_mcp_server/__main__.py
Normal file
353
libs/arcade-mcp-server/arcade_mcp_server/__main__.py
Normal 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()
|
||||
697
libs/arcade-mcp-server/arcade_mcp_server/context.py
Normal file
697
libs/arcade-mcp-server/arcade_mcp_server/context.py
Normal 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)
|
||||
370
libs/arcade-mcp-server/arcade_mcp_server/convert.py
Normal file
370
libs/arcade-mcp-server/arcade_mcp_server/convert.py
Normal 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
|
||||
93
libs/arcade-mcp-server/arcade_mcp_server/exceptions.py
Normal file
93
libs/arcade-mcp-server/arcade_mcp_server/exceptions.py
Normal 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."""
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""FastAPI integration for MCP server."""
|
||||
335
libs/arcade-mcp-server/arcade_mcp_server/fastapi/routes.py
Normal file
335
libs/arcade-mcp-server/arcade_mcp_server/fastapi/routes.py
Normal 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
|
||||
161
libs/arcade-mcp-server/arcade_mcp_server/lifespan.py
Normal file
161
libs/arcade-mcp-server/arcade_mcp_server/lifespan.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
146
libs/arcade-mcp-server/arcade_mcp_server/managers/base.py
Normal file
146
libs/arcade-mcp-server/arcade_mcp_server/managers/base.py
Normal 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
|
||||
122
libs/arcade-mcp-server/arcade_mcp_server/managers/prompt.py
Normal file
122
libs/arcade-mcp-server/arcade_mcp_server/managers/prompt.py
Normal 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
|
||||
102
libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py
Normal file
102
libs/arcade-mcp-server/arcade_mcp_server/managers/resource.py
Normal 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)
|
||||
94
libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py
Normal file
94
libs/arcade-mcp-server/arcade_mcp_server/managers/tool.py
Normal 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"]
|
||||
316
libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
Normal file
316
libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
241
libs/arcade-mcp-server/arcade_mcp_server/middleware/base.py
Normal file
241
libs/arcade-mcp-server/arcade_mcp_server/middleware/base.py
Normal 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
|
||||
|
|
@ -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
|
||||
121
libs/arcade-mcp-server/arcade_mcp_server/middleware/logging.py
Normal file
121
libs/arcade-mcp-server/arcade_mcp_server/middleware/logging.py
Normal 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))
|
||||
898
libs/arcade-mcp-server/arcade_mcp_server/server.py
Normal file
898
libs/arcade-mcp-server/arcade_mcp_server/server.py
Normal 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)
|
||||
637
libs/arcade-mcp-server/arcade_mcp_server/session.py
Normal file
637
libs/arcade-mcp-server/arcade_mcp_server/session.py
Normal 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
|
||||
252
libs/arcade-mcp-server/arcade_mcp_server/settings.py
Normal file
252
libs/arcade-mcp-server/arcade_mcp_server/settings.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
transport→session read stream (via `_read_stream_writer`).
|
||||
- Consume session→transport 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`: transport→session channel for inbound
|
||||
client messages.
|
||||
- `_write_stream` / `_write_stream_reader`: session→transport 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}")
|
||||
209
libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py
Normal file
209
libs/arcade-mcp-server/arcade_mcp_server/transports/stdio.py
Normal 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)
|
||||
666
libs/arcade-mcp-server/arcade_mcp_server/types.py
Normal file
666
libs/arcade-mcp-server/arcade_mcp_server/types.py
Normal 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
|
||||
)
|
||||
291
libs/arcade-mcp-server/arcade_mcp_server/worker.py
Normal file
291
libs/arcade-mcp-server/arcade_mcp_server/worker.py
Normal 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",
|
||||
)
|
||||
189
libs/arcade-mcp-server/docs/advanced/transports.md
Normal file
189
libs/arcade-mcp-server/docs/advanced/transports.md
Normal 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...")
|
||||
```
|
||||
105
libs/arcade-mcp-server/docs/api/cli.md
Normal file
105
libs/arcade-mcp-server/docs/api/cli.md
Normal 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
|
||||
```
|
||||
47
libs/arcade-mcp-server/docs/api/mcp_app.md
Normal file
47
libs/arcade-mcp-server/docs/api/mcp_app.md
Normal 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
|
||||
```
|
||||
36
libs/arcade-mcp-server/docs/api/server/errors.md
Normal file
36
libs/arcade-mcp-server/docs/api/server/errors.md
Normal 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}")
|
||||
```
|
||||
50
libs/arcade-mcp-server/docs/api/server/middleware.md
Normal file
50
libs/arcade-mcp-server/docs/api/server/middleware.md
Normal 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])
|
||||
```
|
||||
53
libs/arcade-mcp-server/docs/api/server/server.md
Normal file
53
libs/arcade-mcp-server/docs/api/server/server.md
Normal 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())
|
||||
```
|
||||
49
libs/arcade-mcp-server/docs/api/server/settings.md
Normal file
49
libs/arcade-mcp-server/docs/api/server/settings.md
Normal 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()
|
||||
```
|
||||
37
libs/arcade-mcp-server/docs/api/server/types.md
Normal file
37
libs/arcade-mcp-server/docs/api/server/types.md
Normal 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
|
||||
)
|
||||
```
|
||||
BIN
libs/arcade-mcp-server/docs/clients/claude.md
Normal file
BIN
libs/arcade-mcp-server/docs/clients/claude.md
Normal file
Binary file not shown.
BIN
libs/arcade-mcp-server/docs/clients/cursor.md
Normal file
BIN
libs/arcade-mcp-server/docs/clients/cursor.md
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue