arcade-mcp/libs/arcade-core/arcade_core/utils.py
Eric Gustin e727af3a21
Fix MCP capabilities, examples, tests, and more (#657)
# 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)
2025-10-30 11:59:00 -07:00

109 lines
3.2 KiB
Python

from __future__ import annotations
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
T = TypeVar("T")
def first_or_none(_type: type[T], iterable: Iterable[Any]) -> T | None:
"""
Returns the first item in the iterable that is an instance of the given type, or None if no such item is found.
"""
for item in iterable:
if isinstance(item, _type):
return item
return None
def pascal_to_snake_case(name: str) -> str:
"""
Converts a PascalCase name to snake_case.
"""
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def space_to_snake_case(name: str) -> str:
"""
Converts a space delimited name to snake_case.
"""
return name.replace(" ", "_")
def snake_to_pascal_case(name: str) -> str:
"""
Converts a snake_case name to PascalCase.
"""
if "_" in name:
return "".join(x.capitalize() or "_" for x in name.split("_"))
# check if first letter is uppercase
if name[0].isupper():
return name
return name.capitalize()
def is_string_literal(_type: type) -> bool:
"""
Returns True if the given type is a string literal, i.e. a Literal[str] or Literal[str, str, ...] etc.
"""
return get_origin(_type) is Literal and all(isinstance(arg, str) for arg in get_args(_type))
def is_union(_type: type) -> bool:
"""
Returns True if the given type is a union, i.e. a Union[T1, T2, ...] or T1 | T2 | ... etc.
"""
return get_origin(_type) in {Union, UnionType}
def is_strict_optional(_type: type) -> bool:
"""
Returns True if the given type is a strict optional type, i.e. a union with exactly two types
where one type is None. This covers Optional[T], Union[T, None] and T | None
"""
return is_union(_type) and len(get_args(_type)) == 2 and type(None) in get_args(_type)
def does_function_return_value(func: Callable) -> bool:
"""
Returns True if the given function returns a value, i.e. if it has a return statement with a value.
"""
try:
source: str | None = inspect.getsource(func)
except OSError:
# Workaround for parameterized unit tests that use a dynamically-generated function
source = getattr(func, "__source__", None)
if source is None:
raise ValueError("Source code not found")
# 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:
self.returns_value = False
def visit_Return(self, node: ast.Return) -> None:
if node.value is not None:
self.returns_value = True
visitor = ReturnVisitor()
visitor.visit(tree)
return visitor.returns_value
def coerce_empty_list_to_none(lst: list[Any] | None) -> list[Any] | None:
"""
Coerces empty lists to None, otherwise returns the list unchanged.
"""
if isinstance(lst, list) and len(lst) == 0:
return None
return lst