Owner directive "Do it" (2026-10-09): expand the MCP tool set so the
"manage your entire infrastructure" claim is true of the product. 11 new
tools over EXISTING routes: calendar (events/add_source), security
(events/summary), license_status, capabilities, DNS add/remove record,
deploy_recipe, run_workflow, tailscale_status. Handlers verified against
the actual route declarations; credential TYPE passthrough and the DC-162
machine-only tools/call gate unchanged (tests included).
judge r1 (grade C, urn:ump:fyvivcvrphjm5wk227ujr2yzfcgcdvwekjmw6lvnqvjosqrjhtbq)
blocking issues — all fixed in this revision:
1. WORKFLOWS DOUBLE-PREFIX (real product bug the judge caught):
routes/workflows.js declared literal '/workflows/...' paths while
src/app.js mounts the router at /workflows — composed path was
/api/v1/workflows/workflows/..., which the SHIPPED dashboard bundle
(status/js/bundled-workflows.js calls single-prefix /api/v1/workflows/)
never hits: the workflows UI has been broken in production. Fixed by
making route paths RELATIVE to the mount (same class as the recipes
fix); run-workflow MCP path now matches. Also reordered /history
before /:workflowId/history so the literal segment is not captured as
an id (limit 100 vs 50 asserted in tests).
2. Verifier substring check replaced: NEW integration suite
(__tests__/mcp/mcp-core-dc166-integration.test.js) resolves EVERY MCP
tool call's complete mounted (method, path) against the declared route
patterns extracted from the owning route file (params matched as
patterns, comments excluded) — a double prefix or wrong method fails
the run. Plus real-composition dispatch through the actual workflows
router: POST /api/v1/workflows/:id/run reaches the run handler
(sentinel '"ran":true'), list/history handlers land correctly, and a
static guard fails any route file that re-declares its own mount name.
3. Both verbatim-command claims in the brief now name their file.
polish applied: calendar window-math test bounds tightened to exactly
~7 days (was a weak 6-hour lower bound); extra blank line before
tools/list removed; calendar_add_source schema gained the token property
the route already accepts (schema now matches the documented API).
Tests: 73 prior + 15 dc166 unit + 6 integration = 94 MCP tests green;
full suite 6 failed + 3697 passed == 3703 total (known-red billing
family, band-guarded; base-proof shows the same 6 red at a1b8fa3).
eslint: 0 errors on touched files (warnings count unchanged vs base).
200 lines
9.6 KiB
JavaScript
200 lines
9.6 KiB
JavaScript
/**
|
|
* 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/);
|
|
});
|
|
});
|