diff --git a/services/mcp-server/src/modules/chronomind/chronomind-tools.test.ts b/services/mcp-server/src/modules/chronomind/chronomind-tools.test.ts index fd680a23..d0f86563 100644 --- a/services/mcp-server/src/modules/chronomind/chronomind-tools.test.ts +++ b/services/mcp-server/src/modules/chronomind/chronomind-tools.test.ts @@ -1,14 +1,23 @@ /** - * ChronoMind MCP tools — registration + schema validation tests. + * ChronoMind MCP tools - registration + execution tests. * - * Verifies that all 13 ChronoMind tools are registered with correct schemas, - * descriptions, and required roles. Does NOT call chronomind-backend (unit tests only). + * Verifies that all ChronoMind tools are registered with correct schemas, + * descriptions, and required roles, and that the five new ChronoMind MCP + * proxies call the expected backend endpoints. */ -import { describe, it, expect } from 'vitest'; -import { getTool, listTools } from '../tools/registry.js'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -// Importing the module causes all registerTool() calls to execute +vi.hoisted(() => { + process.env.JWT_SECRET ??= 'test-secret'; + process.env.CHRONOMIND_BACKEND_URL ??= 'http://localhost:4011'; + return {}; +}); + +import { getTool, listTools } from '../tools/registry.js'; +import type { McpToolRequest } from '../tools/types.js'; + +// Importing the module causes all registerTool() calls to execute. import './chronomind-tools.js'; const EXPECTED_TOOLS = [ @@ -53,8 +62,6 @@ describe('ChronoMind MCP tools registration', () => { } }); - // ── Schema validation tests ── - it('chronomind.timers.create requires id + label + type', () => { const all = listTools(); const tool = all.find(t => t.name === 'chronomind.timers.create'); @@ -101,3 +108,263 @@ describe('ChronoMind MCP tools registration', () => { expect(props).toHaveProperty('minSlotMinutes'); }); }); + +const fetchMock = vi.fn(); + +const req: McpToolRequest = { + id: 'req_1', + headers: { authorization: 'Bearer token_1' }, + jwtPayload: { sub: 'user_1', role: 'admin', productId: 'chronomind' }, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +}; + +beforeAll(() => { + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + fetchMock.mockReset(); +}); + +describe('chronomind MCP tool execution', () => { + it('reschedules a timer and records an audit action', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'aa_1', + userId: 'user_1', + productId: 'chronomind', + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.timers.reschedule', + actionType: 'timer.reschedule', + state: 'proposed', + payload: { timerId: 'timer_1', deltaSeconds: 600 }, + createdAt: '2026-03-10T00:00:00.000Z', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'timer_1', + userId: 'user_1', + productId: 'chronomind', + label: 'Team meeting', + type: 'alarm', + state: 'active', + urgency: 'standard', + targetTime: '2026-03-10T10:10:00.000Z', + createdAt: '2026-03-10T00:00:00.000Z', + lastSyncedAt: '2026-03-10T00:10:00.000Z', + syncVersion: 2, + }), + }); + + const tool = getTool('chronomind.timers.reschedule'); + const result = await tool?.execute( + { timerId: 'timer_1', deltaSeconds: 600, syncVersion: 2, reason: 'delayed by 10m' }, + req + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(new URL(String(fetchMock.mock.calls[0]?.[0])).pathname).toBe('/api/agent-actions'); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.timers.reschedule', + actionType: 'timer.reschedule', + reason: 'delayed by 10m', + payload: { timerId: 'timer_1', deltaSeconds: 600 }, + }); + expect(new URL(String(fetchMock.mock.calls[1]?.[0])).pathname).toBe( + '/api/timers/timer_1/reschedule' + ); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + deltaSeconds: 600, + syncVersion: 2, + }); + expect(result).toMatchObject({ + id: 'timer_1', + label: 'Team meeting', + syncVersion: 2, + }); + }); + + it('looks up free slots in a time window', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + slots: [ + { + start: '2026-03-10T09:00:00.000Z', + end: '2026-03-10T09:30:00.000Z', + durationMinutes: 30, + }, + ], + totalFreeMinutes: 30, + }), + }); + + const tool = getTool('chronomind.timers.availability'); + const result = await tool?.execute( + { + start: '2026-03-10T09:00:00.000Z', + end: '2026-03-10T12:00:00.000Z', + minSlotMinutes: 30, + }, + req + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe('/api/timers/availability'); + expect(url.searchParams.get('start')).toBe('2026-03-10T09:00:00.000Z'); + expect(url.searchParams.get('end')).toBe('2026-03-10T12:00:00.000Z'); + expect(url.searchParams.get('minSlotMinutes')).toBe('30'); + expect(result).toEqual({ + slots: [ + { start: '2026-03-10T09:00:00.000Z', end: '2026-03-10T09:30:00.000Z', durationMinutes: 30 }, + ], + totalFreeMinutes: 30, + }); + }); + + it('starts a routine and records an audit action', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'aa_2', + userId: 'user_1', + productId: 'chronomind', + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.routines.start', + actionType: 'routine.start', + state: 'proposed', + payload: { routineId: 'routine_1' }, + createdAt: '2026-03-10T00:00:00.000Z', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'routine_1', + userId: 'user_1', + productId: 'chronomind', + name: 'Morning routine', + steps: [], + totalDurationMinutes: 15, + status: 'active', + isTemplate: false, + createdAt: '2026-03-10T00:00:00.000Z', + lastSyncedAt: '2026-03-10T00:10:00.000Z', + syncVersion: 3, + }), + }); + + const tool = getTool('chronomind.routines.start'); + const result = await tool?.execute({ routineId: 'routine_1', reason: 'start the day' }, req); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(new URL(String(fetchMock.mock.calls[0]?.[0])).pathname).toBe('/api/agent-actions'); + expect(new URL(String(fetchMock.mock.calls[1]?.[0])).pathname).toBe( + '/api/routines/routine_1/start' + ); + expect(result).toMatchObject({ id: 'routine_1', status: 'active' }); + }); + + it('lists agent actions with filters', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: [ + { + id: 'aa_1', + userId: 'user_1', + productId: 'chronomind', + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.timers.reschedule', + actionType: 'timer.reschedule', + state: 'proposed', + payload: {}, + createdAt: '2026-03-10T00:00:00.000Z', + }, + ], + total: 1, + }), + }); + + const tool = getTool('chronomind.agentActions.list'); + const result = await tool?.execute( + { + state: 'proposed', + actorId: 'mcp-server', + toolName: 'chronomind.timers.reschedule', + limit: 25, + offset: 0, + }, + req + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe('/api/agent-actions'); + expect(url.searchParams.get('state')).toBe('proposed'); + expect(url.searchParams.get('actorId')).toBe('mcp-server'); + expect(url.searchParams.get('toolName')).toBe('chronomind.timers.reschedule'); + expect(result).toEqual({ + items: [ + { + id: 'aa_1', + userId: 'user_1', + productId: 'chronomind', + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.timers.reschedule', + actionType: 'timer.reschedule', + state: 'proposed', + payload: {}, + createdAt: '2026-03-10T00:00:00.000Z', + }, + ], + total: 1, + }); + }); + + it('approves a proposed agent action', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 'aa_1', + userId: 'user_1', + productId: 'chronomind', + actorId: 'mcp-server', + actorType: 'mcp', + toolName: 'chronomind.timers.reschedule', + actionType: 'timer.reschedule', + state: 'approved', + payload: {}, + createdAt: '2026-03-10T00:00:00.000Z', + reviewedAt: '2026-03-10T00:10:00.000Z', + reviewedBy: 'user_1', + }), + }); + + const tool = getTool('chronomind.agentActions.approve'); + const result = await tool?.execute({ actionId: 'aa_1' }, req); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(new URL(String(fetchMock.mock.calls[0]?.[0])).pathname).toBe( + '/api/agent-actions/aa_1/approve' + ); + expect(result).toMatchObject({ id: 'aa_1', state: 'approved', reviewedBy: 'user_1' }); + }); +});