client-py connected

This commit is contained in:
2026-03-27 16:33:40 -04:00
parent c76887ab92
commit c3a8fae132
55 changed files with 1598 additions and 426 deletions

View File

@@ -18,12 +18,20 @@ from pathlib import Path
from typing import Optional
import yaml
import uvicorn
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount
from starlette.requests import Request
from starlette.responses import Response
from sse_starlette import EventSourceResponse
from dexorder import EventPublisher, start_lifecycle_manager, get_lifecycle_manager
from dexorder.events import EventType, UserEvent, DeliverySpec
from dexorder.api.workspace_tools import get_workspace_store
from dexorder.api.category_tools import get_category_manager
# =============================================================================
@@ -68,6 +76,9 @@ class Config:
# MCP server settings
self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user")
self.mcp_transport: str = os.getenv("MCP_TRANSPORT", "sse") # "stdio" or "sse"
self.mcp_http_port: int = int(os.getenv("MCP_HTTP_PORT", "3000"))
self.mcp_http_host: str = os.getenv("MCP_HTTP_HOST", "0.0.0.0")
# Lifecycle settings
self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15"))
@@ -134,6 +145,10 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
workspace_store = get_workspace_store(config.workspace_dir)
logging.info(f"Workspace store initialized at {config.workspace_dir}")
# Initialize category file manager
category_manager = get_category_manager(config.data_dir)
logging.info(f"Category manager initialized at {config.data_dir}")
@server.list_resources()
async def list_resources():
"""List available resources"""
@@ -143,6 +158,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
"name": "Hello World",
"description": "A simple hello world resource",
"mimeType": "text/plain",
"annotations": {
"agent_accessible": True, # Available to agent for ad-hoc queries
}
}
]
@@ -170,7 +188,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
@server.list_tools()
async def list_tools():
"""List available tools including workspace tools"""
"""List available tools including workspace and category tools"""
return [
{
"name": "workspace_read",
@@ -184,6 +202,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name"]
},
"annotations": {
"agent_accessible": True, # Agent can read workspace stores
}
},
{
@@ -201,6 +222,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name", "data"]
},
"annotations": {
"agent_accessible": True, # Agent can write workspace stores
}
},
{
@@ -228,13 +252,124 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
},
"required": ["store_name", "patch"]
},
"annotations": {
"agent_accessible": True, # Agent can patch workspace stores
}
},
{
"name": "category_write",
"description": "Write a new strategy, indicator, or research script with validation",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Category of the script"
},
"name": {
"type": "string",
"description": "Display name (can contain special characters)"
},
"description": {
"type": "string",
"description": "LLM-generated description of what this does (required)"
},
"code": {
"type": "string",
"description": "Python implementation code"
},
"metadata": {
"type": "object",
"description": "Optional category-specific metadata (e.g., default_length for indicators, data_feeds for strategies)"
}
},
"required": ["category", "name", "description", "code"]
},
"annotations": {
"agent_accessible": True,
}
},
{
"name": "category_edit",
"description": "Edit an existing category script (updates code, description, or metadata)",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Category of the script"
},
"name": {
"type": "string",
"description": "Display name of the existing item"
},
"code": {
"type": "string",
"description": "Updated Python code (optional, omit to keep existing)"
},
"description": {
"type": "string",
"description": "Updated description (optional, omit to keep existing)"
},
"metadata": {
"type": "object",
"description": "Updated metadata fields (optional)"
}
},
"required": ["category", "name"]
},
"annotations": {
"agent_accessible": True,
}
},
{
"name": "category_read",
"description": "Read a category script and its metadata",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Category of the script"
},
"name": {
"type": "string",
"description": "Display name of the item"
}
},
"required": ["category", "name"]
},
"annotations": {
"agent_accessible": True,
}
},
{
"name": "category_list",
"description": "List all items in a category with names and descriptions",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Category to list"
}
},
"required": ["category"]
},
"annotations": {
"agent_accessible": True,
}
}
]
@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
"""Handle tool calls including workspace tools"""
"""Handle tool calls including workspace and category tools"""
if name == "workspace_read":
return workspace_store.read(arguments.get("store_name", ""))
elif name == "workspace_write":
@@ -247,13 +382,78 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
arguments.get("store_name", ""),
arguments.get("patch", [])
)
elif name == "category_write":
return category_manager.write(
category=arguments.get("category", ""),
name=arguments.get("name", ""),
description=arguments.get("description", ""),
code=arguments.get("code", ""),
metadata=arguments.get("metadata")
)
elif name == "category_edit":
return category_manager.edit(
category=arguments.get("category", ""),
name=arguments.get("name", ""),
code=arguments.get("code"),
description=arguments.get("description"),
metadata=arguments.get("metadata")
)
elif name == "category_read":
return category_manager.read(
category=arguments.get("category", ""),
name=arguments.get("name", "")
)
elif name == "category_list":
return category_manager.list_items(
category=arguments.get("category", "")
)
else:
raise ValueError(f"Unknown tool: {name}")
logging.info(f"MCP server '{config.mcp_server_name}' created with workspace tools")
logging.info(f"MCP server '{config.mcp_server_name}' created with workspace and category tools")
return server
# =============================================================================
# SSE Transport Setup
# =============================================================================
def create_sse_app(mcp_server: Server) -> Starlette:
"""Create Starlette app with SSE endpoint for MCP"""
# Create SSE transport instance
sse = SseServerTransport("/messages/")
async def handle_sse(request: Request) -> Response:
"""Handle SSE connections for MCP"""
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await mcp_server.run(
streams[0],
streams[1],
mcp_server.create_initialization_options()
)
return Response()
async def handle_health(request: Request) -> Response:
"""Health check endpoint for k8s probes and gateway readiness checks"""
return Response(
content='{"status":"ok"}',
media_type="application/json"
)
app = Starlette(
routes=[
Route("/sse", handle_sse),
Mount("/messages/", app=sse.handle_post_message),
Route("/health", handle_health),
]
)
return app
# =============================================================================
# Main Application
# =============================================================================
@@ -347,17 +547,34 @@ class UserContainer:
logging.info("User container stopped")
async def run(self) -> None:
"""Run the MCP server via stdio"""
"""Run the MCP server with configured transport"""
await self.start()
try:
# Run MCP server on stdio
async with stdio_server() as (read_stream, write_stream):
await self.mcp_server.run(
read_stream,
write_stream,
self.mcp_server.create_initialization_options()
if self.config.mcp_transport == "stdio":
# Run MCP server on stdio (for dev/testing)
logging.info("Starting MCP server with stdio transport")
async with stdio_server() as (read_stream, write_stream):
await self.mcp_server.run(
read_stream,
write_stream,
self.mcp_server.create_initialization_options()
)
elif self.config.mcp_transport == "sse":
# Run MCP server via HTTP/SSE (for production)
logging.info(f"Starting MCP server with SSE transport on {self.config.mcp_http_host}:{self.config.mcp_http_port}")
app = create_sse_app(self.mcp_server)
config = uvicorn.Config(
app,
host=self.config.mcp_http_host,
port=self.config.mcp_http_port,
log_level=os.getenv("LOG_LEVEL", "info").lower(),
access_log=True,
)
server = uvicorn.Server(config)
await server.serve()
else:
raise ValueError(f"Unknown MCP transport: {self.config.mcp_transport}")
finally:
await self.stop()