# PR Description
Consider this PR the result of a full pass through of this repository.
## Add helper for adding tools to an `MCPApp`
You can now add all of the tools in a module to an `MCPApp` via
`app.add_tools_from_module(...)`
## Edit what `arcade new` generates
First, I updated the backend to use hatchling.
Second, the structure generated before this PR was simple, but did not
create a proper Python module.
This hindered developers in the following ways:
1. Difficult to add the tools in your server to an evaluation suite
2. Difficult to add more than one tool to an MCPApp at a time
3. All other niceties that come with being able to import modules
```
# Before
server/
├── .env.example
├── server.py
└── pyproject.toml
```
This PR updates the structure generated such that a valid Python module
is generated:
```
# After
server/
├── pyproject.toml
└── src/
└── server/
├── __init__.py
├── .env.example
└── server.py
```
## Fix Tool Chaining
`self._ctx.server.executor.run(...)` was being called, but `MCPServer`
does not have an instance of `ToolExecutor` (and it's not intended to be
an instance anyways). I updated `Tool.call_raw` to pass the programmatic
tool call through the `MCPServer._handle_call_tool`. This means that the
programmatic tool calls now go through the same steps that a typical
tool call (initiated by the MCP client) would.
This means that **toolA**, which specifies **requirementsA**, is
permitted to call **toolB**, which specifies **requirementsB**, without
needing to explicitly declare or satisfy **requirementsB**. I believe
this is acceptable because the secrets and/or auth token associated with
**toolB's** `Context` are not exposed to **toolA**, and the secrets
and/or auth token associated with **toolA's** `Context` are not exposed
to **toolB**.
## Fix User Elicitation
1. The read & write streams were created with a maximum queue size of 0.
I increased this to 100.
2. I updated `ServerSession`'s run loop to both read messages from the
stream & process them concurrently. This enables server initiated
requests (like user elicitation and progress reporting) to be handled
while tools are being executed. Otherwise, the server initiated requests
would wait for the tool to finish executing and the tool execution would
wait for the server initiated request to finish.
3.
## Fix Progress Reporting
Progress tokens sent by the client were not being stored. Therefore
there was no way to notify a client with progress updates. I am now
storing the `progressToken`, along with other `_meta` sent from the
client, in the `ServerSession`'s `_request_meta`. I am setting
`_request_meta` whenever the `MCPServer` is handling an incoming message
from a client.
## Fix handling of server names with spaces
Before:
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "The_simple_server_name_WhisperSecret"
After
Server name: "The simple server name"
Tool name: whisper_secret
Name seen by client: "TheSimpleServerName_WhisperSecret"
## Add Integration Tests
The stdio integration test is much more comprehensive than the http
integration test. These tests will let me sleep a bit more at night
## Add Example MCP Servers
Example servers for sampling, user-elicitation, progress reporting,
logging, tool chaining, combining prebuilt tools with custom tools, tool
secrets, tool auth, evaluations, and more!
## Add Docker template
Added a Docker template for running an MCP server in Docker (and removed
the old docker stuff)
382 lines
13 KiB
Python
382 lines
13 KiB
Python
import importlib.metadata
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
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
|
|
from arcade_core.parse import get_tools_from_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Toolkit(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
name: str
|
|
"""Name of the toolkit"""
|
|
|
|
package_name: str
|
|
"""Name of the package holding the toolkit"""
|
|
|
|
tools: dict[str, list[str]] = defaultdict(list)
|
|
"""Mapping of module names to tools"""
|
|
|
|
# Other python package metadata
|
|
version: str
|
|
description: str
|
|
author: list[str] = []
|
|
repository: str | None = None
|
|
homepage: str | None = None
|
|
|
|
@field_validator("name", mode="before")
|
|
def strip_arcade_prefix(cls, value: str) -> str:
|
|
"""
|
|
Validator to strip the 'arcade_' prefix from the name if it exists.
|
|
"""
|
|
return cls._strip_arcade_prefix(value)
|
|
|
|
@classmethod
|
|
def _strip_arcade_prefix(cls, value: str) -> str:
|
|
"""
|
|
Strip the 'arcade_' prefix from the name if it exists.
|
|
"""
|
|
if value.startswith("arcade_"):
|
|
return value[len("arcade_") :]
|
|
return value
|
|
|
|
@classmethod
|
|
def from_module(cls, module: types.ModuleType) -> "Toolkit":
|
|
"""
|
|
Load a toolkit from an imported python module
|
|
|
|
>>> import arcade_math
|
|
>>> toolkit = Toolkit.from_module(arcade_math)
|
|
"""
|
|
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":
|
|
"""
|
|
Load a Toolkit from a Python package
|
|
"""
|
|
try:
|
|
metadata = importlib.metadata.metadata(package)
|
|
name = metadata["Name"]
|
|
package_name = package
|
|
version = metadata["Version"]
|
|
description = metadata.get("Summary", "") # type: ignore[attr-defined]
|
|
author = metadata.get_all("Author-email")
|
|
homepage = metadata.get("Home-page", None) # type: ignore[attr-defined]
|
|
repo = metadata.get("Repository", None) # type: ignore[attr-defined]
|
|
|
|
except importlib.metadata.PackageNotFoundError as e:
|
|
raise ToolkitLoadError(f"Package '{package}' not found.") from e
|
|
except KeyError as e:
|
|
raise ToolkitLoadError(f"Metadata key error for package '{package}'.") from e
|
|
except Exception as e:
|
|
raise ToolkitLoadError(f"Failed to load metadata for package '{package}'.") from e
|
|
|
|
# Get the package directory
|
|
try:
|
|
package_dir = Path(get_package_directory(package))
|
|
except (ImportError, AttributeError) as e:
|
|
raise ToolkitLoadError(f"Failed to locate package directory for '{package}'.") from e
|
|
|
|
toolkit = cls(
|
|
name=name,
|
|
package_name=package_name,
|
|
version=version,
|
|
description=description,
|
|
author=author if author else [],
|
|
homepage=homepage,
|
|
repository=repo,
|
|
)
|
|
|
|
toolkit.tools = cls.tools_from_directory(package_dir, package_name)
|
|
|
|
return toolkit
|
|
|
|
@classmethod
|
|
def from_entrypoint(cls, entry: importlib.metadata.EntryPoint) -> "Toolkit":
|
|
"""
|
|
Load a Toolkit from an entrypoint.
|
|
|
|
The entrypoint value is used as the toolkit name, while the package name
|
|
is extracted from the distribution that owns the entrypoint.
|
|
|
|
Args:
|
|
entry: The EntryPoint object from importlib.metadata
|
|
|
|
Returns:
|
|
A Toolkit instance
|
|
|
|
Raises:
|
|
ToolkitLoadError: If the toolkit cannot be loaded
|
|
"""
|
|
# Get the package name from the distribution that owns this entrypoint
|
|
if not hasattr(entry, "dist") or entry.dist is None:
|
|
raise ToolkitLoadError(
|
|
f"Entry point '{entry.name}' does not have distribution metadata. "
|
|
f"This may indicate an incomplete package installation."
|
|
)
|
|
|
|
package_name = entry.dist.name
|
|
|
|
toolkit = cls.from_package(package_name)
|
|
toolkit.name = cls._strip_arcade_prefix(entry.value)
|
|
|
|
return toolkit
|
|
|
|
@classmethod
|
|
def find_arcade_toolkits_from_entrypoints(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that have a
|
|
registered entrypoint under the 'arcade.toolkits' group.
|
|
"""
|
|
toolkits = []
|
|
toolkit_entries: list[importlib.metadata.EntryPoint] = []
|
|
|
|
try:
|
|
toolkit_entries = importlib.metadata.entry_points(
|
|
group="arcade_toolkits", name="toolkit_name"
|
|
)
|
|
for entry in toolkit_entries:
|
|
try:
|
|
toolkit = cls.from_entrypoint(entry)
|
|
toolkits.append(toolkit)
|
|
logger.debug(
|
|
f"Loaded toolkit from entry point: {entry.name} = '{toolkit.name}'"
|
|
)
|
|
except ToolkitLoadError as e:
|
|
logger.warning(
|
|
f"Warning: {e} Skipping toolkit from entry point '{entry.value}'"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Entry point discovery failed or not available: {e}")
|
|
|
|
return toolkits
|
|
|
|
@classmethod
|
|
def find_arcade_toolkits_from_prefix(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that are prefixed with 'arcade_'.
|
|
"""
|
|
import sysconfig
|
|
|
|
toolkits = []
|
|
site_packages_dir = sysconfig.get_paths()["purelib"]
|
|
|
|
arcade_packages = [
|
|
dist.metadata["Name"]
|
|
for dist in importlib.metadata.distributions(path=[site_packages_dir])
|
|
if dist.metadata["Name"].startswith("arcade_")
|
|
]
|
|
|
|
for package in arcade_packages:
|
|
try:
|
|
toolkit = cls.from_package(package)
|
|
toolkits.append(toolkit)
|
|
logger.debug(f"Loaded toolkit from prefix discovery: {package}")
|
|
except ToolkitLoadError as e:
|
|
logger.warning(f"Warning: {e} Skipping toolkit {package}")
|
|
|
|
return toolkits
|
|
|
|
@classmethod
|
|
def find_all_arcade_toolkits(cls) -> list["Toolkit"]:
|
|
"""
|
|
Find and load as Toolkits all installed packages in the
|
|
current Python interpreter's environment that either
|
|
1. Have a registered entrypoint under the 'arcade.toolkits' group, or
|
|
2. Are prefixed with 'arcade_'
|
|
|
|
Returns:
|
|
List[Toolkit]: A list of Toolkit instances.
|
|
"""
|
|
# Find toolkits
|
|
entrypoint_toolkits = cls.find_arcade_toolkits_from_entrypoints()
|
|
prefix_toolkits = cls.find_arcade_toolkits_from_prefix()
|
|
|
|
# Deduplicate. Entrypoints are preferred over prefix-based toolkits.
|
|
seen_package_names = set()
|
|
all_toolkits = []
|
|
for toolkit in entrypoint_toolkits + prefix_toolkits:
|
|
if toolkit.package_name not in seen_package_names:
|
|
all_toolkits.append(toolkit)
|
|
seen_package_names.add(toolkit.package_name)
|
|
|
|
return all_toolkits
|
|
|
|
@classmethod
|
|
def tools_from_directory(cls, package_dir: Path, package_name: str) -> dict[str, list[str]]:
|
|
"""
|
|
Load a Toolkit from a directory.
|
|
"""
|
|
# Get all python files in the package directory
|
|
try:
|
|
modules = [f for f in package_dir.glob("**/*.py") if f.is_file() and Validate.path(f)]
|
|
except OSError as e:
|
|
raise ToolkitLoadError(
|
|
f"Failed to locate Python files in package directory for '{package_name}'."
|
|
) from e
|
|
|
|
tools: dict[str, list[str]] = {}
|
|
|
|
for module_path in modules:
|
|
relative_path = module_path.relative_to(package_dir)
|
|
cls.validate_file(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}")
|
|
|
|
return tools
|
|
|
|
@classmethod
|
|
def validate_file(cls, file_path: str | Path) -> None:
|
|
"""
|
|
Validate that the Python code in the given file is syntactically correct.
|
|
|
|
Args:
|
|
file_path: Path to the Python file to validate
|
|
"""
|
|
# Convert string path to Path object if needed
|
|
path = Path(file_path) if isinstance(file_path, str) else file_path
|
|
|
|
# Check if file exists
|
|
if not path.exists():
|
|
raise ValueError(f"❌ File not found: {path}")
|
|
|
|
# Check if it's a Python file
|
|
if not path.suffix == ".py":
|
|
raise ValueError(f"❌ Not a Python file: {path}")
|
|
|
|
try:
|
|
# Try to compile the code to check for syntax errors
|
|
with open(path, encoding="utf-8") as f:
|
|
source = f.read()
|
|
|
|
compile(source, str(path), "exec")
|
|
except Exception as e:
|
|
raise SyntaxError(f"{path}: {e}")
|
|
|
|
|
|
def get_package_directory(package_name: str) -> str:
|
|
"""
|
|
Get the directory of a Python package
|
|
"""
|
|
|
|
spec = importlib.util.find_spec(package_name)
|
|
if spec is None:
|
|
raise ImportError(f"Cannot find package named {package_name}")
|
|
|
|
if spec.origin:
|
|
# If the package has an origin, return the directory of the origin
|
|
return os.path.dirname(spec.origin)
|
|
elif spec.submodule_search_locations:
|
|
# If the package is a namespace package, return the first search location
|
|
return spec.submodule_search_locations[0]
|
|
else:
|
|
raise ImportError(f"Package {package_name} does not have a file path associated with it")
|
|
|
|
|
|
class Validate:
|
|
@classmethod
|
|
def path(cls, path: str | Path) -> bool:
|
|
"""
|
|
Validate if a path is valid to be served or deployed.
|
|
"""
|
|
# Check both POSIX and Windows interpretations
|
|
posix_path = PurePosixPath(path)
|
|
windows_path = PureWindowsPath(path)
|
|
|
|
# Get all possible parts from both interpretations
|
|
all_parts = set(posix_path.parts) | set(windows_path.parts)
|
|
|
|
for part in all_parts:
|
|
if part in {"dist", "build", "__pycache__", "coverage.xml"}:
|
|
return False
|
|
if part.endswith(".lock"):
|
|
return False
|
|
|
|
return True
|