Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 146 additions & 50 deletions condor/acp/pydantic_ai_client.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion condor/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def _chat_mcp_tools() -> tuple[str, ...]:
Names come from ``mcp_servers.*.profiles`` for the same reason
:func:`toolsets.seat_tools` reads them there: those are leaf string modules,
the definition site of each ring, and importing a ``server.py`` to ask would
parse argv and build a ``FastMCP`` singleton as a side effect. The import is
parse argv and build a ``MCPServer`` singleton as a side effect. The import is
function-local to keep that dependency off ``context``'s import path.

The tick seat keeps its own, deliberately narrower list in
Expand Down
2 changes: 1 addition & 1 deletion condor/runtime/toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def seat_tools(agent_slug: str | None, tick: bool = False) -> list[dict[str, Any

Names come from ``mcp_servers.*.profiles`` rather than from the servers
themselves on purpose: importing either ``server.py`` parses argv and builds
a ``FastMCP`` singleton, neither of which a web request has any business
a ``MCPServer`` singleton, neither of which a web request has any business
doing. ``server.py`` resolves the same names against its own functions at
import, so a table that drifts fails there, loudly, and not here.

Expand Down
2 changes: 1 addition & 1 deletion condor/web/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1936,7 +1936,7 @@ def _tool_cards(slug: str, allowlist: list[str]) -> list[ToolCard]:
``condor.runtime.toolsets.seat_tools`` is the single place that knows what a
seat mounts, so the panel asks it rather than re-deriving the rings here.
It reads the two ``profiles.py`` leaf modules — never ``server.py``, whose
import parses argv and builds a ``FastMCP`` singleton.
import parses argv and builds a ``MCPServer`` singleton.
"""
from condor.runtime.toolsets import canonical_tool_name, seat_tools

Expand Down
42 changes: 37 additions & 5 deletions mcp_servers/_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@
once instead of drifting between two copies.

A leaf module on purpose. It must import neither ``server.py`` (importing one
parses argv and builds a ``FastMCP`` singleton as a side effect) nor anything
parses argv and builds a ``MCPServer`` singleton as a side effect) nor anything
that builds one — ``mcp_servers/hummingbot_api/__init__.py`` lazy-loads ``main``
precisely so that the tables stay reachable without waking a server. Hence the
``FastMCP`` annotation below is a type-checking import only.
``MCPServer`` annotation below is a type-checking import only, and the one SDK
import this module needs at runtime is deferred to registration, by which point
the caller is a server.
"""

from __future__ import annotations

import argparse
import functools
import inspect
from collections.abc import Callable, Iterable, Mapping
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: # pragma: no cover - annotation only, never imported at runtime
from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer


def parse_profile_flags(default_profile: str) -> tuple[str, tuple[str, ...]]:
Expand Down Expand Up @@ -101,8 +105,29 @@ def resolve_profiles(
}


def _surface_errors(fn):
"""Keep a tool's exception message in the result the model reads.

MCP SDK >= 2.1 reports any non-SDK exception as a bare
``Error executing tool <name>``. Agents act on the message, so re-raise
as the SDK's ToolError, which is passed through verbatim.
"""
from mcp.server.mcpserver.exceptions import ToolError

@functools.wraps(fn)
async def wrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except ToolError:
raise
except Exception as exc:
raise ToolError(str(exc)) from exc

return wrapper


def register_tools(
server: FastMCP,
server: MCPServer,
tool_profiles: Mapping[str, tuple],
profile: str,
muted: Iterable[str] = (),
Expand All @@ -119,6 +144,11 @@ def register_tools(
and a name this profile never mounts is ignored rather than refused: seats
mount different rings, so "off here, never mounted there" is an ordinary
difference between seats and not a mistake to report.

Every tool must be a coroutine function. The SDK runs a sync ``def`` on a
worker thread with no running loop, and ``_surface_errors`` awaits what it
wraps — so a sync tool is refused here, at startup, rather than discovered
on its first call.
"""
try:
tools = tool_profiles[profile]
Expand All @@ -129,6 +159,8 @@ def register_tools(
) from None
switched_off = set(muted)
for fn in tools:
if not inspect.iscoroutinefunction(fn):
raise TypeError(f"MCP tool {fn.__name__!r} must be an async function")
if fn.__name__ in switched_off:
continue
server.tool()(fn)
server.tool()(_surface_errors(fn))
2 changes: 1 addition & 1 deletion mcp_servers/condor/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A leaf module on purpose: strings and nothing else. ``server.py`` cannot be
imported to ask what it registers — importing it parses argv and builds a
``FastMCP`` singleton as an import side effect — so the web process, which has
``MCPServer`` singleton as an import side effect — so the web process, which has
to render a switch per tool, reads the tables from here instead.

``server.py`` resolves every name below in its own module namespace at import
Expand Down
8 changes: 4 additions & 4 deletions mcp_servers/condor/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import Iterable
from typing import Any, Literal

from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer

from condor.telemetry import taps as telemetry_taps
from mcp_servers._profiles import make_resolver
Expand Down Expand Up @@ -350,7 +350,7 @@ def _build_instructions() -> str:
return "\n\n".join(sections)


mcp = FastMCP("condor", instructions=_build_instructions())
mcp = MCPServer("condor", instructions=_build_instructions())


@handle_errors("delegate task")
Expand Down Expand Up @@ -1333,7 +1333,7 @@ async def trading_agent_journal_write(
# The rings themselves — which tool sits in which one, and why — moved to
# ``profiles.py`` as plain name strings (FEAT-091), because the web process has
# to read them to draw a switch per tool and cannot import *this* module to ask:
# importing it parses argv and builds the ``FastMCP`` singleton. Here the names
# importing it parses argv and builds the ``MCPServer`` singleton. Here the names
# are resolved back into functions, at import, which is what keeps the table and
# the functions provably in step.

Expand All @@ -1349,7 +1349,7 @@ async def trading_agent_journal_write(


def register_tools(
server: FastMCP,
server: MCPServer,
profile: str = DEFAULT_TOOL_PROFILE,
muted: Iterable[str] = (),
) -> None:
Expand Down
2 changes: 1 addition & 1 deletion mcp_servers/hummingbot_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __getattr__(name: str):

It used to be a plain ``from .server import main`` up here, which made
importing *anything* in this package wake the server — and ``server.py``
parses argv and builds a ``FastMCP`` singleton at import. ``profiles.py``
parses argv and builds a ``MCPServer`` singleton at import. ``profiles.py``
(FEAT-091) exists so the web process can read the tool tables without any of
that, and a package-level import of the server would have quietly undone it,
since importing a submodule imports its package first.
Expand Down
2 changes: 1 addition & 1 deletion mcp_servers/hummingbot_api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A leaf module on purpose: strings and nothing else. ``server.py`` cannot be
imported to ask what it registers — importing it parses argv and builds a
``FastMCP`` singleton as an import side effect — so the web process, which has
``MCPServer`` singleton as an import side effect — so the web process, which has
to render a switch per tool, reads the tables from here instead.

``server.py`` resolves every name below in its own module namespace at import
Expand Down
12 changes: 6 additions & 6 deletions mcp_servers/hummingbot_api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from collections.abc import Iterable
from typing import Any, Literal

from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer

from mcp_servers._profiles import make_resolver
from mcp_servers._profiles import register_tools as _register_tools
Expand Down Expand Up @@ -66,8 +66,8 @@
)
logger = logging.getLogger("hummingbot-mcp")

# Initialize FastMCP server
mcp = FastMCP("hummingbot-mcp")
# Initialize MCPServer server
mcp = MCPServer("hummingbot-mcp")


# Server Management Tools
Expand Down Expand Up @@ -2203,7 +2203,7 @@ async def explore_geckoterminal(
# The rings themselves — which tool sits in which one, and why — moved to
# ``profiles.py`` as plain name strings (FEAT-091), because the web process has
# to read them to draw a switch per tool and cannot import *this* module to ask:
# importing it parses argv and builds the ``FastMCP`` singleton. Here the names
# importing it parses argv and builds the ``MCPServer`` singleton. Here the names
# are resolved back into functions, at import, which is what keeps the table and
# the functions provably in step.

Expand All @@ -2219,7 +2219,7 @@ async def explore_geckoterminal(


def register_tools(
server: FastMCP,
server: MCPServer,
profile: str = DEFAULT_TOOL_PROFILE,
muted: Iterable[str] = (),
) -> None:
Expand Down Expand Up @@ -2281,7 +2281,7 @@ async def _run():
"💡 Use 'configure_server' tool to view or update the API server connection"
)

# Run the server with FastMCP
# Run the server with MCPServer
# Connection to API will happen lazily on first tool use
try:
await mcp.run_stdio_async()
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ dependencies = [
# the API container runs (2.2.6), so the two environments stay aligned.
"pandas-ta>=0.4.71b",
"geckoterminal-py",
"mcp",
"mcp>=2.2,<3",
"fastapi",
"uvicorn[standard]",
"python-jose[cryptography]",
"faster-whisper",
"matplotlib>=3.10.8",
"scipy>=1.17.1",
"pydantic-ai[mcp]",
"pydantic-ai[groq]>=2.52,<3",
"beautifulsoup4>=4.14.3",
"markdown>=3.10.2",
"bleach>=6.4.0",
Expand Down
Loading
Loading