320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""Canonical MCP Configuration Format.
|
|
|
|
This module defines the standard configuration format for Model Context Protocol (MCP) servers.
|
|
It provides a client-agnostic, extensible format that can be used across all MCP implementations.
|
|
|
|
The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive
|
|
field definitions for server metadata, authentication, and execution parameters.
|
|
|
|
Example configuration:
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-server": {
|
|
"command": "npx",
|
|
"args": ["-y", "@my/mcp-server"],
|
|
"env": {"API_KEY": "secret"},
|
|
"timeout": 30000,
|
|
"description": "My MCP server"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import re
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
from pydantic import (
|
|
AnyUrl,
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
ValidationInfo,
|
|
model_validator,
|
|
)
|
|
from typing_extensions import Self, override
|
|
|
|
from fastmcp.tools.tool_transform import ToolTransformConfig
|
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
|
|
if TYPE_CHECKING:
|
|
from fastmcp.client.transports import (
|
|
ClientTransport,
|
|
FastMCPTransport,
|
|
SSETransport,
|
|
StdioTransport,
|
|
StreamableHttpTransport,
|
|
)
|
|
|
|
|
|
def infer_transport_type_from_url(
|
|
url: str | AnyUrl,
|
|
) -> Literal["http", "sse"]:
|
|
"""
|
|
Infer the appropriate transport type from the given URL.
|
|
"""
|
|
url = str(url)
|
|
if not url.startswith("http"):
|
|
raise ValueError(f"Invalid URL: {url}")
|
|
|
|
parsed_url = urlparse(url)
|
|
path = parsed_url.path
|
|
|
|
# Match /sse followed by /, ?, &, or end of string
|
|
if re.search(r"/sse(/|\?|&|$)", path):
|
|
return "sse"
|
|
else:
|
|
return "http"
|
|
|
|
|
|
class _TransformingMCPServerMixin(FastMCPBaseModel):
|
|
"""A mixin that enables wrapping an MCP Server with tool transforms."""
|
|
|
|
tools: dict[str, ToolTransformConfig] = Field(...)
|
|
"""The multi-tool transform to apply to the tools."""
|
|
|
|
include_tags: set[str] | None = Field(
|
|
default=None,
|
|
description="The tags to include in the proxy.",
|
|
)
|
|
|
|
exclude_tags: set[str] | None = Field(
|
|
default=None,
|
|
description="The tags to exclude in the proxy.",
|
|
)
|
|
|
|
def to_transport(self) -> FastMCPTransport:
|
|
"""Get the transport for the server."""
|
|
from fastmcp.client.transports import FastMCPTransport
|
|
from fastmcp.server.server import FastMCP
|
|
|
|
transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
|
|
|
|
wrapped_mcp_server = FastMCP.as_proxy(
|
|
transport,
|
|
tool_transformations=self.tools,
|
|
include_tags=self.include_tags,
|
|
exclude_tags=self.exclude_tags,
|
|
)
|
|
|
|
return FastMCPTransport(wrapped_mcp_server)
|
|
|
|
|
|
class StdioMCPServer(BaseModel):
|
|
"""MCP server configuration for stdio transport.
|
|
|
|
This is the canonical configuration format for MCP servers using stdio transport.
|
|
"""
|
|
|
|
# Required fields
|
|
command: str
|
|
|
|
# Common optional fields
|
|
args: list[str] = Field(default_factory=list)
|
|
env: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
# Transport specification
|
|
transport: Literal["stdio"] = "stdio"
|
|
type: Literal["stdio"] | None = None # Alternative transport field name
|
|
|
|
# Execution context
|
|
cwd: str | None = None # Working directory for command execution
|
|
timeout: int | None = None # Maximum response time in milliseconds
|
|
|
|
# Metadata
|
|
description: str | None = None # Human-readable server description
|
|
icon: str | None = None # Icon path or URL for UI display
|
|
|
|
# Authentication configuration
|
|
authentication: dict[str, Any] | None = None # Auth configuration object
|
|
|
|
model_config = ConfigDict(extra="allow") # Preserve unknown fields
|
|
|
|
def to_transport(self) -> StdioTransport:
|
|
from fastmcp.client.transports import StdioTransport
|
|
|
|
return StdioTransport(
|
|
command=self.command,
|
|
args=self.args,
|
|
env=self.env,
|
|
cwd=self.cwd,
|
|
)
|
|
|
|
|
|
class TransformingStdioMCPServer(_TransformingMCPServerMixin, StdioMCPServer):
|
|
"""A Stdio server with tool transforms."""
|
|
|
|
|
|
class RemoteMCPServer(BaseModel):
|
|
"""MCP server configuration for HTTP/SSE transport.
|
|
|
|
This is the canonical configuration format for MCP servers using remote transports.
|
|
"""
|
|
|
|
# Required fields
|
|
url: str
|
|
|
|
# Transport configuration
|
|
transport: Literal["http", "streamable-http", "sse"] | None = None
|
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
|
|
# Authentication
|
|
auth: Annotated[
|
|
str | Literal["oauth"] | httpx.Auth | None,
|
|
Field(
|
|
description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
|
|
),
|
|
] = None
|
|
|
|
# Timeout configuration
|
|
sse_read_timeout: datetime.timedelta | int | float | None = None
|
|
timeout: int | None = None # Maximum response time in milliseconds
|
|
|
|
# Metadata
|
|
description: str | None = None # Human-readable server description
|
|
icon: str | None = None # Icon path or URL for UI display
|
|
|
|
# Authentication configuration
|
|
authentication: dict[str, Any] | None = None # Auth configuration object
|
|
|
|
model_config = ConfigDict(
|
|
extra="allow", arbitrary_types_allowed=True
|
|
) # Preserve unknown fields
|
|
|
|
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
|
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
|
|
if self.transport is None:
|
|
transport = infer_transport_type_from_url(self.url)
|
|
else:
|
|
transport = self.transport
|
|
|
|
if transport == "sse":
|
|
return SSETransport(
|
|
self.url,
|
|
headers=self.headers,
|
|
auth=self.auth,
|
|
sse_read_timeout=self.sse_read_timeout,
|
|
)
|
|
else:
|
|
# Both "http" and "streamable-http" map to StreamableHttpTransport
|
|
return StreamableHttpTransport(
|
|
self.url,
|
|
headers=self.headers,
|
|
auth=self.auth,
|
|
sse_read_timeout=self.sse_read_timeout,
|
|
)
|
|
|
|
|
|
class TransformingRemoteMCPServer(_TransformingMCPServerMixin, RemoteMCPServer):
|
|
"""A Remote server with tool transforms."""
|
|
|
|
|
|
TransformingMCPServerTypes = TransformingStdioMCPServer | TransformingRemoteMCPServer
|
|
|
|
CanonicalMCPServerTypes = StdioMCPServer | RemoteMCPServer
|
|
|
|
MCPServerTypes = TransformingMCPServerTypes | CanonicalMCPServerTypes
|
|
|
|
|
|
class MCPConfig(BaseModel):
|
|
"""A configuration object for MCP Servers that conforms to the canonical MCP configuration format
|
|
while adding additional fields for enabling FastMCP-specific features like tool transformations
|
|
and filtering by tags.
|
|
|
|
For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class.
|
|
"""
|
|
|
|
mcpServers: dict[str, MCPServerTypes]
|
|
|
|
model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
|
|
|
|
@model_validator(mode="before")
|
|
def validate_mcp_servers(self, info: ValidationInfo) -> dict[str, Any]:
|
|
"""Validate the MCP servers."""
|
|
if not isinstance(self, dict):
|
|
raise ValueError("MCPConfig format requires a dictionary of servers.")
|
|
|
|
if "mcpServers" not in self:
|
|
self = {"mcpServers": self}
|
|
|
|
return self
|
|
|
|
def add_server(self, name: str, server: MCPServerTypes) -> None:
|
|
"""Add or update a server in the configuration."""
|
|
self.mcpServers[name] = server
|
|
|
|
@classmethod
|
|
def from_dict(cls, config: dict[str, Any]) -> Self:
|
|
"""Parse MCP configuration from dictionary format."""
|
|
return cls.model_validate(config)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert MCPConfig to dictionary format, preserving all fields."""
|
|
return self.model_dump(exclude_none=True)
|
|
|
|
def write_to_file(self, file_path: Path) -> None:
|
|
"""Write configuration to JSON file."""
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
file_path.write_text(self.model_dump_json(indent=2))
|
|
|
|
@classmethod
|
|
def from_file(cls, file_path: Path) -> Self:
|
|
"""Load configuration from JSON file."""
|
|
if file_path.exists():
|
|
if content := file_path.read_text().strip():
|
|
return cls.model_validate_json(content)
|
|
|
|
raise ValueError(f"No MCP servers defined in the config: {file_path}")
|
|
|
|
|
|
class CanonicalMCPConfig(MCPConfig):
|
|
"""Canonical MCP configuration format.
|
|
|
|
This defines the standard configuration format for Model Context Protocol servers.
|
|
The format is designed to be client-agnostic and extensible for future use cases.
|
|
"""
|
|
|
|
mcpServers: dict[str, CanonicalMCPServerTypes]
|
|
|
|
@override
|
|
def add_server(self, name: str, server: CanonicalMCPServerTypes) -> None:
|
|
"""Add or update a server in the configuration."""
|
|
self.mcpServers[name] = server
|
|
|
|
|
|
def update_config_file(
|
|
file_path: Path,
|
|
server_name: str,
|
|
server_config: CanonicalMCPServerTypes,
|
|
) -> None:
|
|
"""Update an MCP configuration file from a server object, preserving existing fields.
|
|
|
|
This is used for updating the mcpServer configurations of third-party tools so we do not
|
|
worry about transforming server objects here."""
|
|
config = MCPConfig.from_file(file_path)
|
|
|
|
# If updating an existing server, merge with existing configuration
|
|
# to preserve any unknown fields
|
|
if existing_server := config.mcpServers.get(server_name):
|
|
# Get the raw dict representation of both servers
|
|
existing_dict = existing_server.model_dump()
|
|
|
|
new_dict = server_config.model_dump(exclude_none=True)
|
|
|
|
# Merge, with new values taking precedence
|
|
merged_config = server_config.model_validate({**existing_dict, **new_dict})
|
|
|
|
config.add_server(server_name, merged_config)
|
|
else:
|
|
config.add_server(server_name, server_config)
|
|
|
|
config.write_to_file(file_path)
|