ScuttleBot

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

Keyboard Shortcuts

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