Files
synapbus/internal/mcp/tools.go
T
Algis DumbrisandClaude Opus 4.6 cad0138337 chore: migrate to github.com/synapbus org and add logo assets
Move module path from github.com/smart-mcp-proxy/synapbus to
github.com/synapbus/synapbus across all Go imports (47 files).
Add constellation logo options generated via FLUX 1.1 Pro.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 05:46:46 +02:00

479 lines
16 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/search"
)
// ToolRegistrar registers all SynapBus MCP tools on the given server.
type ToolRegistrar struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
searchService *search.Service
logger *slog.Logger
}
// NewToolRegistrar creates a new tool registrar.
func NewToolRegistrar(msgService *messaging.MessagingService, agentService *agents.AgentService) *ToolRegistrar {
return &ToolRegistrar{
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "mcp-tools"),
}
}
// SetSearchService sets the search service for semantic search support.
func (tr *ToolRegistrar) SetSearchService(svc *search.Service) {
tr.searchService = svc
}
// RegisterAll registers all tools on the MCP server.
func (tr *ToolRegistrar) RegisterAll(s *server.MCPServer) {
s.AddTool(tr.sendMessageTool(), tr.handleSendMessage)
s.AddTool(tr.readInboxTool(), tr.handleReadInbox)
s.AddTool(tr.claimMessagesTool(), tr.handleClaimMessages)
s.AddTool(tr.markDoneTool(), tr.handleMarkDone)
s.AddTool(tr.searchMessagesTool(), tr.handleSearchMessages)
s.AddTool(tr.registerAgentTool(), tr.handleRegisterAgent)
s.AddTool(tr.discoverAgentsTool(), tr.handleDiscoverAgents)
s.AddTool(tr.updateAgentTool(), tr.handleUpdateAgent)
s.AddTool(tr.deregisterAgentTool(), tr.handleDeregisterAgent)
tr.logger.Info("all MCP tools registered", "count", 9)
}
// --- Tool Definitions ---
func (tr *ToolRegistrar) sendMessageTool() mcp.Tool {
return mcp.NewTool("send_message",
mcp.WithDescription("Send a direct message to another agent or to a channel"),
mcp.WithString("to", mcp.Description("Name of the recipient agent (required for DMs, omit for channel messages)")),
mcp.WithString("body", mcp.Description("Message body text"), mcp.Required()),
mcp.WithString("subject", mcp.Description("Conversation subject (optional)")),
mcp.WithNumber("priority", mcp.Description("Message priority (1-10, default 5)"), mcp.Min(1), mcp.Max(10)),
mcp.WithString("metadata", mcp.Description("JSON metadata object (optional)")),
mcp.WithNumber("channel_id", mcp.Description("Channel ID for channel messages (optional)")),
mcp.WithNumber("reply_to", mcp.Description("ID of the message to reply to (optional, for threading)")),
)
}
func (tr *ToolRegistrar) readInboxTool() mcp.Tool {
return mcp.NewTool("read_inbox",
mcp.WithDescription("Read messages from the authenticated agent's inbox"),
mcp.WithNumber("limit", mcp.Description("Maximum number of messages to return (default 50)")),
mcp.WithString("status_filter", mcp.Description("Filter by message status: pending, processing, done, failed")),
mcp.WithBoolean("include_read", mcp.Description("Include previously read messages (default false)")),
mcp.WithNumber("min_priority", mcp.Description("Minimum priority filter (1-10)")),
mcp.WithString("from_agent", mcp.Description("Filter by sender agent name")),
)
}
func (tr *ToolRegistrar) claimMessagesTool() mcp.Tool {
return mcp.NewTool("claim_messages",
mcp.WithDescription("Atomically claim pending messages for processing"),
mcp.WithNumber("limit", mcp.Description("Maximum number of messages to claim (default 10)")),
)
}
func (tr *ToolRegistrar) markDoneTool() mcp.Tool {
return mcp.NewTool("mark_done",
mcp.WithDescription("Mark a claimed message as done or failed"),
mcp.WithNumber("message_id", mcp.Description("ID of the message to mark"), mcp.Required()),
mcp.WithString("status", mcp.Description("New status: 'done' or 'failed' (default 'done')")),
mcp.WithString("reason", mcp.Description("Failure reason (only for status='failed')")),
)
}
func (tr *ToolRegistrar) searchMessagesTool() mcp.Tool {
return mcp.NewTool("search_messages",
mcp.WithDescription("Search messages using semantic search (if configured) or full-text search. Returns messages ranked by relevance."),
mcp.WithString("query", mcp.Description("Search query string — supports natural language for semantic search")),
mcp.WithNumber("limit", mcp.Description("Maximum results to return (default 10, max 100)")),
mcp.WithNumber("min_priority", mcp.Description("Minimum priority filter (1-10)")),
mcp.WithString("from_agent", mcp.Description("Filter by sender agent name")),
mcp.WithString("status", mcp.Description("Filter by message status")),
mcp.WithString("search_mode", mcp.Description("Search mode: 'auto' (default), 'semantic', or 'fulltext'")),
mcp.WithBoolean("semantic", mcp.Description("Force semantic search (shorthand for search_mode='semantic')")),
)
}
func (tr *ToolRegistrar) registerAgentTool() mcp.Tool {
return mcp.NewTool("register_agent",
mcp.WithDescription("Register a new agent and receive an API key"),
mcp.WithString("name", mcp.Description("Unique agent name"), mcp.Required()),
mcp.WithString("display_name", mcp.Description("Human-readable display name")),
mcp.WithString("type", mcp.Description("Agent type: 'ai' or 'human' (default 'ai')")),
mcp.WithString("capabilities", mcp.Description("JSON capabilities object")),
)
}
func (tr *ToolRegistrar) discoverAgentsTool() mcp.Tool {
return mcp.NewTool("discover_agents",
mcp.WithDescription("Discover agents by capability keywords"),
mcp.WithString("query", mcp.Description("Capability keyword to search for")),
)
}
func (tr *ToolRegistrar) updateAgentTool() mcp.Tool {
return mcp.NewTool("update_agent",
mcp.WithDescription("Update the authenticated agent's display name or capabilities"),
mcp.WithString("display_name", mcp.Description("New display name")),
mcp.WithString("capabilities", mcp.Description("New JSON capabilities object")),
)
}
func (tr *ToolRegistrar) deregisterAgentTool() mcp.Tool {
return mcp.NewTool("deregister_agent",
mcp.WithDescription("Deregister the authenticated agent (soft delete)"),
)
}
// --- Tool Handlers ---
func (tr *ToolRegistrar) handleSendMessage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
to := req.GetString("to", "")
body := req.GetString("body", "")
subject := req.GetString("subject", "")
priority := req.GetInt("priority", 5)
metadataStr := req.GetString("metadata", "")
if body == "" {
return mcp.NewToolResultError("'body' parameter is required"), nil
}
var channelID *int64
if cid := req.GetInt("channel_id", 0); cid > 0 {
v := int64(cid)
channelID = &v
}
var replyTo *int64
if rtID := req.GetInt("reply_to", 0); rtID > 0 {
v := int64(rtID)
replyTo = &v
}
opts := messaging.SendOptions{
Subject: subject,
Priority: priority,
Metadata: metadataStr,
ChannelID: channelID,
ReplyTo: replyTo,
}
msg, err := tr.msgService.SendMessage(ctx, agentName, to, body, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("send_message failed: %s", err)), nil
}
return resultJSON(map[string]any{
"message_id": msg.ID,
"conversation_id": msg.ConversationID,
"status": msg.Status,
})
}
func (tr *ToolRegistrar) handleReadInbox(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
opts := messaging.ReadOptions{
Limit: req.GetInt("limit", 50),
Status: req.GetString("status_filter", ""),
MinPriority: req.GetInt("min_priority", 0),
FromAgent: req.GetString("from_agent", ""),
}
// Handle include_read boolean
args := req.GetArguments()
if v, ok := args["include_read"]; ok {
if b, ok := v.(bool); ok {
opts.IncludeRead = b
}
}
messages, err := tr.msgService.ReadInbox(ctx, agentName, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("read_inbox failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
})
}
func (tr *ToolRegistrar) handleClaimMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
limit := req.GetInt("limit", 10)
messages, err := tr.msgService.ClaimMessages(ctx, agentName, limit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("claim_messages failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
})
}
func (tr *ToolRegistrar) handleMarkDone(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
messageID, err := req.RequireInt("message_id")
if err != nil {
return mcp.NewToolResultError("'message_id' parameter is required"), nil
}
status := req.GetString("status", "done")
reason := req.GetString("reason", "")
switch status {
case "done":
if err := tr.msgService.MarkDone(ctx, int64(messageID), agentName); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("mark_done failed: %s", err)), nil
}
case "failed":
if err := tr.msgService.MarkFailed(ctx, int64(messageID), agentName, reason); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("mark_failed failed: %s", err)), nil
}
default:
return mcp.NewToolResultError("status must be 'done' or 'failed'"), nil
}
return resultJSON(map[string]any{
"message_id": messageID,
"status": status,
})
}
func (tr *ToolRegistrar) handleSearchMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
query := req.GetString("query", "")
// If search service is available, use it for unified search
if tr.searchService != nil {
searchMode := req.GetString("search_mode", "auto")
// Handle boolean "semantic" shorthand
args := req.GetArguments()
if v, ok := args["semantic"]; ok {
if b, ok := v.(bool); ok && b {
searchMode = "semantic"
}
}
opts := search.SearchOptions{
Query: query,
Mode: searchMode,
Limit: req.GetInt("limit", 10),
FromAgent: req.GetString("from_agent", ""),
MinPriority: req.GetInt("min_priority", 0),
}
resp, err := tr.searchService.Search(ctx, agentName, opts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search_messages failed: %s", err)), nil
}
// Format results
resultMsgs := make([]map[string]any, len(resp.Results))
for i, r := range resp.Results {
entry := map[string]any{
"message": r.Message,
"match_type": r.MatchType,
}
if r.SimilarityScore > 0 {
entry["similarity_score"] = r.SimilarityScore
}
if r.RelevanceScore > 0 {
entry["relevance_score"] = r.RelevanceScore
}
resultMsgs[i] = entry
}
result := map[string]any{
"results": resultMsgs,
"count": resp.TotalResults,
"search_mode": resp.SearchMode,
}
if resp.Warning != "" {
result["warning"] = resp.Warning
}
return resultJSON(result)
}
// Fallback: use messaging service directly (no search service configured)
msgOpts := messaging.SearchOptions{
Limit: req.GetInt("limit", 20),
MinPriority: req.GetInt("min_priority", 0),
FromAgent: req.GetString("from_agent", ""),
Status: req.GetString("status", ""),
}
messages, err := tr.msgService.SearchMessages(ctx, agentName, query, msgOpts)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search_messages failed: %s", err)), nil
}
return resultJSON(map[string]any{
"messages": messages,
"count": len(messages),
"search_mode": "fulltext",
})
}
func (tr *ToolRegistrar) handleRegisterAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// register_agent does not require authentication
name := req.GetString("name", "")
if name == "" {
return mcp.NewToolResultError("'name' parameter is required"), nil
}
displayName := req.GetString("display_name", name)
agentType := req.GetString("type", "ai")
capsStr := req.GetString("capabilities", "{}")
var caps json.RawMessage
if capsStr != "" {
if !json.Valid([]byte(capsStr)) {
return mcp.NewToolResultError("capabilities must be valid JSON"), nil
}
caps = json.RawMessage(capsStr)
}
// Use owner_id=1 as default (first user). In production, this would come from auth.
agent, apiKey, err := tr.agentService.Register(ctx, name, displayName, agentType, caps, 1)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("register_agent failed: %s", err)), nil
}
return resultJSON(map[string]any{
"agent_id": agent.ID,
"name": agent.Name,
"api_key": apiKey,
"created_at": agent.CreatedAt,
})
}
func (tr *ToolRegistrar) handleDiscoverAgents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
query := req.GetString("query", "")
_ = agentName // just verifying auth
agentsList, err := tr.agentService.DiscoverAgents(ctx, query)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("discover_agents failed: %s", err)), nil
}
// Strip sensitive fields
result := make([]map[string]any, len(agentsList))
for i, a := range agentsList {
result[i] = map[string]any{
"name": a.Name,
"display_name": a.DisplayName,
"type": a.Type,
"capabilities": a.Capabilities,
"status": a.Status,
}
}
return resultJSON(map[string]any{
"agents": result,
"count": len(result),
})
}
func (tr *ToolRegistrar) handleUpdateAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
displayName := req.GetString("display_name", "")
capsStr := req.GetString("capabilities", "")
var caps json.RawMessage
if capsStr != "" {
if !json.Valid([]byte(capsStr)) {
return mcp.NewToolResultError("capabilities must be valid JSON"), nil
}
caps = json.RawMessage(capsStr)
}
agent, err := tr.agentService.UpdateAgent(ctx, agentName, displayName, caps)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("update_agent failed: %s", err)), nil
}
return resultJSON(map[string]any{
"name": agent.Name,
"display_name": agent.DisplayName,
"capabilities": agent.Capabilities,
})
}
func (tr *ToolRegistrar) handleDeregisterAgent(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcp.NewToolResultError("authentication required"), nil
}
// Get the agent to find owner_id
agent, err := tr.agentService.GetAgent(ctx, agentName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("deregister_agent failed: %s", err)), nil
}
if err := tr.agentService.Deregister(ctx, agentName, agent.OwnerID); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("deregister_agent failed: %s", err)), nil
}
return resultJSON(map[string]any{
"name": agentName,
"status": "deregistered",
})
}
// resultJSON marshals data to a JSON text MCP result.
func resultJSON(data any) (*mcp.CallToolResult, error) {
b, err := json.Marshal(data)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil
}
return mcp.NewToolResultText(string(b)), nil
}