@vibescope/mcp-server 0.3.13 → 0.3.14

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.
@@ -0,0 +1,438 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ cleanupStaleCloudAgents,
4
+ listCloudAgents,
5
+ } from './cloud-agents.js';
6
+ import { ValidationError } from '../validators.js';
7
+ import { createMockContext, testUUID } from './__test-utils__.js';
8
+ import { mockApiClient } from './__test-setup__.js';
9
+
10
+ // ============================================================================
11
+ // cleanupStaleCloudAgents Tests
12
+ // ============================================================================
13
+
14
+ describe('cleanupStaleCloudAgents', () => {
15
+ beforeEach(() => vi.clearAllMocks());
16
+
17
+ it('should throw error for missing project_id', async () => {
18
+ const ctx = createMockContext();
19
+
20
+ await expect(
21
+ cleanupStaleCloudAgents({}, ctx)
22
+ ).rejects.toThrow(ValidationError);
23
+ });
24
+
25
+ it('should throw error for invalid project_id UUID', async () => {
26
+ const ctx = createMockContext();
27
+
28
+ await expect(
29
+ cleanupStaleCloudAgents({ project_id: 'invalid' }, ctx)
30
+ ).rejects.toThrow(ValidationError);
31
+ });
32
+
33
+ it('should cleanup stale agents successfully', async () => {
34
+ mockApiClient.proxy.mockResolvedValue({
35
+ ok: true,
36
+ data: {
37
+ cleaned: 2,
38
+ failed: 0,
39
+ agents: [
40
+ { id: 'agent-1', name: 'Agent 1', success: true },
41
+ { id: 'agent-2', name: 'Agent 2', success: true },
42
+ ],
43
+ },
44
+ });
45
+ const ctx = createMockContext();
46
+
47
+ const result = await cleanupStaleCloudAgents(
48
+ { project_id: testUUID() },
49
+ ctx
50
+ );
51
+
52
+ expect(result.result).toMatchObject({
53
+ cleaned: 2,
54
+ failed: 0,
55
+ message: 'Cleaned up 2 stale agents',
56
+ });
57
+ });
58
+
59
+ it('should call API with correct parameters', async () => {
60
+ mockApiClient.proxy.mockResolvedValue({
61
+ ok: true,
62
+ data: { cleaned: 0, agents: [] },
63
+ });
64
+ const ctx = createMockContext();
65
+
66
+ await cleanupStaleCloudAgents(
67
+ {
68
+ project_id: testUUID(),
69
+ stale_minutes: 10,
70
+ include_running: true,
71
+ dry_run: false,
72
+ },
73
+ ctx
74
+ );
75
+
76
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
77
+ 'cleanup_stale_cloud_agents',
78
+ {
79
+ project_id: testUUID(),
80
+ staleMinutes: 10,
81
+ includeRunning: true,
82
+ dryRun: false,
83
+ }
84
+ );
85
+ });
86
+
87
+ it('should use default values for optional parameters', async () => {
88
+ mockApiClient.proxy.mockResolvedValue({
89
+ ok: true,
90
+ data: { cleaned: 0, agents: [] },
91
+ });
92
+ const ctx = createMockContext();
93
+
94
+ await cleanupStaleCloudAgents(
95
+ { project_id: testUUID() },
96
+ ctx
97
+ );
98
+
99
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
100
+ 'cleanup_stale_cloud_agents',
101
+ {
102
+ project_id: testUUID(),
103
+ staleMinutes: 5, // default
104
+ includeRunning: false, // default
105
+ dryRun: false, // default
106
+ }
107
+ );
108
+ });
109
+
110
+ it('should return dry run preview when dry_run is true', async () => {
111
+ mockApiClient.proxy.mockResolvedValue({
112
+ ok: true,
113
+ data: {
114
+ wouldClean: 3,
115
+ dryRun: true,
116
+ agents: [
117
+ { id: 'agent-1', status: 'failed', createdAt: '2025-01-14T10:00:00Z' },
118
+ { id: 'agent-2', status: 'starting', createdAt: '2025-01-14T10:05:00Z' },
119
+ { id: 'agent-3', status: 'stopped', createdAt: '2025-01-14T10:10:00Z' },
120
+ ],
121
+ },
122
+ });
123
+ const ctx = createMockContext();
124
+
125
+ const result = await cleanupStaleCloudAgents(
126
+ { project_id: testUUID(), dry_run: true },
127
+ ctx
128
+ );
129
+
130
+ expect(result.result).toMatchObject({
131
+ dryRun: true,
132
+ wouldClean: 3,
133
+ message: 'Found 3 stale agents that would be cleaned up',
134
+ });
135
+ expect((result.result as { agents: unknown[] }).agents).toHaveLength(3);
136
+ });
137
+
138
+ it('should include failed count in response', async () => {
139
+ mockApiClient.proxy.mockResolvedValue({
140
+ ok: true,
141
+ data: {
142
+ cleaned: 2,
143
+ failed: 1,
144
+ agents: [
145
+ { id: 'agent-1', success: true },
146
+ { id: 'agent-2', success: true },
147
+ { id: 'agent-3', success: false, error: 'Agent still running' },
148
+ ],
149
+ },
150
+ });
151
+ const ctx = createMockContext();
152
+
153
+ const result = await cleanupStaleCloudAgents(
154
+ { project_id: testUUID() },
155
+ ctx
156
+ );
157
+
158
+ expect(result.result).toMatchObject({
159
+ cleaned: 2,
160
+ failed: 1,
161
+ });
162
+ });
163
+
164
+ it('should return error when API call fails', async () => {
165
+ mockApiClient.proxy.mockResolvedValue({
166
+ ok: false,
167
+ error: 'Failed to cleanup agents',
168
+ });
169
+ const ctx = createMockContext();
170
+
171
+ const result = await cleanupStaleCloudAgents(
172
+ { project_id: testUUID() },
173
+ ctx
174
+ );
175
+
176
+ expect(result.isError).toBe(true);
177
+ expect(result.result).toMatchObject({
178
+ error: 'Failed to cleanup agents',
179
+ });
180
+ });
181
+
182
+ it('should return error when API returns undefined data', async () => {
183
+ mockApiClient.proxy.mockResolvedValue({
184
+ ok: true,
185
+ data: undefined,
186
+ });
187
+ const ctx = createMockContext();
188
+
189
+ const result = await cleanupStaleCloudAgents(
190
+ { project_id: testUUID() },
191
+ ctx
192
+ );
193
+
194
+ expect(result.isError).toBe(true);
195
+ expect(result.result).toMatchObject({
196
+ error: 'Failed to cleanup stale agents',
197
+ });
198
+ });
199
+ });
200
+
201
+ // ============================================================================
202
+ // listCloudAgents Tests
203
+ // ============================================================================
204
+
205
+ describe('listCloudAgents', () => {
206
+ beforeEach(() => vi.clearAllMocks());
207
+
208
+ it('should throw error for missing project_id', async () => {
209
+ const ctx = createMockContext();
210
+
211
+ await expect(
212
+ listCloudAgents({}, ctx)
213
+ ).rejects.toThrow(ValidationError);
214
+ });
215
+
216
+ it('should throw error for invalid project_id UUID', async () => {
217
+ const ctx = createMockContext();
218
+
219
+ await expect(
220
+ listCloudAgents({ project_id: 'invalid' }, ctx)
221
+ ).rejects.toThrow(ValidationError);
222
+ });
223
+
224
+ it('should throw error for invalid status value', async () => {
225
+ const ctx = createMockContext();
226
+
227
+ await expect(
228
+ listCloudAgents({ project_id: testUUID(), status: 'invalid_status' }, ctx)
229
+ ).rejects.toThrow(ValidationError);
230
+ });
231
+
232
+ it('should return empty list when no agents', async () => {
233
+ mockApiClient.proxy.mockResolvedValue({
234
+ ok: true,
235
+ data: { agents: [] },
236
+ });
237
+ const ctx = createMockContext();
238
+
239
+ const result = await listCloudAgents(
240
+ { project_id: testUUID() },
241
+ ctx
242
+ );
243
+
244
+ expect(result.result).toMatchObject({
245
+ agents: [],
246
+ count: 0,
247
+ });
248
+ });
249
+
250
+ it('should return agents list with count', async () => {
251
+ const mockAgents = [
252
+ {
253
+ id: 'agent-1',
254
+ name: 'Test Agent 1',
255
+ status: 'running',
256
+ created_at: '2025-01-14T10:00:00Z',
257
+ last_heartbeat: '2025-01-14T12:00:00Z',
258
+ public_ip: '10.0.0.1',
259
+ ecs_task_id: 'task-abc123',
260
+ },
261
+ {
262
+ id: 'agent-2',
263
+ name: 'Test Agent 2',
264
+ status: 'stopped',
265
+ created_at: '2025-01-14T09:00:00Z',
266
+ },
267
+ ];
268
+ mockApiClient.proxy.mockResolvedValue({
269
+ ok: true,
270
+ data: { agents: mockAgents },
271
+ });
272
+ const ctx = createMockContext();
273
+
274
+ const result = await listCloudAgents(
275
+ { project_id: testUUID() },
276
+ ctx
277
+ );
278
+
279
+ expect(result.result).toMatchObject({
280
+ agents: mockAgents,
281
+ count: 2,
282
+ });
283
+ });
284
+
285
+ it('should call API with status filter', async () => {
286
+ mockApiClient.proxy.mockResolvedValue({
287
+ ok: true,
288
+ data: { agents: [] },
289
+ });
290
+ const ctx = createMockContext();
291
+
292
+ await listCloudAgents(
293
+ { project_id: testUUID(), status: 'running' },
294
+ ctx
295
+ );
296
+
297
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
298
+ 'list_cloud_agents',
299
+ {
300
+ project_id: testUUID(),
301
+ status: 'running',
302
+ }
303
+ );
304
+ });
305
+
306
+ it('should not pass status when "all" is specified', async () => {
307
+ mockApiClient.proxy.mockResolvedValue({
308
+ ok: true,
309
+ data: { agents: [] },
310
+ });
311
+ const ctx = createMockContext();
312
+
313
+ await listCloudAgents(
314
+ { project_id: testUUID(), status: 'all' },
315
+ ctx
316
+ );
317
+
318
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
319
+ 'list_cloud_agents',
320
+ {
321
+ project_id: testUUID(),
322
+ status: undefined,
323
+ }
324
+ );
325
+ });
326
+
327
+ it('should use default status of "all"', async () => {
328
+ mockApiClient.proxy.mockResolvedValue({
329
+ ok: true,
330
+ data: { agents: [] },
331
+ });
332
+ const ctx = createMockContext();
333
+
334
+ await listCloudAgents(
335
+ { project_id: testUUID() },
336
+ ctx
337
+ );
338
+
339
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
340
+ 'list_cloud_agents',
341
+ {
342
+ project_id: testUUID(),
343
+ status: undefined, // 'all' becomes undefined
344
+ }
345
+ );
346
+ });
347
+
348
+ it('should filter by starting status', async () => {
349
+ mockApiClient.proxy.mockResolvedValue({
350
+ ok: true,
351
+ data: { agents: [{ id: 'a1', status: 'starting', created_at: '2025-01-14T10:00:00Z' }] },
352
+ });
353
+ const ctx = createMockContext();
354
+
355
+ const result = await listCloudAgents(
356
+ { project_id: testUUID(), status: 'starting' },
357
+ ctx
358
+ );
359
+
360
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
361
+ 'list_cloud_agents',
362
+ { project_id: testUUID(), status: 'starting' }
363
+ );
364
+ expect((result.result as { count: number }).count).toBe(1);
365
+ });
366
+
367
+ it('should filter by failed status', async () => {
368
+ mockApiClient.proxy.mockResolvedValue({
369
+ ok: true,
370
+ data: { agents: [] },
371
+ });
372
+ const ctx = createMockContext();
373
+
374
+ await listCloudAgents(
375
+ { project_id: testUUID(), status: 'failed' },
376
+ ctx
377
+ );
378
+
379
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
380
+ 'list_cloud_agents',
381
+ { project_id: testUUID(), status: 'failed' }
382
+ );
383
+ });
384
+
385
+ it('should filter by stopped status', async () => {
386
+ mockApiClient.proxy.mockResolvedValue({
387
+ ok: true,
388
+ data: { agents: [] },
389
+ });
390
+ const ctx = createMockContext();
391
+
392
+ await listCloudAgents(
393
+ { project_id: testUUID(), status: 'stopped' },
394
+ ctx
395
+ );
396
+
397
+ expect(mockApiClient.proxy).toHaveBeenCalledWith(
398
+ 'list_cloud_agents',
399
+ { project_id: testUUID(), status: 'stopped' }
400
+ );
401
+ });
402
+
403
+ it('should return error when API call fails', async () => {
404
+ mockApiClient.proxy.mockResolvedValue({
405
+ ok: false,
406
+ error: 'Database error',
407
+ });
408
+ const ctx = createMockContext();
409
+
410
+ const result = await listCloudAgents(
411
+ { project_id: testUUID() },
412
+ ctx
413
+ );
414
+
415
+ expect(result.isError).toBe(true);
416
+ expect(result.result).toMatchObject({
417
+ error: 'Database error',
418
+ });
419
+ });
420
+
421
+ it('should return error when API returns undefined data', async () => {
422
+ mockApiClient.proxy.mockResolvedValue({
423
+ ok: true,
424
+ data: undefined,
425
+ });
426
+ const ctx = createMockContext();
427
+
428
+ const result = await listCloudAgents(
429
+ { project_id: testUUID() },
430
+ ctx
431
+ );
432
+
433
+ expect(result.isError).toBe(true);
434
+ expect(result.result).toMatchObject({
435
+ error: 'Failed to list cloud agents',
436
+ });
437
+ });
438
+ });
@@ -60,16 +60,11 @@ interface ListAgentsResponse {
60
60
  * Clean up stale cloud agents that failed to start or lost connection.
61
61
  * Only operates on agents in the specified project (security scoped).
62
62
  */
63
- export const cleanupStaleCloudAgents: Handler = async (args, ctx) => {
63
+ export const cleanupStaleCloudAgents: Handler = async (args, _ctx) => {
64
64
  const { project_id, stale_minutes, include_running, dry_run } = parseArgs(args, cleanupStaleAgentsSchema);
65
65
 
66
- // Ensure user has an active session with this project (security check)
67
- if (ctx.session.currentProjectId && ctx.session.currentProjectId !== project_id) {
68
- return error('Cannot cleanup agents for a different project than your current session');
69
- }
70
-
71
66
  const apiClient = getApiClient();
72
-
67
+
73
68
  // Call the cleanup endpoint via fetch (since it's a new endpoint not in the client)
74
69
  const response = await apiClient.proxy<CleanupResponse>('cleanup_stale_cloud_agents', {
75
70
  project_id,
@@ -78,12 +73,12 @@ export const cleanupStaleCloudAgents: Handler = async (args, ctx) => {
78
73
  dryRun: dry_run,
79
74
  });
80
75
 
81
- if (!response.ok) {
76
+ if (!response.ok || !response.data) {
82
77
  return error(response.error || 'Failed to cleanup stale agents');
83
78
  }
84
79
 
85
80
  const data = response.data!;
86
-
81
+
87
82
  if (dry_run) {
88
83
  return success({
89
84
  dryRun: true,
@@ -104,22 +99,17 @@ export const cleanupStaleCloudAgents: Handler = async (args, ctx) => {
104
99
  /**
105
100
  * List cloud agents for a project with optional status filter.
106
101
  */
107
- export const listCloudAgents: Handler = async (args, ctx) => {
102
+ export const listCloudAgents: Handler = async (args, _ctx) => {
108
103
  const { project_id, status } = parseArgs(args, listCloudAgentsSchema);
109
104
 
110
- // Ensure user has an active session with this project (security check)
111
- if (ctx.session.currentProjectId && ctx.session.currentProjectId !== project_id) {
112
- return error('Cannot list agents for a different project than your current session');
113
- }
114
-
115
105
  const apiClient = getApiClient();
116
-
106
+
117
107
  const response = await apiClient.proxy<ListAgentsResponse>('list_cloud_agents', {
118
108
  project_id,
119
109
  status: status === 'all' ? undefined : status,
120
110
  });
121
111
 
122
- if (!response.ok) {
112
+ if (!response.ok || !response.data) {
123
113
  return error(response.error || 'Failed to list cloud agents');
124
114
  }
125
115
 
@@ -327,6 +327,7 @@ export const TOOL_CATEGORIES: Record<string, { description: string; tools: Array
327
327
  cloud_agents: {
328
328
  description: 'Cloud agent management and cleanup',
329
329
  tools: [
330
+ { name: 'update_agent_status', brief: 'Update agent dashboard status message' },
330
331
  { name: 'cleanup_stale_cloud_agents', brief: 'Clean up stale cloud agents' },
331
332
  { name: 'list_cloud_agents', brief: 'List cloud agents for project' },
332
333
  ],
@@ -16,7 +16,7 @@ import { getApiClient } from '../api-client.js';
16
16
 
17
17
  const VALID_FINDING_CATEGORIES = ['performance', 'security', 'code_quality', 'accessibility', 'documentation', 'architecture', 'testing', 'other'] as const;
18
18
  const VALID_FINDING_SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'] as const;
19
- const VALID_FINDING_STATUSES = ['open', 'addressed', 'dismissed', 'wontfix'] as const;
19
+ const VALID_FINDING_STATUSES = ['open', 'in_development', 'implemented', 'addressed', 'dismissed', 'wontfix'] as const;
20
20
 
21
21
  type FindingCategory = typeof VALID_FINDING_CATEGORIES[number];
22
22
  type FindingSeverity = typeof VALID_FINDING_SEVERITIES[number];
@@ -21,7 +21,7 @@ import {
21
21
  } from '../validators.js';
22
22
  import { getApiClient } from '../api-client.js';
23
23
 
24
- const VALID_IDEA_STATUSES = ['raw', 'exploring', 'planned', 'in_development', 'shipped'] as const;
24
+ const VALID_IDEA_STATUSES = ['raw', 'exploring', 'planned', 'in_development', 'implemented', 'shipped'] as const;
25
25
  type IdeaStatus = typeof VALID_IDEA_STATUSES[number];
26
26
 
27
27
  // Argument schemas for type-safe parsing
@@ -337,6 +337,7 @@ export const startWorkSession: Handler = async (args, ctx) => {
337
337
  }
338
338
  agentRules.push('COMPLETE TASKS: Always call complete_task() after creating a PR. This is mandatory.');
339
339
  agentRules.push('REVIEW REQUIRED: All tasks must be reviewed by another agent before merging.');
340
+ agentRules.push('STATUS UPDATES: Call update_agent_status(status_message: "Working on: TASK_TITLE") whenever you start a new task, and update_task(task_id, status: "in_progress") to claim it.');
340
341
 
341
342
  result.AGENT_RULES = agentRules;
342
343