[grade=A urn:ump:5clu4udrbwuwxn74tqwxtukfjnrwkrbiykp6uh66ipipzxr32xsq] Merge dc/DC-166-mcp-completion (e524d16): MCP 17->28 tools + workflows double-prefix fix
Resolves 0 conflicts (auto-merge; the branches touched disjoint regions
of src/mcp/mcp-core.js). Fixes audit P0-4 (DC-166 drift: the fix branch
was never merged, so the branch shipped 17 tools while changelog/
capabilities claimed the expanded set, and the workflows UI was broken)
and the production Workflows outage.
Incoming:
- src/mcp/mcp-core.js: 11 new tool handlers over existing routes
(calendar events/add_source, security events/summary, license_status,
capabilities, DNS add/remove record, deploy_recipe, run_workflow,
tailscale_status); DC-162 machine-only gate preserved
- routes/workflows.js: double-prefix fix — declarations are mount-
relative (/history before /:workflowId/history); the dashboard's
single-prefix calls no longer 404 (Premium feature was dead in prod)
- __tests__/mcp/mcp-core-dc166{,-integration}.test.js: tool/schema +
route-existence verifier (scoped to the 11 new tools; extension to
all 28 is the MCP truth batch, audit #40)
Merge-commit fixes (judge r1 C -> r2 A, urn:ump:2jdpii43.../5clu4udr...):
1. integration-test header over-claimed COMPLETE tool coverage — scope
narrowed to the DC-166 additions, stated explicitly
2. routes/workflows.js: shadowed top-level ok import removed (all uses
inside the factory use deps.ok) + comment documenting the trap
Verified on merged tree: TOOLS.length=28, MCP+workflows 44/44.
Judge A — urn:ump:5clu4udrbwuwxn74tqwxtukfjnrwkrbiykp6uh66ipipzxr32xsq
This commit is contained in:
4 files changed
+586
-15
No files matched your search
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* DC-166 r2 (judge round 1 blocking demand): resolve the mounted
|
||||
* method+path for the DC-166 MCP tool calls — no substring greps.
|
||||
*
|
||||
* SCOPE (merge-judge r1 blocker, urn:ump:2jdpii43xlasarlxl2jug64xab465xnzzbdt3bnd4fbr7y23l26q):
|
||||
* this verifier covers the 11 NEW DC-166 tools (12 requests — security-summary
|
||||
* fans out) — NOT the whole 28-tool catalog. The 17 pre-existing tools are
|
||||
* resolved against declared routes by the follow-up MCP truth batch
|
||||
* (audit 2026-10-27 #40: extend to every tool + fail on any unresolved path).
|
||||
*
|
||||
* Layer 1 (composition): boots the routers whose dependency surface can be
|
||||
* satisfied in-process (workflows — the one the judge flagged) and
|
||||
* dispatches synthetic requests, asserting the composed path from the
|
||||
* MCP tool reaches the intended handler (sentinel body), plus ordering
|
||||
* (literal /history vs /:id/history).
|
||||
* Layer 2 (exact table): each covered tool call's (method, path) is resolved
|
||||
* against the route declarations extracted from the owning route file
|
||||
* (router.<method>('<path>') with the mount prefix from src/app.js) —
|
||||
* an exact normalized match, so a double prefix or wrong method fails.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const API = path.join(__dirname, '..', '..');
|
||||
|
||||
// ── exact expected contract for each DC-166 tool call ────────────────
|
||||
// (method, full path the tool's apiCall produces) -> (mount, route file,
|
||||
// relative route decl expected AFTER prefix-stripping)
|
||||
const EXPECTED = [
|
||||
{ method: 'GET', path: '/api/v1/calendar/events', mount: '/calendar', file: 'routes/calendar.js', rel: '/events' },
|
||||
{ method: 'POST', path: '/api/v1/calendar/sources', mount: '/calendar', file: 'routes/calendar.js', rel: '/sources' },
|
||||
{ method: 'GET', path: '/api/v1/security/events', mount: '/security', file: 'routes/security.js', rel: '/events' },
|
||||
{ method: 'GET', path: '/api/v1/security/events/stats', mount: '/security', file: 'routes/security.js', rel: '/events/stats' },
|
||||
{ method: 'GET', path: '/api/v1/security/events/perimeter', mount: '/security', file: 'routes/security.js', rel: '/events/perimeter' },
|
||||
{ method: 'GET', path: '/api/v1/license/status', mount: '/license', file: 'routes/license.js', rel: '/status' },
|
||||
{ method: 'GET', path: '/api/v1/capabilities', mount: 'BARE', file: 'routes/capabilities.js', rel: '/' },
|
||||
{ method: 'POST', path: '/api/v1/dns/record', mount: '/dns', file: 'routes/dns.js', rel: '/record' },
|
||||
{ method: 'DELETE', path: '/api/v1/dns/record', mount: '/dns', file: 'routes/dns.js', rel: '/record' },
|
||||
{ method: 'POST', path: '/api/v1/recipes/deploy', mount: '/recipes', file: 'routes/recipes/deploy.js', rel: '/deploy' },
|
||||
{ method: 'POST', path: '/api/v1/workflows/disk-space-alert/run', mount: '/workflows', file: 'routes/workflows.js', rel: '/:workflowId/run' },
|
||||
{ method: 'GET', path: '/api/v1/tailscale/status', mount: '/tailscale', file: 'routes/tailscale.js', rel: '/status' },
|
||||
];
|
||||
|
||||
function extractRoutes(file) {
|
||||
const src = fs.readFileSync(path.join(API, file), 'utf8');
|
||||
const out = [];
|
||||
for (const raw of src.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('//') || line.startsWith('*')) continue; // skip comments
|
||||
for (const m of raw.matchAll(/router\.(get|post|put|delete|patch)\(\s*'([^']*)'/g)) {
|
||||
out.push({ method: m[1].toUpperCase(), rel: m[2] || '/' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalize(p) {
|
||||
return p.replace(/\/index$/, '') || '/';
|
||||
}
|
||||
|
||||
describe('DC-166 r2: exact (method, path) resolution — no double prefixes', () => {
|
||||
test('app.js really mounts every prefix the table claims', () => {
|
||||
const appSrc = fs.readFileSync(path.join(API, 'src/app.js'), 'utf8');
|
||||
for (const e of EXPECTED) {
|
||||
if (e.mount === 'BARE') {
|
||||
expect(appSrc).toMatch(/apiRouter\.use\(capabilitiesRoute\.buildRouter\(\)\)/);
|
||||
} else {
|
||||
expect(appSrc).toContain(`apiRouter.use('${e.mount}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every MCP tool path resolves exactly to a declared route (method+pattern)', () => {
|
||||
const segMatch = (decl, segs) => {
|
||||
const parts = decl.split('/').filter(Boolean);
|
||||
if (parts.length !== segs.length) return false;
|
||||
return parts.every((p, i) => p.startsWith(':') || p === segs[i]);
|
||||
};
|
||||
for (const e of EXPECTED) {
|
||||
const routes = extractRoutes(e.file).map((r) => ({ ...r, rel: normalize(r.rel) }));
|
||||
const base = `/api/v1${e.mount === 'BARE' ? '' : e.mount}`;
|
||||
const segs = e.path.slice(base.length).split('/').filter(Boolean);
|
||||
const relForErr = '/' + segs.join('/');
|
||||
const hit = routes.find((r) => r.method === e.method && segMatch(r.rel, segs));
|
||||
expect(hit || { tool_path: e.path, wanted: `${e.method} ${relForErr}`, declared: routes }).toBeDefined();
|
||||
expect(hit).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('workflows history ordering: /history before /:workflowId/history', () => {
|
||||
const routes = extractRoutes('routes/workflows.js');
|
||||
const allIdx = routes.findIndex((r) => r.rel === '/history');
|
||||
const idIdx = routes.findIndex((r) => r.rel === '/:workflowId/history');
|
||||
expect(allIdx).toBeGreaterThan(-1);
|
||||
expect(idIdx).toBeGreaterThan(allIdx);
|
||||
});
|
||||
|
||||
test('no route file in the table double-prefixes its own mount', () => {
|
||||
for (const e of EXPECTED) {
|
||||
for (const r of extractRoutes(e.file)) {
|
||||
const first = (r.rel || '/').split('/').filter(Boolean)[0];
|
||||
const mountName = e.mount.replace('/', '');
|
||||
if (e.mount !== 'BARE' && first && first === mountName) {
|
||||
throw new Error(`${e.file} declares ${r.method} ${r.rel} — double prefix under ${e.mount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-166 r2: composition dispatch through the REAL workflows router', () => {
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const ah = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const licenseStub = {
|
||||
requirePremium: () => (req, res, next) => next(),
|
||||
};
|
||||
const ctx = {
|
||||
workflowEngine: {
|
||||
listWorkflows: () => [{ id: 'w1' }],
|
||||
setWorkflowEnabled: (id, on) => ({ id, enabled: on }),
|
||||
executeWorkflow: async (id) => ({ id, ran: true }),
|
||||
getHistory: (id, limit) => [{ id: id || null, limit }],
|
||||
},
|
||||
licenseManager: licenseStub,
|
||||
asyncHandler: ah,
|
||||
ok: (res, data) => res.status(200).json({ success: true, data }),
|
||||
};
|
||||
app.use('/api/v1/workflows', require('../../routes/workflows')(ctx));
|
||||
return app;
|
||||
}
|
||||
|
||||
function dispatch(app, method, url) {
|
||||
return new Promise((resolve) => {
|
||||
const req = { method, url, headers: {} };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader() {}, removeHeader() {}, on() {}, writeHead(c) { this.statusCode = c; return this; },
|
||||
end(b) { resolve({ status: this.statusCode, body: b ? String(b) : '' }); },
|
||||
json(b) { this.end(JSON.stringify(b)); },
|
||||
status(c) { this.statusCode = c; return this; },
|
||||
};
|
||||
app._router.handle(req, res, () => resolve({ status: 404, body: '' }));
|
||||
});
|
||||
}
|
||||
|
||||
test('MCP-composed POST /api/v1/workflows/:id/run reaches the run handler (judge r1 defect — now fixed)', async () => {
|
||||
const r = await dispatch(buildApp(), 'POST', '/api/v1/workflows/w1/run');
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toContain('"ran":true');
|
||||
});
|
||||
|
||||
test('list and both history forms resolve with correct handlers and order', async () => {
|
||||
const app = buildApp();
|
||||
const list = await dispatch(app, 'GET', '/api/v1/workflows');
|
||||
expect(list.body).toContain('"workflows":[');
|
||||
const all = await dispatch(app, 'GET', '/api/v1/workflows/history');
|
||||
expect(all.body).toContain('"limit":100');
|
||||
const one = await dispatch(app, 'GET', '/api/v1/workflows/w1/history');
|
||||
expect(one.body).toContain('"limit":50');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user