ScuttleBot

feat: MCP server — JSON-RPC 2.0, all v0 tools, wired into daemon Tools: get_status, list_channels, register_agent, send_message, get_history. Sender + HistoryQuerier are optional interfaces for future relay/scribe wiring. Auth: same Bearer tokens as REST API. Runs on :8081 (SCUTTLEBOT_MCP_ADDR). 12 tests covering auth rejection, initialize, tools/list, each tool call, missing args, unknown tool/method. Closes #11

lmata 2026-03-31 05:23 trunk
Commit 550b35ed61c9342adcb431eb95e44556e9718b5ec5809aa0c563eda93c58b022
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -14,10 +14,11 @@
1414
"time"
1515
1616
"github.com/conflicthq/scuttlebot/internal/api"
1717
"github.com/conflicthq/scuttlebot/internal/config"
1818
"github.com/conflicthq/scuttlebot/internal/ergo"
19
+ "github.com/conflicthq/scuttlebot/internal/mcp"
1920
"github.com/conflicthq/scuttlebot/internal/registry"
2021
)
2122
2223
var version = "dev"
2324
@@ -85,39 +86,76 @@
8586
8687
// Build registry backed by Ergo's NickServ API.
8788
signingKey := []byte(mustGenToken())
8889
reg := registry.New(manager.API(), signingKey)
8990
90
- // Start HTTP API server.
91
+ // Shared API token — used by both REST and MCP servers.
9192
apiToken := mustGenToken()
9293
log.Info("api token", "token", apiToken) // printed once on startup — user copies this
93
- apiSrv := api.New(reg, []string{apiToken}, log)
94
+ tokens := []string{apiToken}
95
+
96
+ // Start HTTP REST API server.
97
+ apiSrv := api.New(reg, tokens, log)
9498
httpServer := &http.Server{
9599
Addr: cfg.APIAddr,
96100
Handler: apiSrv.Handler(),
97101
}
98
-
99102
go func() {
100103
log.Info("api server listening", "addr", httpServer.Addr)
101104
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
102105
log.Error("api server error", "err", err)
103106
}
104107
}()
108
+
109
+ // Start MCP server.
110
+ mcpSrv := mcp.New(reg, &ergoChannelLister{manager.API()}, tokens, log)
111
+ mcpServer := &http.Server{
112
+ Addr: cfg.MCPAddr,
113
+ Handler: mcpSrv.Handler(),
114
+ }
115
+ go func() {
116
+ log.Info("mcp server listening", "addr", mcpServer.Addr)
117
+ if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
118
+ log.Error("mcp server error", "err", err)
119
+ }
120
+ }()
105121
106122
<-ctx.Done()
107123
log.Info("shutting down")
108124
109125
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
110126
defer shutdownCancel()
111127
_ = httpServer.Shutdown(shutdownCtx)
128
+ _ = mcpServer.Shutdown(shutdownCtx)
112129
113130
log.Info("goodbye")
114131
}
132
+
133
+// ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
134
+type ergoChannelLister struct {
135
+ api *ergo.APIClient
136
+}
137
+
138
+func (e *ergoChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
139
+ resp, err := e.api.ListChannels()
140
+ if err != nil {
141
+ return nil, err
142
+ }
143
+ out := make([]mcp.ChannelInfo, len(resp.Channels))
144
+ for i, ch := range resp.Channels {
145
+ out[i] = mcp.ChannelInfo{
146
+ Name: ch.Name,
147
+ Topic: ch.Topic,
148
+ Count: ch.UserCount,
149
+ }
150
+ }
151
+ return out, nil
152
+}
115153
116154
func mustGenToken() string {
117155
b := make([]byte, 24)
118156
if _, err := rand.Read(b); err != nil {
119157
fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
120158
os.Exit(1)
121159
}
122160
return hex.EncodeToString(b)
123161
}
124162
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -14,10 +14,11 @@
14 "time"
15
16 "github.com/conflicthq/scuttlebot/internal/api"
17 "github.com/conflicthq/scuttlebot/internal/config"
18 "github.com/conflicthq/scuttlebot/internal/ergo"
 
19 "github.com/conflicthq/scuttlebot/internal/registry"
20 )
21
22 var version = "dev"
23
@@ -85,39 +86,76 @@
85
86 // Build registry backed by Ergo's NickServ API.
87 signingKey := []byte(mustGenToken())
88 reg := registry.New(manager.API(), signingKey)
89
90 // Start HTTP API server.
91 apiToken := mustGenToken()
92 log.Info("api token", "token", apiToken) // printed once on startup — user copies this
93 apiSrv := api.New(reg, []string{apiToken}, log)
 
 
 
94 httpServer := &http.Server{
95 Addr: cfg.APIAddr,
96 Handler: apiSrv.Handler(),
97 }
98
99 go func() {
100 log.Info("api server listening", "addr", httpServer.Addr)
101 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
102 log.Error("api server error", "err", err)
103 }
104 }()
 
 
 
 
 
 
 
 
 
 
 
 
 
105
106 <-ctx.Done()
107 log.Info("shutting down")
108
109 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
110 defer shutdownCancel()
111 _ = httpServer.Shutdown(shutdownCtx)
 
112
113 log.Info("goodbye")
114 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
116 func mustGenToken() string {
117 b := make([]byte, 24)
118 if _, err := rand.Read(b); err != nil {
119 fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
120 os.Exit(1)
121 }
122 return hex.EncodeToString(b)
123 }
124
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -14,10 +14,11 @@
14 "time"
15
16 "github.com/conflicthq/scuttlebot/internal/api"
17 "github.com/conflicthq/scuttlebot/internal/config"
18 "github.com/conflicthq/scuttlebot/internal/ergo"
19 "github.com/conflicthq/scuttlebot/internal/mcp"
20 "github.com/conflicthq/scuttlebot/internal/registry"
21 )
22
23 var version = "dev"
24
@@ -85,39 +86,76 @@
86
87 // Build registry backed by Ergo's NickServ API.
88 signingKey := []byte(mustGenToken())
89 reg := registry.New(manager.API(), signingKey)
90
91 // Shared API token — used by both REST and MCP servers.
92 apiToken := mustGenToken()
93 log.Info("api token", "token", apiToken) // printed once on startup — user copies this
94 tokens := []string{apiToken}
95
96 // Start HTTP REST API server.
97 apiSrv := api.New(reg, tokens, log)
98 httpServer := &http.Server{
99 Addr: cfg.APIAddr,
100 Handler: apiSrv.Handler(),
101 }
 
102 go func() {
103 log.Info("api server listening", "addr", httpServer.Addr)
104 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
105 log.Error("api server error", "err", err)
106 }
107 }()
108
109 // Start MCP server.
110 mcpSrv := mcp.New(reg, &ergoChannelLister{manager.API()}, tokens, log)
111 mcpServer := &http.Server{
112 Addr: cfg.MCPAddr,
113 Handler: mcpSrv.Handler(),
114 }
115 go func() {
116 log.Info("mcp server listening", "addr", mcpServer.Addr)
117 if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
118 log.Error("mcp server error", "err", err)
119 }
120 }()
121
122 <-ctx.Done()
123 log.Info("shutting down")
124
125 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
126 defer shutdownCancel()
127 _ = httpServer.Shutdown(shutdownCtx)
128 _ = mcpServer.Shutdown(shutdownCtx)
129
130 log.Info("goodbye")
131 }
132
133 // ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
134 type ergoChannelLister struct {
135 api *ergo.APIClient
136 }
137
138 func (e *ergoChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
139 resp, err := e.api.ListChannels()
140 if err != nil {
141 return nil, err
142 }
143 out := make([]mcp.ChannelInfo, len(resp.Channels))
144 for i, ch := range resp.Channels {
145 out[i] = mcp.ChannelInfo{
146 Name: ch.Name,
147 Topic: ch.Topic,
148 Count: ch.UserCount,
149 }
150 }
151 return out, nil
152 }
153
154 func mustGenToken() string {
155 b := make([]byte, 24)
156 if _, err := rand.Read(b); err != nil {
157 fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
158 os.Exit(1)
159 }
160 return hex.EncodeToString(b)
161 }
162
--- internal/config/config.go
+++ internal/config/config.go
@@ -14,10 +14,14 @@
1414
Datastore DatastoreConfig `yaml:"datastore"`
1515
1616
// APIAddr is the address for scuttlebot's own HTTP management API.
1717
// Default: ":8080"
1818
APIAddr string `yaml:"api_addr"`
19
+
20
+ // MCPAddr is the address for the MCP server.
21
+ // Default: ":8081"
22
+ MCPAddr string `yaml:"mcp_addr"`
1923
}
2024
2125
// ErgoConfig holds settings for the managed Ergo IRC server.
2226
type ErgoConfig struct {
2327
// External disables subprocess management. When true, scuttlebot expects
@@ -115,10 +119,13 @@
115119
c.Datastore.DSN = "./data/scuttlebot.db"
116120
}
117121
if c.APIAddr == "" {
118122
c.APIAddr = ":8080"
119123
}
124
+ if c.MCPAddr == "" {
125
+ c.MCPAddr = ":8081"
126
+ }
120127
}
121128
122129
func envStr(key string) string { return os.Getenv(key) }
123130
124131
// LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -178,7 +185,10 @@
178185
if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" {
179186
c.Ergo.NetworkName = v
180187
}
181188
if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" {
182189
c.Ergo.ServerName = v
190
+ }
191
+ if v := envStr("SCUTTLEBOT_MCP_ADDR"); v != "" {
192
+ c.MCPAddr = v
183193
}
184194
}
185195
--- internal/config/config.go
+++ internal/config/config.go
@@ -14,10 +14,14 @@
14 Datastore DatastoreConfig `yaml:"datastore"`
15
16 // APIAddr is the address for scuttlebot's own HTTP management API.
17 // Default: ":8080"
18 APIAddr string `yaml:"api_addr"`
 
 
 
 
19 }
20
21 // ErgoConfig holds settings for the managed Ergo IRC server.
22 type ErgoConfig struct {
23 // External disables subprocess management. When true, scuttlebot expects
@@ -115,10 +119,13 @@
115 c.Datastore.DSN = "./data/scuttlebot.db"
116 }
117 if c.APIAddr == "" {
118 c.APIAddr = ":8080"
119 }
 
 
 
120 }
121
122 func envStr(key string) string { return os.Getenv(key) }
123
124 // LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -178,7 +185,10 @@
178 if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" {
179 c.Ergo.NetworkName = v
180 }
181 if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" {
182 c.Ergo.ServerName = v
 
 
 
183 }
184 }
185
--- internal/config/config.go
+++ internal/config/config.go
@@ -14,10 +14,14 @@
14 Datastore DatastoreConfig `yaml:"datastore"`
15
16 // APIAddr is the address for scuttlebot's own HTTP management API.
17 // Default: ":8080"
18 APIAddr string `yaml:"api_addr"`
19
20 // MCPAddr is the address for the MCP server.
21 // Default: ":8081"
22 MCPAddr string `yaml:"mcp_addr"`
23 }
24
25 // ErgoConfig holds settings for the managed Ergo IRC server.
26 type ErgoConfig struct {
27 // External disables subprocess management. When true, scuttlebot expects
@@ -115,10 +119,13 @@
119 c.Datastore.DSN = "./data/scuttlebot.db"
120 }
121 if c.APIAddr == "" {
122 c.APIAddr = ":8080"
123 }
124 if c.MCPAddr == "" {
125 c.MCPAddr = ":8081"
126 }
127 }
128
129 func envStr(key string) string { return os.Getenv(key) }
130
131 // LoadFile reads a YAML config file into c. Missing file is not an error —
@@ -178,7 +185,10 @@
185 if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" {
186 c.Ergo.NetworkName = v
187 }
188 if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" {
189 c.Ergo.ServerName = v
190 }
191 if v := envStr("SCUTTLEBOT_MCP_ADDR"); v != "" {
192 c.MCPAddr = v
193 }
194 }
195
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -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
113
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
+}
2421
3422
ADDED internal/mcp/mcp_test.go
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -1,1 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
1 package mcp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED internal/mcp/mcp_test.go
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -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
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 }
421
422 DDED internal/mcp/mcp_test.go
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -0,0 +1,366 @@
1
+package mcp_test
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "sync"
11
+ "testing"
12
+
13
+ "github.com/conflicthq/scuttlebot/internal/mcp"
14
+ "github.com/conflicthq/scuttlebot/internal/registry"
15
+ "log/slog"
16
+ "os"
17
+)
18
+
19
+var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
+
21
+const testToken = "test-mcp-token"
22
+
23
+// --- mocks ---
24
+
25
+type mockProvisioner struct {
26
+ mu sync.Mutex
27
+ accounts map[string]string
28
+}
29
+
30
+func newMock() *mockProvisioner {
31
+ return &mockProvisioner{accounts: make(map[string]string)}
32
+}
33
+
34
+func (m *mockProvisioner) RegisterAccount(name, pass string) error {
35
+ m.mu.Lock()
36
+ defer m.mu.Unlock()
37
+ if _, ok := m.accounts[name]; ok {
38
+ return fmt.Errorf("ACCOUNT_EXISTS")
39
+ }
40
+ m.accounts[name] = pass
41
+ return nil
42
+}
43
+
44
+func (m *mockProvisioner) ChangePassword(name, pass string) error {
45
+ m.mu.Lock()
46
+ defer m.mu.Unlock()
47
+ if _, ok := m.accounts[name]; !ok {
48
+ return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
49
+ }
50
+ m.accounts[name] = pass
51
+ return nil
52
+}
53
+
54
+type mockChannelLister struct {
55
+ channels []mcp.ChannelInfo
56
+}
57
+
58
+func (m *mockChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
59
+ return m.channels, nil
60
+}
61
+
62
+type mockSender struct {
63
+ sent []string
64
+}
65
+
66
+func (m *mockSender) Send(_ context.Context, channel, msgType string, _ any) error {
67
+ m.sent = append(m.sent, channel+"/"+msgType)
68
+ return nil
69
+}
70
+
71
+type mockHistory struct {
72
+ entries map[string][]mcp.HistoryEntry
73
+}
74
+
75
+func (m *mockHistory) Query(channel string, limit int) ([]mcp.HistoryEntry, error) {
76
+ entries := m.entries[channel]
77
+ if len(entries) > limit {
78
+ entries = entries[len(entries)-limit:]
79
+ }
80
+ return entries, nil
81
+}
82
+
83
+// --- test server setup ---
84
+
85
+func newTestServer(t *testing.T) *httptest.Server {
86
+ t.Helper()
87
+ reg := registry.New(newMock(), []byte("test-signing-key"))
88
+ channels := &mockChannelLister{channels: []mcp.ChannelInfo{
89
+ {Name: "#fleet", Topic: "main coordination", Count: 3},
90
+ {Name: "#task.abc", Count: 1},
91
+ }}
92
+ sender := &mockSender{}
93
+ hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94
+ "#fleet": {
95
+ {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96
+ },
97
+ }}
98
+ srv[]string{testTokenpackage mcp_test
99
+
100
+import (
101
+ "bytes"
102
+ "context"
103
+ "encoding/json"
104
+ "fmt"
105
+ "net/http"
106
+ "net/http/httptest"
107
+ "sync"
108
+ "testing"
109
+
110
+ "github.com/conflicthq/scuttlebot/internal/mcp"
111
+ "github.com/conflicthq/scuttlebot/internal/registry"
112
+ "log/slog"
113
+ "os"
114
+)
115
+
116
+var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
117
+
118
+const testToken = "test-mcp-token"
119
+
120
+// --- mocks ---
121
+
122
+type tokenSet map[string]struct{}
123
+
124
+func (t tokenSet) ValidToken(tok string) bool {
125
+ _, ok := t[tok]
126
+ return ok
127
+}
128
+
129
+type mockProvisioner struct {
130
+ mu sync.Mutex
131
+ accounts map[string]string
132
+}
133
+
134
+func newMock() *mockProvisioner {
135
+ return &mockProvisioner{accounts: make(map[string]string)}
136
+}
137
+
138
+func (m *mockProvisioner) RegisterAccount(name, pass string) error {
139
+ m.mu.Lock()
140
+ defer m.mu.Unlock()
141
+ if _, ok := m.accounts[name]; ok {
142
+ return fmt.Errorf("ACCOUNT_EXISTS")
143
+ }
144
+ m.accounts[name] = pass
145
+ return nil
146
+}
147
+
148
+func (m *mockProvisioner) ChangePassword(name, pass string) error {
149
+ m.mu.Lock()
150
+ defer m.mu.Unlock()
151
+ if _, ok := m.accounts[name]; !ok {
152
+ return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
153
+ }
154
+ m.accounts[name] = pass
155
+ return nil
156
+}
157
+
158
+type mockChannelLister struct {
159
+ channels []mcp.ChannelInfo
160
+}
161
+
162
+func (m *mockChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
163
+ return m.channels, nil
164
+}
165
+
166
+type mockSender struct {
167
+ sent []string
168
+}
169
+
170
+func (m *mockSender) Send(_ context.Context, channel, msgType string, _ any) error {
171
+ m.sent = append(m.sent, channel+"/"+msgType)
172
+ return nil
173
+}
174
+
175
+type mockHistory struct {
176
+ entries map[string][]mcp.HistoryEntry
177
+}
178
+
179
+func (m *mockHistory) Query(channel string, limit int) ([]mcp.HistoryEntry, error) {
180
+ entries := m.entries[channel]
181
+ if len(entries) > limit {
182
+ entries = entries[len(entries)-limit:]
183
+ }
184
+ return entries, nil
185
+}
186
+
187
+// --- test server setup ---
188
+
189
+func newTestServer(t *testing.T) *httptest.Server {
190
+ t.Helper()
191
+ reg := registry.New(newMock(), []byte("test-signing-key"))
192
+ channels := &mockChannelLister{channels: []mcp.ChannelInfo{
193
+ {Name: "#fleet", Topic: "main coordination", Count: 3},
194
+ {Name: "#task.abc", Count: 1},
195
+ }}
196
+ sender := &mockSender{}
197
+ hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
198
+ "#fleet": {
199
+ {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
200
+ },
201
+ }}
202
+ srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
203
+ WithSender(sender).
204
+ WithHistory(hist)
205
+ return httptest.NewServer(srv.Handler())
206
+}
207
+
208
+func rpc(t *testing.T, srv *httptest.Server, method string, params any, token string) map[string]any {
209
+ t.Helper()
210
+ body := map[string]any{
211
+ "jsonrpc": "2.0",
212
+ "id": 1,
213
+ "method": method,
214
+ }
215
+ if params != nil {
216
+ body["params"] = params
217
+ }
218
+ data, _ := json.Marshal(body)
219
+ req, _ := http.NewRequest("POST", srv.URL+"/mcp", bytes.NewReader(data))
220
+ req.Header.Set("Content-Type", "application/json")
221
+ if token != "" {
222
+ req.Header.Set("Authorization", "Bearer "+token)
223
+ }
224
+ resp, err := http.DefaultClient.Do(req)
225
+ if err != nil {
226
+ t.Fatalf("request: %v", err)
227
+ }
228
+ defer resp.Body.Close()
229
+
230
+ var result map[string]any
231
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
232
+ t.Fatalf("decode: %v", err)
233
+ }
234
+ return result
235
+}
236
+
237
+// --- tests ---
238
+
239
+func TestAuthRequired(t *testing.T) {
240
+ srv := newTestServer(t)
241
+ defer srv.Close()
242
+
243
+ resp := rpc(t, srv, "initialize", nil, "") // no token
244
+ if resp["error"] == nil {
245
+ t.Error("expected error for missing auth, got none")
246
+ }
247
+}
248
+
249
+func TestAuthInvalid(t *testing.T) {
250
+ srv := newTestServer(t)
251
+ defer srv.Close()
252
+
253
+ resp := rpc(t, srv, "initialize", nil, "wrong-token")
254
+ if resp["error"] == nil {
255
+ t.Error("expected error for invalid token")
256
+ }
257
+}
258
+
259
+func TestInitialize(t *testing.T) {
260
+ srv := newTestServer(t)
261
+ defer srv.Close()
262
+
263
+ resp := rpc(t, srv, "initialize", map[string]any{
264
+ "protocolVersion": "2024-11-05",
265
+ "capabilities": map[string]any{},
266
+ "clientInfo": map[string]any{"name": "test", "version": "1"},
267
+ }, testToken)
268
+
269
+ result, ok := resp["result"].(map[string]any)
270
+ if !ok {
271
+ t.Fatalf("no result: %v", resp)
272
+ }
273
+ if result["protocolVersion"] == nil {
274
+ t.Error("missing protocolVersion in initialize response")
275
+ }
276
+}
277
+
278
+func TestToolsList(t *testing.T) {
279
+ srv := newTestServer(t)
280
+ defer srv.Close()
281
+
282
+ resp := rpc(t, srv, "tools/list", nil, testToken)
283
+ result, _ := resp["result"].(map[string]any)
284
+ tools, _ := result["tools"].([]any)
285
+ if len(tools) == 0 {
286
+ t.Error("expected at least one tool")
287
+ }
288
+ // Check all expected tool names are present.
289
+ want := map[string]bool{
290
+ "get_status": false, "list_channels": false,
291
+ "register_agent": false, "send_message": false, "get_history": false,
292
+ }
293
+ for _, tool := range tools {
294
+ m, _ := tool.(map[string]any)
295
+ if name, ok := m["name"].(string); ok {
296
+ want[name] = true
297
+ }
298
+ }
299
+ for name, found := range want {
300
+ if !found {
301
+ t.Errorf("tool %q missing from tools/list", name)
302
+ }
303
+ }
304
+}
305
+
306
+func TestToolGetStatus(t *testing.T) {
307
+ srv := newTestServer(t)
308
+ defer srv.Close()
309
+
310
+ resp := rpc(t, srv, "tools/call", map[string]any{
311
+ "name": "get_status",
312
+ "arguments": map[string]any{},
313
+ }, testToken)
314
+
315
+ if resp["error"] != nil {
316
+ t.Fatalf("unexpected rpc error: %v", resp["error"])
317
+ }
318
+ result := toolText(t, resp)
319
+ if result == "" {
320
+ t.Error("expected non-empty status text")
321
+ }
322
+}
323
+
324
+func TestToolListChannels(t *testing.T) {
325
+ srv := newTestServer(t)
326
+ defer srv.Close()
327
+
328
+ resp := rpc(t, srv, "tools/call", map[string]any{
329
+ "name": "list_channels",
330
+ "arguments": map[string]any{},
331
+ }, testToken)
332
+
333
+ text := toolText(t, resp)
334
+ if !contains(text, "#fleet") {
335
+ t.Errorf("expected #fleet in channel list, got: %s", text)
336
+ }
337
+}
338
+
339
+func TestToolRegisterAgent(t *testing.T) {
340
+ srv := newTestServer(t)
341
+ defer srv.Close()
342
+
343
+ resp := rpc(t, srv, "tools/call", map[string]any{
344
+ "name": "register_agent",
345
+ "arguments": map[string]any{
346
+ "nick": "mcp-agent",
347
+ "type": "worker",
348
+ "channels": []any{"#fleet"},
349
+ },
350
+ }, testToken)
351
+
352
+ if isToolError(resp) {
353
+ t.Fatalf("unexpected tool error: %s", toolText(t, resp))
354
+ }
355
+ text := toolText(t, resp)
356
+ if !contains(text, "mcp-agent") {
357
+ t.Errorf("expected nick in response, got: %s", text)
358
+ }
359
+ if !contains(text, "password") {
360
+ t.Errorf("expected password in response, got: %s", text)
361
+ }
362
+}
363
+
364
+func TestToolRegisterAgentMissingNick(t *testing.T) {
365
+ srv := newTestServer(t)
366
+ defer srv.
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -0,0 +1,366 @@
1 package mcp_test
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "sync"
11 "testing"
12
13 "github.com/conflicthq/scuttlebot/internal/mcp"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 "log/slog"
16 "os"
17 )
18
19 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
20
21 const testToken = "test-mcp-token"
22
23 // --- mocks ---
24
25 type mockProvisioner struct {
26 mu sync.Mutex
27 accounts map[string]string
28 }
29
30 func newMock() *mockProvisioner {
31 return &mockProvisioner{accounts: make(map[string]string)}
32 }
33
34 func (m *mockProvisioner) RegisterAccount(name, pass string) error {
35 m.mu.Lock()
36 defer m.mu.Unlock()
37 if _, ok := m.accounts[name]; ok {
38 return fmt.Errorf("ACCOUNT_EXISTS")
39 }
40 m.accounts[name] = pass
41 return nil
42 }
43
44 func (m *mockProvisioner) ChangePassword(name, pass string) error {
45 m.mu.Lock()
46 defer m.mu.Unlock()
47 if _, ok := m.accounts[name]; !ok {
48 return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
49 }
50 m.accounts[name] = pass
51 return nil
52 }
53
54 type mockChannelLister struct {
55 channels []mcp.ChannelInfo
56 }
57
58 func (m *mockChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
59 return m.channels, nil
60 }
61
62 type mockSender struct {
63 sent []string
64 }
65
66 func (m *mockSender) Send(_ context.Context, channel, msgType string, _ any) error {
67 m.sent = append(m.sent, channel+"/"+msgType)
68 return nil
69 }
70
71 type mockHistory struct {
72 entries map[string][]mcp.HistoryEntry
73 }
74
75 func (m *mockHistory) Query(channel string, limit int) ([]mcp.HistoryEntry, error) {
76 entries := m.entries[channel]
77 if len(entries) > limit {
78 entries = entries[len(entries)-limit:]
79 }
80 return entries, nil
81 }
82
83 // --- test server setup ---
84
85 func newTestServer(t *testing.T) *httptest.Server {
86 t.Helper()
87 reg := registry.New(newMock(), []byte("test-signing-key"))
88 channels := &mockChannelLister{channels: []mcp.ChannelInfo{
89 {Name: "#fleet", Topic: "main coordination", Count: 3},
90 {Name: "#task.abc", Count: 1},
91 }}
92 sender := &mockSender{}
93 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
94 "#fleet": {
95 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
96 },
97 }}
98 srv[]string{testTokenpackage mcp_test
99
100 import (
101 "bytes"
102 "context"
103 "encoding/json"
104 "fmt"
105 "net/http"
106 "net/http/httptest"
107 "sync"
108 "testing"
109
110 "github.com/conflicthq/scuttlebot/internal/mcp"
111 "github.com/conflicthq/scuttlebot/internal/registry"
112 "log/slog"
113 "os"
114 )
115
116 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
117
118 const testToken = "test-mcp-token"
119
120 // --- mocks ---
121
122 type tokenSet map[string]struct{}
123
124 func (t tokenSet) ValidToken(tok string) bool {
125 _, ok := t[tok]
126 return ok
127 }
128
129 type mockProvisioner struct {
130 mu sync.Mutex
131 accounts map[string]string
132 }
133
134 func newMock() *mockProvisioner {
135 return &mockProvisioner{accounts: make(map[string]string)}
136 }
137
138 func (m *mockProvisioner) RegisterAccount(name, pass string) error {
139 m.mu.Lock()
140 defer m.mu.Unlock()
141 if _, ok := m.accounts[name]; ok {
142 return fmt.Errorf("ACCOUNT_EXISTS")
143 }
144 m.accounts[name] = pass
145 return nil
146 }
147
148 func (m *mockProvisioner) ChangePassword(name, pass string) error {
149 m.mu.Lock()
150 defer m.mu.Unlock()
151 if _, ok := m.accounts[name]; !ok {
152 return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
153 }
154 m.accounts[name] = pass
155 return nil
156 }
157
158 type mockChannelLister struct {
159 channels []mcp.ChannelInfo
160 }
161
162 func (m *mockChannelLister) ListChannels() ([]mcp.ChannelInfo, error) {
163 return m.channels, nil
164 }
165
166 type mockSender struct {
167 sent []string
168 }
169
170 func (m *mockSender) Send(_ context.Context, channel, msgType string, _ any) error {
171 m.sent = append(m.sent, channel+"/"+msgType)
172 return nil
173 }
174
175 type mockHistory struct {
176 entries map[string][]mcp.HistoryEntry
177 }
178
179 func (m *mockHistory) Query(channel string, limit int) ([]mcp.HistoryEntry, error) {
180 entries := m.entries[channel]
181 if len(entries) > limit {
182 entries = entries[len(entries)-limit:]
183 }
184 return entries, nil
185 }
186
187 // --- test server setup ---
188
189 func newTestServer(t *testing.T) *httptest.Server {
190 t.Helper()
191 reg := registry.New(newMock(), []byte("test-signing-key"))
192 channels := &mockChannelLister{channels: []mcp.ChannelInfo{
193 {Name: "#fleet", Topic: "main coordination", Count: 3},
194 {Name: "#task.abc", Count: 1},
195 }}
196 sender := &mockSender{}
197 hist := &mockHistory{entries: map[string][]mcp.HistoryEntry{
198 "#fleet": {
199 {Nick: "agent-01", MessageType: "task.create", MessageID: "01HX", Raw: `{"v":1}`},
200 },
201 }}
202 srv := mcp.New(reg, channels, tokenSet{testToken: {}}, testLog).
203 WithSender(sender).
204 WithHistory(hist)
205 return httptest.NewServer(srv.Handler())
206 }
207
208 func rpc(t *testing.T, srv *httptest.Server, method string, params any, token string) map[string]any {
209 t.Helper()
210 body := map[string]any{
211 "jsonrpc": "2.0",
212 "id": 1,
213 "method": method,
214 }
215 if params != nil {
216 body["params"] = params
217 }
218 data, _ := json.Marshal(body)
219 req, _ := http.NewRequest("POST", srv.URL+"/mcp", bytes.NewReader(data))
220 req.Header.Set("Content-Type", "application/json")
221 if token != "" {
222 req.Header.Set("Authorization", "Bearer "+token)
223 }
224 resp, err := http.DefaultClient.Do(req)
225 if err != nil {
226 t.Fatalf("request: %v", err)
227 }
228 defer resp.Body.Close()
229
230 var result map[string]any
231 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
232 t.Fatalf("decode: %v", err)
233 }
234 return result
235 }
236
237 // --- tests ---
238
239 func TestAuthRequired(t *testing.T) {
240 srv := newTestServer(t)
241 defer srv.Close()
242
243 resp := rpc(t, srv, "initialize", nil, "") // no token
244 if resp["error"] == nil {
245 t.Error("expected error for missing auth, got none")
246 }
247 }
248
249 func TestAuthInvalid(t *testing.T) {
250 srv := newTestServer(t)
251 defer srv.Close()
252
253 resp := rpc(t, srv, "initialize", nil, "wrong-token")
254 if resp["error"] == nil {
255 t.Error("expected error for invalid token")
256 }
257 }
258
259 func TestInitialize(t *testing.T) {
260 srv := newTestServer(t)
261 defer srv.Close()
262
263 resp := rpc(t, srv, "initialize", map[string]any{
264 "protocolVersion": "2024-11-05",
265 "capabilities": map[string]any{},
266 "clientInfo": map[string]any{"name": "test", "version": "1"},
267 }, testToken)
268
269 result, ok := resp["result"].(map[string]any)
270 if !ok {
271 t.Fatalf("no result: %v", resp)
272 }
273 if result["protocolVersion"] == nil {
274 t.Error("missing protocolVersion in initialize response")
275 }
276 }
277
278 func TestToolsList(t *testing.T) {
279 srv := newTestServer(t)
280 defer srv.Close()
281
282 resp := rpc(t, srv, "tools/list", nil, testToken)
283 result, _ := resp["result"].(map[string]any)
284 tools, _ := result["tools"].([]any)
285 if len(tools) == 0 {
286 t.Error("expected at least one tool")
287 }
288 // Check all expected tool names are present.
289 want := map[string]bool{
290 "get_status": false, "list_channels": false,
291 "register_agent": false, "send_message": false, "get_history": false,
292 }
293 for _, tool := range tools {
294 m, _ := tool.(map[string]any)
295 if name, ok := m["name"].(string); ok {
296 want[name] = true
297 }
298 }
299 for name, found := range want {
300 if !found {
301 t.Errorf("tool %q missing from tools/list", name)
302 }
303 }
304 }
305
306 func TestToolGetStatus(t *testing.T) {
307 srv := newTestServer(t)
308 defer srv.Close()
309
310 resp := rpc(t, srv, "tools/call", map[string]any{
311 "name": "get_status",
312 "arguments": map[string]any{},
313 }, testToken)
314
315 if resp["error"] != nil {
316 t.Fatalf("unexpected rpc error: %v", resp["error"])
317 }
318 result := toolText(t, resp)
319 if result == "" {
320 t.Error("expected non-empty status text")
321 }
322 }
323
324 func TestToolListChannels(t *testing.T) {
325 srv := newTestServer(t)
326 defer srv.Close()
327
328 resp := rpc(t, srv, "tools/call", map[string]any{
329 "name": "list_channels",
330 "arguments": map[string]any{},
331 }, testToken)
332
333 text := toolText(t, resp)
334 if !contains(text, "#fleet") {
335 t.Errorf("expected #fleet in channel list, got: %s", text)
336 }
337 }
338
339 func TestToolRegisterAgent(t *testing.T) {
340 srv := newTestServer(t)
341 defer srv.Close()
342
343 resp := rpc(t, srv, "tools/call", map[string]any{
344 "name": "register_agent",
345 "arguments": map[string]any{
346 "nick": "mcp-agent",
347 "type": "worker",
348 "channels": []any{"#fleet"},
349 },
350 }, testToken)
351
352 if isToolError(resp) {
353 t.Fatalf("unexpected tool error: %s", toolText(t, resp))
354 }
355 text := toolText(t, resp)
356 if !contains(text, "mcp-agent") {
357 t.Errorf("expected nick in response, got: %s", text)
358 }
359 if !contains(text, "password") {
360 t.Errorf("expected password in response, got: %s", text)
361 }
362 }
363
364 func TestToolRegisterAgentMissingNick(t *testing.T) {
365 srv := newTestServer(t)
366 defer srv.

Keyboard Shortcuts

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