71b6343e92
Refresh the benchmark and performance surface across the repo. This updates the benchmark wrappers and helper scripts, regenerates the checked-in benchmark and perf-contract artifacts, and folds in the related roadmap, compatibility, and example notebook changes that belong with this performance-focused pass. Harden the Python CI and local pre-push flow so the same checks pass reliably in both places. The workflow and pre-push script now use module-safe uv typecheck invocations, the Python test environment installs the optional MCP dependency needed by the MCP server tests, and one-off root benchmark outputs are ignored to keep the repo clean. Align local tooling with the current project configuration by updating the Ruff pre-commit hook, tightening the API typing and MCP server helpers, and refreshing the lockfile to pick up the audited PyJWT fix while preserving the rest of the staged source changes.
1349 lines
42 KiB
Python
1349 lines
42 KiB
Python
"""Optional MCP server exposing the public ferro-ta API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import enum
|
|
import importlib
|
|
import inspect
|
|
import json
|
|
import re
|
|
from collections.abc import Callable, Mapping
|
|
from datetime import date, datetime, time
|
|
from functools import lru_cache
|
|
from itertools import count
|
|
from typing import Any, cast, get_args, get_origin, get_type_hints
|
|
|
|
import numpy as np
|
|
|
|
import ferro_ta
|
|
from ferro_ta.tools import compute_indicator, run_backtest
|
|
from ferro_ta.tools.api_info import methods as api_methods
|
|
|
|
__all__ = ["create_server", "run_server", "handle_list_tools", "handle_call_tool"]
|
|
|
|
_MCP_INSTALL_HINT = (
|
|
"The ferro-ta MCP server requires the optional 'mcp' dependency. "
|
|
'Install it with `pip install "ferro-ta[mcp]"` or `uv sync --extra mcp`.'
|
|
)
|
|
|
|
_REFERENCE_HELP = (
|
|
"Use {'instance_id': '<id>'} for stored objects or "
|
|
"{'callable': '<public ferro-ta name>'} for public callables."
|
|
)
|
|
|
|
_JSON_ANY_TYPE: list[str] = [
|
|
"array",
|
|
"boolean",
|
|
"integer",
|
|
"null",
|
|
"number",
|
|
"object",
|
|
"string",
|
|
]
|
|
|
|
_NO_DEFAULT = object()
|
|
_INSTANCE_STORE: dict[str, Any] = {}
|
|
_INSTANCE_META: dict[str, dict[str, Any]] = {}
|
|
_INSTANCE_COUNTER = count(1)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class _ToolSpec:
|
|
"""Resolved metadata and dispatcher for a single MCP tool."""
|
|
|
|
name: str
|
|
description: str
|
|
input_schema: dict[str, Any]
|
|
wrapper_signature: inspect.Signature
|
|
invoke: Callable[[dict[str, Any]], Any]
|
|
|
|
|
|
def _import_object(module_name: str, name: str) -> Any:
|
|
"""Import *name* from *module_name*."""
|
|
module = importlib.import_module(module_name)
|
|
return getattr(module, name)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _discover_public_callables() -> tuple[list[dict[str, str]], dict[str, Any]]:
|
|
"""Return canonical public callable metadata and lookup targets."""
|
|
entries = api_methods()
|
|
top_level = sorted(
|
|
[item for item in entries if item["category"] == "top_level"],
|
|
key=lambda item: item["name"],
|
|
)
|
|
top_level_names = {item["name"] for item in top_level}
|
|
extras = sorted(
|
|
[
|
|
item
|
|
for item in entries
|
|
if item["category"] != "top_level" and item["name"] not in top_level_names
|
|
],
|
|
key=lambda item: item["name"],
|
|
)
|
|
|
|
public_entries: list[dict[str, str]] = []
|
|
targets: dict[str, Any] = {}
|
|
for item in [*top_level, *extras]:
|
|
name = item["name"]
|
|
if name in targets:
|
|
continue
|
|
targets[name] = _import_object(item["module"], name)
|
|
public_entries.append(item)
|
|
|
|
return public_entries, targets
|
|
|
|
|
|
def _slugify(value: str) -> str:
|
|
"""Convert *value* into a short identifier fragment."""
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
return slug or "object"
|
|
|
|
|
|
def _instance_ref(value: Any, *, source_tool: str) -> dict[str, Any]:
|
|
"""Store *value* and return a serialisable reference payload."""
|
|
identifier = f"{_slugify(type(value).__name__)}-{next(_INSTANCE_COUNTER):04d}"
|
|
_INSTANCE_STORE[identifier] = value
|
|
payload = {
|
|
"instance_id": identifier,
|
|
"type": f"{type(value).__module__}.{type(value).__name__}",
|
|
"repr": repr(value),
|
|
"callable": callable(value),
|
|
"source_tool": source_tool,
|
|
}
|
|
snapshot = _object_snapshot(value)
|
|
if snapshot is not None:
|
|
payload["snapshot"] = snapshot
|
|
_INSTANCE_META[identifier] = payload
|
|
return payload
|
|
|
|
|
|
def _get_instance(identifier: str) -> Any:
|
|
"""Return the stored instance for *identifier* or raise a clear error."""
|
|
try:
|
|
return _INSTANCE_STORE[identifier]
|
|
except KeyError as exc:
|
|
raise KeyError(f"Unknown instance_id: {identifier!r}") from exc
|
|
|
|
|
|
def _is_instance_ref(value: Any) -> bool:
|
|
"""Return whether *value* is a stored-object reference payload."""
|
|
return isinstance(value, dict) and set(value) == {"instance_id"}
|
|
|
|
|
|
def _is_callable_ref(value: Any) -> bool:
|
|
"""Return whether *value* is a public-callable reference payload."""
|
|
return isinstance(value, dict) and set(value) == {"callable"}
|
|
|
|
|
|
def _public_method_summaries(value: Any) -> list[dict[str, str]]:
|
|
"""Return public callable methods for *value*."""
|
|
result: list[dict[str, str]] = []
|
|
for method_name, member in inspect.getmembers(value):
|
|
if method_name.startswith("_") or not callable(member):
|
|
continue
|
|
try:
|
|
signature = str(inspect.signature(member))
|
|
except (TypeError, ValueError):
|
|
signature = "()"
|
|
result.append({"name": method_name, "signature": signature})
|
|
return result
|
|
|
|
|
|
def _object_snapshot(value: Any) -> Any:
|
|
"""Return a serialisable snapshot for common non-primitive objects."""
|
|
if isinstance(value, enum.Enum):
|
|
return value.value
|
|
|
|
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
return _normalise_json(dataclasses.asdict(value), store_objects=False)
|
|
|
|
dynamic_value = cast(Any, value)
|
|
|
|
if hasattr(dynamic_value, "to_dict") and callable(dynamic_value.to_dict):
|
|
try:
|
|
return _normalise_json(dynamic_value.to_dict(), store_objects=False)
|
|
except TypeError:
|
|
if dynamic_value.__class__.__module__.startswith("pandas"):
|
|
return _normalise_json(
|
|
dynamic_value.to_dict(orient="list"), store_objects=False
|
|
)
|
|
if dynamic_value.__class__.__module__.startswith("polars"):
|
|
return _normalise_json(
|
|
dynamic_value.to_dict(as_series=False), store_objects=False
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
if hasattr(dynamic_value, "__dict__"):
|
|
fields = {
|
|
key: val
|
|
for key, val in vars(dynamic_value).items()
|
|
if not key.startswith("_") and not callable(val)
|
|
}
|
|
if fields:
|
|
return _normalise_json(fields, store_objects=False)
|
|
|
|
slots = getattr(type(dynamic_value), "__slots__", ())
|
|
if slots:
|
|
fields = {}
|
|
for slot in slots:
|
|
if slot.startswith("_") or not hasattr(dynamic_value, slot):
|
|
continue
|
|
slot_value = getattr(dynamic_value, slot)
|
|
if callable(slot_value):
|
|
continue
|
|
fields[slot] = slot_value
|
|
if fields:
|
|
return _normalise_json(fields, store_objects=False)
|
|
|
|
return None
|
|
|
|
|
|
def _normalise_json(value: Any, *, store_objects: bool = True) -> Any:
|
|
"""Convert Python and numpy-rich values into JSON-safe values."""
|
|
if value is None or isinstance(value, (str, bool)):
|
|
return value
|
|
|
|
if isinstance(value, (int, np.integer)):
|
|
return int(value)
|
|
|
|
if isinstance(value, (float, np.floating)):
|
|
return None if np.isnan(value) else float(value)
|
|
|
|
if isinstance(value, (date, datetime, time)):
|
|
return value.isoformat()
|
|
|
|
if isinstance(value, enum.Enum):
|
|
return _normalise_json(value.value, store_objects=store_objects)
|
|
|
|
if isinstance(value, np.ndarray):
|
|
return [
|
|
_normalise_json(item, store_objects=store_objects)
|
|
for item in value.tolist()
|
|
]
|
|
|
|
if isinstance(value, np.generic):
|
|
return _normalise_json(value.item(), store_objects=store_objects)
|
|
|
|
if isinstance(value, Mapping):
|
|
return {
|
|
str(key): _normalise_json(item, store_objects=store_objects)
|
|
for key, item in value.items()
|
|
}
|
|
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return [_normalise_json(item, store_objects=store_objects) for item in value]
|
|
|
|
if hasattr(value, "tolist") and callable(value.tolist):
|
|
try:
|
|
return _normalise_json(value.tolist(), store_objects=store_objects)
|
|
except Exception:
|
|
pass
|
|
|
|
snapshot = _object_snapshot(value)
|
|
if snapshot is not None:
|
|
return snapshot
|
|
|
|
if store_objects:
|
|
return _instance_ref(value, source_tool="return_value")
|
|
|
|
return repr(value)
|
|
|
|
|
|
def _json_result(
|
|
payload: Any,
|
|
*,
|
|
structured_key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Wrap *payload* in the helper response shape."""
|
|
structured: dict[str, Any] | None = None
|
|
if isinstance(payload, dict):
|
|
structured = payload
|
|
elif structured_key is not None:
|
|
structured = {structured_key: payload}
|
|
|
|
result: dict[str, Any] = {
|
|
"content": [{"type": "text", "text": json.dumps(payload)}],
|
|
}
|
|
if structured is not None:
|
|
result["structuredContent"] = structured
|
|
return result
|
|
|
|
|
|
def _text_result(
|
|
text: str,
|
|
*,
|
|
structured: dict[str, Any] | None = None,
|
|
is_error: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Wrap *text* in the helper response shape."""
|
|
result: dict[str, Any] = {
|
|
"content": [{"type": "text", "text": text}],
|
|
}
|
|
if structured is not None:
|
|
result["structuredContent"] = structured
|
|
if is_error:
|
|
result["isError"] = True
|
|
return result
|
|
|
|
|
|
def _response_from_payload(payload: Any) -> dict[str, Any]:
|
|
"""Create a helper response from a normalised payload."""
|
|
if isinstance(payload, str):
|
|
return _text_result(payload, structured={"value": payload})
|
|
return _json_result(payload)
|
|
|
|
|
|
def _load_mcp_sdk() -> tuple[type[Any], type[Any], type[Any]]:
|
|
"""Import the optional MCP SDK lazily."""
|
|
try:
|
|
fastmcp = importlib.import_module("mcp.server.fastmcp")
|
|
types = importlib.import_module("mcp.types")
|
|
except ImportError as exc: # pragma: no cover - exercised via tests
|
|
raise RuntimeError(_MCP_INSTALL_HINT) from exc
|
|
|
|
return fastmcp.FastMCP, types.CallToolResult, types.TextContent
|
|
|
|
|
|
def _to_call_tool_result(result: dict[str, Any]) -> Any:
|
|
"""Convert helper-style results into an MCP SDK CallToolResult."""
|
|
_, call_tool_result_type, text_content_type = _load_mcp_sdk()
|
|
content = [
|
|
text_content_type(type="text", text=item["text"])
|
|
for item in result.get("content", [])
|
|
if item.get("type") == "text"
|
|
]
|
|
return call_tool_result_type(
|
|
content=content,
|
|
structuredContent=result.get("structuredContent"),
|
|
isError=result.get("isError", False),
|
|
)
|
|
|
|
|
|
def _safe_default(value: Any) -> Any:
|
|
"""Return a JSON-safe schema default or a sentinel when unavailable."""
|
|
if value is inspect._empty:
|
|
return _NO_DEFAULT
|
|
if isinstance(value, enum.Enum):
|
|
return value.value
|
|
if isinstance(value, (str, bool, int, float)) or value is None:
|
|
return value
|
|
if isinstance(value, tuple) and all(
|
|
isinstance(item, (str, bool, int, float)) or item is None for item in value
|
|
):
|
|
return list(value)
|
|
if isinstance(value, list) and all(
|
|
isinstance(item, (str, bool, int, float)) or item is None for item in value
|
|
):
|
|
return value
|
|
return _NO_DEFAULT
|
|
|
|
|
|
def _wrapper_default(value: Any) -> Any:
|
|
"""Return a lightweight default for generated wrapper signatures."""
|
|
default = _safe_default(value)
|
|
if default is _NO_DEFAULT:
|
|
return None
|
|
return default
|
|
|
|
|
|
def _annotation_label(annotation: Any) -> str:
|
|
"""Return a readable label for *annotation*."""
|
|
if annotation is inspect._empty:
|
|
return "Any"
|
|
if isinstance(annotation, str):
|
|
return annotation
|
|
origin = get_origin(annotation)
|
|
if origin is not None:
|
|
return str(annotation).replace("typing.", "")
|
|
return getattr(annotation, "__name__", repr(annotation))
|
|
|
|
|
|
def _annotation_options(annotation: Any) -> list[Any]:
|
|
"""Flatten simple union annotations into a list of options."""
|
|
if annotation is inspect._empty:
|
|
return [Any]
|
|
|
|
origin = get_origin(annotation)
|
|
if origin in (None,):
|
|
return [annotation]
|
|
|
|
if origin in (list, tuple, dict):
|
|
return [annotation]
|
|
|
|
if origin in (Callable,):
|
|
return [annotation]
|
|
|
|
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
return args or [annotation]
|
|
|
|
|
|
def _is_enum_annotation(annotation: Any) -> type[enum.Enum] | None:
|
|
"""Return the enum class inside *annotation*, if any."""
|
|
for option in _annotation_options(annotation):
|
|
if inspect.isclass(option) and issubclass(option, enum.Enum):
|
|
return option
|
|
return None
|
|
|
|
|
|
def _annotation_includes_callable(annotation: Any) -> bool:
|
|
"""Return whether *annotation* includes a callable type."""
|
|
label = _annotation_label(annotation)
|
|
if "Callable" in label:
|
|
return True
|
|
for option in _annotation_options(annotation):
|
|
origin = get_origin(option)
|
|
if origin in (Callable,):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _annotation_includes_custom_class(annotation: Any) -> bool:
|
|
"""Return whether *annotation* includes a non-builtin class."""
|
|
for option in _annotation_options(annotation):
|
|
if not inspect.isclass(option):
|
|
continue
|
|
if issubclass(option, enum.Enum):
|
|
return True
|
|
if option.__module__ == "builtins":
|
|
continue
|
|
if option in (date, datetime, time):
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def _schema_and_py_type(
|
|
annotation: Any, *, param_name: str
|
|
) -> tuple[dict[str, Any], Any]:
|
|
"""Map Python annotations to JSON Schema and wrapper annotations."""
|
|
enum_type = _is_enum_annotation(annotation)
|
|
if enum_type is not None:
|
|
raw_values = [member.value for member in enum_type]
|
|
if all(isinstance(item, str) for item in raw_values):
|
|
schema = {"type": "string", "enum": list(raw_values)}
|
|
return schema, str
|
|
if all(isinstance(item, int) for item in raw_values):
|
|
schema = {"type": "integer", "enum": list(raw_values)}
|
|
return schema, int
|
|
|
|
label = _annotation_label(annotation)
|
|
lower = label.lower()
|
|
|
|
if "bool" in lower:
|
|
return {"type": "boolean"}, bool
|
|
if "int" in lower and "point" not in lower:
|
|
return {"type": "integer"}, int
|
|
if (
|
|
"float" in lower
|
|
or "number" in lower
|
|
or "scalar" in lower
|
|
or "ndarray" in lower
|
|
or "arraylike" in lower
|
|
):
|
|
if "scalarorarray" in lower:
|
|
return {
|
|
"type": _JSON_ANY_TYPE,
|
|
"description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
|
|
}, Any
|
|
if "ndarray" in lower or "arraylike" in lower:
|
|
return {"type": "array", "items": {}}, list[Any]
|
|
return {"type": "number"}, float
|
|
if (
|
|
"list" in lower
|
|
or "tuple" in lower
|
|
or "sequence" in lower
|
|
or "iterable" in lower
|
|
):
|
|
return {"type": "array", "items": {}}, list[Any]
|
|
if "dict" in lower or "mapping" in lower:
|
|
return {"type": "object"}, dict[str, Any]
|
|
if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower:
|
|
return {"type": "string"}, str
|
|
if _annotation_includes_callable(annotation) or _annotation_includes_custom_class(
|
|
annotation
|
|
):
|
|
return {
|
|
"type": _JSON_ANY_TYPE,
|
|
"description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
|
|
}, Any
|
|
return {"type": _JSON_ANY_TYPE}, Any
|
|
|
|
|
|
def _build_signature_and_schema(
|
|
signature: inspect.Signature,
|
|
*,
|
|
type_hints: dict[str, Any],
|
|
) -> tuple[inspect.Signature, dict[str, Any]]:
|
|
"""Build a wrapper signature and JSON schema for *signature*."""
|
|
wrapper_parameters: list[inspect.Parameter] = []
|
|
properties: dict[str, Any] = {}
|
|
required: list[str] = []
|
|
|
|
for parameter in signature.parameters.values():
|
|
if parameter.name in {"self", "cls"}:
|
|
continue
|
|
|
|
if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
properties["args"] = {
|
|
"type": "array",
|
|
"items": {},
|
|
"description": "Extra positional arguments.",
|
|
}
|
|
wrapper_parameters.append(
|
|
inspect.Parameter(
|
|
"args",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[Any],
|
|
default=None,
|
|
)
|
|
)
|
|
continue
|
|
|
|
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
|
|
properties["kwargs"] = {
|
|
"type": "object",
|
|
"description": "Extra keyword arguments.",
|
|
}
|
|
wrapper_parameters.append(
|
|
inspect.Parameter(
|
|
"kwargs",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=dict[str, Any],
|
|
default=None,
|
|
)
|
|
)
|
|
continue
|
|
|
|
annotation = type_hints.get(parameter.name, parameter.annotation)
|
|
schema, py_type = _schema_and_py_type(annotation, param_name=parameter.name)
|
|
default = _safe_default(parameter.default)
|
|
if default is not _NO_DEFAULT:
|
|
schema["default"] = default
|
|
else:
|
|
required.append(parameter.name)
|
|
|
|
properties[parameter.name] = schema
|
|
wrapper_parameters.append(
|
|
inspect.Parameter(
|
|
parameter.name,
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=py_type,
|
|
default=(
|
|
inspect._empty
|
|
if parameter.default is inspect._empty
|
|
else _wrapper_default(parameter.default)
|
|
),
|
|
)
|
|
)
|
|
|
|
wrapper_signature = inspect.Signature(parameters=wrapper_parameters)
|
|
input_schema: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
"additionalProperties": False,
|
|
}
|
|
return wrapper_signature, input_schema
|
|
|
|
|
|
def _type_hints_for_target(target: Any) -> dict[str, Any]:
|
|
"""Return best-effort type hints for *target*."""
|
|
hinted = target.__init__ if inspect.isclass(target) else target
|
|
try:
|
|
return get_type_hints(hinted, include_extras=True)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _resolve_public_callable(name: str) -> Any:
|
|
"""Resolve a public ferro-ta callable by its exposed MCP name."""
|
|
_, targets = _discover_public_callables()
|
|
if name in targets:
|
|
return targets[name]
|
|
aliases = {
|
|
"sma": ferro_ta.SMA,
|
|
"ema": ferro_ta.EMA,
|
|
"rsi": ferro_ta.RSI,
|
|
"macd": ferro_ta.MACD,
|
|
"backtest": run_backtest,
|
|
}
|
|
try:
|
|
return aliases[name]
|
|
except KeyError as exc:
|
|
raise KeyError(f"Unknown callable reference: {name!r}") from exc
|
|
|
|
|
|
def _coerce_enum(value: Any, enum_type: type[enum.Enum]) -> enum.Enum:
|
|
"""Convert *value* into *enum_type*."""
|
|
if isinstance(value, enum_type):
|
|
return value
|
|
if isinstance(value, str):
|
|
if value in enum_type.__members__:
|
|
return enum_type[value]
|
|
for member in enum_type:
|
|
if member.value == value:
|
|
return member
|
|
return enum_type(value)
|
|
|
|
|
|
def _decode_value(value: Any, annotation: Any = Any) -> Any:
|
|
"""Resolve stored-object and callable references inside *value*."""
|
|
if _is_instance_ref(value):
|
|
return _get_instance(str(value["instance_id"]))
|
|
|
|
if _is_callable_ref(value):
|
|
return _resolve_public_callable(str(value["callable"]))
|
|
|
|
enum_type = _is_enum_annotation(annotation)
|
|
if enum_type is not None:
|
|
try:
|
|
return _coerce_enum(value, enum_type)
|
|
except Exception:
|
|
pass
|
|
|
|
if _annotation_includes_callable(annotation) and isinstance(value, str):
|
|
try:
|
|
return _resolve_public_callable(value)
|
|
except KeyError:
|
|
pass
|
|
|
|
if isinstance(value, list):
|
|
return [_decode_value(item, Any) for item in value]
|
|
|
|
if isinstance(value, tuple):
|
|
return tuple(_decode_value(item, Any) for item in value)
|
|
|
|
if isinstance(value, dict):
|
|
return {str(key): _decode_value(item, Any) for key, item in value.items()}
|
|
|
|
return value
|
|
|
|
|
|
def _invoke_target(
|
|
target: Any,
|
|
*,
|
|
signature: inspect.Signature,
|
|
type_hints: dict[str, Any],
|
|
arguments: dict[str, Any],
|
|
) -> Any:
|
|
"""Call *target* with decoded arguments."""
|
|
positional_args: list[Any] = []
|
|
keyword_args: dict[str, Any] = {}
|
|
has_varargs = any(
|
|
parameter.kind == inspect.Parameter.VAR_POSITIONAL
|
|
for parameter in signature.parameters.values()
|
|
)
|
|
before_varargs = True
|
|
|
|
for parameter in signature.parameters.values():
|
|
if parameter.name in {"self", "cls"}:
|
|
continue
|
|
|
|
annotation = type_hints.get(parameter.name, parameter.annotation)
|
|
|
|
if parameter.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
before_varargs = False
|
|
extra_args = arguments.get("args", []) or []
|
|
positional_args.extend(_decode_value(item, Any) for item in extra_args)
|
|
continue
|
|
|
|
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
|
|
extra_kwargs = arguments.get("kwargs", {}) or {}
|
|
if not isinstance(extra_kwargs, dict):
|
|
raise TypeError("kwargs must be a JSON object")
|
|
keyword_args.update(
|
|
{
|
|
str(key): _decode_value(item, Any)
|
|
for key, item in extra_kwargs.items()
|
|
}
|
|
)
|
|
continue
|
|
|
|
expects_positional = (
|
|
before_varargs
|
|
and has_varargs
|
|
and parameter.kind
|
|
in (
|
|
inspect.Parameter.POSITIONAL_ONLY,
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
)
|
|
)
|
|
|
|
if parameter.name in arguments:
|
|
decoded = _decode_value(arguments[parameter.name], annotation)
|
|
elif parameter.default is not inspect._empty:
|
|
if expects_positional:
|
|
decoded = parameter.default
|
|
else:
|
|
continue
|
|
else:
|
|
raise KeyError(f"Missing required argument: {parameter.name}")
|
|
|
|
if expects_positional or parameter.kind == inspect.Parameter.POSITIONAL_ONLY:
|
|
positional_args.append(decoded)
|
|
else:
|
|
keyword_args[parameter.name] = decoded
|
|
|
|
return target(*positional_args, **keyword_args)
|
|
|
|
|
|
def _describe_instance_payload(identifier: str) -> dict[str, Any]:
|
|
"""Return a detailed description for a stored instance."""
|
|
value = _get_instance(identifier)
|
|
payload = dict(_INSTANCE_META.get(identifier, {}))
|
|
payload.update(
|
|
{
|
|
"instance_id": identifier,
|
|
"module": type(value).__module__,
|
|
"class_name": type(value).__name__,
|
|
"methods": _public_method_summaries(value),
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
def _list_instances_payload() -> list[dict[str, Any]]:
|
|
"""Return current stored-object metadata."""
|
|
return [
|
|
_describe_instance_payload(identifier) for identifier in sorted(_INSTANCE_STORE)
|
|
]
|
|
|
|
|
|
def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
|
|
"""Create a generated tool spec for a public ferro-ta callable."""
|
|
is_enum = inspect.isclass(target) and issubclass(target, enum.Enum)
|
|
type_hints = _type_hints_for_target(target)
|
|
|
|
if is_enum:
|
|
signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"value",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default=inspect._empty,
|
|
)
|
|
]
|
|
)
|
|
wrapper_signature, input_schema = _build_signature_and_schema(
|
|
signature,
|
|
type_hints={"value": str},
|
|
)
|
|
description = (
|
|
item["doc"]
|
|
or f"Construct a {item['name']} enum member. Returns a stored instance reference."
|
|
)
|
|
|
|
def invoke_enum(
|
|
arguments: dict[str, Any], *, enum_type: type[enum.Enum] = target
|
|
) -> Any:
|
|
if "value" not in arguments:
|
|
raise KeyError("Missing required argument: value")
|
|
member = _coerce_enum(arguments["value"], enum_type)
|
|
return _instance_ref(member, source_tool=item["name"])
|
|
|
|
return _ToolSpec(
|
|
name=item["name"],
|
|
description=description,
|
|
input_schema=input_schema,
|
|
wrapper_signature=wrapper_signature,
|
|
invoke=invoke_enum,
|
|
)
|
|
|
|
signature = inspect.signature(target)
|
|
wrapper_signature, input_schema = _build_signature_and_schema(
|
|
signature,
|
|
type_hints=type_hints,
|
|
)
|
|
is_class = inspect.isclass(target)
|
|
description = item["doc"] or f"Call {item['name']}."
|
|
if is_class:
|
|
description = (
|
|
item["doc"]
|
|
or f"Construct a {item['name']} instance. Returns a stored instance reference."
|
|
)
|
|
|
|
def invoke_target(
|
|
arguments: dict[str, Any],
|
|
*,
|
|
raw_target: Any = target,
|
|
raw_signature: inspect.Signature = signature,
|
|
raw_type_hints: dict[str, Any] = type_hints,
|
|
returns_instance: bool = is_class,
|
|
source_tool: str = item["name"],
|
|
) -> Any:
|
|
result = _invoke_target(
|
|
raw_target,
|
|
signature=raw_signature,
|
|
type_hints=raw_type_hints,
|
|
arguments=arguments,
|
|
)
|
|
if returns_instance:
|
|
return _instance_ref(result, source_tool=source_tool)
|
|
return _normalise_json(result)
|
|
|
|
return _ToolSpec(
|
|
name=item["name"],
|
|
description=description,
|
|
input_schema=input_schema,
|
|
wrapper_signature=wrapper_signature,
|
|
invoke=invoke_target,
|
|
)
|
|
|
|
|
|
def _legacy_series_tool(
|
|
name: str,
|
|
*,
|
|
indicator_name: str,
|
|
description: str,
|
|
) -> _ToolSpec:
|
|
"""Return a legacy lowercase indicator alias tool."""
|
|
wrapper_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"close",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[float],
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"timeperiod",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=int,
|
|
default=14,
|
|
),
|
|
]
|
|
)
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"close": {
|
|
"type": "array",
|
|
"items": {"type": "number"},
|
|
"description": "Close price series.",
|
|
},
|
|
"timeperiod": {
|
|
"type": "integer",
|
|
"description": "Look-back period.",
|
|
"default": 14,
|
|
},
|
|
},
|
|
"required": ["close"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
def invoke(arguments: dict[str, Any]) -> Any:
|
|
close = np.asarray(arguments["close"], dtype=np.float64)
|
|
timeperiod = int(arguments.get("timeperiod", 14))
|
|
return _normalise_json(
|
|
compute_indicator(indicator_name, close, timeperiod=timeperiod)
|
|
)
|
|
|
|
return _ToolSpec(
|
|
name=name,
|
|
description=description,
|
|
input_schema=input_schema,
|
|
wrapper_signature=wrapper_signature,
|
|
invoke=invoke,
|
|
)
|
|
|
|
|
|
def _legacy_macd_tool() -> _ToolSpec:
|
|
"""Return the legacy lowercase MACD alias tool."""
|
|
wrapper_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"close",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[float],
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"fastperiod",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=int,
|
|
default=12,
|
|
),
|
|
inspect.Parameter(
|
|
"slowperiod",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=int,
|
|
default=26,
|
|
),
|
|
inspect.Parameter(
|
|
"signalperiod",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=int,
|
|
default=9,
|
|
),
|
|
]
|
|
)
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"close": {
|
|
"type": "array",
|
|
"items": {"type": "number"},
|
|
"description": "Close price series.",
|
|
},
|
|
"fastperiod": {
|
|
"type": "integer",
|
|
"description": "Fast EMA period.",
|
|
"default": 12,
|
|
},
|
|
"slowperiod": {
|
|
"type": "integer",
|
|
"description": "Slow EMA period.",
|
|
"default": 26,
|
|
},
|
|
"signalperiod": {
|
|
"type": "integer",
|
|
"description": "Signal EMA period.",
|
|
"default": 9,
|
|
},
|
|
},
|
|
"required": ["close"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
def invoke(arguments: dict[str, Any]) -> Any:
|
|
close = np.asarray(arguments["close"], dtype=np.float64)
|
|
result = compute_indicator(
|
|
"MACD",
|
|
close,
|
|
fastperiod=int(arguments.get("fastperiod", 12)),
|
|
slowperiod=int(arguments.get("slowperiod", 26)),
|
|
signalperiod=int(arguments.get("signalperiod", 9)),
|
|
)
|
|
return _normalise_json(result)
|
|
|
|
return _ToolSpec(
|
|
name="macd",
|
|
description=(
|
|
"Compute MACD (Moving Average Convergence/Divergence). "
|
|
"Returns the line, signal, and histogram."
|
|
),
|
|
input_schema=input_schema,
|
|
wrapper_signature=wrapper_signature,
|
|
invoke=invoke,
|
|
)
|
|
|
|
|
|
def _legacy_backtest_tool() -> _ToolSpec:
|
|
"""Return the legacy lowercase backtest alias tool."""
|
|
wrapper_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"close",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[float],
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"strategy",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default="rsi_30_70",
|
|
),
|
|
inspect.Parameter(
|
|
"commission_per_trade",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=float,
|
|
default=0.0,
|
|
),
|
|
inspect.Parameter(
|
|
"slippage_bps",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=float,
|
|
default=0.0,
|
|
),
|
|
]
|
|
)
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"close": {
|
|
"type": "array",
|
|
"items": {"type": "number"},
|
|
"description": "Close price series.",
|
|
},
|
|
"strategy": {
|
|
"type": "string",
|
|
"description": (
|
|
"Strategy name: 'rsi_30_70', 'sma_crossover', or 'macd_crossover'."
|
|
),
|
|
"default": "rsi_30_70",
|
|
},
|
|
"commission_per_trade": {
|
|
"type": "number",
|
|
"description": "Fixed commission per trade.",
|
|
"default": 0.0,
|
|
},
|
|
"slippage_bps": {
|
|
"type": "number",
|
|
"description": "Slippage in basis points.",
|
|
"default": 0.0,
|
|
},
|
|
},
|
|
"required": ["close"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
def invoke(arguments: dict[str, Any]) -> Any:
|
|
close = np.asarray(arguments["close"], dtype=np.float64)
|
|
result = run_backtest(
|
|
str(arguments.get("strategy", "rsi_30_70")),
|
|
close,
|
|
commission_per_trade=float(arguments.get("commission_per_trade", 0.0)),
|
|
slippage_bps=float(arguments.get("slippage_bps", 0.0)),
|
|
)
|
|
return _normalise_json(result)
|
|
|
|
return _ToolSpec(
|
|
name="backtest",
|
|
description=(
|
|
"Run a vectorized backtest on close prices using a named strategy. "
|
|
"Returns final equity, trade count, and the equity curve."
|
|
),
|
|
input_schema=input_schema,
|
|
wrapper_signature=wrapper_signature,
|
|
invoke=invoke,
|
|
)
|
|
|
|
|
|
def _instance_management_specs() -> list[_ToolSpec]:
|
|
"""Return generic stored-object management tools."""
|
|
list_signature = inspect.Signature([])
|
|
simple_schema = {
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": [],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
describe_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"instance_id",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default=inspect._empty,
|
|
)
|
|
]
|
|
)
|
|
describe_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"instance_id": {
|
|
"type": "string",
|
|
"description": "Stored object identifier.",
|
|
}
|
|
},
|
|
"required": ["instance_id"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
call_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"instance_id",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"method",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"args",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[Any],
|
|
default=None,
|
|
),
|
|
inspect.Parameter(
|
|
"kwargs",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=dict[str, Any],
|
|
default=None,
|
|
),
|
|
]
|
|
)
|
|
call_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"instance_id": {
|
|
"type": "string",
|
|
"description": "Stored object identifier.",
|
|
},
|
|
"method": {
|
|
"type": "string",
|
|
"description": "Public method name.",
|
|
},
|
|
"args": {
|
|
"type": "array",
|
|
"items": {},
|
|
"description": "Optional positional arguments.",
|
|
},
|
|
"kwargs": {
|
|
"type": "object",
|
|
"description": f"Optional keyword arguments. {_REFERENCE_HELP}",
|
|
},
|
|
},
|
|
"required": ["instance_id", "method"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
callable_signature = inspect.Signature(
|
|
[
|
|
inspect.Parameter(
|
|
"instance_id",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=str,
|
|
default=inspect._empty,
|
|
),
|
|
inspect.Parameter(
|
|
"args",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=list[Any],
|
|
default=None,
|
|
),
|
|
inspect.Parameter(
|
|
"kwargs",
|
|
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
annotation=dict[str, Any],
|
|
default=None,
|
|
),
|
|
]
|
|
)
|
|
callable_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"instance_id": {
|
|
"type": "string",
|
|
"description": "Stored callable identifier.",
|
|
},
|
|
"args": {
|
|
"type": "array",
|
|
"items": {},
|
|
"description": "Optional positional arguments.",
|
|
},
|
|
"kwargs": {
|
|
"type": "object",
|
|
"description": f"Optional keyword arguments. {_REFERENCE_HELP}",
|
|
},
|
|
},
|
|
"required": ["instance_id"],
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
def list_instances_tool(arguments: dict[str, Any]) -> Any:
|
|
del arguments
|
|
return _list_instances_payload()
|
|
|
|
def describe_instance_tool(arguments: dict[str, Any]) -> Any:
|
|
return _describe_instance_payload(str(arguments["instance_id"]))
|
|
|
|
def call_instance_method_tool(arguments: dict[str, Any]) -> Any:
|
|
value = _get_instance(str(arguments["instance_id"]))
|
|
method_name = str(arguments["method"])
|
|
if method_name.startswith("_"):
|
|
raise ValueError("Only public methods can be called")
|
|
method = getattr(value, method_name)
|
|
if not callable(method):
|
|
raise TypeError(
|
|
f"{method_name!r} is not callable on {arguments['instance_id']!r}"
|
|
)
|
|
args = [_decode_value(item, Any) for item in (arguments.get("args") or [])]
|
|
kwargs = {
|
|
str(key): _decode_value(item, Any)
|
|
for key, item in (arguments.get("kwargs") or {}).items()
|
|
}
|
|
return _normalise_json(method(*args, **kwargs))
|
|
|
|
def call_stored_callable_tool(arguments: dict[str, Any]) -> Any:
|
|
value = _get_instance(str(arguments["instance_id"]))
|
|
if not callable(value):
|
|
raise TypeError(f"{arguments['instance_id']!r} is not callable")
|
|
args = [_decode_value(item, Any) for item in (arguments.get("args") or [])]
|
|
kwargs = {
|
|
str(key): _decode_value(item, Any)
|
|
for key, item in (arguments.get("kwargs") or {}).items()
|
|
}
|
|
return _normalise_json(value(*args, **kwargs))
|
|
|
|
def delete_instance_tool(arguments: dict[str, Any]) -> Any:
|
|
identifier = str(arguments["instance_id"])
|
|
payload = _describe_instance_payload(identifier)
|
|
_INSTANCE_STORE.pop(identifier)
|
|
_INSTANCE_META.pop(identifier, None)
|
|
return {"deleted": True, **payload}
|
|
|
|
return [
|
|
_ToolSpec(
|
|
name="list_instances",
|
|
description="List stored MCP object references created during this session.",
|
|
input_schema=simple_schema,
|
|
wrapper_signature=list_signature,
|
|
invoke=list_instances_tool,
|
|
),
|
|
_ToolSpec(
|
|
name="describe_instance",
|
|
description="Describe a stored MCP object reference and list its public methods.",
|
|
input_schema=describe_schema,
|
|
wrapper_signature=describe_signature,
|
|
invoke=describe_instance_tool,
|
|
),
|
|
_ToolSpec(
|
|
name="call_instance_method",
|
|
description="Call a public method on a stored MCP object reference.",
|
|
input_schema=call_schema,
|
|
wrapper_signature=call_signature,
|
|
invoke=call_instance_method_tool,
|
|
),
|
|
_ToolSpec(
|
|
name="call_stored_callable",
|
|
description="Invoke a stored callable object reference.",
|
|
input_schema=callable_schema,
|
|
wrapper_signature=callable_signature,
|
|
invoke=call_stored_callable_tool,
|
|
),
|
|
_ToolSpec(
|
|
name="delete_instance",
|
|
description="Delete a stored MCP object reference.",
|
|
input_schema=describe_schema,
|
|
wrapper_signature=describe_signature,
|
|
invoke=delete_instance_tool,
|
|
),
|
|
]
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _tool_catalog() -> dict[str, _ToolSpec]:
|
|
"""Return the full MCP tool catalog."""
|
|
catalog: dict[str, _ToolSpec] = {}
|
|
|
|
legacy_specs = [
|
|
_legacy_series_tool(
|
|
"sma",
|
|
indicator_name="SMA",
|
|
description="Compute the Simple Moving Average (SMA) of a price series.",
|
|
),
|
|
_legacy_series_tool(
|
|
"ema",
|
|
indicator_name="EMA",
|
|
description="Compute the Exponential Moving Average (EMA) of a price series.",
|
|
),
|
|
_legacy_series_tool(
|
|
"rsi",
|
|
indicator_name="RSI",
|
|
description="Compute the Relative Strength Index (RSI) of a price series.",
|
|
),
|
|
_legacy_macd_tool(),
|
|
_legacy_backtest_tool(),
|
|
]
|
|
for spec in legacy_specs:
|
|
catalog[spec.name] = spec
|
|
|
|
public_entries, targets = _discover_public_callables()
|
|
for item in public_entries:
|
|
spec = _build_public_tool_spec(item, targets[item["name"]])
|
|
catalog[spec.name] = spec
|
|
|
|
for spec in _instance_management_specs():
|
|
catalog[spec.name] = spec
|
|
|
|
return catalog
|
|
|
|
|
|
def _make_fastmcp_wrapper(spec: _ToolSpec) -> Callable[..., Any]:
|
|
"""Create a FastMCP-friendly wrapper for *spec*."""
|
|
|
|
def wrapper(**kwargs: Any) -> Any:
|
|
return _to_call_tool_result(handle_call_tool(spec.name, dict(kwargs)))
|
|
|
|
wrapper.__name__ = f"tool_{_slugify(spec.name).replace('-', '_')}"
|
|
wrapper.__doc__ = spec.description
|
|
setattr(wrapper, "__signature__", spec.wrapper_signature)
|
|
wrapper.__annotations__ = {
|
|
parameter.name: (
|
|
Any if parameter.annotation is inspect._empty else parameter.annotation
|
|
)
|
|
for parameter in spec.wrapper_signature.parameters.values()
|
|
}
|
|
wrapper.__annotations__["return"] = Any
|
|
return wrapper
|
|
|
|
|
|
def handle_list_tools() -> dict[str, Any]:
|
|
"""Return the MCP ListTools response."""
|
|
return {
|
|
"tools": [
|
|
{
|
|
"name": spec.name,
|
|
"description": spec.description,
|
|
"inputSchema": spec.input_schema,
|
|
}
|
|
for spec in _tool_catalog().values()
|
|
]
|
|
}
|
|
|
|
|
|
def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
"""Dispatch an MCP CallTool request and return helper-style content."""
|
|
try:
|
|
spec = _tool_catalog().get(name)
|
|
if spec is None:
|
|
return _text_result(
|
|
f"Unknown tool: {name!r}",
|
|
structured={"error": f"Unknown tool: {name!r}"},
|
|
is_error=True,
|
|
)
|
|
|
|
payload = spec.invoke(arguments)
|
|
return _response_from_payload(payload)
|
|
|
|
except Exception as exc:
|
|
return _text_result(
|
|
f"Error: {exc}",
|
|
structured={"error": str(exc)},
|
|
is_error=True,
|
|
)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def create_server() -> Any:
|
|
"""Create the FastMCP server lazily so MCP stays optional."""
|
|
fast_mcp_type, _, _ = _load_mcp_sdk()
|
|
app = fast_mcp_type(
|
|
"ferro-ta",
|
|
instructions=(
|
|
"Expose ferro-ta's public API over MCP. "
|
|
"Use exact public ferro-ta names such as SMA, RSI, compute_indicator, "
|
|
"TickAggregator, or AlertManager, or the legacy aliases sma, ema, rsi, "
|
|
"macd, and backtest. Use list_instances, describe_instance, "
|
|
"call_instance_method, call_stored_callable, and delete_instance for "
|
|
"stateful objects and stored callables."
|
|
),
|
|
)
|
|
|
|
for spec in _tool_catalog().values():
|
|
app.add_tool(
|
|
_make_fastmcp_wrapper(spec), name=spec.name, description=spec.description
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
def run_server() -> None: # pragma: no cover
|
|
"""Run the stdio MCP server."""
|
|
try:
|
|
create_server().run(transport="stdio")
|
|
except RuntimeError as exc:
|
|
raise SystemExit(str(exc)) from exc
|