release.sh printed `WARNING: Smoke tests failed!` and exited 0. That is how four broken readiness probes shipped unnoticed on 2026-08-10 — the warning scrolled past in the build log and the release reported success. It now exits 1, with `MCPCTL_ALLOW_SMOKE_FAILURE=1` as the escape hatch. The message is explicit that smoke runs LAST, against the installed binary: the package is already published and installed, so the failure reports fleet breakage rather than preventing a bad artifact. Turning the gate on required fixing a latent hang first, or every release would have blocked on it. `security.test.ts > /inspect SSE endpoint …` waited for a response body that by design never ends, so it could only settle via the socket's *inactivity* timeout — and /inspect relays every project's MCP traffic, so during a full smoke run it is never idle. Run alone it passed and looked flaky; run with the suite it failed every time. httpRequest gains `headersOnly`, which resolves on the response headers and hangs up. The assertion only ever needed the status line. Verified: full smoke suite 158/158 (was 157/158 with this test timing out); the gate block lifted verbatim from release.sh exits 1 with a stubbed failing smoke run, and exits 0 reaching subsequent code under MCPCTL_ALLOW_SMOKE_FAILURE=1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
43 KiB
mcpctl — Comprehensive Project Summary
kubectl for Model Context Protocol servers.
mcpctl is a production-grade management system for MCP servers, providing a Kubernetes-inspired declarative interface for deploying, orchestrating, and observing MCP servers that connect to Claude and other LLM clients.
Table of Contents
- System Architecture
- Component Overview
- Resource Model & Design Decisions
- CLI Reference
- API Surface (mcpd)
- Database Schema
- Local Proxy (mcplocal)
- ProxyModel Plugin System
- Gated Sessions
- Content Pipeline & Stages
- LLM Provider Integration
- Caching
- Authentication & RBAC
- Audit Infrastructure & Trust Model
- Container Orchestration
- Deployment & Distribution
- Testing Strategy
- Technology Stack
- Project Structure
- Deferred & Future Work
1. System Architecture
Three-Tier Design
Claude Code / LLM Client
| (STDIO — MCP JSON-RPC protocol)
v
mcplocal (Local Daemon — developer machine)
| (HTTP REST)
v
mcpd (Remote Daemon — server/NAS, e.g. 10.0.0.194)
| (Docker/Podman API)
v
MCP Server Containers (isolated network)
┌────────────┐
┌─────────────────┐ HTTP ┌──────────────┐ │ PostgreSQL │
│ mcpctl CLI │──────────────>│ mcpd │──>│ │
│ (Commander.js) │ │ (Fastify 5) │ └────────────┘
└─────────────────┘ └──────┬───────┘
│ Docker/Podman API
v
┌──────────────┐
│ Containers │
│ (MCP servers)│
└──────────────┘
┌─────────────────┐ STDIO ┌──────────────┐ STDIO/HTTP ┌────────────┐
│ Claude / LLM │────────────>│ mcplocal │───────────────>│ MCP Servers│
│ │ │ (McpRouter) │ │ │
└─────────────────┘ └──────────────┘ └────────────┘
Key Principle
- mcpd owns the database (PostgreSQL) — the only component that talks to the DB
- mcplocal is stateless — config-only, no database, acts as intelligent proxy
- mcpctl stores only credentials —
~/.mcpctl/config.jsonand~/.mcpctl/credentials.json - All MCP servers run inside the mcpd container on the NAS via podman (container-in-container)
2. Component Overview
mcpctl (CLI)
- kubectl-like interface for managing the entire system
- Talks to mcplocal (local daemon) via HTTP REST, or directly to mcpd with
--direct - Distributed as RPM/DEB package via Gitea registry
- Built with Commander.js, Ink/React for TUI, Inquirer for prompts
mcplocal (Local Daemon)
- Runs on developer machine as a systemd user service
- Exposes MCP protocol via STDIO to Claude
- Exposes HTTP REST API for mcpctl management commands
- Core responsibilities:
- Tool namespacing and routing (
server/toolformat) - Gated sessions and prompt delivery
- Content pipeline (transformation stages)
- LLM integration for intelligent prompt selection
- Pipeline result caching
- Audit event collection
- Tool namespacing and routing (
mcpd (Remote Daemon)
- Server-side daemon on NAS/cloud (Fastify 5)
- Manages MCP server containers (Docker/Podman via dockerode)
- PostgreSQL for state, audit logs, access control
- Owns credentials (never exposed to mcplocal)
- REST API for all management operations
- MCP proxy endpoint for direct tool invocation
- Health probe runner for container monitoring
- Git-based backup system
@mcpctl/db (Database Layer)
- Prisma ORM with PostgreSQL
- 22 models, 11 migrations
- Template seeding from YAML files at startup
@mcpctl/shared (Shared Utilities)
- Constants, types, validation schemas (Zod)
- Secret encryption/decryption utilities
- Zero external dependencies beyond Zod
3. Resource Model & Design Decisions
ADR-001: Kubernetes-Style Resource Model
| mcpctl Resource | K8s Analogy | Behavior |
|---|---|---|
| Server | Deployment | Self-contained, complete definition. Contains image, command, transport, env refs, replicas. No external template dependencies at runtime. |
| Instance | Pod | Immutable, ephemeral, auto-managed by reconciliation loop. No create instance or edit instance. Delete triggers re-creation. |
| Secret | Secret | Holds sensitive key-value pairs. Servers reference via env[].valueFrom.secretRef. |
| Project | Namespace | Groups servers, configures ProxyModel and LLM provider. Generates .mcp.json. |
| Prompt | ConfigMap (sort of) | Instruction text delivered to Claude. Global or project-scoped. Priority-ranked. |
| Template | — | Blueprints for server creation. Used at create-time only, not runtime. |
| RbacDefinition | ClusterRoleBinding | Named policies with subjects and roleBindings. |
ADR-002: Profiles Replaced with Secrets
The original McpProfile resource tried to be secrets, configmaps, and project-server links simultaneously. Environment variables declared in profiles were never actually passed to running containers.
Decision: Replace with dedicated Secret resource following Kubernetes conventions:
servers:
- name: ha-mcp
env:
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretRef:
name: ha-credentials
key: HOMEASSISTANT_TOKEN
ADR-003: Self-Contained Servers with Source Tracking
Servers store complete definitions (no runtime template dependencies). Optional source metadata enables registry-based upgrades via 3-way diff (old snapshot vs current server vs new template).
Rationale: Matches kubectl mental model. get server X -o yaml > new.yaml && edit && apply works naturally. Duplication is minimal (~10 lines YAML).
ADR-004: ConfigMaps Deferred
Only Secrets implemented. ConfigMap separation can be added later if needed. Keeps the model simple.
ADR-005: Apply-Compatible YAML Round-Trip
mcpctl get server ha-mcp -o yaml > s.yaml && mcpctl apply -f s.yaml must work:
get -o yaml/jsonstrips internal fields (id, createdAt, updatedAt, version, ownerId)- Output wrapped in resource key:
{ servers: [...] } describe -o yaml/jsonkeeps full raw output (for debugging)
ADR-006: CLI Design Principles
- Everything possible via
apply -fMUST also be possible viacreateCLI flags - Support
-o yamland-o jsonlike kubectl describeshows visually clean sectioned output with tables- Name resolution works everywhere (not just IDs)
- Instances are immutable (like pods) — no create/edit
4. CLI Reference
Global Options
--daemon-url <url> mcplocal daemon URL
--direct bypass mcplocal, connect directly to mcpd
-p, --project <name> Target project
-o, --output <format> table | json | yaml
-v, --version Show version
Resource Operations
| Command | Description |
|---|---|
mcpctl get <resource> [name] |
List resources or fetch by name/ID. Supports glob patterns (graf*). |
mcpctl describe <resource> <name> |
Detailed view with sections and tables. |
mcpctl create <resource> <name> [opts] |
Create resource. Mirrors apply -f capabilities. |
mcpctl edit <resource> <name> |
Open in $EDITOR as YAML, apply on save. |
mcpctl patch <resource> <name> key=val... |
Patch individual fields without editor. |
mcpctl delete <resource> <name> |
Delete resource. |
mcpctl apply -f <file> |
Declarative YAML/JSON application (like kubectl apply). Supports --dry-run. |
Supported Resources
servers, projects, instances, secrets, templates, users, groups, rbac, prompts, promptrequests, serverattachments (virtual), proxymodels (virtual, from mcplocal), all (project export)
Resource Aliases
server/srv → servers project/proj → projects
instance/inst → instances secret/sec → secrets
template/tpl → templates prompt → prompts
user → users group → groups
rbac/rbac-definition → rbac promptrequest/pr → promptrequests
serverattachment/sa → serverattachments proxymodel/pm → proxymodels
Lifecycle & Diagnostics
| Command | Description |
|---|---|
mcpctl status |
Show connectivity, auth status, LLM provider health, available models. |
mcpctl login |
Authenticate with mcpd (first login bootstraps initial user). |
mcpctl logout |
Clear stored credentials. |
mcpctl logs <name> [-t N] [-i index] |
Stream container logs. Resolves server name → running instance. |
mcpctl cache stats |
Show pipeline cache statistics per namespace. |
mcpctl cache clear [ns] [--older-than N] |
Clear pipeline cache. |
mcpctl backup |
Show git backup status, public SSH key. |
mcpctl backup log [-n N] |
Show backup commit history. |
mcpctl backup restore list/diff/to |
Restore to specific backup commit. |
Console & Inspection
| Command | Description |
|---|---|
mcpctl console [project] |
Interactive TUI — request/response timeline, tool inspection. |
mcpctl console --stdin-mcp |
MCP server mode over stdin/stdout (for Claude integration). |
mcpctl console --audit |
Browse audit events from mcpd interactively. |
Configuration
| Command | Description |
|---|---|
mcpctl config view |
Show current configuration. |
mcpctl config set <key> <value> |
Set config value (mcplocalUrl, mcpdUrl, registries, outputFormat, etc.). |
mcpctl config path |
Show config file path. |
mcpctl config setup |
Interactive configuration wizard. |
mcpctl config claude -p <project> |
Generate .mcp.json for Claude Code. |
Create Subcommands
mcpctl create server <name> [--package-name X] [--docker-image X] [--transport STDIO|SSE|STREAMABLE_HTTP]
[--runtime node|python] [--replicas N] [--env KEY=val] [--from-template name:version]
mcpctl create secret <name> [--data key=val ...] [--data-file path.json]
mcpctl create project <name> [-d desc] [--proxy-model default|gate|content-pipeline] [--server name ...]
mcpctl create user <email> [--password pass] [--name name]
mcpctl create group <name> [-d desc] [--member email ...]
mcpctl create rbac <name> [--subject kind:name] [--role-binding role:resource[:name]]
mcpctl create prompt <name> [--content text] [--project name] [--priority 1-10] [--link url]
Apply File Format
secrets:
- name: my-secret
data:
KEY: value
servers:
- name: my-server
transport: STDIO
packageName: "@modelcontextprotocol/server-example"
env:
- name: API_KEY
valueFrom:
secretRef:
name: my-secret
key: KEY
projects:
- name: my-project
proxyModel: default
servers:
- my-server
serverattachments:
- server: my-server
project: my-project
prompts:
- name: my-prompt
project: my-project
content: "Instruction text..."
priority: 5
5. API Surface (mcpd)
All endpoints under /api/v1/ require Bearer token auth except /auth/* and /health*.
Authentication
| Endpoint | Method | Description |
|---|---|---|
/auth/bootstrap |
POST | First-user setup (creates admin + bootstrap RBAC) |
/auth/status |
GET | {hasUsers: boolean} (unauthenticated) |
/auth/login |
POST | Returns token + user info |
/auth/logout |
POST | Invalidate session |
/auth/me |
GET | Current user identity |
/auth/impersonate |
POST | Create session for another user (requires run:impersonate) |
Servers
| Endpoint | Method | Description |
|---|---|---|
/servers |
GET | List all servers |
/servers/:id |
GET | Get server by CUID |
/servers |
POST | Create server (validates name uniqueness, image/package) |
/servers/:id |
PUT | Update server, re-reconciles replicas |
/servers/:id |
DELETE | Delete server + cascade-delete all instances |
Instances
| Endpoint | Method | Description |
|---|---|---|
/instances |
GET | List (optional ?serverId= filter) |
/instances/:id |
GET | Get instance |
/instances/:id |
DELETE | Delete instance, triggers reconciliation |
/instances/:id/inspect |
GET | Docker inspect output (state, port, IP) |
/instances/:id/logs |
GET | Container logs (?tail=N) |
Projects
| Endpoint | Method | Description |
|---|---|---|
/projects |
GET | List (RBAC-filtered) |
/projects/:id |
GET/POST/PUT/DELETE | CRUD by CUID or name |
/projects/:id/mcp-config |
GET | Generate .mcp.json |
/projects/:id/instructions |
GET | Get prompt + attached servers for system message |
/projects/:id/servers |
GET/POST | List/attach servers |
/projects/:id/servers/:name |
DELETE | Detach server |
Prompts & Prompt Requests
| Endpoint | Method | Description |
|---|---|---|
/prompts |
GET/POST | List/create approved prompts |
/prompts/:id |
PUT/DELETE | Update/delete (system prompts reset to default) |
/prompts/:id/regenerate-summary |
POST | Force re-generate summary/chapters |
/promptrequests |
GET/POST | List/create pending requests |
/promptrequests/:id/approve |
POST | Atomic delete request → create prompt |
/projects/:name/prompts/visible |
GET | Approved + session's pending |
/projects/:name/prompt-index |
GET | Compact index for gating |
Secrets, Users, Groups, RBAC
Standard CRUD on /secrets, /users, /groups, /rbac-definitions.
Health & Monitoring
| Endpoint | Method | Description |
|---|---|---|
/health/overview |
GET | System health, instance counts, error rate |
/health/instances/:id |
GET | Instance-specific health, uptime, latency |
/metrics |
GET | Request counts, error counts, last request time |
/healthz |
GET | Liveness probe |
Backup, Restore, Audit
| Endpoint | Method | Description |
|---|---|---|
/backup |
POST | Create encrypted bundle (servers, secrets, projects, users, groups, rbac) |
/restore |
POST | Restore bundle (merge/skip/overwrite strategy) |
/audit/events |
POST/GET | Batch insert from mcplocal / query with filters |
/audit/sessions |
GET | Session aggregates (first/last seen, event counts) |
/git/backup/init |
POST | Initialize git backup with SSH credentials |
/git/backup/status |
GET | Backup sync status |
/git/backup/sync |
POST | Manual trigger sync |
MCP Proxy
| Endpoint | Method | Description |
|---|---|---|
/mcp/proxy |
POST | Forward JSON-RPC to running MCP server instance. Dispatches by transport (STDIO via docker exec, SSE/HTTP via direct HTTP). Maintains persistent STDIO connections. |
6. Database Schema
PostgreSQL via Prisma ORM. 22 models across 11 migrations.
Core Models
User & Auth:
User— email/password (bcrypt), role (USER/ADMIN), optional OAuthSession— Bearer token with 30-day TTLGroup/GroupMember— user groups for RBAC
MCP Infrastructure:
McpServer— transport (STDIO/SSE/STREAMABLE_HTTP), docker image, package name, runtime (node/python), env vars (JSON), health check config, replicas, external URLMcpTemplate— reusable blueprints for server creation (mirrors McpServer fields)McpInstance— running containers, status (STARTING/RUNNING/STOPPING/STOPPED/ERROR), container ID, port, health status, events
Organization:
Project— LLM config (provider, model), proxy model, gated flag, prompt instructions, server overridesProjectServer— junction table linking projects to serversSecret— named secret bundles (data as encrypted JSON), versioned
Content:
Prompt— approved system prompts (global or project-scoped), priority, summary/chapters, optional link targetPromptRequest— pending prompt proposals from LLM sessions
Audit & Backup:
AuditLog— user action trail (action, resource, resourceId, details)AuditEvent— pipeline/gate/tool trace events from mcplocal (sessionId, projectName, eventKind, correlationId, userName)BackupPending— queue for git-based backup syncRbacDefinition— named RBAC policies
7. Local Proxy (mcplocal)
Request Flow
Claude (STDIO JSON-RPC)
↓
StdioProxyServer (reads from stdin)
↓
McpRouter.route(request)
├→ PluginSessionContext (per-session state)
├→ ProxyModelPlugin hooks (intercept/transform)
├→ Upstream lookup (tool name prefix → server)
└→ Response (with optional drill-down sections)
Router Responsibilities
- Manages upstream connections (STDIO child processes, HTTP)
- Maps tools/resources/prompts to servers via name prefix (
servername/toolname) - Maintains prompt index + system prompt cache (TTL-based)
- Dispatches plugin hooks via
getOrCreatePluginContext - Section storage for drill-down navigation
- Audit event collection and batching
- Link resolution (relative → absolute URLs)
Upstream Transports
| Transport | Implementation |
|---|---|
| STDIO | Spawns child process, bidirectional pipe, JSON-RPC over newline-delimited JSON |
| SSE | HTTP GET for event stream, POST for messages |
| Streamable HTTP | HTTP POST with JSON-RPC payloads |
HTTP Endpoints (mcplocal)
| Endpoint | Description |
|---|---|
GET /proxymodels |
List all models (YAML pipelines + TS plugins) |
GET /proxymodels/:name |
Single model details |
GET /proxymodels/stages |
List available stages |
POST /proxymodels/reload |
Force reload stages from disk |
GET /cache/stats |
Per-namespace cache statistics |
DELETE /cache |
Clear all or by age |
DELETE /cache/:namespace |
Clear specific namespace |
POST /mcp |
JSON-RPC request forwarding |
8. ProxyModel Plugin System
A ProxyModel is either a Pipeline (YAML) or a Plugin (TypeScript).
Plugin Interface
| Hook | When it fires |
|---|---|
onSessionCreate |
New MCP session established |
onSessionDestroy |
Session ends |
onInitialize |
MCP initialize request — can inject instructions |
onToolsList |
tools/list — can filter/modify tool list |
onToolCallBefore |
Before forwarding a tool call — can intercept |
onToolCallAfter |
After receiving tool result — can transform |
onResourcesList |
resources/list — can filter resources |
onResourceRead |
resources/read — can intercept reads |
onPromptsList |
prompts/list — can filter prompts |
onPromptGet |
prompts/get — can intercept reads |
Built-in Plugins
| Plugin | Extends | Gating | Content Pipeline | Use Case |
|---|---|---|---|---|
gate |
— | Yes | No | Gating + prompt delivery only |
content-pipeline |
— | No | Yes | Content transformation only |
default |
gate + content-pipeline | Yes | Yes | Full pipeline (most common) |
Inheritance: Plugins can extend parents. Conflicting hooks from multiple parents cause load-time errors (except chainable lifecycle hooks which run sequentially).
Pipeline Configuration (YAML)
name: default
spec:
controller: gate
controllerConfig: { byteBudget: 8192 }
stages:
- type: passthrough
- type: paginate
config: { pageSize: 8000 }
appliesTo: [prompt, toolResult]
cacheable: true
Per-Session Context
Each session gets a PluginSessionContext providing:
- Session state (
Map<string, unknown>) - LLM provider, cache provider, structured logger
- Virtual tool/server registration
- Upstream routing and tool discovery
- Content processing and notifications
- Audit event emission
9. Gated Sessions
Problem
When Claude connects to an MCP server, it sees all tools immediately and starts using them. In a managed environment, you want to deliver relevant context (prompts/instructions) before granting tool access.
Solution: Keyword-Driven Prompt Retrieval
- Initialize: Instructions include prompt index + "call
begin_sessionimmediately" - Gated
tools/list: Onlybegin_sessionvisible - Claude calls
begin_sessionwith keywords describing the task - Prompt matching: Keywords matched against prompt summaries/chapters
- Ungating: Matched prompts returned +
tools/list_changednotification sent - Full access: All upstream tools now visible
Prompt Scoring
Formula: priority + (matchCount * priority)
- Priority alone is baseline — ensures global prompts compete for inclusion
- Tag matches multiply priority — relevant prompts score higher
- Priority 10 = always included (bypasses budget)
- 8KB byte budget cap; overflow prompts listed as index-only
Critical Design Lessons
What works:
- One gate tool (
begin_session), zero ambiguity - Instructions say "check its input schema" (not naming specific parameters)
- "immediately" and "required" prevent Claude from exploring first
- Tool names listed as preview in instructions (helps keyword generation)
tools/list_changednotification mandatory after ungating- Auto-ungate fallback if Claude bypasses gate
What fails:
- Naming parameters that don't match the schema
- Complex conditional instructions (Claude prefers simple paths)
- Multiple tools in gated state (Claude skips the gate)
- Gate instructions only in tool description (must be in initialize response)
- Burying the call-to-action after 200 lines of context
Complete Flow
Client mcplocal upstream
│── initialize ────────>│
│<── instructions ──────│ (gate instructions + prompt index + tool preview)
│── tools/list ────────>│
│<── [begin_session] ───│ (ONLY begin_session visible)
│── tools/call ────────>│
│ begin_session │── match prompts ─────────>│
│ {tags:[...]} │<── prompt content ────────│
│<── matched prompts ───│ (full content + encouragement)
│<── notification ──────│ (tools/list_changed)
│── tools/list ────────>│
│<── [108+ tools] ──────│ (ALL tools now visible)
│ │
│ Claude proceeds with full tool access
10. Content Pipeline & Stages
How It Works
Tool results pass through an ordered sequence of stages before reaching Claude:
- Each stage receives previous stage's content
- Returns
{content, sections?, metadata?} - Sections enable drill-down navigation
- Stage errors are caught — pipeline continues with previous content
Built-in Stages
| Stage | Purpose |
|---|---|
passthrough |
Identity transform (testing/baseline) |
paginate |
Split large content into numbered pages (8KB default). LLM-generated page titles (cached). |
section-split |
Split by structure: JSON arrays/objects → elements/keys, YAML → keys, prose → ## headers, code → function/class boundaries. Merges tiny sections, re-splits oversized ones. |
summarize-tree |
Hierarchical LLM-generated section summaries. Groups sections into trees. Cached. |
Section Drill-Down
After pipeline produces sections:
- Full content replaced with compact table of contents +
_resultId - Sections stored in session-scoped store (5-minute TTL)
- Client calls same tool with
_resultId+_sectionto retrieve specific section - Supports hierarchical navigation (sections within sections)
Custom Stages
Drop .js files in ~/.mcpctl/stages/:
export default async function myStage(input, context) {
// context.llm, context.cache, context.log available
return { content: transformedContent, sections: [...] };
}
Hot-reload with 300ms file watch debounce. Built-in stages take precedence.
11. LLM Provider Integration
Supported Providers
| Provider | Type | Tier |
|---|---|---|
| Gemini CLI | Local | Fast |
| Ollama | Local | Fast |
| DeepSeek | API | Fast/Heavy |
| OpenAI | API | Heavy |
| Anthropic | API | Heavy |
| vLLM | Local | Configurable |
| vLLM Managed | Auto-managed local | Configurable |
Tier System
- Fast tier: Quick, cheap models for pipeline stages and keyword extraction
- Heavy tier: Full models for complex prompt selection and summarization
- Legacy active: Single default provider (fallback)
LLM Adapter
Stages use a simple interface:
interface LLMProvider {
complete(prompt: string, options?): Promise<string>;
available(): boolean;
}
Resolution order: named provider → fast tier → heavy tier → active provider.
Multi-Provider Configuration
{
"llm": {
"providers": [
{ "name": "fast-local", "type": "ollama", "model": "llama3", "tier": "fast" },
{ "name": "heavy-api", "type": "openai", "model": "gpt-4", "tier": "heavy" }
]
}
}
12. Caching
Architecture: L1 Memory + L2 Disk
- L1 in-memory: LRU map (default 500 entries) for fast lookups
- L2 disk:
~/.mcpctl/cache/<namespace>/<key>.dat- Namespace:
provider--model--proxymodel(e.g.,openai--gpt-4o--content-pipeline) - Key: 16-char hex SHA256 prefix of content
- Value: raw content (no JSON wrapper)
- Namespace:
Configuration
| Option | Default | Examples |
|---|---|---|
maxSize |
256MB | "1GB", "10%" (of partition), 536870912 (bytes) |
ttlMs |
30 days | Any millisecond value |
maxMemoryEntries |
500 | L1 LRU cap |
dir |
~/.mcpctl/cache |
Custom path |
Management
mcpctl cache stats # Per-namespace breakdown
mcpctl cache clear # Clear everything
mcpctl cache clear openai--gpt-4--default # Clear specific namespace
mcpctl cache clear --older-than 7 # Clear entries older than 7 days
HTTP API
GET /cache/stats # Per-namespace stats
DELETE /cache # Clear all (or ?olderThan=N)
DELETE /cache/:namespace # Clear specific namespace
13. Authentication & RBAC
Auth Flow
mcpctl login→ prompts for email/password- First login (no users in system) → bootstrap: creates admin user + admin group + bootstrap RBAC
- POST
/auth/login→ returns 30-day bearer token - Token stored in
~/.mcpctl/credentials.json - CLI passes token to mcplocal config
- mcplocal attaches
Authorization: Bearer <token>to all mcpd requests - mcpd validates token against Session table
RBAC Model
Subjects: User (by email), Group, ServiceAccount
Roles & Capabilities:
| Role | Grants |
|---|---|
edit |
view, create, delete, edit, expose |
view |
view |
create |
create |
delete |
delete |
run |
run (for operations) |
expose |
expose, view |
Resources: *, servers, instances, secrets, projects, templates, users, groups, rbac, prompts, promptrequests
Binding Types:
- Resource binding:
{role: 'edit', resource: 'servers', name?: 'my-server'}- With
name: user can only access that specific resource - Without
name: user can access all resources of that type
- With
- Operation binding:
{role: 'run', action: 'impersonate'}- Grants permission for named operations (backup, restore, audit-purge, logs, impersonate)
Resolution
- CLI resolves name → CUID client-side before API calls
- RBAC hook resolves CUID → name before checking bindings
- List filtering:
getAllowedScope()computes allowed names,preSerializationhook filters arrays - Wildcard scope: user has unscoped binding → sees all resources
- Named scope: user has only name-scoped bindings → filtered to allowed names
14. Audit Infrastructure & Trust Model
Event Kinds
| Event Kind | Description |
|---|---|
pipeline_execution |
Full pipeline run summary (duration, stage count, sizes) |
stage_execution |
Individual stage detail (duration, input/output size, error) |
gate_decision |
Gate open/close with client intent and matched prompts |
prompt_delivery |
Which prompts were sent, match scores |
tool_call_trace |
Tool call with server + timing + result size |
rbac_decision |
Access control decisions |
session_bind |
Session initialization |
Trust Model
| Source | Verified | Meaning |
|---|---|---|
client |
false | Client LLM claims (begin_session intent, tags) |
mcplocal |
true | Server-side data (prompt matches, pipeline transforms) |
mcpd |
true | mcpd-originated events |
AuditCollector
Fire-and-forget batching: 50 events max, 5-second flush interval. POSTs to mcpd. Non-blocking — audit failures don't affect tool calls.
Correlation & Causality
correlationIdlinks related events (all events from one tool call)parentEventIdenables causal chains (gate_decision → pipeline_execution)userNametracks which user triggered the event- Designed for future graphiti knowledge graph ingestion
Per-Server Targeting
Different servers in a project can have different proxymodel configs via serverOverrides on the project resource. Resolution: server override → project default → null.
Future (Designed, Not Implemented)
- Virtual MCP Audit Server: mcpd-hosted virtual server providing
query_audit_log,get_session_timelinetools. Claude can directly query audit data. - Graphiti Integration: Causal graph with entity types (Session, Tool, Server, ProxyModel, Prompt, Stage) and edges (
triggered_by,transformed_by,verified_by). - Lab Parameter Simulation: Select any pipeline event, retrieve original input, re-run with different proxyModel/LLM/stages, side-by-side diff.
- Audit Level Config: Per-server
auditLevel: 'full' | 'hash-only' | 'disabled'.
15. Container Orchestration
Orchestrator Interface
McpOrchestrator abstracts container management (Docker/Podman today, Kubernetes in the future):
pullImage,createContainer,stopContainer,removeContainerinspectContainer,getContainerLogs,execInContainer,ping
Container Management
- Labels:
mcpctl.managed=truefor filtering - Network:
mcp-servers(configurable viaMCPD_MCP_NETWORK) - Resource limits: 512MB RAM, 0.5 CPU (configurable)
- Internal container IP exposed via inspect
Runtime Spawn Commands
| Runtime | Command |
|---|---|
| Node | npx --prefer-offline -y <packageName> |
| Python | uvx <packageName> |
| Custom | Explicit command field |
Health Probes
Periodic MCP tool-call probes (like K8s livenessProbe):
- Default interval: 15 seconds
- Dispatch by transport: STDIO (docker exec), HTTP (JSON-RPC)
- Failure threshold: 3 consecutive failures → unhealthy
- Updates instance
healthStatusandlastHealthCheck
Reconciliation Loop
Maintains desired replica count:
- If running < desired → start new instances
- If running > desired → stop excess instances
- Detects crashed containers → marks ERROR → triggers re-creation
Persistent STDIO Connections
For STDIO transport, mcpd maintains long-lived exec sessions (PersistentStdioClient) to avoid repeated docker exec overhead. Bidirectional streaming for interactive sessions.
16. Deployment & Distribution
Production Deployment
mcpd runs on 10.0.0.194 (NAS, managed via Portainer), NOT on the dev machine.
# Full deploy (preferred after merging)
bash fulldeploy.sh
fulldeploy.sh runs three steps:
scripts/build-mcpd.sh— build + push Docker image tomysources.co.uk/michal/mcpctl-mcpddeploy.sh— deploy stack to production via Portainer API athttp://10.0.0.194:9000scripts/release.sh— build RPM + publish to Gitea + install locally + smoke tests
Docker Images
| Image | Purpose |
|---|---|
mcpctl-mcpd |
Multi-stage build: Node 20 Alpine, includes git/ssh, Prisma |
mcpctl-node-runner |
Node 20 slim, runs npx -y for npm packages |
mcpctl-python-runner |
Python 3.12 slim, uses uv for Python packages |
All pushed to mysources.co.uk/michal/ registry.
Stack Services (Production)
postgres— PostgreSQL 16 (port 5432)mcpd— Daemon (port 3100)node-runner,python-runner— Base images- Networks:
mcpctl(management),mcp-servers(container communication)
RPM/DEB Distribution
source .env && bash scripts/release.sh
The build refuses to run from a branch that is behind main. Everyone
branches off main, so a stale branch still builds and installs cleanly — it just
ships a binary missing whatever landed on main meanwhile, and rpm -U --force
overwrites the good one with it. That happened on 2026-08-10: a build from a
stale checkout replaced /usr/bin/mcpctl with one that had no statusline
command, months after the status line landed. scripts/check-main-sync.sh
fetches main, compares, and fails before any work happens, listing the commits
you are missing. It gates every path that produces something others consume:
build-rpm.sh, build-deb.sh, build-mcpd.sh (each is also run standalone, so
none can rely on another having checked) and deploy-k8s.sh — where a stale
branch would pin its sha in Pulumi and make it the cluster's source of truth.
deploy-k8s.sh --dry-run skips the check: it builds and cuts over nothing, and
blocking a read-only inspection only teaches people to export the escape hatch
permanently, disabling the gate for real deploys too.
git merge main # the fix
MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build
MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch
Offline it falls back to the last fetched origin/main, then to a local main,
and says which it used; outside a git checkout it skips entirely.
A failing smoke run fails the release. It used to print
WARNING: Smoke tests failed! and exit 0 — which is exactly how four broken
readiness probes shipped unnoticed (see docs/reliability.md): the warning
scrolled past and the release reported success. Note what the gate does and does
not do — smoke runs last, against the installed binary, so the package is
already published and installed by the time it fails. It reports the breakage
rather than preventing it, so investigate the fleet rather than assuming the
artifact is bad. Override with MCPCTL_ALLOW_SMOKE_FAILURE=1.
Installs via nfpm:
/usr/bin/mcpctl— CLI binary (bun compiled)/usr/bin/mcpctl-local— Local proxy binary (bun compiled)/usr/share/fish/vendor_completions.d/mcpctl.fish— Fish completions/usr/share/bash-completion/completions/mcpctl— Bash completions/usr/lib/systemd/user/mcplocal.service— Systemd user service
User install:
dnf config-manager --add-repo https://mysources.co.uk/api/packages/michal/rpm.repo
dnf install mcpctl
Git & PR Workflow
- Gitea at
http://10.0.0.194:3012(internal) /https://mysources.co.uk/michal/mcpctl(public) pr.shin project root creates PRs via Gitea APIghCLI not installed — usepr.shor direct API calls
17. Testing Strategy
Test Tiers
| Tier | Tool | Scope | When |
|---|---|---|---|
| Unit tests | Vitest | Package-level, mocked dependencies | pnpm test:run |
| DB tests | Vitest | Full Prisma + test PostgreSQL | pnpm --filter db exec vitest run (separate) |
| Smoke tests | Vitest | Live mcplocal + mcpd (not mocked) | pnpm test:smoke (post-deploy) |
Convention
- Every new feature MUST include smoke tests
- Smoke tests live in
src/mcplocal/tests/smoke/ - Use
SmokeMcpSessionfromtests/smoke/mcp-client.tsfor MCP protocol interactions - Smoke tests run automatically in the build/deploy pipeline
Critical Rules
- NEVER pipe pnpm test output to
tail,grep,head— pnpm hangs when it detects non-TTY - Always capture full output with
2>&1and read directly - DB tests excluded from workspace-root vitest (need test database)
- Tests integrated into pipeline:
build-rpm.shruns unit tests;release.shruns smoke tests
18. Technology Stack
| Layer | Technology |
|---|---|
| CLI Framework | Commander.js, Ink/React (TUI), Inquirer |
| API Server | Fastify 5, TypeScript strict mode |
| Database | PostgreSQL 16, Prisma ORM v6 |
| Container Runtime | Docker/Podman via dockerode |
| MCP Protocol | @modelcontextprotocol/sdk |
| Validation | Zod schemas everywhere |
| LLM Providers | OpenAI, Anthropic, Google Gemini, Ollama, DeepSeek, Groq, Mistral, OpenRouter, Azure |
| Testing | Vitest, coverage via v8 |
| Build | TypeScript project references, pnpm workspaces |
| Compilation | Bun (binary compilation for RPM) |
| Packaging | nfpm (RPM/DEB), Docker multi-stage |
| CI/CD | fulldeploy.sh → Portainer API + Gitea packages |
| Shell Completions | Fish + Bash (auto-generated via scripts/generate-completions.ts) |
Design Patterns
- Monorepo — pnpm workspaces with shared base TypeScript config
- Layered architecture — Routes → Services → Repositories (Prisma)
- Interface-based repositories — all data access through interfaces for testability
- Dependency injection — services receive dependencies via constructor
- Zod validation — all input validated at API boundary
- Plugin inheritance — composable ProxyModel plugins with conflict detection
- Content-addressed caching — SHA256 hash keys for deduplication
- TTL-based stores — prompt index (60s), system prompts (5min), sections (5min)
- Fire-and-forget audit — non-blocking event collection
- Declarative config — kubectl-style YAML/JSON for all resource management
19. Project Structure
mcpctl/
├── src/
│ ├── cli/ @mcpctl/cli CLI (Commander.js)
│ │ ├── src/commands/ 22 command handlers
│ │ ├── src/registry/ MCP server registry client
│ │ ├── src/formatters/ Output formatting (table/json/yaml)
│ │ └── src/auth/ Credential storage
│ ├── mcpd/ @mcpctl/mcpd Daemon (Fastify 5)
│ │ ├── src/routes/ 18 route handlers
│ │ ├── src/services/ 13 services
│ │ ├── src/repositories/ Data access layer
│ │ ├── src/middleware/ Auth, logging, error handling
│ │ └── src/validation/ Zod schemas, RBAC rules
│ ├── mcplocal/ @mcpctl/mcplocal Local proxy
│ │ ├── src/gate/ Session gating + tag matching
│ │ ├── src/proxymodel/ Plugin system + stages + cache
│ │ ├── src/providers/ 6 LLM providers
│ │ ├── src/upstream/ STDIO + HTTP upstream connections
│ │ ├── src/audit/ Event collection + batching
│ │ └── src/health/ Health monitoring
│ ├── db/ @mcpctl/db Database (Prisma)
│ │ ├── prisma/schema.prisma 22 models
│ │ └── prisma/migrations/ 11 migrations
│ └── shared/ @mcpctl/shared Constants, types, validation
├── deploy/ Dockerfiles + entrypoint
├── stack/ Production docker-compose + env
├── scripts/ Build, release, deploy scripts
├── completions/ Fish + Bash completions
├── templates/ MCP server YAML templates
├── docs/ Architecture + design docs
├── fulldeploy.sh Full build → deploy → release
├── deploy.sh Portainer stack deploy
├── pr.sh Gitea PR creation
├── nfpm.yaml RPM/DEB package metadata
├── vitest.config.ts Root test config
├── vitest.workspace.ts Workspace test config
├── tsconfig.base.json Base TypeScript config (strict)
└── pnpm-workspace.yaml Monorepo workspace definition
20. Deferred & Future Work
Deferred Tasks
| ID | Description | Status |
|---|---|---|
| 88 | Rename proxyMode: filtered → proxy | Deferred |
| 105-109 | Model Studio TUI | Deferred |
| 110 | RBAC for ProxyModels | Deferred |
| 113 | Model Studio docs | Deferred |
Future Architecture
- Virtual MCP Audit Server — Claude-queryable audit tools
- Graphiti Knowledge Graph — causal graph from audit events
- Lab Parameter Simulation — re-run pipelines with different configs
- Kubernetes Orchestrator — beyond Docker/Podman
- ConfigMaps — non-sensitive config separate from Secrets
- Multi-provider failover — automatic LLM provider cascading
Completed Major Features
- Project structure + monorepo setup
- MCP Registry Client (official, glama, smithery — 53 tests)
- Health Probe Runner (STDIO, SSE, Streamable HTTP — 12 tests)
- Container orchestration with reconciliation
- Full RBAC with name-scoped bindings
- Gated sessions with prompt scoring
- ProxyModel plugin system with inheritance
- Content pipeline with 4 built-in stages
- Pipeline cache (L1 memory + L2 disk)
- Audit infrastructure with trust model
- Git-based backup and restore
- Shell completions (Fish + Bash)
- RPM/DEB packaging and distribution
- Smoke test framework
- Console inspector for debugging