sensorium-mcp 2.17.26 → 2.17.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  2. package/dist/dashboard/routes/threads.js +18 -5
  3. package/dist/dashboard/routes/threads.js.map +1 -1
  4. package/dist/data/memory/bootstrap.js +2 -2
  5. package/dist/data/memory/bootstrap.js.map +1 -1
  6. package/dist/data/memory/consolidation.d.ts.map +1 -1
  7. package/dist/data/memory/consolidation.js +75 -4
  8. package/dist/data/memory/consolidation.js.map +1 -1
  9. package/dist/data/memory/index.d.ts +1 -0
  10. package/dist/data/memory/index.d.ts.map +1 -1
  11. package/dist/data/memory/index.js +1 -0
  12. package/dist/data/memory/index.js.map +1 -1
  13. package/dist/data/memory/quality-scoring.d.ts +32 -0
  14. package/dist/data/memory/quality-scoring.d.ts.map +1 -0
  15. package/dist/data/memory/quality-scoring.js +182 -0
  16. package/dist/data/memory/quality-scoring.js.map +1 -0
  17. package/dist/data/memory/semantic.d.ts +12 -0
  18. package/dist/data/memory/semantic.d.ts.map +1 -1
  19. package/dist/data/memory/semantic.js +45 -2
  20. package/dist/data/memory/semantic.js.map +1 -1
  21. package/dist/data/memory/thread-registry.d.ts +7 -0
  22. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  23. package/dist/data/memory/thread-registry.js +11 -1
  24. package/dist/data/memory/thread-registry.js.map +1 -1
  25. package/dist/index.js +17 -5
  26. package/dist/index.js.map +1 -1
  27. package/dist/tools/defs/memory-defs.d.ts.map +1 -1
  28. package/dist/tools/defs/memory-defs.js +19 -0
  29. package/dist/tools/defs/memory-defs.js.map +1 -1
  30. package/dist/tools/memory-tools.d.ts.map +1 -1
  31. package/dist/tools/memory-tools.js +15 -0
  32. package/dist/tools/memory-tools.js.map +1 -1
  33. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  34. package/dist/tools/thread-lifecycle.js +31 -17
  35. package/dist/tools/thread-lifecycle.js.map +1 -1
  36. package/package.json +10 -2
  37. package/scripts/install-supervisor.ps1 +67 -0
  38. package/scripts/install-supervisor.sh +43 -0
  39. package/scripts/start-supervisor.ps1 +46 -0
  40. package/scripts/start-supervisor.sh +20 -0
  41. package/supervisor/config.go +140 -0
  42. package/supervisor/go.mod +3 -0
  43. package/supervisor/health.go +390 -0
  44. package/supervisor/health_test.go +93 -0
  45. package/supervisor/keeper.go +303 -0
  46. package/supervisor/keeper_test.go +27 -0
  47. package/supervisor/lock.go +56 -0
  48. package/supervisor/lock_test.go +54 -0
  49. package/supervisor/log.go +114 -0
  50. package/supervisor/log_test.go +45 -0
  51. package/supervisor/main.go +325 -0
  52. package/supervisor/notify.go +53 -0
  53. package/supervisor/process.go +222 -0
  54. package/supervisor/process_test.go +94 -0
  55. package/supervisor/process_unix.go +14 -0
  56. package/supervisor/process_windows.go +15 -0
  57. package/supervisor/updater.go +281 -0
  58. package/templates/coding-task.default.md +12 -0
  59. package/dist/claude-keeper.d.ts +0 -24
  60. package/dist/claude-keeper.d.ts.map +0 -1
  61. package/dist/claude-keeper.js +0 -374
  62. package/dist/claude-keeper.js.map +0 -1
  63. package/dist/watcher-service.d.ts +0 -2
  64. package/dist/watcher-service.d.ts.map +0 -1
  65. package/dist/watcher-service.js +0 -997
  66. package/dist/watcher-service.js.map +0 -1
@@ -0,0 +1,140 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strconv"
8
+ "time"
9
+ )
10
+
11
+ // Config holds all supervisor configuration, sourced from environment variables
12
+ // with sensible defaults matching the TypeScript watcher-service.ts CONFIG object.
13
+ type Config struct {
14
+ // Watcher
15
+ Mode string
16
+ PollAtHour int
17
+ PollInterval time.Duration
18
+ GracePeriod time.Duration
19
+ MinUptime time.Duration
20
+ MCPStartCommand string
21
+ DataDir string
22
+ MCPHttpPort int
23
+ MCPHttpSecret string
24
+ TelegramToken string
25
+ TelegramChatID string
26
+ HealthFailThresh int
27
+
28
+ // Keeper defaults
29
+ KeeperBaseBackoff time.Duration
30
+ KeeperMaxBackoff time.Duration
31
+ KeeperHealthCheckInterval time.Duration
32
+ KeeperMaxRetries int
33
+ KeeperCooldown time.Duration
34
+ KeeperReadyPollInterval time.Duration
35
+ KeeperReadyTimeout time.Duration
36
+ FastExitThreshold time.Duration
37
+ FastExitMaxCount int
38
+ FastExitBaseCooldown time.Duration
39
+ FastExitMaxCooldown time.Duration
40
+ StuckThreshold time.Duration
41
+
42
+ // Derived paths
43
+ Paths Paths
44
+ }
45
+
46
+ // Paths holds all filesystem paths derived from DataDir.
47
+ type Paths struct {
48
+ MaintenanceFlag string
49
+ VersionFile string
50
+ LastActivity string
51
+ ServerPID string
52
+ WatcherLock string
53
+ WatcherLog string
54
+ PIDsDir string
55
+ HeartbeatsDir string
56
+ }
57
+
58
+ func LoadConfig() Config {
59
+ dataDir := filepath.Join(homeDir(), ".remote-copilot-mcp")
60
+
61
+ mode := envOr("WATCHER_MODE", "development")
62
+ graceDef := "300"
63
+ if mode == "development" {
64
+ graceDef = "10"
65
+ }
66
+
67
+ c := Config{
68
+ Mode: mode,
69
+ PollAtHour: envInt("WATCHER_POLL_HOUR", 4),
70
+ PollInterval: time.Duration(envInt("WATCHER_POLL_INTERVAL", 60)) * time.Second,
71
+ GracePeriod: time.Duration(envInt("WATCHER_GRACE_PERIOD", safeAtoi(graceDef))) * time.Second,
72
+ MinUptime: 600 * time.Second,
73
+ MCPStartCommand: envOr("MCP_START_COMMAND", "npx -y sensorium-mcp@latest"),
74
+ DataDir: dataDir,
75
+ MCPHttpPort: envInt("MCP_HTTP_PORT", 0),
76
+ MCPHttpSecret: os.Getenv("MCP_HTTP_SECRET"),
77
+ TelegramToken: os.Getenv("TELEGRAM_TOKEN"),
78
+ TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"),
79
+ HealthFailThresh: 3,
80
+
81
+ KeeperBaseBackoff: 5 * time.Second,
82
+ KeeperMaxBackoff: 5 * time.Minute,
83
+ KeeperHealthCheckInterval: 2 * time.Minute,
84
+ KeeperMaxRetries: 5,
85
+ KeeperCooldown: 5 * time.Minute,
86
+ KeeperReadyPollInterval: 3 * time.Second,
87
+ KeeperReadyTimeout: 2 * time.Minute,
88
+ FastExitThreshold: 60 * time.Second,
89
+ FastExitMaxCount: 3,
90
+ FastExitBaseCooldown: 10 * time.Minute,
91
+ FastExitMaxCooldown: 4 * time.Hour,
92
+ StuckThreshold: 10 * time.Minute,
93
+
94
+ Paths: Paths{
95
+ MaintenanceFlag: filepath.Join(dataDir, "maintenance.flag"),
96
+ VersionFile: filepath.Join(dataDir, "current-version.txt"),
97
+ LastActivity: filepath.Join(dataDir, "last-activity.txt"),
98
+ ServerPID: filepath.Join(dataDir, "server.pid"),
99
+ WatcherLock: filepath.Join(dataDir, "watcher.lock"),
100
+ WatcherLog: filepath.Join(dataDir, "watcher.log"),
101
+ PIDsDir: filepath.Join(dataDir, "pids"),
102
+ HeartbeatsDir: filepath.Join(dataDir, "heartbeats"),
103
+ },
104
+ }
105
+
106
+ return c
107
+ }
108
+
109
+ func homeDir() string {
110
+ h, err := os.UserHomeDir()
111
+ if err != nil {
112
+ fmt.Fprintf(os.Stderr, "FATAL: cannot determine home directory: %v\n", err)
113
+ os.Exit(1)
114
+ }
115
+ return h
116
+ }
117
+
118
+ func envOr(key, fallback string) string {
119
+ if v := os.Getenv(key); v != "" {
120
+ return v
121
+ }
122
+ return fallback
123
+ }
124
+
125
+ func envInt(key string, fallback int) int {
126
+ s := os.Getenv(key)
127
+ if s == "" {
128
+ return fallback
129
+ }
130
+ v, err := strconv.Atoi(s)
131
+ if err != nil {
132
+ return fallback
133
+ }
134
+ return v
135
+ }
136
+
137
+ func safeAtoi(s string) int {
138
+ v, _ := strconv.Atoi(s)
139
+ return v
140
+ }
@@ -0,0 +1,3 @@
1
+ module github.com/andriyshevchenko/sensorium-supervisor
2
+
3
+ go 1.22
@@ -0,0 +1,390 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // MCPClient wraps HTTP interactions with the MCP server.
14
+ type MCPClient struct {
15
+ BaseURL string
16
+ Secret string
17
+ Client *http.Client
18
+ Log *Logger
19
+ }
20
+
21
+ func NewMCPClient(port int, secret string) *MCPClient {
22
+ return &MCPClient{
23
+ BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
24
+ Secret: secret,
25
+ Client: &http.Client{Timeout: 35 * time.Second},
26
+ }
27
+ }
28
+
29
+ func (m *MCPClient) authHeaders() map[string]string {
30
+ h := map[string]string{
31
+ "Content-Type": "application/json",
32
+ "Accept": "application/json",
33
+ }
34
+ if m.Secret != "" {
35
+ h["Authorization"] = "Bearer " + m.Secret
36
+ }
37
+ return h
38
+ }
39
+
40
+ func (m *MCPClient) doReq(ctx context.Context, method, path string, body any) (*http.Response, error) {
41
+ var bodyReader io.Reader
42
+ if body != nil {
43
+ data, err := json.Marshal(body)
44
+ if err != nil {
45
+ return nil, err
46
+ }
47
+ bodyReader = strings.NewReader(string(data))
48
+ }
49
+
50
+ req, err := http.NewRequestWithContext(ctx, method, m.BaseURL+path, bodyReader)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ for k, v := range m.authHeaders() {
55
+ req.Header.Set(k, v)
56
+ }
57
+ return m.Client.Do(req)
58
+ }
59
+
60
+ // IsServerReady checks if the MCP server is responding (OPTIONS /mcp).
61
+ func (m *MCPClient) IsServerReady(ctx context.Context) bool {
62
+ ctx2, cancel := context.WithTimeout(ctx, 3*time.Second)
63
+ defer cancel()
64
+ resp, err := m.doReq(ctx2, "OPTIONS", "/mcp", nil)
65
+ if err != nil {
66
+ if m.Log != nil {
67
+ m.Log.Debug("IsServerReady: OPTIONS /mcp failed: %v", err)
68
+ }
69
+ return false
70
+ }
71
+ defer resp.Body.Close()
72
+ ready := resp.StatusCode < 500
73
+ if m.Log != nil {
74
+ m.Log.Debug("IsServerReady: OPTIONS /mcp => %d (ready=%v)", resp.StatusCode, ready)
75
+ }
76
+ return ready
77
+ }
78
+
79
+ // WaitForReady polls the MCP server until it's ready, or timeout expires.
80
+ func (m *MCPClient) WaitForReady(ctx context.Context, pollInterval, timeout time.Duration) bool {
81
+ ctx2, cancel := context.WithTimeout(ctx, timeout)
82
+ defer cancel()
83
+ ticker := time.NewTicker(pollInterval)
84
+ defer ticker.Stop()
85
+
86
+ for {
87
+ if m.IsServerReady(ctx2) {
88
+ return true
89
+ }
90
+ select {
91
+ case <-ctx2.Done():
92
+ return false
93
+ case <-ticker.C:
94
+ // try again
95
+ }
96
+ }
97
+ }
98
+
99
+ // GetRootThreads fetches the list of root threads from the server.
100
+ func (m *MCPClient) GetRootThreads(ctx context.Context) ([]map[string]any, error) {
101
+ ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
102
+ defer cancel()
103
+ resp, err := m.doReq(ctx2, "GET", "/api/threads/roots", nil)
104
+ if err != nil {
105
+ return nil, err
106
+ }
107
+ defer resp.Body.Close()
108
+ if resp.StatusCode != 200 {
109
+ return nil, fmt.Errorf("GET /api/threads/roots: %d", resp.StatusCode)
110
+ }
111
+ body, err := io.ReadAll(resp.Body)
112
+ if err != nil {
113
+ return nil, err
114
+ }
115
+
116
+ // Try bare array first, then wrapped {"threads": [...]}
117
+ var result []map[string]any
118
+ if err := json.Unmarshal(body, &result); err == nil {
119
+ return result, nil
120
+ }
121
+ var wrapped struct {
122
+ Threads []map[string]any `json:"threads"`
123
+ }
124
+ if err := json.Unmarshal(body, &wrapped); err != nil {
125
+ return nil, fmt.Errorf("cannot parse /api/threads/roots response: %w", err)
126
+ }
127
+ return wrapped.Threads, nil
128
+ }
129
+
130
+ // IsThreadRunning checks if a specific thread is running on the MCP server.
131
+ func (m *MCPClient) IsThreadRunning(ctx context.Context, threadID int) bool {
132
+ ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
133
+ defer cancel()
134
+ resp, err := m.doReq(ctx2, "GET", fmt.Sprintf("/api/threads/%d/running", threadID), nil)
135
+ if err != nil {
136
+ if m.Log != nil {
137
+ m.Log.Debug("IsThreadRunning(%d): request failed: %v", threadID, err)
138
+ }
139
+ return false
140
+ }
141
+ defer resp.Body.Close()
142
+ if resp.StatusCode != 200 {
143
+ if m.Log != nil {
144
+ m.Log.Debug("IsThreadRunning(%d): HTTP %d", threadID, resp.StatusCode)
145
+ }
146
+ return false
147
+ }
148
+ var result struct{ Running bool `json:"running"` }
149
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
150
+ if m.Log != nil {
151
+ m.Log.Debug("IsThreadRunning(%d): decode error: %v", threadID, err)
152
+ }
153
+ return false
154
+ }
155
+ if m.Log != nil {
156
+ m.Log.Debug("IsThreadRunning(%d): running=%v", threadID, result.Running)
157
+ }
158
+ return result.Running
159
+ }
160
+
161
+ // IsThreadStuck checks the per-thread heartbeat to detect stuck threads.
162
+ func (m *MCPClient) IsThreadStuck(ctx context.Context, threadID int, threshold time.Duration) bool {
163
+ ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
164
+ defer cancel()
165
+ resp, err := m.doReq(ctx2, "GET", fmt.Sprintf("/api/threads/%d/heartbeat", threadID), nil)
166
+ if err != nil {
167
+ if m.Log != nil {
168
+ m.Log.Debug("IsThreadStuck(%d): heartbeat request failed: %v", threadID, err)
169
+ }
170
+ return false // can't determine — default to not stuck
171
+ }
172
+ defer resp.Body.Close()
173
+ if resp.StatusCode != 200 {
174
+ if m.Log != nil {
175
+ m.Log.Debug("IsThreadStuck(%d): heartbeat HTTP %d", threadID, resp.StatusCode)
176
+ }
177
+ return false
178
+ }
179
+ var result struct {
180
+ LastActivityMs int64 `json:"lastActivityMs"`
181
+ }
182
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
183
+ if m.Log != nil {
184
+ m.Log.Debug("IsThreadStuck(%d): heartbeat decode error: %v", threadID, err)
185
+ }
186
+ return false
187
+ }
188
+ if result.LastActivityMs <= 0 {
189
+ if m.Log != nil {
190
+ m.Log.Debug("IsThreadStuck(%d): no lastActivityMs data", threadID)
191
+ }
192
+ return false
193
+ }
194
+ age := time.Duration(time.Now().UnixMilli()-result.LastActivityMs) * time.Millisecond
195
+ stuck := age > threshold
196
+ if m.Log != nil {
197
+ m.Log.Debug("IsThreadStuck(%d): age=%v threshold=%v stuck=%v", threadID, age.Round(time.Second), threshold, stuck)
198
+ }
199
+ return stuck
200
+ }
201
+
202
+ // parseJsonOrSse extracts JSON from either a plain JSON response or an SSE stream.
203
+ // The MCP SDK may return SSE format when Accept includes text/event-stream.
204
+ func parseJsonOrSse(body []byte, contentType string) (map[string]any, error) {
205
+ if strings.Contains(contentType, "text/event-stream") {
206
+ // Extract JSON from SSE data: lines
207
+ for _, line := range strings.Split(string(body), "\n") {
208
+ line = strings.TrimSpace(line)
209
+ if strings.HasPrefix(line, "data:") {
210
+ jsonStr := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
211
+ if jsonStr == "" {
212
+ continue
213
+ }
214
+ var result map[string]any
215
+ if err := json.Unmarshal([]byte(jsonStr), &result); err == nil {
216
+ return result, nil
217
+ }
218
+ }
219
+ }
220
+ return nil, fmt.Errorf("no valid JSON found in SSE stream")
221
+ }
222
+ var result map[string]any
223
+ if err := json.Unmarshal(body, &result); err != nil {
224
+ return nil, err
225
+ }
226
+ return result, nil
227
+ }
228
+
229
+ // OpenMCPSession creates an MCP session via the initialize handshake.
230
+ // Returns the session ID or empty string on failure.
231
+ func (m *MCPClient) OpenMCPSession(ctx context.Context) (string, error) {
232
+ if m.Log != nil {
233
+ m.Log.Debug("OpenMCPSession: initiating handshake")
234
+ }
235
+ ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
236
+ defer cancel()
237
+
238
+ initPayload := map[string]any{
239
+ "jsonrpc": "2.0",
240
+ "id": 1,
241
+ "method": "initialize",
242
+ "params": map[string]any{
243
+ "protocolVersion": "2025-03-26",
244
+ "capabilities": map[string]any{},
245
+ "clientInfo": map[string]any{
246
+ "name": "sensorium-supervisor",
247
+ "version": "1.0.0",
248
+ },
249
+ },
250
+ }
251
+
252
+ resp, err := m.doReq(ctx2, "POST", "/mcp", initPayload)
253
+ if err != nil {
254
+ return "", fmt.Errorf("initialize: %w", err)
255
+ }
256
+ defer resp.Body.Close()
257
+
258
+ if resp.StatusCode >= 400 {
259
+ return "", fmt.Errorf("initialize HTTP %d", resp.StatusCode)
260
+ }
261
+
262
+ sessionID := resp.Header.Get("Mcp-Session-Id")
263
+ if sessionID == "" {
264
+ if m.Log != nil {
265
+ m.Log.Debug("OpenMCPSession: no Mcp-Session-Id in response headers")
266
+ }
267
+ return "", fmt.Errorf("initialize succeeded but no session ID returned")
268
+ }
269
+
270
+ // Send initialized notification
271
+ notifPayload := map[string]any{
272
+ "jsonrpc": "2.0",
273
+ "method": "notifications/initialized",
274
+ }
275
+ notifReq, _ := http.NewRequestWithContext(ctx2, "POST", m.BaseURL+"/mcp", nil)
276
+ data, err := json.Marshal(notifPayload)
277
+ if err != nil {
278
+ return sessionID, nil // session created, notification failed — non-fatal
279
+ }
280
+ notifReq.Body = io.NopCloser(strings.NewReader(string(data)))
281
+ for k, v := range m.authHeaders() {
282
+ notifReq.Header.Set(k, v)
283
+ }
284
+ notifReq.Header.Set("Mcp-Session-Id", sessionID)
285
+ nResp, err := m.Client.Do(notifReq)
286
+ if err == nil {
287
+ nResp.Body.Close()
288
+ }
289
+
290
+ return sessionID, nil
291
+ }
292
+
293
+ // CloseMCPSession closes a session via HTTP DELETE.
294
+ func (m *MCPClient) CloseMCPSession(ctx context.Context, sessionID string) {
295
+ if m.Log != nil {
296
+ m.Log.Debug("CloseMCPSession: closing session %s", sessionID)
297
+ }
298
+ ctx2, cancel := context.WithTimeout(ctx, 5*time.Second)
299
+ defer cancel()
300
+ req, _ := http.NewRequestWithContext(ctx2, "DELETE", m.BaseURL+"/mcp", nil)
301
+ for k, v := range m.authHeaders() {
302
+ req.Header.Set(k, v)
303
+ }
304
+ req.Header.Set("Mcp-Session-Id", sessionID)
305
+ resp, err := m.Client.Do(req)
306
+ if err == nil {
307
+ resp.Body.Close()
308
+ }
309
+ }
310
+
311
+ // CallStartThread invokes the start_thread MCP tool via JSON-RPC.
312
+ func (m *MCPClient) CallStartThread(ctx context.Context, sessionID string, threadID int, sessionName, client, workingDir string) (string, error) {
313
+ if m.Log != nil {
314
+ m.Log.Debug("CallStartThread: threadID=%d session=%q client=%q workDir=%q", threadID, sessionName, client, workingDir)
315
+ }
316
+ ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
317
+ defer cancel()
318
+
319
+ args := map[string]any{
320
+ "targetThreadId": threadID,
321
+ "mode": "resume",
322
+ "name": sessionName,
323
+ "agentType": client,
324
+ }
325
+ if workingDir != "" {
326
+ args["workingDirectory"] = workingDir
327
+ }
328
+
329
+ payload := map[string]any{
330
+ "jsonrpc": "2.0",
331
+ "id": 2,
332
+ "method": "tools/call",
333
+ "params": map[string]any{
334
+ "name": "start_thread",
335
+ "arguments": args,
336
+ },
337
+ }
338
+
339
+ req, err := http.NewRequestWithContext(ctx2, "POST", m.BaseURL+"/mcp", nil)
340
+ if err != nil {
341
+ return "", err
342
+ }
343
+ data, _ := json.Marshal(payload)
344
+ req.Body = io.NopCloser(strings.NewReader(string(data)))
345
+ for k, v := range m.authHeaders() {
346
+ req.Header.Set(k, v)
347
+ }
348
+ req.Header.Set("Mcp-Session-Id", sessionID)
349
+
350
+ resp, err := m.Client.Do(req)
351
+ if err != nil {
352
+ return "", fmt.Errorf("start_thread: %w", err)
353
+ }
354
+ defer resp.Body.Close()
355
+
356
+ if resp.StatusCode >= 400 {
357
+ return "", fmt.Errorf("start_thread HTTP %d", resp.StatusCode)
358
+ }
359
+
360
+ body, err := io.ReadAll(resp.Body)
361
+ if err != nil {
362
+ return "", err
363
+ }
364
+
365
+ result, err := parseJsonOrSse(body, resp.Header.Get("Content-Type"))
366
+ if err != nil {
367
+ return "", fmt.Errorf("start_thread parse: %w", err)
368
+ }
369
+
370
+ // Check for JSON-RPC error
371
+ if errObj, ok := result["error"]; ok {
372
+ if errMap, ok := errObj.(map[string]any); ok {
373
+ return "", fmt.Errorf("start_thread RPC error: %v", errMap["message"])
374
+ }
375
+ return "", fmt.Errorf("start_thread RPC error: %v", errObj)
376
+ }
377
+
378
+ // Extract text from result.content[0].text
379
+ if res, ok := result["result"].(map[string]any); ok {
380
+ if content, ok := res["content"].([]any); ok && len(content) > 0 {
381
+ if item, ok := content[0].(map[string]any); ok {
382
+ if text, ok := item["text"].(string); ok {
383
+ return text, nil
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ return "", nil
390
+ }
@@ -0,0 +1,93 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "testing"
8
+ )
9
+
10
+ func TestGetRootThreads_WrappedJSON(t *testing.T) {
11
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12
+ w.Header().Set("Content-Type", "application/json")
13
+ w.Write([]byte(`{"threads":[{"threadId":1327,"name":"Sensorium 1","keepAlive":true}]}`))
14
+ }))
15
+ defer srv.Close()
16
+
17
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
18
+ threads, err := mcp.GetRootThreads(context.Background())
19
+ if err != nil {
20
+ t.Fatalf("unexpected error: %v", err)
21
+ }
22
+ if len(threads) != 1 {
23
+ t.Fatalf("got %d threads, want 1", len(threads))
24
+ }
25
+ if threads[0]["name"] != "Sensorium 1" {
26
+ t.Errorf("name = %v, want Sensorium 1", threads[0]["name"])
27
+ }
28
+ }
29
+
30
+ func TestGetRootThreads_BareArray(t *testing.T) {
31
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ w.Header().Set("Content-Type", "application/json")
33
+ w.Write([]byte(`[{"threadId":7526,"name":"Thread A"},{"threadId":8888,"name":"Thread B"}]`))
34
+ }))
35
+ defer srv.Close()
36
+
37
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
38
+ threads, err := mcp.GetRootThreads(context.Background())
39
+ if err != nil {
40
+ t.Fatalf("unexpected error: %v", err)
41
+ }
42
+ if len(threads) != 2 {
43
+ t.Fatalf("got %d threads, want 2", len(threads))
44
+ }
45
+ }
46
+
47
+ func TestIsServerReady_Healthy(t *testing.T) {
48
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+ if r.Method == "OPTIONS" {
50
+ w.WriteHeader(204)
51
+ return
52
+ }
53
+ }))
54
+ defer srv.Close()
55
+
56
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
57
+ if !mcp.IsServerReady(context.Background()) {
58
+ t.Error("expected server to be ready")
59
+ }
60
+ }
61
+
62
+ func TestIsServerReady_Down(t *testing.T) {
63
+ mcp := &MCPClient{BaseURL: "http://127.0.0.1:1", Client: http.DefaultClient}
64
+ if mcp.IsServerReady(context.Background()) {
65
+ t.Error("expected server to be unreachable")
66
+ }
67
+ }
68
+
69
+ func TestIsThreadRunning_True(t *testing.T) {
70
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71
+ w.Header().Set("Content-Type", "application/json")
72
+ w.Write([]byte(`{"running":true}`))
73
+ }))
74
+ defer srv.Close()
75
+
76
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
77
+ if !mcp.IsThreadRunning(context.Background(), 1234) {
78
+ t.Error("expected thread to be running")
79
+ }
80
+ }
81
+
82
+ func TestIsThreadRunning_False(t *testing.T) {
83
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84
+ w.Header().Set("Content-Type", "application/json")
85
+ w.Write([]byte(`{"running":false}`))
86
+ }))
87
+ defer srv.Close()
88
+
89
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
90
+ if mcp.IsThreadRunning(context.Background(), 1234) {
91
+ t.Error("expected thread to not be running")
92
+ }
93
+ }