mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
151 lines
7.7 KiB
Python
151 lines
7.7 KiB
Python
"""Call ONE tool on a connected MCP server, over whichever transport its config names.
|
|
|
|
The dispatch half of the apps-SDK tool grant gate: the grant decides IF a call may happen,
|
|
this module is HOW it happens. Reuses the exact credential guards and config derivation the
|
|
discovery path uses, so an app can never reach a server an agent could not."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
from typeguard import typechecked
|
|
|
|
|
|
@typechecked
|
|
def render_tool_result(content: List[Any]) -> str:
|
|
"""Flatten MCP content blocks to the text an app can actually use."""
|
|
parts: List[str] = []
|
|
for block in content:
|
|
kind = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None)
|
|
if kind == "text":
|
|
parts.append(getattr(block, "text", None) or (block.get("text", "") if isinstance(block, dict) else ""))
|
|
else:
|
|
try:
|
|
parts.append(json.dumps(block if isinstance(block, dict) else block.__dict__))
|
|
except Exception:
|
|
parts.append(str(block))
|
|
return "\n".join(p for p in parts if p)
|
|
|
|
|
|
@typechecked
|
|
async def call_mcp_tool_stdio(command: str, args: Optional[List[str]], env: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
params = StdioServerParameters(command=command, args=args or [], env={**os.environ, **(env or {})})
|
|
async with stdio_client(params) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream) as session:
|
|
await session.initialize()
|
|
result = await session.call_tool(tool_name, arguments)
|
|
text = render_tool_result(list(result.content))
|
|
if getattr(result, "isError", False):
|
|
raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error")
|
|
return text
|
|
|
|
|
|
@typechecked
|
|
async def call_mcp_tool_http(url: str, headers: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
from backend.apps.tools_lib.mcp_discovery import parse_sse_json
|
|
|
|
h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(headers or {})}
|
|
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
init_resp = await client.post(url, headers=h, json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": "2025-03-26", "capabilities": {},
|
|
"clientInfo": {"name": "self-swarm", "version": "0.1.0"}},
|
|
})
|
|
if init_resp.status_code not in (200, 201):
|
|
raise HTTPException(status_code=502, detail=f"MCP initialize failed: {init_resp.status_code}")
|
|
session_id = init_resp.headers.get("mcp-session-id", "")
|
|
if session_id:
|
|
h["mcp-session-id"] = session_id
|
|
await client.post(url, headers=h, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
call_resp = await client.post(url, headers=h, json={
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
"params": {"name": tool_name, "arguments": arguments},
|
|
})
|
|
if call_resp.status_code not in (200, 201):
|
|
raise HTTPException(status_code=502, detail=f"MCP tools/call failed: {call_resp.status_code}")
|
|
data = parse_sse_json(call_resp.text) if "text/event-stream" in call_resp.headers.get("content-type", "") else call_resp.json()
|
|
if not data:
|
|
raise HTTPException(status_code=502, detail="Empty response from MCP server")
|
|
if data.get("error"):
|
|
raise HTTPException(status_code=502, detail=str(data["error"].get("message", data["error"])))
|
|
result = data.get("result", {})
|
|
text = render_tool_result(result.get("content", []))
|
|
if result.get("isError"):
|
|
raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error")
|
|
return text
|
|
|
|
|
|
@typechecked
|
|
async def call_mcp_tool_sse(url: str, headers: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
from mcp import ClientSession
|
|
from mcp.client.sse import sse_client
|
|
from mcp.types import Implementation
|
|
|
|
try:
|
|
async with sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=90) as (read_stream, write_stream):
|
|
async with ClientSession(read_stream, write_stream, client_info=Implementation(name="self-swarm", version="0.1.0")) as session:
|
|
await session.initialize()
|
|
result = await session.call_tool(tool_name, arguments)
|
|
text = render_tool_result(list(result.content))
|
|
if getattr(result, "isError", False):
|
|
raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error")
|
|
return text
|
|
except BaseExceptionGroup as eg:
|
|
first = eg.exceptions[0] if eg.exceptions else eg
|
|
raise HTTPException(status_code=502, detail=f"SSE tool call failed: {first}") from first
|
|
|
|
|
|
@typechecked
|
|
async def call_mcp_tool(tool_id: str, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
"""Resolve the tool's transport + credentials exactly like discovery does, then call it."""
|
|
from backend.apps.tools_lib.mcp_config import derive_mcp_config
|
|
from backend.apps.tools_lib.oauth_tokens import refresh_airtable_token, refresh_google_token, refresh_hubspot_token
|
|
from backend.apps.tools_lib.tools_lib import load
|
|
|
|
tool = load(tool_id)
|
|
if not tool.enabled:
|
|
raise HTTPException(status_code=403, detail=f"{tool.name} is disabled in Settings.")
|
|
if tool.auth_type == "env_vars" and not tool.credentials:
|
|
raise HTTPException(status_code=409, detail=f"{tool.name} isn't connected yet.")
|
|
# Same refresh dance as discover_tools: a stale OAuth token fails the child, not the user.
|
|
if tool.auth_type == "oauth2" and tool.auth_status == "connected" and tool.oauth_tokens.get("refresh_token"):
|
|
if tool.name.lower() == "airtable":
|
|
refreshed = await refresh_airtable_token(tool)
|
|
elif tool.name.lower() == "hubspot":
|
|
refreshed = await refresh_hubspot_token(tool)
|
|
else:
|
|
refreshed = await refresh_google_token(tool)
|
|
if not refreshed and tool.oauth_tokens.get("access_token") and time.time() >= tool.oauth_tokens.get("token_expiry", 0) - 60:
|
|
raise HTTPException(status_code=502, detail=f"OAuth token expired and refresh failed. Reconnect {tool.name}.")
|
|
|
|
config = derive_mcp_config(tool)
|
|
if not config:
|
|
raise HTTPException(status_code=400, detail="Cannot derive MCP config for tool")
|
|
transport = config.get("type", "")
|
|
call = None
|
|
if transport == "stdio":
|
|
if not config.get("command"):
|
|
raise HTTPException(status_code=400, detail="stdio transport requires a 'command'")
|
|
call = call_mcp_tool_stdio(config["command"], config.get("args"), config.get("env"), tool_name, arguments)
|
|
elif transport in ("http", "sse") or config.get("url"):
|
|
url = config.get("url", "")
|
|
if not url:
|
|
raise HTTPException(status_code=400, detail="HTTP/SSE transport requires a 'url'")
|
|
if transport == "sse":
|
|
call = call_mcp_tool_sse(url, config.get("headers"), tool_name, arguments)
|
|
else:
|
|
call = call_mcp_tool_http(url, config.get("headers"), tool_name, arguments)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Unsupported MCP transport type: '{transport}'.")
|
|
try:
|
|
return await asyncio.wait_for(call, timeout=120.0)
|
|
except asyncio.TimeoutError:
|
|
raise HTTPException(status_code=504, detail=f"{tool_name} timed out after 120s")
|