test(mcp-server): cover chronomind tool proxies

This commit is contained in:
root 2026-05-18 09:33:30 +00:00
parent 32c7b1ba7e
commit 95d8f90ce3

View File

@ -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, * Verifies that all ChronoMind tools are registered with correct schemas,
* descriptions, and required roles. Does NOT call chronomind-backend (unit tests only). * descriptions, and required roles, and that the five new ChronoMind MCP
* proxies call the expected backend endpoints.
*/ */
import { describe, it, expect } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { getTool, listTools } from '../tools/registry.js';
// 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'; import './chronomind-tools.js';
const EXPECTED_TOOLS = [ const EXPECTED_TOOLS = [
@ -53,8 +62,6 @@ describe('ChronoMind MCP tools registration', () => {
} }
}); });
// ── Schema validation tests ──
it('chronomind.timers.create requires id + label + type', () => { it('chronomind.timers.create requires id + label + type', () => {
const all = listTools(); const all = listTools();
const tool = all.find(t => t.name === 'chronomind.timers.create'); const tool = all.find(t => t.name === 'chronomind.timers.create');
@ -101,3 +108,263 @@ describe('ChronoMind MCP tools registration', () => {
expect(props).toHaveProperty('minSlotMinutes'); 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' });
});
});