Compare commits

...
Author SHA1 Message Date
Hermes e524d1663d [pending-judge] feat(DC-166 r2): MCP tool-set expansion + workflows double-prefix route fix (17 -> 28 tools)
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).
2026-10-09 20:18:42 -07:00
4 changed files with 578 additions and 14 deletions

No files matched your search

@@ -0,0 +1,161 @@
/**
* DC-166 r2 (judge round 1 blocking demand): resolve the COMPLETE mounted
* method+path for the MCP tool routes — no substring greps.
*
* 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): every MCP 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/);
});
});
+22 -14
View File
@@ -32,15 +32,21 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
router.use(licenseManager.requirePremium('workflows'));
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
// PATH CONTRACT: mounted at apiRouter.use('/workflows', ...) in src/app.js,
// so paths here are RELATIVE to that mount. The previous literal
// router.get('/workflows', ...) produced a double prefix
// (/api/v1/workflows/workflows/...) that the shipped dashboard bundle
// (status/js/bundled-workflows.js calls single-prefix /api/v1/workflows/)
// never hits; the DC-166 judge flagged the composed-path mismatch.
// List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => {
// List bundled workflows
router.get('/', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows();
ok(res, { workflows });
}, 'workflows-list'));
// Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
router.post('/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
@@ -48,7 +54,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
}, 'workflows-enable'));
// Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
router.post('/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
@@ -56,7 +62,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
}, 'workflows-disable'));
// Manually trigger a workflow
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
router.post('/:workflowId/run', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const triggerData = req.body || {};
@@ -66,20 +72,22 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
ok(res, { result });
}, 'workflows-run'));
// Get execution history for a workflow
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
// ALL-history route declared BEFORE /:workflowId/history so the literal
// 'history' segment is not captured as a workflowId.
// Get all workflow execution history
router.get('/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
ok(res, { history });
}, 'workflows-all-history'));
// Get execution history for one workflow
router.get('/:workflowId/history', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit);
ok(res, { history });
}, 'workflows-history'));
// Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
ok(res, { history });
}, 'workflows-all-history'));
return router;
};
+196
View File
@@ -192,6 +192,123 @@ const TOOLS = [
description: 'List all hosts in the DashCaddy fleet (for multi-server management).',
inputSchema: { type: 'object', properties: {} },
},
// ── Calendar (DC-166) ──
{
name: 'dashcaddy_calendar_events',
description: 'List upcoming calendar events from configured sources (shared with the dashboard calendar widget). Useful for "what is on my schedule this week?"',
inputSchema: {
type: 'object',
properties: {
days: { type: 'number', description: 'Horizon in days from now (default 14, max 60)' },
},
},
},
{
name: 'dashcaddy_calendar_add_source',
description: 'Add a calendar source: a CalDAV URL with user+pass or a token, or a read-only ICS feed URL. DashCaddy-deployed Radicale servers auto-register themselves; this tool is for external calendars.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Display name (e.g. "Personal")' },
type: { type: 'string', enum: ['caldav', 'ics'], description: 'Source type' },
url: { type: 'string', description: 'CalDAV base URL or .ics feed URL (https required)' },
user: { type: 'string', description: 'CalDAV username (caldav with user+pass auth)' },
pass: { type: 'string', description: 'CalDAV password (caldav with user+pass auth)' },
token: { type: 'string', description: 'Auth token (CalDAV token auth or secret-bearing ICS URL)' },
},
required: ['name', 'type', 'url'],
},
},
// ── Security Center (DC-166) ──
{
name: 'dashcaddy_security_events',
description: 'Query the unified Security Center event pipeline (docker/caddy/dns/tailscale/audit/health sources, PII-masked). Useful for "any suspicious activity?" or "recent auth failures".',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max events (default 20)' },
source_type: { type: 'string', description: 'Filter by source (caddy, docker, dns, tailscale, audit, health…)' },
severity: { type: 'string', description: 'Filter by severity (info, warning, error, critical)' },
since: { type: 'string', description: 'ISO datetime lower bound (optional)' },
},
},
},
{
name: 'dashcaddy_security_summary',
description: 'Security Center aggregate stats plus per-IP/per-vhost perimeter analytics. Useful for "which IPs are attacking me?"',
inputSchema: {
type: 'object',
properties: {
withPerimeter: { type: 'boolean', description: 'Include perimeter rollups (default true)' },
},
},
},
// ── License + capabilities (DC-166) ──
{
name: 'dashcaddy_license_status',
description: 'License status: active/inactive, tier, lifetime flag, days remaining, granted premium features, and the gated-feature catalog.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'dashcaddy_capabilities',
description: 'Machine-readable capability manifest of the running build: version, app-template count + categories, MCP tool list, premium feature list, language count. Ground truth for "what can this DashCaddy do?"',
inputSchema: { type: 'object', properties: {} },
},
// ── DNS write (DC-166) ──
{
name: 'dashcaddy_dns_add_record',
description: 'Create a DNS A record via the configured DNS provider (Technitium/universal). Points a hostname at a machine without touching the dashboard.',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'FQDN or hostname to add (e.g. "printer.home.example.com")' },
ip: { type: 'string', description: 'IPv4 address for the A record' },
ttl: { type: 'number', description: 'TTL seconds (optional, provider default)' },
},
required: ['domain', 'ip'],
},
},
{
name: 'dashcaddy_dns_remove_record',
description: 'Delete a DNS record via the configured provider. Pass the identifying fields that dashcaddy_list_dns returned (domain, type, ipAddress).',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'Record name (e.g. "printer.home.example.com")' },
type: { type: 'string', description: 'Record type (A, CNAME…; default A)' },
ipAddress: { type: 'string', description: 'Record data (IP) to disambiguate' },
},
required: ['domain'],
},
},
// ── Recipes & workflows (DC-166) ──
{
name: 'dashcaddy_deploy_recipe',
description: 'Deploy a multi-container Recipe stack (premium) by recipe id with optional per-service overrides. List ids via dashcaddy_search_catalog-style GET /api/v1/recipes/templates.',
inputSchema: {
type: 'object',
properties: {
recipeId: { type: 'string', description: 'Recipe id (e.g. "media-stack")' },
overrides: { type: 'object', description: 'Per-service config overrides (optional)', additionalProperties: true },
},
required: ['recipeId'],
},
},
{
name: 'dashcaddy_run_workflow',
description: 'Run a bundled maintenance workflow (premium): auto-restart-on-crash, backup-before-update, health-check-on-interval, disk-space-alert, weekly-container-report.',
inputSchema: {
type: 'object',
properties: { workflowId: { type: 'string', description: 'Workflow id' } },
required: ['workflowId'],
},
},
// ── Tailscale (DC-166) ──
{
name: 'dashcaddy_tailscale_status',
description: 'Tailscale integration status and enrolled devices (when the Tailscale feature is configured). Useful for checking VPN coverage of tailnet-only services.',
inputSchema: { type: 'object', properties: {} },
},
];
const RESOURCES = [
@@ -424,6 +541,85 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
return apiCall('GET', '/fleet/hosts');
}
// ── Calendar (DC-166) ──
case 'dashcaddy_calendar_events': {
// Route contract (routes/calendar.js): ISO start/end, capped 400-day
// range. Translate the friendlier `days` horizon into that shape.
const days = Math.min(Math.max(Number(args.days) || 14, 1), 60);
const from = new Date();
const to = new Date(from.getTime() + days * 86400000);
const p = `/calendar/events?start=${encodeURIComponent(from.toISOString())}&end=${encodeURIComponent(to.toISOString())}`;
return apiCall('GET', p);
}
case 'dashcaddy_calendar_add_source': {
return apiCall('POST', '/calendar/sources', {
name: args.name, type: args.type, url: args.url,
...(args.user ? { user: args.user } : {}),
...(args.pass ? { pass: args.pass } : {}),
...(args.token ? { token: args.token } : {}),
});
}
// ── Security Center (DC-166) ──
case 'dashcaddy_security_events': {
const qs = new URLSearchParams();
for (const k of ['limit', 'source_type', 'severity', 'since']) {
if (args[k] !== undefined && args[k] !== null) qs.set(k, String(args[k]));
}
const q = qs.toString();
return apiCall('GET', `/security/events${q ? `?${q}` : ''}`);
}
case 'dashcaddy_security_summary': {
const stats = await apiCall('GET', '/security/events/stats');
if (args.withPerimeter === false) return stats;
const peri = await apiCall('GET', '/security/events/perimeter');
return { stats, perimeter: peri };
}
// ── License + capabilities (DC-166) ──
case 'dashcaddy_license_status': {
return apiCall('GET', '/license/status');
}
case 'dashcaddy_capabilities': {
return apiCall('GET', '/capabilities');
}
// ── DNS write (DC-166) ──
case 'dashcaddy_dns_add_record': {
return apiCall('POST', '/dns/record', {
domain: args.domain, ip: args.ip,
...(args.ttl !== undefined ? { ttl: args.ttl } : {}),
});
}
case 'dashcaddy_dns_remove_record': {
const qs = new URLSearchParams();
qs.set('domain', String(args.domain));
qs.set('type', String(args.type || 'A'));
if (args.ipAddress !== undefined) qs.set('ipAddress', String(args.ipAddress));
return apiCall('DELETE', `/dns/record?${qs.toString()}`);
}
// ── Recipes & workflows (DC-166) ──
case 'dashcaddy_deploy_recipe': {
return apiCall('POST', '/recipes/deploy', {
recipeId: args.recipeId,
...(args.overrides ? { config: args.overrides } : {}),
});
}
case 'dashcaddy_run_workflow': {
return apiCall('POST', `/workflows/${encodeURIComponent(args.workflowId)}/run`, {});
}
// ── Tailscale (DC-166) ──
case 'dashcaddy_tailscale_status': {
return apiCall('GET', '/tailscale/status');
}
default:
return { error: true, message: `Unknown tool: ${name}` };
}