/** * DC-166: MCP control-plane completion — 11 new tools (28 total). * Proves: tool catalog integrity (names unique/aligned with handlers), * each new handler hits the RIGHT method+path+body (captured fetchImpl), * calendar days->ISO window translation, security summary fan-out, * DNS delete query construction, and credential passthrough on writes. */ 'use strict'; const { TOOLS, createToolHandler } = require('../../src/mcp/mcp-core'); function harness(handlerOpts = {}) { const calls = []; const fetchImpl = async (url, opts) => { calls.push({ url, method: opts.method, headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : undefined }); return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, echo: calls[calls.length - 1] }) }; }; const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_' + 'test_key', fetchImpl, ...handlerOpts, }); return { calls, handleTool }; } const NEW_TOOLS = [ 'dashcaddy_calendar_events', 'dashcaddy_calendar_add_source', 'dashcaddy_security_events', 'dashcaddy_security_summary', 'dashcaddy_license_status', 'dashcaddy_capabilities', 'dashcaddy_dns_add_record', 'dashcaddy_dns_remove_record', 'dashcaddy_deploy_recipe', 'dashcaddy_run_workflow', 'dashcaddy_tailscale_status', ]; describe('DC-166 tool catalog', () => { test('all 11 new tools declared, total 28, names unique', () => { const names = TOOLS.map((t) => t.name); for (const n of NEW_TOOLS) expect(names).toContain(n); expect(names.length).toBe(28); expect(new Set(names).size).toBe(28); }); test('every tool has description + object inputSchema', () => { for (const t of TOOLS) { expect(typeof t.description).toBe('string'); expect(t.description.length).toBeGreaterThan(10); expect(t.inputSchema.type).toBe('object'); } }); test('required fields declared on write tools', () => { const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t])); expect(byName.dashcaddy_dns_add_record.inputSchema.required).toEqual(['domain', 'ip']); expect(byName.dashcaddy_deploy_recipe.inputSchema.required).toEqual(['recipeId']); expect(byName.dashcaddy_run_workflow.inputSchema.required).toEqual(['workflowId']); expect(byName.dashcaddy_calendar_add_source.inputSchema.required).toEqual(['name', 'type', 'url']); expect(byName.dashcaddy_calendar_add_source.inputSchema.properties.type.enum).toEqual(['caldav', 'ics']); }); }); describe('DC-166 handler routing (captured fetchImpl)', () => { test('calendar_events translates days into ISO start/end window', async () => { const { calls, handleTool } = harness(); const before = Date.now(); await handleTool('dashcaddy_calendar_events', { days: 7 }); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('GET'); const u = new URL(calls[0].url); expect(u.pathname).toBe('/api/v1/calendar/events'); const start = new Date(u.searchParams.get('start')); const end = new Date(u.searchParams.get('end')); expect(Math.abs(start.getTime() - before)).toBeLessThan(60000); // ~7-day window: allow 1s scheduling slack around exactly 7*86400s expect(end - start).toBeGreaterThanOrEqual(7 * 86400e3 - 1000); expect(end - start).toBeLessThanOrEqual(7 * 86400e3 + 60000); }); test('calendar_events defaults to 14 days and clamps to 60', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_calendar_events', {}); const u0 = new URL(calls[0].url); const span0 = new Date(u0.searchParams.get('end')) - new Date(u0.searchParams.get('start')); expect(Math.round(span0 / 86400e3)).toBe(14); await handleTool('dashcaddy_calendar_events', { days: 999 }); const u = new URL(calls[1].url); const span = new Date(u.searchParams.get('end')) - new Date(u.searchParams.get('start')); expect(Math.round(span / 86400e3)).toBe(60); }); test('calendar_add_source posts credentials through, omits absent fields', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_calendar_add_source', { name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' }); await handleTool('dashcaddy_calendar_add_source', { name: 'F', type: 'ics', url: 'https://f.example/a.ics' }); await handleTool('dashcaddy_calendar_add_source', { name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' }); expect(calls[0].method).toBe('POST'); expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/calendar/sources'); expect(calls[0].body).toEqual({ name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' }); expect(calls[1].body.user).toBeUndefined(); expect(calls[1].body.pass).toBeUndefined(); // token auth variant is forwarded (route accepts user+pass OR token) expect(calls[2].body).toEqual({ name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' }); }); test('security_events passes only provided filters as query string', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_security_events', { limit: 5, severity: 'critical' }); await handleTool('dashcaddy_security_events', {}); const u1 = new URL(calls[0].url); expect(u1.pathname).toBe('/api/v1/security/events'); expect(u1.searchParams.get('limit')).toBe('5'); expect(u1.searchParams.get('severity')).toBe('critical'); expect(u1.searchParams.get('source_type')).toBeNull(); expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/security/events'); }); test('security_summary fans out stats+perimeter, respects withPerimeter false', async () => { const { calls, handleTool } = harness(); const r1 = await handleTool('dashcaddy_security_summary', {}); expect(calls.map(c => c.url)).toEqual([ 'http://127.0.0.1:1/api/v1/security/events/stats', 'http://127.0.0.1:1/api/v1/security/events/perimeter', ]); expect(r1.stats).toBeDefined(); expect(r1.perimeter).toBeDefined(); calls.length = 0; const r2 = await handleTool('dashcaddy_security_summary', { withPerimeter: false }); expect(calls).toHaveLength(1); expect(r2.success).toBe(true); }); test('license_status and capabilities read the right endpoints', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_license_status', {}); await handleTool('dashcaddy_capabilities', {}); expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/license/status'); expect(calls[0].method).toBe('GET'); expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/capabilities'); }); test('dns_add_record posts the provider schema body', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_dns_add_record', { domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 }); expect(calls[0].method).toBe('POST'); expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/dns/record'); expect(calls[0].body).toEqual({ domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 }); await handleTool('dashcaddy_dns_add_record', { domain: 'y.home.example', ip: '10.0.0.6' }); expect(calls[1].body.ttl).toBeUndefined(); }); test('dns_remove_record deletes with encoded query, type defaults A', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_dns_remove_record', { domain: 'a b.example', type: 'CNAME' }); await handleTool('dashcaddy_dns_remove_record', { domain: 'z.example', ipAddress: '1.2.3.4' }); expect(calls[0].method).toBe('DELETE'); const u0 = new URL(calls[0].url); expect(u0.pathname).toBe('/api/v1/dns/record'); expect(u0.searchParams.get('domain')).toBe('a b.example'); expect(u0.searchParams.get('type')).toBe('CNAME'); const u1 = new URL(calls[1].url); expect(u1.searchParams.get('type')).toBe('A'); expect(u1.searchParams.get('ipAddress')).toBe('1.2.3.4'); }); test('deploy_recipe maps overrides to config body', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_deploy_recipe', { recipeId: 'media-stack', overrides: { plex: { port: 32400 } } }); expect(calls[0].method).toBe('POST'); expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/recipes/deploy'); expect(calls[0].body).toEqual({ recipeId: 'media-stack', config: { plex: { port: 32400 } } }); await handleTool('dashcaddy_deploy_recipe', { recipeId: 'x' }); expect(calls[1].body.config).toBeUndefined(); }); test('run_workflow posts encoded id to /run, tailscale_status reads status', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_run_workflow', { workflowId: 'disk space alert' }); expect(calls[0].method).toBe('POST'); expect(decodeURIComponent(calls[0].url)).toBe('http://127.0.0.1:1/api/v1/workflows/disk space alert/run'); expect(calls[0].url).toContain('disk%20space%20alert'); await handleTool('dashcaddy_tailscale_status', {}); expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/tailscale/status'); expect(calls[1].method).toBe('GET'); }); test('credential passthrough preserved on the new write tools (x-api-key)', async () => { const { calls, handleTool } = harness(); await handleTool('dashcaddy_dns_add_record', { domain: 'w.example', ip: '1.1.1.1' }); expect(calls[0].headers['x-api-key']).toBe('dk_' + 'test_key'); }); test('unknown tool still rejected after catalog extension', async () => { const { handleTool } = harness(); const r = await handleTool('dashcaddy_delete_everything', {}); expect(r.error).toBe(true); expect(r.message).toMatch(/Unknown tool/); }); });