170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
"""In-process MCP server with the gateway's own tools (§3.1, §3.2).
|
|
|
|
One server per live session so every tool knows which conversation is
|
|
calling; ``alwaysLoad`` keeps the tools out of tool search. Which names a
|
|
session gets comes from ``ClaudeAgent.gateway_tools``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
from claude_agent_sdk import McpSdkServerConfig, SdkMcpTool
|
|
|
|
from beaver_gateway.core.conversations import Conversations
|
|
|
|
__all__ = ["SERVER_NAME", "TOOL_NAMES", "build_tool_server"]
|
|
|
|
_log = logging.getLogger("beaver_gateway.core.gateway_tools")
|
|
|
|
SERVER_NAME = "gateway"
|
|
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject")
|
|
|
|
|
|
def build_tool_server(
|
|
conversations: Conversations, *, conversation_key: str, names: Iterable[str]
|
|
) -> McpSdkServerConfig | None:
|
|
wanted = set(names)
|
|
unknown = wanted - set(TOOL_NAMES)
|
|
if unknown:
|
|
msg = f"unknown gateway tools: {sorted(unknown)}"
|
|
raise ValueError(msg)
|
|
tools = [t for t in _tools(conversations, conversation_key) if t.name in wanted]
|
|
if not tools:
|
|
return None
|
|
server = create_sdk_mcp_server(SERVER_NAME, tools=tools)
|
|
return cast("McpSdkServerConfig", {**server, "alwaysLoad": True})
|
|
|
|
|
|
def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
|
|
async def current() -> Any:
|
|
conv = await conversations.get(key)
|
|
if conv is None:
|
|
msg = f"conversation {key} not found"
|
|
raise LookupError(msg)
|
|
return conv
|
|
|
|
@tool(
|
|
"read_conversation",
|
|
"Read another conversation (a branch, the master, a deep chat) as plain "
|
|
"text. `window` limits it to the last N user turns.",
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "conversation id"},
|
|
"window": {"type": "integer", "minimum": 1},
|
|
},
|
|
"required": ["id"],
|
|
},
|
|
)
|
|
async def read_conversation(args: dict[str, Any]) -> dict[str, Any]:
|
|
conv = await conversations.get(str(args["id"]))
|
|
if conv is None:
|
|
return _error(f"conversation {args['id']} not found")
|
|
text = await conversations.read(conv, window=args.get("window"))
|
|
return _text(text or "(empty)")
|
|
|
|
@tool(
|
|
"spawn",
|
|
"Open a new conversation of the given kind (branch = your own thread, "
|
|
"deep = a long research chat, job = a headless task). `seed` is how it "
|
|
"starts: clean (nothing), morning (handout), copy (copy of this "
|
|
"conversation, last `window` turns), brief (your `text`). Returns the id.",
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {"type": "string", "enum": ["branch", "deep", "job"]},
|
|
"seed": {
|
|
"type": "string",
|
|
"enum": ["clean", "morning", "copy", "brief"],
|
|
"default": "clean",
|
|
},
|
|
"text": {"type": "string", "description": "brief for seed=brief"},
|
|
"title": {"type": "string"},
|
|
"window": {"type": "integer", "minimum": 1},
|
|
},
|
|
"required": ["kind"],
|
|
},
|
|
)
|
|
async def spawn(args: dict[str, Any]) -> dict[str, Any]:
|
|
parent = await current()
|
|
child = await conversations.spawn(
|
|
kind=str(args["kind"]),
|
|
agent=parent.agent_name,
|
|
seed=str(args.get("seed") or "clean"),
|
|
parent=parent,
|
|
text=args.get("text"),
|
|
title=args.get("title"),
|
|
window=args.get("window"),
|
|
origin="mcp",
|
|
)
|
|
return _text(f"spawned {child.kind} {child.external_id}")
|
|
|
|
@tool(
|
|
"say",
|
|
"Say something to the human in the frontend this conversation is bound "
|
|
"to. The only way an inject-started turn can speak; silence is simply "
|
|
"not calling it.",
|
|
{"text": str},
|
|
)
|
|
async def say(args: dict[str, Any]) -> dict[str, Any]:
|
|
conv = await current()
|
|
await conversations.say(conv, str(args["text"]))
|
|
return _text("ok")
|
|
|
|
@tool(
|
|
"schedule",
|
|
"Promise yourself an inject later: `at` is `+15m`, `+2h`, `+1d` or an "
|
|
"ISO datetime; `text` arrives in this conversation at that time.",
|
|
{"at": str, "text": str},
|
|
)
|
|
async def schedule(args: dict[str, Any]) -> dict[str, Any]:
|
|
conv = await current()
|
|
row = await conversations.schedule(conv, str(args["at"]), str(args["text"]))
|
|
return _text(f"scheduled #{row.id} at {row.execute_at.isoformat()}")
|
|
|
|
@tool(
|
|
"inject",
|
|
"Put a system-origin message into another conversation's queue.",
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"conversation": {"type": "string"},
|
|
"text": {"type": "string"},
|
|
"urgency": {
|
|
"type": "string",
|
|
"enum": ["normal", "urgent"],
|
|
"default": "normal",
|
|
},
|
|
},
|
|
"required": ["conversation", "text"],
|
|
},
|
|
)
|
|
async def inject(args: dict[str, Any]) -> dict[str, Any]:
|
|
target = await conversations.get(str(args["conversation"]))
|
|
if target is None:
|
|
return _error(f"conversation {args['conversation']} not found")
|
|
item = await conversations.inject(
|
|
target,
|
|
str(args["text"]),
|
|
urgency=cast("Any", args.get("urgency") or "normal"),
|
|
origin="агент",
|
|
)
|
|
return _text(f"queued #{item.id}")
|
|
|
|
return [read_conversation, spawn, say, schedule, inject]
|
|
|
|
|
|
def _text(text: str) -> dict[str, Any]:
|
|
return {"content": [{"type": "text", "text": text}]}
|
|
|
|
|
|
def _error(text: str) -> dict[str, Any]:
|
|
return {"content": [{"type": "text", "text": text}], "is_error": True}
|