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');
}