feat: web prompt editor + agent personalities #58

Merged
michal merged 6 commits from feat/web-prompt-editor-personalities into main 2026-04-26 20:21:54 +00:00
10 changed files with 665 additions and 1 deletions
Showing only changes of commit 4cbf58d212 - Show all commits

View File

@@ -546,6 +546,31 @@ mcpctl chat reviewer --thread <id>
Full reference: [docs/agents.md](docs/agents.md). User-facing chat guide:
[docs/chat.md](docs/chat.md).
### Personalities
Same agent, different prompt bundles per turn. A **Personality** is a named
overlay attached to an agent — when selected at chat time it appends extra
prompts to the system block without replacing the agent's own prompt or
project prompts. Think VLAN on top of ethernet: the underlying agent still
works without one; with one, segmentation kicks in.
```bash
# Make a personality on an existing agent
mcpctl create personality grumpy --agent reviewer --description "Be terse and slightly grumpy"
# Add an agent-direct prompt (always-on for this agent — no toggle)
mcpctl create prompt always-terse --agent reviewer --content "Always be terse." --priority 8
# Use it
mcpctl chat reviewer --personality grumpy
```
For binding prompts to personalities and the API surface, see
[docs/personalities.md](docs/personalities.md). The browser editor at
`https://mcpctl.ad.itaz.eu/ui/` covers the same flow with Monaco-based
prompt editing — paste a session token (`mcpctl auth login`) or PAT to log
in.
## Commands
```bash

View File

@@ -10,6 +10,7 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY src/mcpd/package.json src/mcpd/tsconfig.json src/mcpd/
COPY src/db/package.json src/db/tsconfig.json src/db/
COPY src/shared/package.json src/shared/tsconfig.json src/shared/
COPY src/web/package.json src/web/tsconfig.json src/web/
# Install all dependencies
RUN pnpm install --frozen-lockfile
@@ -19,10 +20,13 @@ COPY src/mcpd/src/ src/mcpd/src/
COPY src/db/src/ src/db/src/
COPY src/db/prisma/ src/db/prisma/
COPY src/shared/src/ src/shared/src/
COPY src/web/src/ src/web/src/
COPY src/web/index.html src/web/vite.config.ts src/web/
# Generate Prisma client and build TypeScript
# Generate Prisma client and build TypeScript + web SPA
RUN pnpm -F @mcpctl/db db:generate
RUN pnpm -F @mcpctl/shared build && pnpm -F @mcpctl/db build && pnpm -F @mcpctl/mcpd build
RUN pnpm -F @mcpctl/web build
# Stage 2: Production runtime
FROM node:20-alpine
@@ -50,6 +54,10 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/
COPY --from=builder /app/src/db/dist/ src/db/dist/
COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/
# Copy the web SPA bundle. registerWebUi() looks for it at this path
# (or wherever MCPD_WEB_ROOT is set).
COPY --from=builder /app/src/web/dist/ /usr/share/mcpd/web/
# Copy templates for seeding
COPY templates/ templates/

View File

@@ -195,3 +195,10 @@ mcpctl chat reviewer
`tool_use` / `tool_result` blocks. Use an OpenAI-compatible provider
(LiteLLM, vLLM, OpenAI) for agents that need tool calling until that
translation lands.
## See also
- [personalities.md](./personalities.md) — named overlays of prompts on
top of an agent. Same agent, different prompt bundles, picked per-turn
via `--personality <name>` or `agent.defaultPersonality`.
- [chat.md](./chat.md) — `mcpctl chat` flow and LiteLLM-style flags.

View File

@@ -27,6 +27,8 @@ back to the agent.
--system <text> # replace agent.systemPrompt for this session
--system-file <path> # read --system text from a file
--system-append <text> # append to the agent system block (after project Prompts)
--personality <name> # apply a personality overlay for this turn
# (additive — see docs/personalities.md)
--temperature <n> # 0..2
--top-p <n> # 0..1
--top-k <n> # integer; Anthropic-only, OpenAI ignores

135
docs/personalities.md Normal file
View File

@@ -0,0 +1,135 @@
# Personalities & agent-direct prompts
A **personality** is a named overlay of prompts on top of an existing
agent. Same agent, same LLM, same `systemPrompt` — but a different bundle
of additional context injected at chat time.
The mental model is a VLAN on top of ethernet: ethernet works on its own,
and a VLAN tag adds segmentation without replacing the underlying link.
Without a personality, an agent runs exactly as before. With one selected,
its bound prompts get appended to the system block.
## What goes into the system block
When you call an agent's chat endpoint, mcpd assembles the system block in
this order (top wins by appearing first in the prompt):
```
agent.systemPrompt
+ agent-direct prompts (Prompt.agentId == agent.id, priority desc)
+ project prompts (Prompt.projectId == agent.projectId, priority desc)
+ personality-bound prompts (PersonalityPrompt[chosen], priority desc)
+ systemAppend (per-call override, --system-append)
```
Picking a personality is per-turn. Either:
- pass `--personality <name>` on the CLI (or `personality: "<name>"` in
the chat request body), or
- set `agent.defaultPersonalityId` on the agent — used when no
`--personality` flag is given.
Without either, today's behavior holds: agent + project prompts only.
## Three prompt scopes
A `Prompt` row attaches to **at most one** of `projectId` or `agentId`.
Every prompt fits exactly one of these slots:
| Scope | `projectId` | `agentId` | Where it shows up |
|----------------|-------------|-----------|------------------------------------------------------------|
| Global | `null` | `null` | Any chat (passes the personality's "in scope" check) |
| Project | set | `null` | All agents whose `projectId` matches |
| Agent-direct | `null` | set | Only this agent. Always-on overlay, no toggle |
Personality bindings (`PersonalityPrompt`) further select which of those
prompts get injected when that personality is active. The service layer
enforces a scope rule: a prompt can only be bound to a personality if
it's already in-scope for that agent (agent-direct, agent's project, or
global). Foreign-project prompts are rejected with HTTP 400.
## CLI
```fish
# Make a personality on an existing agent
mcpctl create personality grumpy --agent reviewer --description "Be terse and slightly grumpy"
# Add an agent-direct prompt (always-on for this agent)
mcpctl create prompt always-terse --agent reviewer --content "Always be terse." --priority 8
# Bind the prompt to the personality (HTTP for now — CLI subcommand to come)
PERSONALITY_ID=$(mcpctl get personalities -o json | jq -r '.[] | select(.name=="grumpy") | .id')
PROMPT_ID=$(mcpctl get prompts always-terse -o json | jq -r '.[0].id')
curl -sf -H "Authorization: Bearer $(jq -r .token ~/.mcpctl/credentials)" \
-H "Content-Type: application/json" \
-X POST "https://mcpctl.ad.itaz.eu/api/v1/personalities/${PERSONALITY_ID}/prompts" \
-d "{\"promptId\": \"${PROMPT_ID}\", \"priority\": 9}"
# Use it
mcpctl chat reviewer --personality grumpy
> what's wrong with this code?
# Make it the default for this agent
mcpctl edit agent reviewer
# … in the YAML editor, set:
# defaultPersonality:
# name: grumpy
```
The chat banner shows which personality (if any) is active before the
first prompt:
```
────────────────────────────────────────────────────────────
Agent: reviewer — code review agent
LLM: qwen3-thinking Project: code-quality
Personality: grumpy (--personality)
System prompt:
You are a senior code reviewer. Be terse...
────────────────────────────────────────────────────────────
```
## Web UI
The browser editor at `https://mcpctl.ad.itaz.eu/ui/` covers the same
operations with Monaco for prompt editing:
- **Projects → :name → prompts**: project-scoped prompt CRUD.
- **Agents → :name → Direct prompts**: agent-direct prompt CRUD.
- **Agents → :name → Personalities**: list, create, drill into a
personality to bind/unbind prompts. The "attach prompt" picker only
shows in-scope candidates (agent-direct, same-project, or global).
The web UI uses the same bearer token as the CLI — paste a session
token (`mcpctl auth login` writes one to `~/.mcpctl/credentials`) or
mint a long-lived PAT (`mcpctl create mcptoken …`). The token is kept
in `localStorage`; logout clears it.
## API surface
```
GET /api/v1/personalities
GET /api/v1/personalities?agent=<name>
GET /api/v1/agents/:agentName/personalities
POST /api/v1/agents/:agentName/personalities
GET /api/v1/personalities/:id
PUT /api/v1/personalities/:id
DELETE /api/v1/personalities/:id
GET /api/v1/personalities/:id/prompts
POST /api/v1/personalities/:id/prompts body: { promptId, priority? }
DELETE /api/v1/personalities/:id/prompts/:promptId
GET /api/v1/agents/:agentName/prompts # agent-direct prompts only
POST /api/v1/prompts # body: { name, content, agent: <name>, priority? }
# XOR with project: <name>
```
Chat request body now accepts an optional `personality: "<name>"`. RBAC:
all personality endpoints inherit `agents:view/edit/create/delete`. There
is no separate `personalities` resource in RBAC bindings — managing a
personality is part of managing the parent agent.
## See also
- [agents.md](./agents.md) — the parent resource.
- [chat.md](./chat.md) — `mcpctl chat` flow + LiteLLM-style flags.

84
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
'@fastify/rate-limit':
specifier: ^10.0.0
version: 10.3.0
'@fastify/static':
specifier: ^8.0.0
version: 8.3.0
'@kubernetes/client-node':
specifier: ^1.4.0
version: 1.4.0
@@ -573,6 +576,9 @@ packages:
'@noble/hashes':
optional: true
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
@@ -600,6 +606,12 @@ packages:
'@fastify/rate-limit@10.3.0':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/static@8.3.0':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
'@grpc/grpc-js@1.14.3':
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
engines: {node: '>=12.10.0'}
@@ -776,6 +788,10 @@ packages:
'@types/node':
optional: true
'@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1555,6 +1571,10 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-disposition@1.0.1:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
@@ -1921,6 +1941,10 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -1987,6 +2011,12 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -2172,6 +2202,10 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@4.2.3:
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
engines: {node: 20 || >=22}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -2316,6 +2350,11 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -3517,6 +3556,8 @@ snapshots:
'@exodus/bytes@1.15.0': {}
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/ajv-compiler@4.0.5':
dependencies:
ajv: 8.18.0
@@ -3556,6 +3597,23 @@ snapshots:
fastify-plugin: 5.1.0
toad-cache: 3.7.0
'@fastify/send@4.1.0':
dependencies:
'@lukeed/ms': 2.0.2
escape-html: 1.0.3
fast-decode-uri-component: 1.0.1
http-errors: 2.0.1
mime: 3.0.0
'@fastify/static@8.3.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 0.5.4
fastify-plugin: 5.1.0
fastq: 1.20.1
glob: 11.1.0
'@grpc/grpc-js@1.14.3':
dependencies:
'@grpc/proto-loader': 0.8.0
@@ -3723,6 +3781,8 @@ snapshots:
optionalDependencies:
'@types/node': 25.3.0
'@isaacs/cliui@9.0.0': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4545,6 +4605,10 @@ snapshots:
console-control-strings@1.1.0: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
content-disposition@1.0.1: {}
content-type@1.0.5: {}
@@ -4969,6 +5033,11 @@ snapshots:
flatted@3.3.3: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -5047,6 +5116,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@11.1.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 4.2.3
minimatch: 10.2.2
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 2.0.2
glob@13.0.6:
dependencies:
minimatch: 10.2.2
@@ -5235,6 +5313,10 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@4.2.3:
dependencies:
'@isaacs/cliui': 9.0.0
jiti@2.6.1: {}
jose@6.1.3: {}
@@ -5371,6 +5453,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
mime@3.0.0: {}
mimic-fn@2.1.0: {}
min-indent@1.0.1: {}

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