feat: web prompt editor + agent personalities #58
25
README.md
25
README.md
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
135
docs/personalities.md
Normal 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
84
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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