ScuttleBot

scuttlebot / internal / mcp / mcp.go
Source Blame History 421 lines
550b35e… lmata 1 // Package mcp implements a Model Context Protocol (MCP) server for scuttlebot.
550b35e… lmata 2 //
550b35e… lmata 3 // The server exposes scuttlebot tools to any MCP-compatible AI agent
550b35e… lmata 4 // (Claude, Gemini, Codex, etc.). Transport: HTTP POST /mcp, JSON-RPC 2.0.
550b35e… lmata 5 // Auth: Bearer token in the Authorization header (same tokens as REST API).
550b35e… lmata 6 //
550b35e… lmata 7 // Tools:
550b35e… lmata 8 // - get_status — daemon health and agent count
550b35e… lmata 9 // - list_channels — available IRC channels
550b35e… lmata 10 // - register_agent — register an agent, return credentials
550b35e… lmata 11 // - send_message — send a typed message to a channel
550b35e… lmata 12 // - get_history — recent messages from a channel
cadb504… lmata 13 package mcp
550b35e… lmata 14
550b35e… lmata 15 import (
550b35e… lmata 16 "context"
550b35e… lmata 17 "encoding/json"
550b35e… lmata 18 "fmt"
550b35e… lmata 19 "log/slog"
550b35e… lmata 20 "net/http"
550b35e… lmata 21 "strings"
550b35e… lmata 22
550b35e… lmata 23 "github.com/conflicthq/scuttlebot/internal/registry"
550b35e… lmata 24 )
550b35e… lmata 25
550b35e… lmata 26 // Sender can send a typed message to an IRC channel.
550b35e… lmata 27 // Implement this with pkg/client.Client when the daemon has a relay connection.
550b35e… lmata 28 type Sender interface {
550b35e… lmata 29 Send(ctx context.Context, channel, msgType string, payload any) error
550b35e… lmata 30 }
550b35e… lmata 31
550b35e… lmata 32 // HistoryQuerier returns recent messages from a channel.
550b35e… lmata 33 // Implement this with the scribe Store when wired into the daemon.
550b35e… lmata 34 type HistoryQuerier interface {
550b35e… lmata 35 Query(channel string, limit int) ([]HistoryEntry, error)
550b35e… lmata 36 }
550b35e… lmata 37
550b35e… lmata 38 // HistoryEntry is a single message from channel history.
550b35e… lmata 39 type HistoryEntry struct {
550b35e… lmata 40 Nick string `json:"nick"`
550b35e… lmata 41 MessageType string `json:"type,omitempty"`
550b35e… lmata 42 MessageID string `json:"id,omitempty"`
550b35e… lmata 43 Raw string `json:"raw"`
550b35e… lmata 44 }
550b35e… lmata 45
550b35e… lmata 46 // ChannelLister lists IRC channels.
550b35e… lmata 47 type ChannelLister interface {
550b35e… lmata 48 ListChannels() ([]ChannelInfo, error)
550b35e… lmata 49 }
550b35e… lmata 50
550b35e… lmata 51 // ChannelInfo describes a single IRC channel.
550b35e… lmata 52 type ChannelInfo struct {
550b35e… lmata 53 Name string `json:"name"`
550b35e… lmata 54 Topic string `json:"topic,omitempty"`
550b35e… lmata 55 Count int `json:"count"`
550b35e… lmata 56 }
550b35e… lmata 57
68677f9… noreply 58 // TokenValidator validates API tokens.
68677f9… noreply 59 type TokenValidator interface {
68677f9… noreply 60 ValidToken(token string) bool
68677f9… noreply 61 }
68677f9… noreply 62
550b35e… lmata 63 // Server is the MCP server.
550b35e… lmata 64 type Server struct {
550b35e… lmata 65 registry *registry.Registry
550b35e… lmata 66 channels ChannelLister
0e244d2… lmata 67 sender Sender // optional — send_message returns error if nil
550b35e… lmata 68 history HistoryQuerier // optional — get_history returns error if nil
68677f9… noreply 69 tokens TokenValidator
550b35e… lmata 70 log *slog.Logger
550b35e… lmata 71 }
550b35e… lmata 72
550b35e… lmata 73 // New creates an MCP Server.
68677f9… noreply 74 func New(reg *registry.Registry, channels ChannelLister, tokens TokenValidator, log *slog.Logger) *Server {
550b35e… lmata 75 return &Server{
550b35e… lmata 76 registry: reg,
550b35e… lmata 77 channels: channels,
68677f9… noreply 78 tokens: tokens,
550b35e… lmata 79 log: log,
550b35e… lmata 80 }
550b35e… lmata 81 }
550b35e… lmata 82
550b35e… lmata 83 // WithSender attaches an IRC relay client for send_message.
550b35e… lmata 84 func (s *Server) WithSender(sender Sender) *Server {
550b35e… lmata 85 s.sender = sender
550b35e… lmata 86 return s
550b35e… lmata 87 }
550b35e… lmata 88
550b35e… lmata 89 // WithHistory attaches a history store for get_history.
550b35e… lmata 90 func (s *Server) WithHistory(h HistoryQuerier) *Server {
550b35e… lmata 91 s.history = h
550b35e… lmata 92 return s
550b35e… lmata 93 }
550b35e… lmata 94
550b35e… lmata 95 // Handler returns the HTTP handler for the MCP endpoint. Mount at /mcp.
550b35e… lmata 96 func (s *Server) Handler() http.Handler {
550b35e… lmata 97 mux := http.NewServeMux()
550b35e… lmata 98 mux.HandleFunc("/mcp", s.handleMCP)
550b35e… lmata 99 return s.authMiddleware(mux)
550b35e… lmata 100 }
550b35e… lmata 101
550b35e… lmata 102 // --- Auth ---
550b35e… lmata 103
550b35e… lmata 104 func (s *Server) authMiddleware(next http.Handler) http.Handler {
550b35e… lmata 105 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
550b35e… lmata 106 token := bearerToken(r)
68677f9… noreply 107 if !s.tokens.ValidToken(token) {
550b35e… lmata 108 writeRPCError(w, nil, -32001, "unauthorized")
550b35e… lmata 109 return
550b35e… lmata 110 }
550b35e… lmata 111 next.ServeHTTP(w, r)
550b35e… lmata 112 })
550b35e… lmata 113 }
550b35e… lmata 114
550b35e… lmata 115 func bearerToken(r *http.Request) string {
550b35e… lmata 116 v := r.Header.Get("Authorization")
550b35e… lmata 117 if after, ok := strings.CutPrefix(v, "Bearer "); ok {
550b35e… lmata 118 return strings.TrimSpace(after)
550b35e… lmata 119 }
550b35e… lmata 120 return ""
550b35e… lmata 121 }
550b35e… lmata 122
550b35e… lmata 123 // --- JSON-RPC 2.0 types ---
550b35e… lmata 124
550b35e… lmata 125 type rpcRequest struct {
550b35e… lmata 126 JSONRPC string `json:"jsonrpc"`
550b35e… lmata 127 ID json.RawMessage `json:"id"`
550b35e… lmata 128 Method string `json:"method"`
550b35e… lmata 129 Params json.RawMessage `json:"params,omitempty"`
550b35e… lmata 130 }
550b35e… lmata 131
550b35e… lmata 132 type rpcResponse struct {
550b35e… lmata 133 JSONRPC string `json:"jsonrpc"`
550b35e… lmata 134 ID json.RawMessage `json:"id"`
550b35e… lmata 135 Result any `json:"result,omitempty"`
550b35e… lmata 136 Error *rpcError `json:"error,omitempty"`
550b35e… lmata 137 }
550b35e… lmata 138
550b35e… lmata 139 type rpcError struct {
550b35e… lmata 140 Code int `json:"code"`
550b35e… lmata 141 Message string `json:"message"`
550b35e… lmata 142 }
550b35e… lmata 143
550b35e… lmata 144 func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
550b35e… lmata 145 if r.Method != http.MethodPost {
550b35e… lmata 146 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
550b35e… lmata 147 return
550b35e… lmata 148 }
550b35e… lmata 149
550b35e… lmata 150 var req rpcRequest
550b35e… lmata 151 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
550b35e… lmata 152 writeRPCError(w, nil, -32700, "parse error")
550b35e… lmata 153 return
550b35e… lmata 154 }
550b35e… lmata 155 if req.JSONRPC != "2.0" {
550b35e… lmata 156 writeRPCError(w, req.ID, -32600, "invalid request")
550b35e… lmata 157 return
550b35e… lmata 158 }
550b35e… lmata 159
550b35e… lmata 160 var result any
550b35e… lmata 161 var rpcErr *rpcError
550b35e… lmata 162
550b35e… lmata 163 switch req.Method {
550b35e… lmata 164 case "initialize":
550b35e… lmata 165 result = s.handleInitialize()
550b35e… lmata 166 case "tools/list":
550b35e… lmata 167 result = s.handleToolsList()
550b35e… lmata 168 case "tools/call":
550b35e… lmata 169 result, rpcErr = s.handleToolCall(r.Context(), req.Params)
550b35e… lmata 170 case "ping":
550b35e… lmata 171 result = map[string]string{}
550b35e… lmata 172 default:
550b35e… lmata 173 rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method}
550b35e… lmata 174 }
550b35e… lmata 175
550b35e… lmata 176 resp := rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr}
550b35e… lmata 177 w.Header().Set("Content-Type", "application/json")
550b35e… lmata 178 _ = json.NewEncoder(w).Encode(resp)
550b35e… lmata 179 }
550b35e… lmata 180
550b35e… lmata 181 // --- MCP method handlers ---
550b35e… lmata 182
550b35e… lmata 183 func (s *Server) handleInitialize() any {
550b35e… lmata 184 return map[string]any{
550b35e… lmata 185 "protocolVersion": "2024-11-05",
550b35e… lmata 186 "capabilities": map[string]any{"tools": map[string]any{}},
550b35e… lmata 187 "serverInfo": map[string]any{"name": "scuttlebot", "version": "0.1"},
550b35e… lmata 188 }
550b35e… lmata 189 }
550b35e… lmata 190
550b35e… lmata 191 func (s *Server) handleToolsList() any {
550b35e… lmata 192 return map[string]any{"tools": toolDefs()}
550b35e… lmata 193 }
550b35e… lmata 194
550b35e… lmata 195 type toolCallParams struct {
550b35e… lmata 196 Name string `json:"name"`
550b35e… lmata 197 Arguments map[string]any `json:"arguments"`
550b35e… lmata 198 }
550b35e… lmata 199
550b35e… lmata 200 func (s *Server) handleToolCall(ctx context.Context, raw json.RawMessage) (any, *rpcError) {
550b35e… lmata 201 var p toolCallParams
550b35e… lmata 202 if err := json.Unmarshal(raw, &p); err != nil {
550b35e… lmata 203 return nil, &rpcError{Code: -32602, Message: "invalid params"}
550b35e… lmata 204 }
550b35e… lmata 205
550b35e… lmata 206 var text string
550b35e… lmata 207 var err error
550b35e… lmata 208
550b35e… lmata 209 switch p.Name {
550b35e… lmata 210 case "get_status":
550b35e… lmata 211 text, err = s.toolGetStatus()
550b35e… lmata 212 case "list_channels":
550b35e… lmata 213 text, err = s.toolListChannels()
550b35e… lmata 214 case "register_agent":
550b35e… lmata 215 text, err = s.toolRegisterAgent(p.Arguments)
550b35e… lmata 216 case "send_message":
550b35e… lmata 217 text, err = s.toolSendMessage(ctx, p.Arguments)
550b35e… lmata 218 case "get_history":
550b35e… lmata 219 text, err = s.toolGetHistory(p.Arguments)
550b35e… lmata 220 default:
550b35e… lmata 221 return nil, &rpcError{Code: -32602, Message: "unknown tool: " + p.Name}
550b35e… lmata 222 }
550b35e… lmata 223
550b35e… lmata 224 if err != nil {
550b35e… lmata 225 // Tool errors are returned as content with isError flag, not RPC errors.
550b35e… lmata 226 return toolResult(err.Error(), true), nil
550b35e… lmata 227 }
550b35e… lmata 228 return toolResult(text, false), nil
550b35e… lmata 229 }
550b35e… lmata 230
550b35e… lmata 231 func toolResult(text string, isError bool) map[string]any {
550b35e… lmata 232 return map[string]any{
550b35e… lmata 233 "content": []map[string]any{
550b35e… lmata 234 {"type": "text", "text": text},
550b35e… lmata 235 },
550b35e… lmata 236 "isError": isError,
550b35e… lmata 237 }
550b35e… lmata 238 }
550b35e… lmata 239
550b35e… lmata 240 // --- Tool implementations ---
550b35e… lmata 241
550b35e… lmata 242 func (s *Server) toolGetStatus() (string, error) {
550b35e… lmata 243 agents := s.registry.List()
550b35e… lmata 244 active := 0
550b35e… lmata 245 for _, a := range agents {
550b35e… lmata 246 if !a.Revoked {
550b35e… lmata 247 active++
550b35e… lmata 248 }
550b35e… lmata 249 }
550b35e… lmata 250 return fmt.Sprintf("status: ok\nagents: %d active, %d total", active, len(agents)), nil
550b35e… lmata 251 }
550b35e… lmata 252
550b35e… lmata 253 func (s *Server) toolListChannels() (string, error) {
550b35e… lmata 254 if s.channels == nil {
550b35e… lmata 255 return "", fmt.Errorf("channel listing not available")
550b35e… lmata 256 }
550b35e… lmata 257 channels, err := s.channels.ListChannels()
550b35e… lmata 258 if err != nil {
550b35e… lmata 259 return "", fmt.Errorf("list channels: %w", err)
550b35e… lmata 260 }
550b35e… lmata 261 if len(channels) == 0 {
550b35e… lmata 262 return "no channels", nil
550b35e… lmata 263 }
550b35e… lmata 264 var sb strings.Builder
550b35e… lmata 265 for _, ch := range channels {
550b35e… lmata 266 if ch.Topic != "" {
550b35e… lmata 267 fmt.Fprintf(&sb, "%s (%d members) — %s\n", ch.Name, ch.Count, ch.Topic)
550b35e… lmata 268 } else {
550b35e… lmata 269 fmt.Fprintf(&sb, "%s (%d members)\n", ch.Name, ch.Count)
550b35e… lmata 270 }
550b35e… lmata 271 }
550b35e… lmata 272 return strings.TrimRight(sb.String(), "\n"), nil
550b35e… lmata 273 }
550b35e… lmata 274
550b35e… lmata 275 func (s *Server) toolRegisterAgent(args map[string]any) (string, error) {
550b35e… lmata 276 nick, _ := args["nick"].(string)
550b35e… lmata 277 if nick == "" {
550b35e… lmata 278 return "", fmt.Errorf("nick is required")
550b35e… lmata 279 }
550b35e… lmata 280 agentType := registry.AgentTypeWorker
550b35e… lmata 281 if t, ok := args["type"].(string); ok && t != "" {
550b35e… lmata 282 agentType = registry.AgentType(t)
550b35e… lmata 283 }
550b35e… lmata 284 var channels []string
550b35e… lmata 285 if ch, ok := args["channels"].([]any); ok {
550b35e… lmata 286 for _, c := range ch {
550b35e… lmata 287 if s, ok := c.(string); ok {
550b35e… lmata 288 channels = append(channels, s)
550b35e… lmata 289 }
550b35e… lmata 290 }
550b35e… lmata 291 }
550b35e… lmata 292
7830697… lmata 293 creds, _, err := s.registry.Register(nick, agentType, registry.EngagementConfig{Channels: channels})
550b35e… lmata 294 if err != nil {
550b35e… lmata 295 return "", err
550b35e… lmata 296 }
550b35e… lmata 297
550b35e… lmata 298 return fmt.Sprintf("Agent registered: %s\nnick: %s\npassword: %s",
550b35e… lmata 299 nick, creds.Nick, creds.Passphrase), nil
550b35e… lmata 300 }
550b35e… lmata 301
550b35e… lmata 302 func (s *Server) toolSendMessage(ctx context.Context, args map[string]any) (string, error) {
550b35e… lmata 303 if s.sender == nil {
550b35e… lmata 304 return "", fmt.Errorf("send_message not available: no IRC relay connected")
550b35e… lmata 305 }
550b35e… lmata 306 channel, _ := args["channel"].(string)
550b35e… lmata 307 msgType, _ := args["type"].(string)
550b35e… lmata 308 payload := args["payload"]
550b35e… lmata 309
550b35e… lmata 310 if channel == "" || msgType == "" {
550b35e… lmata 311 return "", fmt.Errorf("channel and type are required")
550b35e… lmata 312 }
550b35e… lmata 313 if err := s.sender.Send(ctx, channel, msgType, payload); err != nil {
550b35e… lmata 314 return "", err
550b35e… lmata 315 }
550b35e… lmata 316 return fmt.Sprintf("message sent to %s", channel), nil
550b35e… lmata 317 }
550b35e… lmata 318
550b35e… lmata 319 func (s *Server) toolGetHistory(args map[string]any) (string, error) {
550b35e… lmata 320 if s.history == nil {
550b35e… lmata 321 return "", fmt.Errorf("get_history not available: no history store connected")
550b35e… lmata 322 }
550b35e… lmata 323 channel, _ := args["channel"].(string)
550b35e… lmata 324 if channel == "" {
550b35e… lmata 325 return "", fmt.Errorf("channel is required")
550b35e… lmata 326 }
550b35e… lmata 327 limit := 20
550b35e… lmata 328 if l, ok := args["limit"].(float64); ok && l > 0 {
550b35e… lmata 329 limit = int(l)
550b35e… lmata 330 }
550b35e… lmata 331
550b35e… lmata 332 entries, err := s.history.Query(channel, limit)
550b35e… lmata 333 if err != nil {
550b35e… lmata 334 return "", err
550b35e… lmata 335 }
550b35e… lmata 336 if len(entries) == 0 {
550b35e… lmata 337 return fmt.Sprintf("no history for %s", channel), nil
550b35e… lmata 338 }
550b35e… lmata 339
550b35e… lmata 340 var sb strings.Builder
550b35e… lmata 341 fmt.Fprintf(&sb, "# history: %s (last %d)\n", channel, len(entries))
550b35e… lmata 342 for _, e := range entries {
550b35e… lmata 343 if e.MessageType != "" {
550b35e… lmata 344 fmt.Fprintf(&sb, "[%s] <%s> type=%s id=%s\n", channel, e.Nick, e.MessageType, e.MessageID)
550b35e… lmata 345 } else {
550b35e… lmata 346 fmt.Fprintf(&sb, "[%s] <%s> %s\n", channel, e.Nick, e.Raw)
550b35e… lmata 347 }
550b35e… lmata 348 }
550b35e… lmata 349 return strings.TrimRight(sb.String(), "\n"), nil
550b35e… lmata 350 }
550b35e… lmata 351
550b35e… lmata 352 // --- Tool schema definitions ---
550b35e… lmata 353
550b35e… lmata 354 func toolDefs() []map[string]any {
550b35e… lmata 355 return []map[string]any{
550b35e… lmata 356 {
550b35e… lmata 357 "name": "get_status",
550b35e… lmata 358 "description": "Get scuttlebot daemon health and agent count.",
550b35e… lmata 359 "inputSchema": schema(nil),
550b35e… lmata 360 },
550b35e… lmata 361 {
550b35e… lmata 362 "name": "list_channels",
550b35e… lmata 363 "description": "List available IRC channels with member count and topic.",
550b35e… lmata 364 "inputSchema": schema(nil),
550b35e… lmata 365 },
550b35e… lmata 366 {
550b35e… lmata 367 "name": "register_agent",
550b35e… lmata 368 "description": "Register a new agent and receive IRC credentials.",
550b35e… lmata 369 "inputSchema": schema(map[string]any{
550b35e… lmata 370 "nick": prop("string", "The agent's IRC nick (unique identifier)."),
98cb896… lmata 371 "type": prop("string", "Agent type: operator, worker, orchestrator, or observer. Default: worker."),
550b35e… lmata 372 "channels": map[string]any{
550b35e… lmata 373 "type": "array",
550b35e… lmata 374 "description": "Channels to join on connect.",
550b35e… lmata 375 "items": map[string]any{"type": "string"},
550b35e… lmata 376 },
550b35e… lmata 377 }),
550b35e… lmata 378 },
550b35e… lmata 379 {
550b35e… lmata 380 "name": "send_message",
550b35e… lmata 381 "description": "Send a typed message to an IRC channel.",
550b35e… lmata 382 "inputSchema": schema(map[string]any{
550b35e… lmata 383 "channel": prop("string", "Target channel (e.g. #fleet)."),
550b35e… lmata 384 "type": prop("string", "Message type (e.g. task.create)."),
550b35e… lmata 385 "payload": map[string]any{
550b35e… lmata 386 "type": "object",
550b35e… lmata 387 "description": "Message payload (any JSON object).",
550b35e… lmata 388 },
550b35e… lmata 389 }),
550b35e… lmata 390 },
550b35e… lmata 391 {
550b35e… lmata 392 "name": "get_history",
550b35e… lmata 393 "description": "Get recent messages from an IRC channel.",
550b35e… lmata 394 "inputSchema": schema(map[string]any{
550b35e… lmata 395 "channel": prop("string", "Target channel (e.g. #fleet)."),
550b35e… lmata 396 "limit": prop("number", "Number of messages to return. Default: 20."),
550b35e… lmata 397 }),
550b35e… lmata 398 },
550b35e… lmata 399 }
550b35e… lmata 400 }
550b35e… lmata 401
550b35e… lmata 402 func schema(properties map[string]any) map[string]any {
550b35e… lmata 403 if len(properties) == 0 {
550b35e… lmata 404 return map[string]any{"type": "object", "properties": map[string]any{}}
550b35e… lmata 405 }
550b35e… lmata 406 return map[string]any{"type": "object", "properties": properties}
550b35e… lmata 407 }
550b35e… lmata 408
550b35e… lmata 409 func prop(typ, desc string) map[string]any {
550b35e… lmata 410 return map[string]any{"type": typ, "description": desc}
550b35e… lmata 411 }
550b35e… lmata 412
550b35e… lmata 413 func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
550b35e… lmata 414 w.Header().Set("Content-Type", "application/json")
550b35e… lmata 415 resp := rpcResponse{
550b35e… lmata 416 JSONRPC: "2.0",
550b35e… lmata 417 ID: id,
550b35e… lmata 418 Error: &rpcError{Code: code, Message: msg},
550b35e… lmata 419 }
550b35e… lmata 420 _ = json.NewEncoder(w).Encode(resp)
550b35e… lmata 421 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button