arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/middleware/base.py
Eric Gustin 3424ec8219
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>
2025-09-25 15:28:15 -07:00

241 lines
8.1 KiB
Python

"""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