[grade=B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q] fix(DC-170): MCP truth batch — 6 dead tools fixed, full-catalog drift gate

Audit P0-5 + deploy_app truth (batch 5 of the 2026-10-27 sweep, AUDIT-2026-10-27.md committed here):

ROUTE ADDITIONS:
- routes/containers.js GET / — container list (dockerode listContainers);
  the router had NO list route, so dashcaddy_list_containers 404'd always
- routes/dns.js GET /records — record list via active provider with a
  list-records capability guard (501 + guidance for push-only providers
  like RFC2136); dashcaddy_list_dns called GET /dns, never existed

HANDLER FIXES (src/mcp/mcp-core.js):
- get_service resolves from GET /services by id/name (single-service GET
  never existed) with a known-ids 404
- check_health + diagnose -> /health/service/:id and /health/services
  (both previously hit nonexistent routes)
- container_action remove -> DELETE /containers/:id (was /:id/remove)
- system_health/system_metrics via apiCall error envelope (metrics text
  capped at 64KB)
- search_catalog applies category filter client-side (/catalog/search
  has no category param — constraint was silently dropped)
- generate_caddyfile defaults authService=status when auth:true (auth
  flag alone silently produced UNGATED Caddyfiles)
- deploy_app honest re-contract: registers service + generates
  Caddyfile, does NOT claim image pull/container creation/deployed:true;
  base domain read from /config (hardcoded owner .sami TLD removed);
  caddy-code failure now surfaces as caddyfileError + FAILED message
- list_fleet description gains Premium hint

VERIFIER (audit #40): EXPECTED table 12 -> 34 rows covering EVERY tool's
(method,path); NEW Layer-3 handler-derived test drives the real
createToolHandler for all 28 tools and strictly resolves every captured
call mount-aware (a vacuous first draft of this gate was caught and
fixed pre-grade); extractRoutes regex extended to apiRouter.get inline
decls; Bearer credential-passthrough test added (DC-162 coverage gap)
NEW TESTS: dns-records.routes.test.js (5 route-level cases incl. 501
capability behavior); deploy_app partial-failure + no-.sami tests

HOST RELIABILITY (same sweep, evidenced here): jest maxWorkers 50% —
default 5-worker runs repeatedly OOM-killed this host's gateway
(dmesg 2026-10-29 00:58:40; swap was 4G/4G full). Gateway protected
separately via systemd OOMScoreAdjust=-500 drop-in + /swapfile2 (host
ops, graded B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q).
DC-171 DNS provider-parity ticket (Sami, plug-in-directly) appended to
backlog.

Full suite: 173/173 suites, 3740/3740 tests. Judge B r1
(urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq) blockers
all fixed: dns.js 501 arg order (the file aliases error() message-first
— the sweep's 'reversed-arg' finding was a false positive for its 7
sites; lesson #14), deploy_app partial-failure honesty, handler-derived
verifier.
This commit is contained in:
Hermes committed 2026-10-29 01:30:42 -07:00
1 parent 94ee76e9de
commit f4f2c26de3
9 files changed
+529 -40

No files matched your search

+109 -31
View File
@@ -106,7 +106,7 @@ const TOOLS = [
// ── Deployment ──
{
name: 'dashcaddy_deploy_app',
description: 'Deploy a self-hosted application from the catalog. This is the main "self-host X" action. Pulls the Docker image, creates the container, generates a Caddyfile reverse proxy route, and adds the service to the dashboard. Returns the URL the user can access.',
description: 'Register a self-hosted app from the catalog on this dashboard: adds the service entry and generates the Caddyfile reverse-proxy route for its URL. Does NOT pull images or create containers — run the container yourself using the returned nextSteps, then verify with dashcaddy_check_health. The URL uses this deployment\'s configured base domain.',
inputSchema: {
type: 'object',
properties: {
@@ -189,7 +189,7 @@ const TOOLS = [
// ── Fleet ──
{
name: 'dashcaddy_list_fleet',
description: 'List all hosts in the DashCaddy fleet (for multi-server management).',
description: 'List all hosts in the DashCaddy fleet (for multi-server management). Requires the Premium Fleet feature — free-tier keys get 403.',
inputSchema: { type: 'object', properties: {} },
},
// ── Calendar (DC-166) ──
@@ -257,7 +257,7 @@ const TOOLS = [
// ── 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.',
description: 'Create a DNS A record via the configured provider (Technitium/Cloudflare/RFC2136/universal). Points a hostname at a machine without touching the dashboard. List existing records first with dashcaddy_list_dns.',
inputSchema: {
type: 'object',
properties: {
@@ -383,26 +383,42 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
}
case 'dashcaddy_get_service': {
return apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}`);
// DC-170: GET /services/:serviceId never existed — resolve from the list
const data = await apiCall('GET', '/services');
if (data.error) return data;
const services = data.services || data.data || [];
const wanted = String(args.serviceId || '').toLowerCase();
const svc = services.find((s) =>
String(s.id || '').toLowerCase() === wanted || String(s.name || '').toLowerCase() === wanted);
if (!svc) {
return { error: true, status: 404,
message: `Service '${args.serviceId}' not found. Known ids: ${services.map((s) => s.id).join(', ') || '(none)'}` };
}
return { id: svc.id, name: svc.name, status: svc.status || 'unknown',
url: svc.url, subdomain: svc.subdomain, type: svc.type, port: svc.port };
}
case 'dashcaddy_check_health': {
// DC-170: real routes are /health/service/:id and /health/services —
// /services/:id/health and /health/all never existed (404 every call)
if (args.serviceId) {
return apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}/health`);
return apiCall('GET', `/health/service/${encodeURIComponent(args.serviceId)}`);
}
return apiCall('GET', '/health/all');
return apiCall('GET', '/health/services');
}
// ── System ──
case 'dashcaddy_system_health': {
// Public endpoint — no auth needed
const response = await doFetch(`${baseUrl}/api/v1/system/health`);
return response.json();
// DC-170: via apiCall like every other tool (error envelope, no raw throw)
return apiCall('GET', '/system/health');
}
case 'dashcaddy_system_metrics': {
const response = await doFetch(`${baseUrl}/api/v1/metrics/prometheus`);
return { metrics: await response.text() };
// DC-170: was a raw doFetch().text() with no error envelope or cap
const data = await apiCall('GET', '/metrics/prometheus');
if (data.error) return data;
const text = typeof data.raw === 'string' ? data.raw : JSON.stringify(data);
return { metrics: text.slice(0, 65536), truncated: text.length > 65536 };
}
// ── Containers ──
@@ -413,15 +429,29 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
case 'dashcaddy_container_action': {
const { containerId, action } = args;
const method = action === 'remove' ? 'DELETE' : 'POST';
return apiCall(method, `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`);
// DC-170: remove is DELETE /containers/:id — /containers/:id/remove never existed
if (action === 'remove') {
return apiCall('DELETE', `/containers/${encodeURIComponent(containerId)}`);
}
return apiCall('POST', `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`);
}
// ── Catalog & Discovery ──
case 'dashcaddy_search_catalog': {
let path = '/catalog';
if (args.query) {
return apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`);
const results = await apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`);
// DC-170: /catalog/search has no category param — apply it here so
// 'search media apps for plex' actually filters instead of silently
// dropping the constraint
if (!results.error && args.category && Array.isArray(results.apps)) {
const cat = String(args.category).toLowerCase();
results.apps = results.apps.filter((a) =>
String(a.category || '').toLowerCase().includes(cat));
results.results = results.apps.length;
results.category = args.category;
}
return results;
}
if (args.category) path += `?category=${encodeURIComponent(args.category)}`;
return apiCall('GET', path);
@@ -433,44 +463,86 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// ── Deployment ──
case 'dashcaddy_deploy_app': {
// Step 1: Get template details
// DC-170 re-contract: this tool REGISTERS the service and generates the
// Caddyfile — it does NOT pull images or create containers (the old
// description and deployed:true claimed it did; its own nextSteps told
// the user to run docker manually). The public /config supplies the
// deployment's real base domain (was hardcoded to the owner's .sami).
const template = await apiCall('GET', `/catalog/${encodeURIComponent(args.templateId)}`);
if (template.error) return template;
// Step 2: Generate Caddyfile route
let baseDomain = '';
const cfg = await apiCall('GET', '/config');
if (!cfg.error) {
const host = cfg.dashboardHost || (cfg.siteConfig && cfg.siteConfig.dashboardHost) || '';
if (String(host).includes('.')) {
baseDomain = String(host).split('.').slice(1).join('.');
} else if (cfg.tld) {
baseDomain = String(cfg.tld).replace(/^\./, '');
} else if (cfg.domain) {
baseDomain = String(cfg.domain).replace(/^\./, '');
}
}
if (!baseDomain) {
return { error: true, status: 409,
message: 'Cannot determine this deployment\'s base domain (no dashboardHost/tld in /config). Set it in the setup wizard, or pass an explicit subdomain + full domain via dashcaddy_generate_caddyfile.' };
}
const port = args.port || template.ports?.[0] || 8080;
const subdomain = args.subdomain || args.templateId;
const domain = `${subdomain}.${baseDomain}`;
const caddy = await apiCall('POST', '/caddycode/generate', {
domain: `${subdomain}.sami`,
domain,
upstream: `localhost:${port}`,
websocket: true,
cors: true,
});
// Step 3: Create service entry
const service = await apiCall('POST', '/services', {
id: subdomain,
name: template.name,
subdomain,
domain: `${subdomain}.sami`,
url: `https://${subdomain}.sami`,
domain,
url: `https://${domain}`,
port,
protocol: 'http',
type: template.category || 'generic',
});
if (service.error) {
return { error: true, status: service.status || 500,
message: `Service registration failed: ${service.message}` };
}
// Judge blocker (urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq):
// a failed Caddyfile generation was swallowed — registered:true was
// returned while the message claimed the route was generated. Report
// the partial failure honestly.
if (caddy.error) {
return {
registered: true,
service,
caddyfile: null,
caddyfileError: caddy.message || 'unknown error',
url: `https://${domain}`,
message: `${template.name} registered on the dashboard, but Caddyfile route generation FAILED (${caddy.message || 'unknown error'}). The URL will not work until a reverse-proxy route exists.`,
nextSteps: [
`Pull the image: docker pull ${template.image || 'unknown'}`,
`Run the container with port ${port} mapped`,
'Create the reverse-proxy route (retry dashcaddy_generate_caddyfile for this domain, or add the route to Caddy manually) — the URL stays dead until then',
],
};
}
return {
deployed: !service.error,
service: service.error ? null : service,
registered: true,
service,
caddyfile: caddy.error ? null : caddy.caddyfile,
url: `https://${subdomain}.sami`,
message: service.error
? `Deployment failed: ${service.message}`
: `${template.name} deployed! Access it at https://${subdomain}.sami`,
url: `https://${domain}`,
message: `${template.name} registered on the dashboard and Caddyfile route generated for https://${domain}. The container is NOT running yet — start it to make the URL live.`,
nextSteps: [
`Pull the Docker image: docker pull ${template.image || 'unknown'}`,
`Pull the image: docker pull ${template.image || 'unknown'}`,
`Run the container with port ${port} mapped`,
`The Caddyfile route is configured — the URL should work once the container is running`,
'The Caddyfile route was generated — install it into Caddy if not auto-loaded, then check dashcaddy_check_health',
],
};
}
@@ -484,18 +556,23 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// ── DNS & Proxy ──
case 'dashcaddy_list_dns': {
let path = '/dns';
// DC-170: GET /dns never existed — records live at GET /dns/records
let path = '/dns/records';
if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`;
return apiCall('GET', path);
}
case 'dashcaddy_generate_caddyfile': {
// DC-170: the route emits the auth gate only when auth && authService
// are BOTH set — auth:true alone was a silent no-op producing an
// UNGATED Caddyfile. Default the service to this dashboard.
return apiCall('POST', '/caddycode/generate', {
domain: args.domain,
upstream: args.upstream,
websocket: args.websocket,
cors: args.cors,
auth: args.auth,
authService: args.auth ? (args.authService || 'status') : undefined,
});
}
@@ -504,8 +581,9 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
const findings = [];
if (args.serviceId) {
// Service-specific diagnosis
const health = await apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}/health`);
// Service-specific diagnosis (DC-170: /health/service/:id, not the
// never-existent /services/:id/health)
const health = await apiCall('GET', `/health/service/${encodeURIComponent(args.serviceId)}`);
if (health.error) {
findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` });
} else {