feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6)
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Failing after 1m8s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped

The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image
bundles the build artifact, a smoke test exercises the personality
HTTP surface end-to-end, and the user-facing docs spell out the
mental model.

mcpd:
- Add @fastify/static dep.
- New routes/web-ui.ts: registers /ui/* against a static bundle. Looks
  for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the
  Docker image path), then a dev-tree fallback. Logs and skips
  cleanly if missing — API-only deploys keep working.
- SPA fallback: any /ui/<path> that doesn't match a file falls through
  to index.html so direct hits to react-router URLs work.
- /ui/* falls through to `kind: skip` in mapUrlToPermission, so the
  static assets are served unauthenticated. Each API call from the
  SPA still carries the bearer token.

Deploy:
- Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder
  stage and copies dist/ to /usr/share/mcpd/web in the runtime image.

Smoke (personality.smoke.test.ts):
- Live mcpd flow: create secret/llm/agent/personality, attach an
  agent-direct prompt, verify the binding listing, reject double-
  attach (409) + foreign-agent prompt (400), set defaultPersonality
  by name, detach + delete cleanup.

Docs:
- New docs/personalities.md: VLAN-on-ethernet model, system-block
  ordering table, three prompt scopes, CLI walkthrough, web UI
  walkthrough, full API surface, RBAC notes.
- agents.md and chat.md cross-link.
- README's Agents section gains a Personalities subsection.

Test count after Stage 6:
  mcpd:   801/801      cli:  430/430
  web:    7/7          db:   58/62 (4 pre-existing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-26 19:48:43 +01:00
parent 0010cc18b7
commit 4cbf58d212
10 changed files with 665 additions and 1 deletions

View File

@@ -17,6 +17,7 @@
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^12.0.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/static": "^8.0.0",
"@kubernetes/client-node": "^1.4.0",
"@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*",

View File

@@ -47,6 +47,7 @@ import { PromptRequestRepository } from './repositories/prompt-request.repositor
import { PersonalityRepository } from './repositories/personality.repository.js';
import { PersonalityService } from './services/personality.service.js';
import { registerPersonalityRoutes } from './routes/personalities.js';
import { registerWebUi } from './routes/web-ui.js';
import { bootstrapSystemProject } from './bootstrap/system-project.js';
import {
McpServerService,
@@ -725,6 +726,11 @@ async function main(): Promise<void> {
});
});
// Web UI: served from /ui (static SPA bundle). Falls through to API
// routes when the prefix doesn't match. Skipped silently if the bundle
// isn't installed (dev tree without `pnpm --filter @mcpctl/web build`).
await registerWebUi(app);
// Start
await app.listen({ port: config.port, host: config.host });
app.log.info(`mcpd listening on ${config.host}:${config.port}`);

View File

@@ -0,0 +1,74 @@
/**
* /ui — serves the @mcpctl/web SPA bundle.
*
* In production the bundle lives at /usr/share/mcpd/web (installed by the
* RPM in Stage 6); in dev it lives at <repo>/src/web/dist after a
* `pnpm --filter @mcpctl/web build`. The location is overridable via the
* `MCPD_WEB_ROOT` env var so deployers can move it freely.
*
* If the directory is missing we log a warning and skip — mcpd still serves
* the API. That lets the dev tree run without forcing a web build first.
*
* SPA routing: anything under /ui/<path> that's not a file falls back to
* index.html so client-side react-router routes work on direct hits.
*/
import path from 'node:path';
import { existsSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
const DEFAULT_PROD_ROOT = '/usr/share/mcpd/web';
function resolveWebRoot(): string | null {
const fromEnv = process.env['MCPD_WEB_ROOT'];
if (fromEnv !== undefined && fromEnv !== '') {
return existsSync(fromEnv) ? fromEnv : null;
}
if (existsSync(DEFAULT_PROD_ROOT)) return DEFAULT_PROD_ROOT;
// Dev fallback: walk up from this file to find <repo>/src/web/dist.
// After bun compile this path doesn't resolve, which is fine — prod uses
// DEFAULT_PROD_ROOT or MCPD_WEB_ROOT instead.
try {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidate = path.resolve(here, '../../../web/dist');
if (existsSync(candidate)) return candidate;
} catch {
// import.meta.url unavailable in some bundled envs — skip.
}
return null;
}
export async function registerWebUi(app: FastifyInstance): Promise<void> {
const root = resolveWebRoot();
if (root === null) {
app.log.warn(
`web UI bundle not found (set MCPD_WEB_ROOT, or place a build at ${DEFAULT_PROD_ROOT}); /ui will return 404`,
);
return;
}
if (!statSync(root).isDirectory()) {
app.log.warn({ root }, 'web UI root is not a directory; /ui will return 404');
return;
}
await app.register(fastifyStatic, {
root,
prefix: '/ui/',
wildcard: false,
decorateReply: false,
});
// SPA fallback — react-router URLs like /ui/agents/foo/personalities/bar
// need index.html to bootstrap the app.
app.get('/ui/*', (_request, reply) => {
return reply.sendFile('index.html', root);
});
// Cover the bare /ui (no trailing slash) too.
app.get('/ui', (_request, reply) => {
return reply.redirect('/ui/');
});
app.log.info({ root }, 'web UI mounted at /ui');
}

View File

@@ -0,0 +1,322 @@
/**
* Smoke tests: Personality + agent-direct prompts against a live mcpd.
*
* Validates Stages 1-4 end-to-end without needing a live LLM upstream:
* 1. Create the supporting Secret + Llm + Agent (mcpctl CLI).
* 2. Create a Personality on the agent (POST /api/v1/agents/:name/personalities).
* 3. Create an agent-direct prompt (POST /api/v1/prompts with `agent: name`).
* 4. Attach the prompt; verify the binding shows up.
* 5. Reject double-attach (409) and out-of-scope attach (400).
* 6. PUT the agent's defaultPersonality by name.
* 7. Cleanup: detach, delete personality, delete agent, delete llm/secret.
*
* The chat-time overlay path is covered by the new mcpd unit tests
* (chat-service.test.ts); a future agent-chat smoke run with the right
* env vars exercises it through the full SSE pipe.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { execSync } from 'node:child_process';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const SUFFIX = Date.now().toString(36);
const SECRET_NAME = `smoke-pers-sec-${SUFFIX}`;
const LLM_NAME = `smoke-pers-llm-${SUFFIX}`;
const AGENT_NAME = `smoke-pers-agent-${SUFFIX}`;
const PERSONALITY_NAME = `smoke-pers-${SUFFIX}`;
const DIRECT_PROMPT_NAME = `smoke-pers-direct-${SUFFIX}`;
interface CliResult { code: number; stdout: string; stderr: string }
function run(args: string): CliResult {
try {
const stdout = execSync(`mcpctl --direct ${args}`, {
encoding: 'utf-8',
timeout: 30_000,
stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
return {
code: e.status ?? 1,
stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '',
stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '',
};
}
}
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
return new Promise((resolve) => {
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
const driver = parsed.protocol === 'https:' ? https : http;
const req = driver.get(
{
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname,
timeout: timeoutMs,
},
(res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); },
);
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
});
}
let mcpdUp = false;
let createdPromptId: string | null = null;
let createdPersonalityId: string | null = null;
describe('personality smoke', () => {
beforeAll(async () => {
mcpdUp = await healthz(MCPD_URL);
if (!mcpdUp) {
// eslint-disable-next-line no-console
console.warn(`\n ○ personality smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
}
}, 20_000);
afterAll(async () => {
if (!mcpdUp) return;
if (createdPersonalityId !== null) {
await httpRequest('DELETE', `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}`, undefined);
}
if (createdPromptId !== null) {
await httpRequest('DELETE', `${MCPD_URL}/api/v1/prompts/${createdPromptId}`, undefined);
}
run(`delete agent ${AGENT_NAME}`);
run(`delete llm ${LLM_NAME}`);
run(`delete secret ${SECRET_NAME}`);
});
it('seeds Secret + Llm + Agent', () => {
if (!mcpdUp) return;
run(`delete secret ${SECRET_NAME}`);
run(`delete llm ${LLM_NAME}`);
run(`delete agent ${AGENT_NAME}`);
expect(run(`create secret ${SECRET_NAME} --data API_KEY=sk-fake`).code).toBe(0);
expect(run([
`create llm ${LLM_NAME}`,
'--type openai',
'--model gpt-4o-mini',
'--url http://localhost:9999',
`--api-key-ref ${SECRET_NAME}/API_KEY`,
].join(' ')).code).toBe(0);
expect(run([
`create agent ${AGENT_NAME}`,
`--llm ${LLM_NAME}`,
`--description "smoke personality agent"`,
].join(' ')).code).toBe(0);
});
it('creates an agent-direct prompt', async () => {
if (!mcpdUp) return;
const res = await httpRequest('POST', `${MCPD_URL}/api/v1/prompts`, {
name: DIRECT_PROMPT_NAME,
content: 'Always be terse.',
agent: AGENT_NAME,
priority: 8,
});
expect(res.status).toBe(201);
const body = JSON.parse(res.body) as { id: string; agentId: string };
expect(body.agentId).toBeTruthy();
createdPromptId = body.id;
});
it('lists agent-direct prompts via GET /api/v1/agents/:name/prompts', async () => {
if (!mcpdUp) return;
const res = await httpRequest('GET', `${MCPD_URL}/api/v1/agents/${AGENT_NAME}/prompts`, undefined);
expect(res.status).toBe(200);
const rows = JSON.parse(res.body) as Array<{ name: string }>;
expect(rows.some((r) => r.name === DIRECT_PROMPT_NAME)).toBe(true);
});
it('creates a personality on the agent', async () => {
if (!mcpdUp) return;
const res = await httpRequest(
'POST',
`${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`,
{
name: PERSONALITY_NAME,
description: 'smoke personality',
priority: 7,
},
);
expect(res.status, res.body).toBe(201);
const body = JSON.parse(res.body) as { id: string; name: string; promptCount: number };
expect(body.name).toBe(PERSONALITY_NAME);
expect(body.promptCount).toBe(0);
createdPersonalityId = body.id;
});
it('rejects duplicate personality name on the same agent (409)', async () => {
if (!mcpdUp) return;
const res = await httpRequest(
'POST',
`${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`,
{ name: PERSONALITY_NAME },
);
expect(res.status).toBe(409);
});
it('lists the personality via /api/v1/personalities and the per-agent route', async () => {
if (!mcpdUp) return;
const all = await httpRequest('GET', `${MCPD_URL}/api/v1/personalities`, undefined);
expect(all.status).toBe(200);
const allRows = JSON.parse(all.body) as Array<{ name: string; agentName: string }>;
expect(allRows.some((r) => r.name === PERSONALITY_NAME && r.agentName === AGENT_NAME)).toBe(true);
const perAgent = await httpRequest(
'GET',
`${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`,
undefined,
);
expect(perAgent.status).toBe(200);
const perAgentRows = JSON.parse(perAgent.body) as Array<{ name: string }>;
expect(perAgentRows.map((r) => r.name)).toContain(PERSONALITY_NAME);
});
it('attaches the agent-direct prompt and lists the binding', async () => {
if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return;
const attach = await httpRequest(
'POST',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`,
{ promptId: createdPromptId, priority: 9 },
);
expect(attach.status, attach.body).toBe(201);
const list = await httpRequest(
'GET',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`,
undefined,
);
expect(list.status).toBe(200);
const rows = JSON.parse(list.body) as Array<{ promptName: string; priority: number }>;
const found = rows.find((r) => r.promptName === DIRECT_PROMPT_NAME);
expect(found, `binding for ${DIRECT_PROMPT_NAME} must be present`).toBeDefined();
expect(found!.priority).toBe(9);
});
it('rejects double-attach of the same prompt (409)', async () => {
if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return;
const res = await httpRequest(
'POST',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`,
{ promptId: createdPromptId },
);
expect(res.status).toBe(409);
});
it('rejects attaching a prompt belonging to a different agent (400)', async () => {
if (!mcpdUp || createdPersonalityId === null) return;
// Spawn a second agent + a prompt direct on it; foreign attach must 400.
const otherAgent = `smoke-pers-other-${SUFFIX}`;
expect(run([
`create agent ${otherAgent}`,
`--llm ${LLM_NAME}`,
].join(' ')).code).toBe(0);
const foreignPrompt = await httpRequest('POST', `${MCPD_URL}/api/v1/prompts`, {
name: `smoke-pers-foreign-${SUFFIX}`,
content: 'foreign',
agent: otherAgent,
});
expect(foreignPrompt.status).toBe(201);
const foreignId = (JSON.parse(foreignPrompt.body) as { id: string }).id;
try {
const res = await httpRequest(
'POST',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`,
{ promptId: foreignId },
);
expect(res.status).toBe(400);
} finally {
await httpRequest('DELETE', `${MCPD_URL}/api/v1/prompts/${foreignId}`, undefined);
run(`delete agent ${otherAgent}`);
}
});
it('sets defaultPersonality on the agent by name', async () => {
if (!mcpdUp) return;
// Resolve agent id for PUT.
const res = await httpRequest('GET', `${MCPD_URL}/api/v1/agents/${AGENT_NAME}`, undefined);
expect(res.status).toBe(200);
const agent = JSON.parse(res.body) as { id: string };
const put = await httpRequest('PUT', `${MCPD_URL}/api/v1/agents/${agent.id}`, {
defaultPersonality: { name: PERSONALITY_NAME },
});
expect(put.status, put.body).toBe(200);
const updated = JSON.parse(put.body) as { defaultPersonality: { name: string } | null };
expect(updated.defaultPersonality?.name).toBe(PERSONALITY_NAME);
});
it('detaches the prompt and deletes the personality', async () => {
if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return;
const detach = await httpRequest(
'DELETE',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts/${createdPromptId}`,
undefined,
);
expect(detach.status).toBe(204);
const del = await httpRequest(
'DELETE',
`${MCPD_URL}/api/v1/personalities/${createdPersonalityId}`,
undefined,
);
expect(del.status).toBe(204);
createdPersonalityId = null;
});
});
interface HttpResponse { status: number; body: string }
function httpRequest(method: string, urlStr: string, body: unknown): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const tokenRaw = readToken();
const parsed = new URL(urlStr);
const driver = parsed.protocol === 'https:' ? https : http;
const headers: Record<string, string> = {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}),
};
const req = driver.request({
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
headers,
timeout: 15_000,
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => {
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') });
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); });
if (body !== undefined) req.write(JSON.stringify(body));
req.end();
});
}
function readToken(): string | null {
try {
const home = process.env.HOME ?? '';
const path = `${home}/.mcpctl/credentials`;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs');
if (!fs.existsSync(path)) return null;
const raw = fs.readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw) as { token?: string };
return parsed.token ?? null;
} catch {
return null;
}
}