test(mcp-server): cover chronomind tool proxies
This commit is contained in:
parent
32c7b1ba7e
commit
95d8f90ce3
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user