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