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
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:
@@ -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:*",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
74
src/mcpd/src/routes/web-ui.ts
Normal file
74
src/mcpd/src/routes/web-ui.ts
Normal 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');
|
||||
}
|
||||
322
src/mcplocal/tests/smoke/personality.smoke.test.ts
Normal file
322
src/mcplocal/tests/smoke/personality.smoke.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user