Files
mcpctl/README.md

1043 lines
38 KiB
Markdown
Raw Normal View History

# mcpctl
**kubectl for MCP servers.** A management system for [Model Context Protocol](https://modelcontextprotocol.io) servers — define, deploy, and connect MCP servers to Claude using familiar kubectl-style commands.
```
mcpctl get servers
NAME TRANSPORT REPLICAS DOCKER IMAGE DESCRIPTION
grafana STDIO 1 grafana/mcp-grafana:latest Grafana MCP server
home-assistant SSE 1 ghcr.io/homeassistant-ai/ha-mcp:latest Home Assistant MCP
docmost SSE 1 10.0.0.194:3012/michal/docmost-mcp:latest Docmost wiki MCP
```
## What is this?
mcpctl manages MCP servers the same way kubectl manages Kubernetes pods. You define servers declaratively in YAML, group them into projects, and connect them to Claude Code or any MCP client through a local proxy.
**The architecture:**
```
Claude Code <--STDIO--> mcplocal (local proxy) <--HTTP--> mcpd (daemon) <--Docker--> MCP servers
```
- **mcpd** — the daemon. Runs on a server, manages MCP server containers (Docker/Podman), stores configuration in PostgreSQL.
- **mcplocal** — local proxy. Runs on your machine, presents a single MCP endpoint to Claude that merges tools from all your servers. Handles namespacing (`grafana/search_dashboards`), plugin execution (gating, content pipelines), and prompt delivery.
- **mcpctl** — the CLI. Talks to mcpd (via mcplocal or directly) to manage everything.
## Quick Start
### 1. Install
```bash
# From RPM repository (Fedora/RHEL)
sudo tee /etc/yum.repos.d/mcpctl.repo <<'EOF'
[mcpctl]
name=mcpctl
baseurl=https://mysources.co.uk/api/packages/michal/rpm
enabled=1
gpgcheck=0
EOF
sudo dnf install mcpctl
# Or build from source
git clone https://mysources.co.uk/michal/mcpctl.git
cd mcpctl
pnpm install
pnpm build
pnpm rpm:build # requires bun and nfpm
```
### 2. Connect to a daemon
```bash
# Login to an mcpd instance
mcpctl login --mcpd-url http://your-server:3000
# Check connectivity
mcpctl status
```
### 3. Create your first secret
Secrets store credentials that servers need — API tokens, passwords, etc.
```bash
mcpctl create secret grafana-creds \
--data GRAFANA_URL=http://grafana.local:3000 \
--data GRAFANA_SERVICE_ACCOUNT_TOKEN=glsa_xxxxxxxxxxxx
```
### 4. Create your first server
Browse available templates, then create a server from one:
```bash
mcpctl get templates # List available server blueprints
mcpctl describe template grafana # See required env vars, health checks, etc.
mcpctl create server my-grafana \
--from-template grafana \
--env-from-secret grafana-creds
```
mcpd pulls the image, starts a container, and keeps it running. Check on it:
```bash
mcpctl get instances # See running containers
mcpctl logs my-grafana # View server logs
mcpctl describe server my-grafana # Full details
```
### 5. Create a project
A project groups servers together and configures how Claude interacts with them.
```bash
mcpctl create project monitoring \
--description "Grafana dashboards and alerting" \
--server my-grafana \
--proxy-model content-pipeline
```
### 6. Connect Claude Code
Generate the `.mcp.json` config for Claude Code:
```bash
mcpctl config claude --project monitoring
```
This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Restart Claude Code and your Grafana tools appear:
```
mcpctl console monitoring # Preview what Claude sees
```
### Connect prime-agent
Prime-agent (Claude's open-source counterpart) talks to the same proxy MCP
gateway over HTTP rather than stdio. Register a project and sync its skills
into `~/.prime/agent/`:
```bash
mcpctl config prime-agent --project monitoring
```
This:
fix(cli): close third review — token collision, migration, ownership Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
1. Provisions the project's bearer credential in `~/.prime/agent/auth.json`
(`mcp:monitoring`, written 0600) — either from `--token <pat>`, an existing
entry that is still active server-side, or a freshly minted project token.
This happens first: if no credential can be provisioned the command stops
here with a non-zero exit and leaves `settings.json` alone, so the project
you are currently on keeps working.
2. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as
`mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }`
fix(cli): close third review — token collision, migration, ownership Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
(merging with any existing servers and preserving all other settings), and
unmounts the previously active mcpctl project so exactly one is live.
Servers you configured by hand are never touched.
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never
fix(cli): close third review — token collision, migration, ownership Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
from `$HOME`) so later syncs resolve the project. Skip with `--skip-marker`.
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
4. Syncs the project's skills into `~/.prime/agent/skills/<name>/` as markdown
skills. The shared tree is ownership-tracked per project: it never deletes
another project's skills or an untracked hand-authored skill.
5. Installs a `/mcpctl` project-switcher extension into
`~/.prime/agent/extensions/` so you can switch mcpctl projects from inside
the prime-agent UI (skip with `--skip-extension`).
Re-sync later with:
```bash
mcpctl skills sync --agent prime-agent --project monitoring
```
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
Skip individual steps as needed:
```bash
mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint
mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills
mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher
fix(cli): close third review — token collision, migration, ownership Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
mcpctl config prime-agent --project monitoring --skip-marker # don't touch .mcpctl-project here
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
```
fix(cli): close third review — token collision, migration, ownership Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
The `/mcpctl` switcher runs with `--skip-extension --skip-marker`, so switching
projects from inside prime-agent never re-scopes whichever repository
prime-agent happened to be started in.
refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs The prime-agent switcher existed only as a 275-line string literal inside prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap that let a wrong ctx.ui.select() option shape ship in the pi extension. It now lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig checking it against the real @earendil-works/pi-coding-agent types, eslint coverage and an embed-freshness test, matching pi and opencode. The extraction was verified byte-identical before any edit, so the behaviour shipped today is exactly what was captured. Linting it then found six problems in code nothing had ever checked: object-truthiness null guards, a nullable string conditional and a missing return type. All behaviour-preserving to fix, but exactly the class of thing that ships silently when nothing is looking. Also: - `config pi` gains --dry-run, the last agent without it. - The SessionStart hook installer now drops untagged duplicates of its own exact command — rows left behind before the marker existed, or by a suite that used to write into a real ~/.claude. Invisible in the UI; they just run the sync twice per session. A hook the user wrote is never touched, even one calling `mcpctl skills sync` with different flags. - docs/claude-integration.md and docs/prime-agent-extension.md, the two integrations that had no page. prime-agent deliberately keeps its per-project MCP entry name rather than the constant `mcpctl` claude and opencode now use: its switcher already unmounts the previous project, so it never accumulates entries, and re-keying auth.json from mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a migration. Documented as its own change rather than folded in here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:20:36 +01:00
See [docs/prime-agent-extension.md](docs/prime-agent-extension.md) for full details.
Preview the change without writing anything:
```bash
mcpctl config prime-agent --project monitoring --dry-run
```
feat(claude): register the MCP server in user scope by default `config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in every checkout you opened — and in a repo that commits `.mcp.json` (this one does) it dirtied the working tree. Every other integration is already global: pi, prime-agent and opencode each have one active project, wired once. Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in every directory and window. That is now the default. `--scope project`, or an explicit `-o/--output`, keeps the old per-directory file for a repo that wants its own pinned project. `--inspect` stays project-scope — it is a debugging server you turn on for one checkout. Details worth knowing: - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside it. Verified against a live Claude Code run with an isolated config dir. - `.claude.json` also holds onboarding state, caches and a per-project map that Claude Code rewrites while running, so this merges into the document and writes through a temp file + rename. - User scope writes no `.mcpctl-project` marker: it scopes nothing to a directory, and a marker beside `.claude.json` would sit in $HOME and scope every repo under it. - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that pinned itself wins), then user scope, then the marker. Scope selection reads Commander's option source rather than process.argv — argv is the test runner's command line when the command is driven in-process, which the suite caught immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00
`config claude` registers in **user scope** by default — one `mcpctl` MCP server
in `~/.claude.json` that applies in every directory and window, so you wire it
once rather than per checkout. Use `--scope project` (or `-o`) for a repo that
wants its own pinned project.
refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs The prime-agent switcher existed only as a 275-line string literal inside prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap that let a wrong ctx.ui.select() option shape ship in the pi extension. It now lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig checking it against the real @earendil-works/pi-coding-agent types, eslint coverage and an embed-freshness test, matching pi and opencode. The extraction was verified byte-identical before any edit, so the behaviour shipped today is exactly what was captured. Linting it then found six problems in code nothing had ever checked: object-truthiness null guards, a nullable string conditional and a missing return type. All behaviour-preserving to fix, but exactly the class of thing that ships silently when nothing is looking. Also: - `config pi` gains --dry-run, the last agent without it. - The SessionStart hook installer now drops untagged duplicates of its own exact command — rows left behind before the marker existed, or by a suite that used to write into a real ~/.claude. Invisible in the UI; they just run the sync twice per session. A hook the user wrote is never touched, even one calling `mcpctl skills sync` with different flags. - docs/claude-integration.md and docs/prime-agent-extension.md, the two integrations that had no page. prime-agent deliberately keeps its per-project MCP entry name rather than the constant `mcpctl` claude and opencode now use: its switcher already unmounts the previous project, so it never accumulates entries, and re-keying auth.json from mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a migration. Documented as its own change rather than folded in here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:20:36 +01:00
Inside Claude Code:
- `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from
`/mcp` (no restart)
- the active project shows in the status line at the bottom of the screen
See [docs/claude-integration.md](docs/claude-integration.md) for full details.
### Connect pi
[pi](https://github.com/earendil-works/pi) does not support MCP, but it supports
**extensions** and **skills**. mcpctl ships a native pi integration that talks
JSON-RPC directly to mcplocal over HTTP — no MCP library, no `~/.claude`
dependency, so it keeps working even if you drop Claude entirely.
```bash
mcpctl config pi --project monitoring
```
This installs the `mcpctl` pi extension into `~/.pi/agent/extensions/mcpctl/`,
registers it (plus a pi skills dir) in `~/.pi/agent/settings.json`, persists the
active project in `~/.mcpctl/pi-state.json`, and runs an initial skills sync into
`~/.pi/agent/skills/`. Then start pi and the project's MCP tools are registered as
native pi tools (`mc_<project>_<tool>`).
Inside pi:
- `/mcpctl` — status, **switch project** (from the GUI), refresh tools, sync skills
- gated projects auto-ungate: call `begin_session` once and the full tool set opens
- re-sync with `mcpctl skills sync --agent pi --project monitoring`
See [docs/pi-extension.md](docs/pi-extension.md) for full details.
feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills sync target, so an mcpctl project can be switched from inside opencode's TUI and the active one is visible at a glance. Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into the host's config. opencode exposes an HTTP API for its own MCP registry (`POST /mcp`), so the project is mounted through the running app: - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a mode-0644 opencode.json users paste into bug reports; - switching projects takes effect on the next turn, with no restart. Inside opencode: /mcpctl filterable project picker; switches live /mcpctl-status active project, mount state, gateway URL /mcpctl-skills re-sync this project's skills plus a `mcpctl:<project>` indicator in the prompt footer, next to the model name and one line above the token counter. Design notes: - the MCP server is registered under a constant name, so tools keep a stable `mcpctl_*` prefix and opencode's per-request tool resolution shows the new project's tools by itself — no "your old tool names are dead" message to the model, unlike the pi extension; - an unchanged mount is never re-registered: mcp.add rebuilds the connection and mcplocal binds a gated project's unlocked state to that connection's mcp-session-id, so re-adding would re-lock a project begin_session had just opened; - the server plugin does not mount during setup — setup runs before the server accepts connections and mcp.add calls back into it, which hangs opencode on a blank screen before the TUI draws; - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token minting, state and skills stay in one place; - no usable credential aborts non-zero with the state file untouched, so a failed switch leaves the previous project working rather than swapping it for a mount that 401s. `skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG aware) with the same shared-tree semantics as pi and prime-agent. The credential plumbing shared with `config prime-agent` is lifted to one place and parameterised by agent rather than copied. The plugin sources are embedded in the CLI (generated, freshness-tested) so an installed binary with no source tree can provision them, and are typechecked against the real @opencode-ai/plugin types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-08 21:03:53 +01:00
### Connect opencode
[opencode](https://opencode.ai) supports MCP natively *and* exposes an API for
its own MCP registry, so mcpctl mounts the project through the running app —
which means you can **switch projects from the TUI without restarting**.
```bash
mcpctl config opencode --project monitoring
```
This mints (or reuses) the project's gateway token into
`~/.mcpctl/opencode-state.json` (0600 — the token never lands in
`opencode.json`), installs a server plugin into
`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into
`~/.config/opencode/mcpctl/`, registers the latter in
`~/.config/opencode/tui.json`, and syncs skills into
`~/.config/opencode/skill/`.
Inside opencode:
- `/mcpctl` (or `<leader>m`) — **switch project** from a filterable picker;
takes effect on the next turn, no restart
feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills sync target, so an mcpctl project can be switched from inside opencode's TUI and the active one is visible at a glance. Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into the host's config. opencode exposes an HTTP API for its own MCP registry (`POST /mcp`), so the project is mounted through the running app: - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a mode-0644 opencode.json users paste into bug reports; - switching projects takes effect on the next turn, with no restart. Inside opencode: /mcpctl filterable project picker; switches live /mcpctl-status active project, mount state, gateway URL /mcpctl-skills re-sync this project's skills plus a `mcpctl:<project>` indicator in the prompt footer, next to the model name and one line above the token counter. Design notes: - the MCP server is registered under a constant name, so tools keep a stable `mcpctl_*` prefix and opencode's per-request tool resolution shows the new project's tools by itself — no "your old tool names are dead" message to the model, unlike the pi extension; - an unchanged mount is never re-registered: mcp.add rebuilds the connection and mcplocal binds a gated project's unlocked state to that connection's mcp-session-id, so re-adding would re-lock a project begin_session had just opened; - the server plugin does not mount during setup — setup runs before the server accepts connections and mcp.add calls back into it, which hangs opencode on a blank screen before the TUI draws; - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token minting, state and skills stay in one place; - no usable credential aborts non-zero with the state file untouched, so a failed switch leaves the previous project working rather than swapping it for a mount that 401s. `skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG aware) with the same shared-tree semantics as pi and prime-agent. The credential plumbing shared with `config prime-agent` is lifted to one place and parameterised by agent rather than copied. The plugin sources are embedded in the CLI (generated, freshness-tested) so an installed binary with no source tree can provision them, and are typechecked against the real @opencode-ai/plugin types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-08 21:03:53 +01:00
- `/mcpctl-status` — active project, mount state, gateway URL
- `/mcpctl-skills` — re-sync this project's skills
- the active project shows as `mcpctl:<project>` in the prompt footer, next to
the model name
Skip individual steps as needed:
```bash
mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint
mcpctl config opencode --project monitoring --skip-skills # don't sync skills
mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone
mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here
mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing
```
Re-sync skills on their own with
`mcpctl skills sync --agent opencode --project monitoring`.
See [docs/opencode-extension.md](docs/opencode-extension.md) for full details.
feat(claude): claude-vllm — run Claude Code against the homelab LLM gateway The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the Anthropic Messages API on /v1/messages — verified with a real completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is the whole integration. `claude-vllm` exists only to stop you pasting four exports each time. It reuses what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then opencode's config, first hit wins — taking the base URL and the credential from the same source so one gateway's URL is never paired with another's key. Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss: - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the background and summarisation calls ask the gateway for a real Haiku it does not serve and every one 404s; - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow, because Claude Code assumes 200k for models it has no table for — and deepseek-v4-* is 393k, so it would auto-compact at half capacity. On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves /api/v1/llms (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves /v1/messages. Routing Claude through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is a mcpd endpoint rather than a wrapper script. Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against deepseek on the homelab, with the unknown-model context warning gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:39:36 +01:00
### Run Claude Code on the homelab LLM
The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic
Messages API, so Claude Code can talk to it directly — no bridge:
```bash
claude-vllm --init # write ~/.config/mcpctl/claude-vllm.jsonc (0600)
claude-vllm # use it
feat(claude): claude-vllm — run Claude Code against the homelab LLM gateway The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the Anthropic Messages API on /v1/messages — verified with a real completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is the whole integration. `claude-vllm` exists only to stop you pasting four exports each time. It reuses what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then opencode's config, first hit wins — taking the base URL and the credential from the same source so one gateway's URL is never paired with another's key. Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss: - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the background and summarisation calls ask the gateway for a real Haiku it does not serve and every one 404s; - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow, because Claude Code assumes 200k for models it has no table for — and deepseek-v4-* is 393k, so it would auto-compact at half capacity. On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves /api/v1/llms (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves /v1/messages. Routing Claude through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is a mcpd endpoint rather than a wrapper script. Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against deepseek on the homelab, with the unknown-model context warning gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:39:36 +01:00
claude-vllm --model deepseek-v4-max
claude-vllm --list # show what it would use
claude-vllm -- -p "summarise this repo" # args after -- go to claude
```
It reads its own config (shaped like `opencode.jsonc`, but a separate file),
falling back to `~/.pi/agent` or `~/.prime/agent` so it works before you have
written one. The key may be a literal in the 0600 file, `${ENV_VAR}`, or an env
var name — it is never stored in the tool.
feat(claude): claude-vllm — run Claude Code against the homelab LLM gateway The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the Anthropic Messages API on /v1/messages — verified with a real completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is the whole integration. `claude-vllm` exists only to stop you pasting four exports each time. It reuses what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then opencode's config, first hit wins — taking the base URL and the credential from the same source so one gateway's URL is never paired with another's key. Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss: - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the background and summarisation calls ask the gateway for a real Haiku it does not serve and every one 404s; - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow, because Claude Code assumes 200k for models it has no table for — and deepseek-v4-* is 393k, so it would auto-compact at half capacity. On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves /api/v1/llms (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves /v1/messages. Routing Claude through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is a mcpd endpoint rather than a wrapper script. Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against deepseek on the homelab, with the unknown-model context warning gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:39:36 +01:00
See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this
through mcpctl would add surface without adding capability.
## Declarative Configuration
Everything can be defined in YAML and applied with `mcpctl apply`:
```yaml
# infrastructure.yaml
secrets:
- name: grafana-creds
data:
GRAFANA_URL: "http://grafana.local:3000"
GRAFANA_SERVICE_ACCOUNT_TOKEN: "glsa_xxxxxxxxxxxx"
servers:
- name: my-grafana
description: "Grafana dashboards and alerting"
fromTemplate: grafana
envFrom:
- secretRef:
name: grafana-creds
projects:
- name: monitoring
description: "Infrastructure monitoring"
proxyModel: content-pipeline
servers:
- my-grafana
```
```bash
mcpctl apply -f infrastructure.yaml
```
Round-trip works too — export, edit, re-apply:
```bash
mcpctl get all --project monitoring -o yaml > state.yaml
# edit state.yaml...
mcpctl apply -f state.yaml
```
## Plugin System (ProxyModel)
ProxyModel is mcpctl's plugin system. Each project is assigned a **plugin** that controls how Claude interacts with its servers.
There are two layers:
- **Plugins** — TypeScript hooks that intercept MCP requests/responses (gating, tool filtering, etc.)
- **Pipelines** — YAML-defined content transformation stages (pagination, summarization, etc.)
### Built-in Plugins
Plugins compose through inheritance. A plugin can `extend` another plugin and inherit all its hooks:
```
gate → gating only (begin_session + prompt delivery)
content-pipeline → content transformation only (pagination, section-split)
default → extends both gate AND content-pipeline (inherits all hooks from both)
```
| Plugin | Gating | Content pipeline | Description |
|--------|:-:|:-:|---|
| **gate** | Yes | No | `begin_session` gate with prompt delivery |
| **content-pipeline** | No | Yes | Content transformation (paginate, section-split) |
| **default** | Yes | Yes | Extends both — gate + content pipeline combined |
The `default` plugin doesn't reimplement anything — it inherits the gating hooks from `gate` and the content hooks from `content-pipeline`. Custom plugins can extend built-in ones the same way.
**Gating** means Claude initially sees only a `begin_session` tool. After calling it with a task description, relevant prompts are delivered and the full tool list is revealed. This keeps Claude's context focused.
```bash
# Gated with content pipeline (default — extends gate + content-pipeline)
mcpctl create project home --server my-ha --proxy-model default
# Ungated, content pipeline only
mcpctl create project tools --server my-grafana --proxy-model content-pipeline
# Gated only, no content transformation
mcpctl create project docs --server my-docs --proxy-model gate
```
### Plugin Hooks
Plugins intercept MCP requests/responses at specific lifecycle points. When a plugin extends another, it inherits all the parent's hooks. If both parent and child define the same hook, the child's version wins.
| 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 resource reads |
| `onPromptsList` | `prompts/list` — can filter prompts |
| `onPromptGet` | `prompts/get` — can intercept prompt reads |
When multiple parents define the same hook, lifecycle hooks (`onSessionCreate`, `onSessionDestroy`) chain sequentially. All other hooks require the child to override — otherwise it's a conflict error.
### Content Pipelines
Content pipelines transform tool results through ordered stages before delivering to Claude:
| Pipeline | Stages | Use case |
|----------|--------|----------|
| **default** | `passthrough``paginate` (8KB pages) | Safe pass-through with pagination for large responses |
| **subindex** | `section-split``summarize-tree` | Splits large content into sections, returns a summary index |
#### How `subindex` Works
1. Upstream returns a large tool result (e.g., 50KB of device states)
2. `section-split` divides content into logical sections (2KB-15KB each)
3. `summarize-tree` generates a compact index with section summaries (~200 tokens each)
4. Client receives the index and can request specific sections via `_section` parameter
### Configuration
Set per-project:
```yaml
kind: project
name: home-automation
proxyModel: default
servers:
- home-assistant
fix(templates): make the shipped templates match reality The templates are what `create server --from-template` builds from and what mcpd seeds on start, so drift there ships broken servers. Nothing ever read these files in a test, and they had rotted badly. - grafana: GRAFANA_URL now defaults to the in-cluster ClusterIP and the description spells out why the public hostname is wrong — reaching a co-located Grafana over its ingress hairpins through the per-host Envoy L7 policy, which drops the caller's identity and returns a bare `Access denied` 403 with a perfectly valid token. That cost a day of looking at the token. - unifi-network: was wrong on every field that mattered. `runtime: python` for an npm package, an env contract (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and no probe. Now UNIFI_TARGETS with the classic-vs-unifi_os distinction and the :8443 egress caveat written down. - docmost, gitea: both carried "health check disabled" comments citing a limitation of the old docker-exec probe, which readiness-via-proxy removed. Both probes verified against the live servers. gitea uses search_repos, not get_me, because get_me needs a `read:user` scope a repo-scoped token lacks. - filesystem: packageName was `@anthropic/filesystem-mcp`, which 404s on npm — the template could never have installed. Points at the real package. - terraform: deleted. `@anthropic/terraform-mcp` 404s too and there is no npm-published replacement to point it at. - node-red: deleted, the service is gone. Two supporting fixes: - The seeder declared no `runtime` field and never wrote the column, so a template asking for the python runner silently seeded as null and got node. - A new templates test reads every shipped file: schema-valid, a runner the orchestrator knows, some way to actually start, unique env names, and a readiness probe (without one an instance can only ever report `live`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
2026-08-09 23:53:22 +01:00
- unifi-network
```
Via CLI:
```bash
mcpctl create project monitoring --server grafana --proxy-model content-pipeline
```
### Custom ProxyModels
Place YAML files in `~/.mcpctl/proxymodels/` to define custom pipelines:
```yaml
kind: ProxyModel
metadata:
name: my-pipeline
spec:
stages:
- type: section-split
config:
minSectionSize: 1000
maxSectionSize: 10000
- type: summarize-tree
config:
maxTokens: 150
maxDepth: 2
appliesTo: [toolResult, prompt]
cacheable: true
```
Inspect available plugins and pipelines:
```bash
mcpctl get proxymodels # List all plugins and pipelines
mcpctl describe proxymodel default # Pipeline details (stages, controller)
mcpctl describe proxymodel gate # Plugin details (hooks, extends)
```
### Custom Stages
Drop `.js` or `.mjs` files in `~/.mcpctl/stages/` to add custom transformation stages. Each file must `export default` an async function matching the `StageHandler` contract:
```javascript
// ~/.mcpctl/stages/redact-keys.js
export default async function(content, ctx) {
// ctx provides: contentType, sourceName, projectName, sessionId,
// originalContent, llm, cache, log, config
const redacted = content.replace(/([A-Z_]+_KEY)=\S+/g, '$1=***');
ctx.log.info(`Redacted ${content.length - redacted.length} chars of secrets`);
return { content: redacted };
}
```
Stages loaded from disk appear as `local` source. Use them in a custom ProxyModel YAML:
```yaml
kind: ProxyModel
metadata:
name: secure-pipeline
spec:
stages:
- type: redact-keys # matches filename without extension
- type: section-split
- type: summarize-tree
```
**Stage contract reference:**
| Field | Type | Description |
|-------|------|-------------|
| `content` | `string` | Input content (from previous stage or raw upstream) |
| `ctx.contentType` | `'toolResult' \| 'prompt' \| 'resource'` | What kind of content is being processed |
| `ctx.sourceName` | `string` | Tool name, prompt name, or resource URI |
| `ctx.originalContent` | `string` | The unmodified content before any stage ran |
| `ctx.llm` | `LLMProvider` | Call `ctx.llm.complete(prompt)` for LLM summarization |
| `ctx.cache` | `CacheProvider` | Call `ctx.cache.getOrCompute(key, fn)` to cache expensive results |
| `ctx.log` | `StageLogger` | `debug()`, `info()`, `warn()`, `error()` |
| `ctx.config` | `Record<string, unknown>` | Config values from the ProxyModel YAML |
**Return value:**
```typescript
{ content: string; sections?: Section[]; metadata?: Record<string, unknown> }
```
If `sections` is returned, the framework stores them and presents a table of contents to the client. The client can drill into individual sections via `_resultId` + `_section` parameters on subsequent tool or prompt calls.
### Section Drill-Down
When a stage (like `section-split`) produces sections, the pipeline automatically:
1. Replaces the full content with a compact table of contents
2. Appends a `_resultId` for subsequent drill-down
3. Stores the full sections in memory (5-minute TTL)
Claude then calls the same tool (or `prompts/get`) again with `_resultId` and `_section` parameters to retrieve a specific section. This works for both tool results and prompt responses.
```
# What Claude sees (tool result):
3 sections (json):
[users] Users (4K chars)
[config] Config (1K chars)
[logs] Logs (8K chars)
_resultId: pm-abc123 — use _resultId and _section parameters to drill into a section.
# Claude drills down:
→ tools/call: grafana/query { _resultId: "pm-abc123", _section: "logs" }
← [full 8K content of the logs section]
```
### Hot-Reload
Stages and ProxyModels reload automatically when files change — no restart needed.
- **Stages** (`~/.mcpctl/stages/*.js`): File watcher with 300ms debounce. Add, edit, or remove stage files and they take effect on the next tool call.
- **ProxyModels** (`~/.mcpctl/proxymodels/*.yaml`): Re-read from disk on every request, so changes are always picked up.
Force a manual reload via the HTTP API:
```bash
curl -X POST http://localhost:3200/proxymodels/reload
# {"loaded": 3}
curl http://localhost:3200/proxymodels/stages
# [{"name":"passthrough","source":"built-in"},{"name":"redact-keys","source":"local"},...]
```
### Built-in Stages Reference
| Stage | Description | Key Config |
|-------|------------|------------|
| `passthrough` | Returns content unchanged | — |
| `paginate` | Splits large content into numbered pages | `pageSize` (default: 8000 chars) |
| `section-split` | Splits content into named sections by structure (headers, JSON keys, code boundaries) | `minSectionSize` (500), `maxSectionSize` (15000) |
| `summarize-tree` | Generates LLM summaries for each section | `maxTokens` (200), `maxDepth` (2) |
`section-split` detects content type automatically:
| Content Type | Split Strategy |
|-------------|---------------|
| JSON array | One section per array element, using `name`/`id`/`label` as section ID |
| JSON object | One section per top-level key |
| YAML | One section per top-level key |
| Markdown | One section per `##` header |
| Code | One section per function/class boundary |
| XML | One section per top-level element |
### Pause Queue (Model Studio)
The pause queue lets you intercept pipeline results in real-time — inspect what the pipeline produced, edit it, or drop it before Claude receives the response.
```bash
# Enable pause mode
curl -X PUT http://localhost:3200/pause -d '{"paused":true}'
# View queued items (blocked tool calls waiting for your decision)
curl http://localhost:3200/pause/queue
# Release an item (send transformed content to Claude)
curl -X POST http://localhost:3200/pause/queue/<id>/release
# Edit and release (send your modified content instead)
curl -X POST http://localhost:3200/pause/queue/<id>/edit -d '{"content":"modified content"}'
# Drop an item (send empty response)
curl -X POST http://localhost:3200/pause/queue/<id>/drop
# Release all queued items at once
curl -X POST http://localhost:3200/pause/release-all
# Disable pause mode
curl -X PUT http://localhost:3200/pause -d '{"paused":false}'
```
The pause queue is also available as MCP tools via `mcpctl console --stdin-mcp`, which gives Claude direct access to `pause`, `get_pause_queue`, and `release_paused` tools for self-monitoring.
## LLM Providers
ProxyModel stages that need LLM capabilities (like `summarize-tree`) use configurable providers. Configure in `~/.mcpctl/config.yaml`:
```yaml
llm:
- name: vllm-local
type: openai-compatible
baseUrl: http://localhost:8000/v1
model: Qwen/Qwen3-32B
- name: anthropic
type: anthropic
model: claude-sonnet-4-20250514
# API key from: mcpctl create secret llm-keys --data ANTHROPIC_API_KEY=sk-...
```
Providers support **tiered routing** (`fast` for quick summaries, `heavy` for complex analysis) and **automatic failover** — if one provider is down, the next is tried.
```bash
# Check active providers
mcpctl status # Shows LLM provider status
# View provider details
curl http://localhost:3200/llm/providers
```
## Pipeline Cache
ProxyModel pipelines cache LLM-generated results (summaries, section indexes) to avoid redundant API calls. The cache is persistent across mcplocal restarts.
### Namespace Isolation
Each combination of **LLM provider + model + ProxyModel** gets its own cache namespace:
```
~/.mcpctl/cache/openai--gpt-4o--content-pipeline/
~/.mcpctl/cache/anthropic--claude-sonnet-4-20250514--content-pipeline/
~/.mcpctl/cache/vllm--qwen-72b--subindex/
```
Switching LLM providers or models automatically uses a fresh cache — no stale results from a different model.
### CLI Management
```bash
# View cache statistics (per-namespace breakdown)
mcpctl cache stats
# Clear all cache entries
mcpctl cache clear
# Clear a specific namespace
mcpctl cache clear openai--gpt-4o--content-pipeline
# Clear entries older than 7 days
mcpctl cache clear --older-than 7
```
### Size Limits
The cache enforces a configurable maximum size (default: 256MB). When exceeded, the oldest entries are evicted (LRU). Entries older than 30 days are automatically expired.
Size can be specified as bytes, human-readable units, or a percentage of the filesystem:
```typescript
new FileCache('ns', { maxSize: '512MB' }) // fixed size
new FileCache('ns', { maxSize: '1.5GB' }) // fractional units
new FileCache('ns', { maxSize: '10%' }) // 10% of partition
```
## Resources
| Resource | What it is | Example |
|----------|-----------|---------|
| **server** | MCP server definition | Docker image + transport + env vars |
| **instance** | Running container (immutable) | Auto-created from server replicas |
| **secret** | Key-value credentials | API tokens, passwords |
| **template** | Reusable server blueprint | Community server configs |
| **project** | Workspace grouping servers | "monitoring", "home-automation" |
feat(agents): smoke tests + README + docs (Stage 6, final) Closes the agents feature. Smoke tests (run via `pnpm test:smoke` against a live mcpd at $MCPD_URL, default https://mcpctl.ad.itaz.eu): * tests/smoke/agent.smoke.test.ts — full CRUD round-trip: create secret + Llm + agent with sampling defaults; `get agents` surfaces it; `get agent foo -o yaml | apply -f` round-trips identically; create + list a thread via the HTTP API; agent delete leaves Llm + secret intact (Restrict + SetNull as designed). Self- skips with a warning when /healthz is unreachable. * tests/smoke/agent-chat.smoke.test.ts — gated on MCPCTL_SMOKE_LLM_URL + MCPCTL_SMOKE_LLM_KEY. Provisions secret + Llm + agent against a real upstream, runs `mcpctl chat -m … --no- stream` (asserts a reply lands), then runs the streaming default (asserts text on stdout + `(thread: …)` on stderr). The fast path for verifying the in-cluster qwen3-thinking deployment: MCPCTL_SMOKE_LLM_URL=http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \ MCPCTL_SMOKE_LLM_MODEL=qwen3-thinking \ MCPCTL_SMOKE_LLM_KEY=$(pulumi config get --stack homelab \ secrets:litellmMcpctlGatewayToken) \ pnpm test:smoke Docs: * README.md — new "Agents" section under Resources with the qwen3-thinking quickstart and links to docs/agents.md and docs/chat.md. Adds llm + agent rows to the resources table. * docs/agents.md (new) — full reference: data model, chat-parameter table, HTTP API, RBAC mapping, tool-use loop semantics, yaml round-trip shorthand, the kubernetes-deployment wiring recipe, and a troubleshooting section (namespace collision, llm-in-use, pending-row recovery, Anthropic-tool limitation). * docs/chat.md (new) — user-facing `mcpctl chat` walkthrough: modes, per-call flags, slash-commands, threads, and a troubleshooting section. * CLAUDE.md — adds a "Resource types" cheatsheet with one-line pointers to each, including the new `agent` row that links to the docs. All suites still green: mcpd 759/759, mcplocal 715/715, cli 430/430. Smoke tests typecheck and self-skip when no live mcpd is reachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:08:37 +01:00
| **llm** | Server-managed LLM provider | OpenAI / Anthropic / vLLM endpoint + key |
| **agent** | LLM persona pinned to one Llm | "I review security; ask after each major change" |
| **prompt** | Curated content for Claude | Instructions, docs, guides |
| **promptrequest** | Pending prompt proposal | LLM-submitted, needs approval |
| **rbac** | Access control bindings | Who can do what |
| **serverattachment** | Server-to-project link | Virtual resource for `apply` |
feat(agents): smoke tests + README + docs (Stage 6, final) Closes the agents feature. Smoke tests (run via `pnpm test:smoke` against a live mcpd at $MCPD_URL, default https://mcpctl.ad.itaz.eu): * tests/smoke/agent.smoke.test.ts — full CRUD round-trip: create secret + Llm + agent with sampling defaults; `get agents` surfaces it; `get agent foo -o yaml | apply -f` round-trips identically; create + list a thread via the HTTP API; agent delete leaves Llm + secret intact (Restrict + SetNull as designed). Self- skips with a warning when /healthz is unreachable. * tests/smoke/agent-chat.smoke.test.ts — gated on MCPCTL_SMOKE_LLM_URL + MCPCTL_SMOKE_LLM_KEY. Provisions secret + Llm + agent against a real upstream, runs `mcpctl chat -m … --no- stream` (asserts a reply lands), then runs the streaming default (asserts text on stdout + `(thread: …)` on stderr). The fast path for verifying the in-cluster qwen3-thinking deployment: MCPCTL_SMOKE_LLM_URL=http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \ MCPCTL_SMOKE_LLM_MODEL=qwen3-thinking \ MCPCTL_SMOKE_LLM_KEY=$(pulumi config get --stack homelab \ secrets:litellmMcpctlGatewayToken) \ pnpm test:smoke Docs: * README.md — new "Agents" section under Resources with the qwen3-thinking quickstart and links to docs/agents.md and docs/chat.md. Adds llm + agent rows to the resources table. * docs/agents.md (new) — full reference: data model, chat-parameter table, HTTP API, RBAC mapping, tool-use loop semantics, yaml round-trip shorthand, the kubernetes-deployment wiring recipe, and a troubleshooting section (namespace collision, llm-in-use, pending-row recovery, Anthropic-tool limitation). * docs/chat.md (new) — user-facing `mcpctl chat` walkthrough: modes, per-call flags, slash-commands, threads, and a troubleshooting section. * CLAUDE.md — adds a "Resource types" cheatsheet with one-line pointers to each, including the new `agent` row that links to the docs. All suites still green: mcpd 759/759, mcplocal 715/715, cli 430/430. Smoke tests typecheck and self-skip when no live mcpd is reachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:08:37 +01:00
## Agents
An **Agent** is an LLM persona — a pinned `Llm`, a system prompt, an optional
project attach, and LiteLLM-style sampling defaults. Once attached to a
project, the agent inherits the project's prompts (merged into its system
block, sorted by priority) and gets to call the project's MCP servers as
tools during chat.
Every agent is also exposed back to MCP clients as a virtual server named
`agent-<name>` with one tool `chat`. So another Claude session connecting to
the same project sees, e.g., `agent-reviewer/chat` in `tools/list` with the
description "I review security design — ask me after each major change."
That's how agents consult each other.
```bash
# 1) point at an LLM. For your in-cluster qwen3-thinking via LiteLLM:
mcpctl create secret litellm-key --data API_KEY=sk-…
mcpctl create llm qwen3-thinking \
--type openai \
--model qwen3-thinking \
--url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \
--api-key-ref litellm-key/API_KEY
# 2) create an agent, pinned to that Llm and attached to a project
mcpctl create agent reviewer \
--llm qwen3-thinking \
--project mcpctl-dev \
--description "I review security design — ask me after each major change." \
--system-prompt-file ./prompts/reviewer.md \
--default-temperature 0.2 --default-max-tokens 4096
# 3) chat with it (interactive REPL — Ctrl-D to exit)
mcpctl chat reviewer
# Or one-shot
mcpctl chat reviewer -m "Look at PR #42 and tell me what's risky."
# Resume a thread
mcpctl get threads --agent reviewer
mcpctl chat reviewer --thread <id>
```
Full reference: [docs/agents.md](docs/agents.md). User-facing chat guide:
[docs/chat.md](docs/chat.md).
feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6) The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image bundles the build artifact, a smoke test exercises the personality HTTP surface end-to-end, and the user-facing docs spell out the mental model. mcpd: - Add @fastify/static dep. - New routes/web-ui.ts: registers /ui/* against a static bundle. Looks for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the Docker image path), then a dev-tree fallback. Logs and skips cleanly if missing — API-only deploys keep working. - SPA fallback: any /ui/<path> that doesn't match a file falls through to index.html so direct hits to react-router URLs work. - /ui/* falls through to `kind: skip` in mapUrlToPermission, so the static assets are served unauthenticated. Each API call from the SPA still carries the bearer token. Deploy: - Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder stage and copies dist/ to /usr/share/mcpd/web in the runtime image. Smoke (personality.smoke.test.ts): - Live mcpd flow: create secret/llm/agent/personality, attach an agent-direct prompt, verify the binding listing, reject double- attach (409) + foreign-agent prompt (400), set defaultPersonality by name, detach + delete cleanup. Docs: - New docs/personalities.md: VLAN-on-ethernet model, system-block ordering table, three prompt scopes, CLI walkthrough, web UI walkthrough, full API surface, RBAC notes. - agents.md and chat.md cross-link. - README's Agents section gains a Personalities subsection. Test count after Stage 6: mcpd: 801/801 cli: 430/430 web: 7/7 db: 58/62 (4 pre-existing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:48:43 +01:00
### 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.
### Virtual LLMs
A user's local LLM (`vllm-local`, Ollama, …) can publish itself into
mcpd's `Llm` registry so anyone authorized sees it under `mcpctl get llm`
and can chat with it via `mcpctl chat-llm <name>`. Inference is relayed
through the publishing mcplocal's SSE control channel — mcpd never holds
the local URL or API key.
```fish
# In ~/.mcpctl/config.json, opt the provider in with `publish: true`:
# { "name": "vllm-local", "type": "openai", "model": "...", "publish": true }
systemctl --user restart mcplocal
mcpctl get llm
# NAME KIND STATUS TYPE MODEL TIER ID
# qwen3-thinking public active openai qwen3-thinking fast ...
# vllm-local virtual active openai Qwen/Qwen2.5-7B-Instruct-AWQ fast ...
mcpctl chat-llm vllm-local
> hello?
```
Lifecycle: 30 s heartbeats, 90 s heartbeat-stale → inactive, 4 h
inactive → auto-deleted. A reconnecting mcplocal adopts the same row
via a sticky `providerSessionId`. Full design: [docs/virtual-llms.md](docs/virtual-llms.md).
## Commands
```bash
# List resources
mcpctl get servers
mcpctl get instances
mcpctl get projects
mcpctl get prompts --project myproject
# Detailed view
mcpctl describe server grafana
mcpctl describe project monitoring
# Create resources
mcpctl create server <name> [flags]
mcpctl create secret <name> --data KEY=value
mcpctl create project <name> --server <srv> [--proxy-model <plugin>]
mcpctl create prompt <name> --project <proj> --content "..."
# Modify resources
mcpctl edit server grafana # Opens in $EDITOR
mcpctl patch project myproj proxyModel=default
mcpctl apply -f config.yaml # Declarative create/update
# Delete resources
mcpctl delete server grafana
# Logs and debugging
mcpctl logs grafana # Container logs
mcpctl console monitoring # Interactive MCP console
mcpctl console --inspect # Traffic inspector
mcpctl console --audit # Audit event timeline
mcpctl console --stdin-mcp # Claude monitor (MCP tools for Claude)
# Backup (git-based)
mcpctl backup # Status and SSH key
mcpctl backup log # Commit history
mcpctl backup restore list # Available restore points
mcpctl backup restore diff abc1234 # Preview a restore
mcpctl backup restore to abc1234 --force # Restore to a commit
# Project management
mcpctl --project monitoring get servers # Project-scoped listing
mcpctl --project monitoring attach-server grafana
mcpctl --project monitoring detach-server grafana
```
## Templates
Templates are reusable server configurations. Create a server from a template without repeating all the config:
```bash
# Register a template
mcpctl create template home-assistant \
--docker-image "ghcr.io/homeassistant-ai/ha-mcp:latest" \
--transport SSE \
--container-port 8086
# Create a server from it
mcpctl create server my-ha \
--from-template home-assistant \
--env-from-secret ha-secrets
```
feat(servers): persistent volumes + self-hosted web search and docs templates Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl-<server>-<name>` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
### Persistent volumes
Instances are immutable — any server edit recreates them — so by default
anything an MCP server writes to its container filesystem is lost at that point.
A server (or template) can declare volumes that outlive its instances:
```bash
mcpctl create server docs \
--from-template docs-mcp \
--volume data:/data:20:longhorn # NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]
```
```yaml
# ...or declaratively
volumes:
- name: data
mountPath: /data
sizeGb: 20
storageClass: longhorn
```
The backing store is named after the **server**, not the instance —
`mcpctl-<server>-<name>` — which is what lets the data survive. On Kubernetes
that is a PVC created in the servers namespace on first start and left in place
afterwards; on Docker it is a named volume. Neither is deleted when an instance
or the server goes away, so reclaiming the space is a deliberate
`kubectl delete pvc` / `docker volume rm`.
Notes:
- Claims are `ReadWriteOnce`, so a server with volumes is limited to one
replica. Asking for more is rejected at validation rather than leaving the
extra replicas unschedulable.
- A fresh claim mounts `root:root`, so mcpctl sets the pod's `fsGroup` to
**1000** — the conventional non-root uid in node/python images. Override with
`fsGroup` on the volume if an image uses a different one; containers running
as root are unaffected either way.
feat(servers): persistent volumes + self-hosted web search and docs templates Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl-<server>-<name>` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
- `storageClass` defaults to `MCPD_VOLUME_STORAGE_CLASS`, and is omitted
entirely when neither is set. **Set it explicitly on any cluster with more
than one default StorageClass**, where an omitted class binds
nondeterministically.
- Growing a volume is an explicit `kubectl edit pvc` (the class must allow
expansion). mcpctl never resizes an existing claim, because most PVC fields
are immutable after binding.
- Backups capture the volume *declaration*, not its contents.
### Web search and docs lookup
The `duckduckgo`, `searxng` and `docs-mcp` templates give an agent web search and
version-pinned library documentation. All are self-hosted and none needs an API key.
`duckduckgo` needs no backing service at all:
```bash
mcpctl create server websearch --from-template duckduckgo
```
See [docs/web-search.md](docs/web-search.md) for the SearXNG engine setup (including
the `json` format setting, without which every search silently returns nothing) and
the `docs-mcp` index-persistence caveat.
## Gated Sessions
Projects using the `default` or `gate` plugin are **gated**. When Claude connects to a gated project:
1. Claude sees only a `begin_session` tool initially
2. Claude calls `begin_session` with a description of its task
3. mcplocal matches relevant prompts and delivers them
4. The full tool list is revealed
This keeps Claude's context focused — instead of dumping 100+ tools and pages of docs upfront, only the relevant ones are delivered based on the task at hand.
```bash
# Gated (default)
mcpctl create project monitoring --server grafana --proxy-model default
# Ungated (direct tool access)
mcpctl create project tools --server grafana --proxy-model content-pipeline
```
## Prompts
Prompts are curated content delivered to Claude through the MCP protocol. They can be plain text or linked to external MCP resources (like wiki pages).
```bash
# Create a text prompt
mcpctl create prompt deployment-guide \
--project monitoring \
--content-file docs/deployment.md \
--priority 7
# Create a linked prompt (content fetched live from an MCP resource)
mcpctl create prompt wiki-page \
--project monitoring \
--link "monitoring/docmost:docmost://pages/abc123" \
--priority 5
```
Claude can also **propose** prompts during a session. These appear as prompt requests that you can review and approve:
```bash
mcpctl get promptrequests
mcpctl approve promptrequest proposed-guide
```
## Interactive Console
The console lets you see exactly what Claude sees — tools, resources, prompts — and call tools interactively:
```bash
mcpctl console monitoring
```
The traffic inspector watches MCP traffic from other clients in real-time:
```bash
mcpctl console --inspect
```
### Claude Monitor (stdin-mcp)
Connect Claude itself as a monitor via the inspect MCP server:
```bash
mcpctl console --stdin-mcp
```
This exposes MCP tools that let Claude observe and control traffic:
| Tool | Description |
|------|------------|
| `list_models` | List configured LLM providers and their status |
| `list_stages` | List all available pipeline stages (built-in + custom) |
| `switch_model` | Change the active LLM provider for pipeline stages |
| `get_model_info` | Get details about a specific LLM provider |
| `reload_stages` | Force reload custom stages from disk |
| `pause` | Toggle pause mode (intercept pipeline results) |
| `get_pause_queue` | List items held in the pause queue |
| `release_paused` | Release, edit, or drop a paused item |
## Architecture
```
┌──────────────┐ ┌─────────────────────────────────────────┐
│ Claude Code │ STDIO │ mcplocal (proxy) │
│ │◄─────────►│ │
│ (or any MCP │ │ Namespace-merging MCP proxy │
│ client) │ │ Gated sessions + prompt delivery │
│ │ │ Per-project endpoints │
└──────────────┘ │ Traffic inspection │
└──────────────┬──────────────────────────┘
│ HTTP (REST + MCP proxy)
┌──────────────┴──────────────────────────┐
│ mcpd (daemon) │
│ │
│ REST API (/api/v1/*) │
│ MCP proxy (routes tool calls) │
│ PostgreSQL (Prisma ORM) │
│ Docker/Podman container management │
│ Health probes (STDIO, SSE, HTTP) │
│ RBAC enforcement │
│ │
│ ┌───────────────────────────────────┐ │
│ │ MCP Server Containers │ │
│ │ │ │
│ │ grafana/ home-assistant/ ... │ │
│ │ (managed + proxied by mcpd) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
Clients never connect to MCP server containers directly — all tool calls go through mcplocal → mcpd, which proxies them to the right container via STDIO/SSE/HTTP. This keeps containers unexposed and lets mcpd enforce RBAC and health checks.
**Tool namespacing**: When Claude connects to a project with servers `grafana` and `slack`, it sees tools like `grafana/search_dashboards` and `slack/send_message`. mcplocal routes each call through mcpd to the correct upstream server.
## Project Structure
```
mcpctl/
├── src/
│ ├── cli/ # mcpctl command-line interface (Commander.js)
│ ├── mcpd/ # Daemon server (Fastify 5, REST API)
│ ├── mcplocal/ # Local MCP proxy (namespace merging, gating)
│ ├── db/ # Database schema (Prisma) and migrations
│ └── shared/ # Shared types and utilities
├── deploy/ # Docker Compose for local development
├── stack/ # Production deployment (Portainer)
├── scripts/ # Build, release, and deploy scripts
├── examples/ # Example YAML configurations
└── completions/ # Shell completions (fish, bash)
```
## Development
```bash
# Prerequisites: Node.js 20+, pnpm 9+, Docker/Podman
# Install dependencies
pnpm install
# Start local database
pnpm db:up
# Generate Prisma client
cd src/db && npx prisma generate && cd ../..
# Build all packages
pnpm build
# Run tests
pnpm test:run
# Development mode (mcpd with hot-reload)
cd src/mcpd && pnpm dev
```
## License
MIT