@kubernetes/client-node v1 raises ApiException, which carries the HTTP status
on `.code`. Every check in the orchestrator read only `.statusCode` — the
pre-1.0 HttpError field — so `status` came back undefined and each "expected"
404/409 was rethrown instead of handled.
Found in production: the first start of a server with a volume read a
not-yet-existing PVC, got a 404 that should have meant "create it", and failed
the instance instead. The same latent bug sat in removeContainer, where a
missing pod failed the delete rather than being treated as already gone.
httpStatusOf() checks .code, .statusCode and .response.statusCode, and falls
back to parsing the rendered "HTTP-Code: NNN" message for wrappers that keep
none of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
`mcpctl config claude --project X` writes user scope and never rewrites a
checkout's `.mcp.json`. The status line, however, preferred that file
unconditionally — so a legacy project-named entry an older mcpctl left behind
(`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project
for good, and every switch looked like it had done nothing.
Reproduced live: with user scope on `sre`, `mcpctl statusline --directory
~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project
Claude Code also had in `disabledMcpServers` for that directory, so the line
named a server that was not even mounted.
Rank the sources by how deliberate each one is instead: a canonical `mcpctl`
pin, then user scope, then legacy residue, then the marker. A pin is a decision
and still wins; residue is not and no longer does. At every step, skip a server
Claude Code has switched off for that directory.
`config claude` now also warns when the working directory's `.mcp.json`
contradicts the switch, naming the file — the two scopes are merged rather than
chosen between, so nothing else would tell you.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
Closes 167. The five new resources fell through to the generic key-value dump,
against the project rule that describe shows clean sections and tables. More to
the point, two things built earlier had no way to reach a human at all: the
roadmap endpoint had no client, and the Epic reconciliation was only visible as
raw JSON.
`describe epic` puts the delivery mismatch directly under the completeness
percentage it contradicts, because reading either one alone is the bug. Rendered
against a live mcpd:
Completeness: [....................] 0% (0/3)
...
Delivery: MISMATCH between what is claimed and what shipped
Tasks: 1 merged, 0 outstanding, 0 cancelled
Delivered, not claimed:
- wheels (1 merged, 0 outstanding)
No roadmap node owns it:
- engine, feel
plus the criteria checklist, accepted tradeoffs, and the roadmap with per-node
completeness and READY flags.
`describe task` derives the gates a task still owes from its risk and recorded
verdicts ("awaiting security_review, sre_review" vs "all passed"), and shows the
legal next transitions, PR linkage, verdicts and full history — so the answer to
"why won't this move" is on screen rather than requiring a failed attempt.
`describe repo` / `describe gitprovider` show the credential REFERENCE only.
The resolved value exists solely on the session-provision path and must never
reach a describe.
The roadmap, verdict and event fetches are all best-effort: an older mcpd 404s
and the resource still renders, rather than the whole describe failing over an
enrichment.
One bug found by looking at the output rather than the types: the Git section
opened on `!== undefined`, but JSON hands back null, so every task without a PR
printed an empty `Git:` header.
Local: build clean, workspace 2557 passed, lint unchanged at 794 errors.
Verified live against a local mcpd for epic, epictask and task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
The extension failed to load outright on older pi installs:
Failed to load extension ".../mcpctl-pi.ts":
Cannot find module '@earendil-works/pi-ai'
pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.
The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.
Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.
Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
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
Closes 175. Epic.doneCriteria and EpicTask.doneCriteria are both JSON blobs
joined only by a free-form id, and nothing checked that a node's ids existed on
its parent Epic. A typo produced a criterion belonging to nobody.
The reconciliation added in 170 surfaces those as `unowned`, which is honest but
late: the mistake was made at write time and is far cheaper to refuse there. Now
create and update both reject unknown ids, naming every one of them and listing
what the Epic actually has — or saying plainly that the Epic has no criteria yet,
which is the likelier cause when a whole roadmap comes back empty.
Enforced in the service, not the schema: production provisions with
`prisma db push`, so a constraint would never exist where it matters.
A node with no criteria of its own is still fine — not every roadmap node claims
one — and an update that doesn't touch criteria doesn't pay for the check.
This deliberately stops short of the EpicCriterion table with a real FK. That
remains the stronger answer if criteria ever need to be addressed independently,
but it costs a schema change plus migrating everything currently in the JSON
blobs, and the invariant closes the actual failure mode today.
Also files 177: agent chat --project reaches project prompts and MCP tools while
checking only run:agents:<name>. Raised by the agent that built membership,
which correctly refused to invent the policy mid-task. Pre-existing behaviour for
a legacy pin, but membership widens the blast radius considerably — it should be
a deliberate decision with a test.
Local: build clean, workspace 2557 passed (+7), lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
`git add -A` swept .claude/worktrees/ into the previous commit as embedded git
repositories. They are transient checkouts created for parallel agent runs and
have no business in the tree — removed from the index and gitignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges feat/agent-project-membership (task 166/152). An agent can finally join
many projects: membership routes, per-membership role and optional brain
override, chat resolving context from the membership, and CLI attach-agent /
detach-agent / get memberships. The legacy Agent.projectId fallback is kept for
this release as the schema comment promises.
Conflict resolved in main.ts: that branch added its RBAC case to
mapUrlToPermission inline, while this branch had extracted the function to
middleware/url-permission.ts so it could be tested. Kept the extraction and
ported the case into it — joining a team is `edit:agents:<name>`, not
`create:agents`, which is the authority to mint a whole agent.
Also fixes something the extraction makes possible. security.test.ts contained a
hand-copied reproduction of mapUrlToPermission, because main.ts self-invokes
main() and nothing in it was importable. A security test asserting against a
copy stops testing the system the moment the real one changes, and this copy had
already drifted. It now imports the real function; all 18 assertions still pass,
against the code that actually runs.
Local: build clean, workspace 2550 passed, lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
Turning readiness probes on took the fleet from 8/8 healthy to three real
failures in under a minute, and all three were network shape rather than code:
an egress port (UniFi :8443), an ingress hairpin through the Envoy L7 policy
(Grafana 403 `Access denied` with a token that worked from a laptop), and a
Tailscale address a pod can never reach (Node-RED, since retired).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
The AgentProject junction has existed since the data-model commit and nothing
read it. `grep -c agentProject src/mcpd/src/services/chat.service.ts` returned
0; chat resolved an agent's project from the legacy single `Agent.projectId`
scalar. So the schema claimed "an agent is a person who joins zero, one, or
many projects" (docs/agentic-teams.md §4) while the running system still gave
every agent exactly one project. A schema that lies about behaviour is worse
than a missing feature: it reads as done, so nobody builds it.
This makes the junction load-bearing.
Membership is now a real surface (task 152):
GET /api/v1/agents/:name/projects memberships
POST /api/v1/agents/:name/projects/:project join, body {role?, llm?}
DELETE /api/v1/agents/:name/projects/:project leave
GET /api/v1/projects/:name/memberships the roster, with roles
`role` is TEXT (developer|qa|security|sre in v1) and is validated in the
service layer, not the database — production provisions with `prisma db push`,
so a CHECK constraint written into a migration would never run there. Adding a
fifth role stays a one-line change.
RBAC: joining/leaving is `edit:agents:<name>`, not the `create:agents` the
default method→action mapping would have produced. Minting a whole new agent
and adding an existing one to a team are not the same authority; this mirrors
project↔server attach/detach already being `edit:projects`. Reading a roster is
`view:` on the side you asked from.
Chat honours it. `prepareContext` resolves the project from the membership when
the caller names one, and the resolution runs *before* the Llm lookup so a
per-membership `llmId` can replace the agent's pinned brain — and therefore its
whole failover pool — rather than patching the dispatch list afterwards. That
override is why the brain-ownership question (§15 Q1) can still go either way
without a migration: today the answer is per membership.
Naming a project the agent has not joined is an error that names the fix, not a
silent fallback. Answering from a *different* project's tools and prompts than
the one asked for is the failure mode worth being loud about.
The legacy scalar keeps working this release, as the schema comment promises:
`--project` accepts a membership *or* a legacy pin naming that same project,
bare `mcpctl chat <agent>` is unchanged, and `GET /projects/:p/agents` returns
the union of members and legacy-pinned agents so nothing disappears from a
project mid-migration (mcplocal's agents plugin reads that endpoint).
CLI: `mcpctl chat <agent> --project <p>` (previously rejected as "not both"),
plus `attach-agent` / `detach-agent` alongside the existing attach-server pair,
`get agents --project`, and `get memberships` with ROLE and BRAIN columns.
Completions come from the generator, not hand-edits.
Threads stay scoped to the agent, not to (agent, project) — §4 wants the
latter, but that needs a schema change and a story for what happens to those
threads when a membership is revoked. Deliberately not decided here.
Tests: 51 new (43 mcpd, 8 CLI). Full suite 2514 pass. Lint unchanged at
794 errors / 77 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard test caught it, which is its job — the generator derives flags from
the commander program, so any new option makes the committed files stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges the Epic reconciliation (task 170) and closes the gap that made it inert
from the terminal.
`mcpctl create epictask` had no `--criterion` flag while `create epic` did, so a
roadmap built from the CLI had no criteria on its nodes. The reconciliation
attributes a criterion to a node by id, so every criterion would have reported
as `unowned` and both mismatch lists would always have been empty — the feature
would have looked like it worked and detected nothing.
Files two follow-ups the reconciliation surfaced:
- 175: criteria and roadmap nodes are linked only by a free-form id convention
that nothing enforces. EpicTaskService never validates ids against the parent
Epic, ids can repeat across nodes, and a node can carry an id the Epic never
had. The reconciliation reports those as `unowned` rather than guessing an
owner, which is right, but the convention should become an invariant before
more is built on it.
- 176: `get epic -o yaml` now emits computed `completeness`/`reconciliation`,
which are not settable. apply tolerates them, but the round-trip rule says get
output is apply input.
Local: build clean, workspace 2499 passed, lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
An Epic could report 0% while every Task under it was merged. That is not a
cosmetic gap — it is exactly the blind spot the Epic exists to remove
(docs/agentic-teams.md §1b: "we completed all the tasks and got a stick with
wheels instead of a car"). An Epic that cannot see its own delivered work
cannot detect that, because `completeness()` reads only `doneCriteria[].met`
and nothing flips those except a human hitting the criteria endpoint.
So read the same question a second time, from the delivery side, and report
where the two readings disagree — in both directions:
- deliveredButUnclaimed — unmet criteria whose owning roadmap node has
merged work. Somebody delivered and nobody ticked the box.
- claimedButUndelivered — criteria marked met with nothing merged
underneath. The box is ticked and nothing shipped. The dangerous
direction, and the one that is easy to forget to look for.
- unowned — criteria no roadmap node claims at all. They cannot appear in
either list above, so without a bucket of their own they would vanish
inside a clean-looking reconciliation.
Nothing is written. `met` stays exactly as a human or agent set it: deriving
it from task state would assert that finishing the tasks means delivering the
vision, which is the assumption under audit. The mismatch is reported, not
silently resolved.
Attribution is by criterion id, because that is the only link the schema
offers — EpicTask.doneCriteria is documented as "the decomposed subset of the
Epic's doneCriteria this node owns" and carries the same ids, but nothing
enforces it. Rather than guess an owner by name similarity or ordering, the
convention is treated as a claim and its failures are surfaced as `unowned`.
A foreign key from criterion to epictask would end the ambiguity; this reports
the ambiguity instead of hiding it.
Judgement calls, made explicit so they can be argued with:
- `merged` alone counts as delivered. `done` only leaves the developer's
loop and `awaiting_review` still needs a human; `reopened` means a merge
did not hold, so it counts as outstanding.
- `cancelled` is neither delivered nor outstanding. Counting it as owed
would leave a node permanently undelivered because someone abandoned a
task; counting it as delivered is a lie. It is reported on its own.
- a node with no tasks is undelivered, not exempt — a met criterion over an
undecomposed node is still a claim with nothing behind it. The empty task
rollup is what distinguishes it from "built but not merged".
- an epictask's own `status` is another claim, not evidence, so it is
carried for context and never counted as delivery.
Surfaced on GET /api/v1/epics/:id next to the completeness it contradicts —
a mismatch is only useful where the misleading percentage is already on
screen — and standalone at GET /api/v1/epics/:id/reconciliation for callers
that do not want a 200KB Epic body with it. Deliberately kept off the list
route, which would pay a query per roadmap node per Epic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes 134/164. The five agentic-teams resources had RBAC slots but the
non-CRUD verbs had no cases, so they fell through to the method default and
every one of them read as `create:<resource>`. That is wrong in both
directions: it let anyone who may CREATE a task also ACCEPT one — the human gate
that fires a merge — and it refused a reviewer who may drive tasks but not
create them.
Corrected, following the verb the codebase already uses for "use this resource
to do work" (llms/infer, agents/chat, projects/chat):
- POST /tasks/:id/{transition,accept,reject,verdicts} → run:tasks:<name>
- {GET,POST} /gitproviders/:id/{repos,import,probe} → run:gitproviders:<name>.
Discovery calls OUT to the forge with its stored token, so holding
view:gitproviders should let you see that a forge is registered, not use its
credential.
- POST /epics/:id/{status,tradeoffs,criteria} → edit:epics:<name>. These rewrite
the vision record.
- GET /epics/:id/roadmap → view:epics:<name>
- {POST,DELETE} /epictasks/:id/edges → edit:epictasks:<name>
mapUrlToPermission moved from main.ts to middleware/url-permission.ts. main.ts
calls main() at module load, so nothing in it is importable from a test — an
authorization mapping that cannot be tested is an authorization mapping nobody
has checked, which is exactly how these verbs shipped mis-scoped.
15 tests now assert the mapping rather than the mere presence of the strings,
including that a bare create grant does NOT cover the gated verbs, that work
items and the inference queue never resolve to the same resource for any method,
and that the pre-existing mappings (llm infer, agent chat, operations, the
personalities/skills piggybacks) still hold.
Local: build clean, workspace 2478 passed (+15), lint back to the repo's
baseline 794 errors after making the five new match guards explicit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
The tasks/repos command groups added with the CLI work sit at the root of
`mcpctl --help`, but both only function inside a project context. Beyond the
clutter, it teaches the wrong data model: a task reads as a peer of servers and
secrets rather than a leaf of the epic tree.
Convention recorded as the `scope-commands-to-their-context` prompt in the
mcpctl-development project. Note `attach-server`/`detach-server` are already
hidden, so the pattern exists and was simply not applied consistently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`mcpctl get instances` showed all eight servers healthy while the UniFi one
had never once reached its controller. The default probe is `tools/list`,
which MCP servers answer from a static in-process table — no credentials, no
upstream, ~3ms. It cannot fail for any reason the user cares about, so it was
reporting `healthy` for every process that managed to start.
Split the two passes:
healthy — readiness: `tools/call` on `healthCheck.tool`. The upstream
answered, so the server can actually do its job.
live — liveness: `tools/list` only. Process up, upstream unverified.
`live` is now the default for any server without a `healthCheck.tool`. It is
not a warning; it is an admission that nothing is watching that server. Probe
events name which probe ran and which tool ("Readiness check (list_sites)
passed"), so the events log distinguishes the two after the fact.
Also:
- `healthCheck.tool` is optional now, so the timings can be tuned without
inventing a readiness probe.
- `create server --health-check-tool/-args/-interval/-timeout/
-failure-threshold`, per the rule that everything applyable is a create
flag. Merges over a `--from-template` healthCheck rather than replacing it.
- `describe instance` explains a `live` verdict instead of leaving it cryptic.
- create.ts held a raw NUL byte in a string literal, which made grep treat the
whole file as binary and silently skip it. Escaped as `\0`.
Verified against the live fleet: with readiness probes configured, my-grafana
went unhealthy (Grafana API 403) and my-node-red degraded (connect timeout to
a Tailscale address) — both had read healthy for months.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
Three findings from a cross-branch review of the competing opencode
implementations, all of which are fair.
1. `readState` type-guards the parsed JSON now. A bare try/catch does not
cover it: `JSON.parse('null')` succeeds and returns null, so the catch never
fires and the next `state.project` throws a TypeError that takes the plugin
down. Verified the crash before fixing; a test pins the guard in the embedded
copies. Credit to the competing 'opencode-mine' branch, which had this right.
2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the
300-line TUI plugin — the largest file in the addon — was linted by nothing.
It was typechecked, which is why this went unnoticed. Confirmed the rules
actually fire on it rather than the file being silently skipped. The
'abhishek' branch was the only entry that got this right.
3. docs/opencode-extension.md overstated the security argument. "The token would
sit in a 0644 opencode.json" is not a point against a `type: local` stdio
bridge, which needs no token at all because it reads your own credentials.
That reason is a consequence of having picked the HTTP gateway, not a
justification for it. The docs now lead with the real reason — live
re-pointing without a restart — and state the trade honestly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
The audit found that none of the new resources were in BACKUP_KINDS, so
git-backup never captured them and a restore would lose every epic, roadmap
node, repo and forge registration. Closes 135/165.
Backed up: gitprovider, repo, epic, epictask — configuration and the vision
layer. NOT tasks: a work item carries in-flight state (lifecycle position,
verdicts, PR links) and restoring a stale one from git would resurrect work that
has since moved on. That is the same reasoning that keeps McpInstance out of the
backup, and there is a test asserting it stays out.
Two properties the tests pin, because both are the kind of thing that fails
silently:
- **Credentials serialize as a REFERENCE, never a value.** The backup repo is a
plaintext git checkout; emitting a resolved token would export every forge
credential to disk in cleartext. credSecretId/credSecretKey are internal
fields and become `credRef: {name, key}`; restore re-resolves against whatever
the secret backend holds now.
- **Relations serialize by name, not foreign key.** A restore into a different
database has different cuids, so a backed-up projectId would dangle.
Restore ordering follows dependencies: a forge before the repos that reference
it, an epic before its roadmap nodes.
Two deliberate refusals on the restore path: an Epic's `status` is never written
by a restore, so a stale backup cannot silently mark it delivered — that
terminal state is only reachable through setStatus and its rules; and a
GitProvider is not probed on restore, because the forge may legitimately be
unreachable while a backup replays and failing the whole restore for that is
worse than a row that needs a later `probe`.
The pre-existing APPLY_ORDER length guard caught this change, which is exactly
what it is for; updated 9 → 13 and tied it to BACKUP_KINDS.length so the two
cannot drift apart again.
Also marks 153 done — `mcpctl tasks accept/reject` shipped with the CLI work; the
generated graph had it blocked behind the git-provider merge, but the gate itself
does not need it.
Local: build clean, workspace 2463 passed (+6), lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
Found while auditing what this branch is missing: skills are serialized into the
backup repo and enqueued on every mutation, but `importResource` and
`deleteResource` in main.ts have no `skill` case — so a git restore silently
drops every skill. SkillService has had upsertByName/deleteByName the whole
time; the switch arms were simply never added. Pre-existing, not introduced
here.
Also records ten tasks (164-173) for gaps found by auditing the branch against
the codebase rather than against the plan:
- 164 RBAC enforcement tests. Five resources and a namespace split shipped with
no test proving the gating works. Also flags that the non-CRUD verbs
(transition/accept/reject, import/probe) currently fall through to the default
method mapping and are probably mis-scoped.
- 165 Backup coverage for the agentic-teams resources — same class of silent
data loss as the skill bug above, for five more kinds.
- 166 AgentProject is inert: chat still resolves context from the legacy
Agent.projectId scalar, so "an agent joins many projects" — a core idea of the
design — is not actually usable yet.
- 167 describe formatters (currently a generic key-value dump, against the
project rule) including a client for the roadmap endpoint, which has no caller.
- 168 Docs, including the breaking `mcpctl get tasks` rename that exists only in
a commit message today.
- 169 Smoke tests for the forge flow.
- 170 Epic completeness ignores delivered work: an Epic can read 0% while every
task under it is merged, which is precisely the blind spot the Epic exists to
remove.
- 171 `mcpctl chat mcpctl` has never been run end to end — the tool layer is
verified, the LLM-picks-a-tool loop and the mcplocal token hop are not.
- 172 Nothing on this branch is live; everything was verified against a local
mcpd on the test database.
- 173 Web UI + Pulumi kinds (cosmetic).
Local: build clean, workspace 2457 passed, lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
installlocal.sh decided the package was up to date by looking only for
`src/**/*.ts` newer than it. nfpm.yaml also packages stack/claude-vllm, the
shell completions and the systemd unit — none of which are TypeScript — so
editing any of those installed a stale package that reported success. Caught by
claude-vllm's new --init being absent from /usr/bin after a clean install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
claude-vllm read `~/.config/opencode/opencode.jsonc` directly. Same shape is
useful; sharing the actual file is not — a credential rotation in opencode would
silently change what Claude Code authenticates with, and it couples two tools'
configs for no reason.
It now has its own `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc`, shaped like
opencode's (`provider.<name>.options.{baseURL,apiKey}` plus a `models` map), and
takes priority. The pi and prime-agent homes stay as a fallback so the command
works before any config exists; reading opencode's file is dropped.
No key is stored in the tool. `apiKey` may be a literal in the 0600 file,
`${ENV_VAR}`, or a bare env var NAME (the form pi's models.json already uses),
so the secret can live in the environment instead of on disk. `--init` reads it
from stdin when `--api-key` is omitted — keeping it out of shell history and out
of the process table, where an argument is visible to every user via `ps`.
`--list` prints at most a 10-character prefix.
`--init` records the context window for every model it can see, not just the
active one: recording only the default meant `--model something-else` silently
fell back to Claude Code's assumed 200k on a 393k model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
One `mcpctl` entry in ~/.claude.json applying to every directory and window,
instead of a per-directory .mcp.json you had to re-create in each checkout.
`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
Closes two stated project rules this work had been violating.
**`get -o yaml` must round-trip through `apply -f`.** The five new resources
were creatable and listable but absent from apply, so what `get` emitted was not
valid apply input. They are now applied in dependency order — gitproviders →
repos → epics → epictasks → tasks — since each references the previous by name,
and each block is independently fault-tolerant so one bad entry does not abort
the rest of a manifest. `edit` accepts them too.
`state` is deliberately NOT in the task spec. Lifecycle moves go through
`mcpctl tasks advance`, which enforces the transition table and the risk gate; a
manifest that could set state would route straight around both. epictask
`dependsOn` edges are re-applied on every run, which is safe because the server
treats a duplicate edge as a no-op.
**Every feature ships smoke tests.** Adds the vertical: create an epic with
mixed outcome/quality criteria, round-trip it through get -o yaml → apply -f,
chain an epictask, then drive a task through the lifecycle asserting the risk
gate blocks `done` without a passing QA verdict, that an illegal transition is
refused, that history carries every transition and the verdict, and that reject
without a comment fails. Skips when mcpd is unreachable; a failure against a
reachable daemon means it predates the feature.
The round-trip assertion is the interesting one: it feeds `get`'s own output
straight back into `apply` and fails if the two surfaces have drifted, rather
than trusting that they agree.
Local: build clean, workspace 2457 passed, db 128 passed (same 4 pre-existing
Secret failures), lint back to the repo's baseline 794 errors after replacing
destructured discards with an explicit omit() helper.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
Adds claude-vllm (the homelab gateway already speaks the Anthropic Messages API,
so there is no bridge to build), one constant `mcpctl` MCP entry for Claude Code
instead of one per project, a Claude Code status line and /mcpctl switcher, and
the prime-agent switcher extracted to typechecked source.
Also fixes a pre-existing bug where the test suite wrote into the developer's
real ~/.claude — the origin of the untagged duplicate skills-sync hook.
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
A system agent, seeded like the system project and system skills, whose entire
job is running mcpctl for the user:
mcpctl chat mcpctl -m 'Make a prompt in the SRE project saying we run multiple
agents on the same source at once, so always use git worktrees'
## The permission model is the design
Every tool call loops back through mcpd's own HTTP API carrying the CALLING
USER's bearer token. The agent holds no credential of its own and never touches
the service layer directly, so the existing auth hook, RBAC hook, zod
validation, ownership scoping and audit trail all apply verbatim. It is exactly
as privileged as the person talking to it, never more.
Calling services directly would have been simpler and would have meant
re-implementing every one of those checks at a second call site — precisely the
RBAC carve-out this project refuses to make. A 403 is surfaced as a 403, not
retried or routed around; the system prompt tells the agent to report it.
Verified live, not just mocked: the same tool with an admin token created a
project, and with a plain user's token returned "Permission denied (403)".
No token at all refuses before any request is made; a bad token is rejected by
mcpd's own auth, because there is no ambient credential to fall back on.
## Shape
- `Agent.toolset` (TEXT, default 'project') selects the surface. 'mcpctl' means
no project prompts and no MCP tools — managing mcpctl is the whole job, and an
unrelated project's context would be noise it might act on.
- Five generic tools (list/get/create/update/delete + resource) rather than one
per resource. mcpctl is a REST API over named resources, so that is the honest
surface, and this repo's own tool-selection findings say a scoped catalogue
beats a large flat one.
- Names are resolved to ids by listing first, and a miss reports what IS
available, so the model never guesses an id.
- Resource is a closed enum: the model cannot invent an endpoint.
Also exercised end to end against a live mcpd: created the worktrees prompt in
the `sre` project, listed it back, and read its content — the user's original
example, working.
Tests: 17 unit tests covering the token plumbing, the no-token and 403 paths,
resource allowlisting, name resolution, and request shaping.
Local: build clean, workspace 2457 passed (+17), lint unchanged at 794 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
Claude Code had neither of the things opencode, pi and prime-agent all have: a
visible active project, and a way to change it from inside a session. It has no
plugin API that can draw a widget or open a picker, but it does run a command
for its status line and it does load slash commands — which is enough for both.
- `mcpctl statusline` prints the active project (from .mcp.json, falling back
to a .mcpctl-project marker) and is wired into settings.json. It reads the
directory out of the JSON Claude Code pipes in, so it follows /cwd rather
than reporting wherever the binary was launched. Prints nothing when no
project is active: an empty line beats "none" on every unrelated repo.
- `/mcpctl [project]` switches and reminds you to reconnect from /mcp.
allowed-tools is scoped to the four exact mcpctl invocations it needs.
Three things found by running it rather than reasoning about it:
- Claude Code REWRITES settings.json against its own schema and strips
unknown keys from `statusLine` — our `_mcpctl_managed` marker came back
gone, so ownership is now determined by the command string. (Hooks keep
their marker; statusLine does not.) A composed line like
`my-prompt && mcpctl statusline` is deliberately not claimed.
- Every `!`-prefixed block in a slash command is permission-checked against
allowed-tools. Omitting `statusline` failed the whole command before the
model saw anything. A test now asserts every pre-executed command is
covered.
- Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn
that auth may not work; claude-vllm now sets only the former and clears an
inherited API key.
Also fixes a pre-existing test-isolation bug this work would have made worse:
`config claude` wrote into the developer's real ~/.claude when the suite ran,
which is how an untagged duplicate of the skills-sync SessionStart hook got
there. Both the hook installer and the new UI installers now honour
CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first,
isolation second), `config claude` gains --claude-dir for parity with --pi-dir
and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical.
Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost`
switches and the line updates to `mcpctl:docmost` in the same session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
`config claude` named the `.mcp.json` entry after the project, and the file is
merged rather than rewritten — so configuring a second project left the first
one mounted alongside it. Every project you had ever configured stayed
connected, with duplicate tool names and nothing saying which was active.
The entry is now always `mcpctl`, and switching rewrites what sits behind that
name. Claude Code can reconnect an existing MCP server from inside a session, so
a switch lands without restarting the app, and the tool prefix stays stable
across switches. Entries an older CLI wrote are retired on the next run —
recognised by the pairing that makes retiring them safe: our command, named
after the very project it bridges to. A hand-configured server is never touched.
The shaping lives in config/claude-mcp.ts as pure functions so the merge,
migration and active-project detection are unit-tested rather than inferred from
a command's side effects.
Also brings two parity gaps in line with `config opencode` / `config prime-agent`:
--dry-run, and --skip-marker for when the caller must not re-scope the directory
it runs in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
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
Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills
sync target, so mcpctl projects can be switched from opencode's TUI and the
active one is visible in the prompt footer.
Unlike the claude and prime-agent integrations, nothing is written into the
host's config: opencode exposes an HTTP API for its own MCP registry, so the
project is mounted through the running app — the bearer token stays in a 0600
state file, and switching takes effect on the next turn with no restart.
Includes the follow-up pass taken from the sibling opencode branches: a
<leader>m keybind, an explicit disconnect before re-pointing the mount, and a
non-wrapping footer label.
Verified end-to-end against opencode 1.18.15 from an installed RPM build.
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
Three improvements taken from reading the sibling opencode branches
(feat/opencode-extension-abhishek in particular):
- `<leader>m` opens the project picker. Switching is the repeated action and
typing `/mcpctl` every time is friction; the other two commands stay
palette-only.
- A switch disconnects before re-adding. `mcp.add` under the same name does
re-point the tools on its own, but leaves it to opencode whether the previous
client is closed — and an abandoned one keeps its `mcp-session-id` alive on
mcplocal, which is exactly what holds a gated project open. Best-effort, so a
first mount still works.
- The footer label renders `wrapMode="none" truncate`. The home prompt row is
narrow enough that the default wrap broke `mcpctl:homeautomation` across two
lines mid-word; clipping the tail of a long name reads far better.
Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is
now one line, and a disconnect-then-add switch still lands — the model called
`mcpctl_begin_session` and listed the new project's tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
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
Includes the review fixes (dead /mcpctl menu, skills target, tool-name
collisions, typecheck + lint coverage for src/pi-ext), the project-picker
filter, the post-switch tool-context message, the Agent Skills frontmatter
fix, and the smoke-test cleanup.
With prime-agent-extension-status.patch in place the tray renders
ctx.ui.setStatus() next to the model name, which is what a status line should
be. The widget was a workaround for its absence and was never a substitute:
widgetContainerBelow sits in the fullscreen *scroll* list, not the dock, so it
scrolled away with the transcript, and with both set the project name appeared
twice.
The startup retries stay: resetExtensionUI() clears extension statuses just as
it cleared widgets, so the value set during session_start is still wiped before
it can be seen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
Verified with a probe extension rather than by reading the bundle: session_start
fires with hasUI=true, ctx.ui.setWidget exists and the call returns without
throwing — and the widget still never appeared.
Cause is prime-agent wiping it immediately afterwards. resetExtensionUI() ->
clearExtensionWidgets() runs from onBeforeSessionInvalidate and from the
connection-state-snapshot handler, both of which land after session_start, so
the indicator was set and cleared before it could be seen. Nothing re-set it
until a turn, which is why a fresh session with no messages showed nothing.
Re-publishes at 1s/3s/6s after session_start to land past that reset.
setWidget is idempotent, so a redundant retry costs one re-render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
341 of 356 projects on the shared mcpd were smoke-test leftovers, plus 11
never-expiring `smoke-*` mcptokens sitting on the real `mcpctl-development`
and `sre` projects. Enough to make the project picker unusable.
Two causes, both silent:
- `delete project X --force` — `--force` is valid on `create project` but NOT
on `delete`, so every cleanup call exited non-zero and deleted nothing.
project-llm-ref's afterAll looked correct and had never worked, which is why
there were 84 each of smoke-proj-{ok,orphan,none}-*.
- mcptoken.smoke had no afterAll at all; its cleanup was an `it()` at the end
of the file, so it was skipped whenever an earlier assertion failed.
Cleanup now lives in `afterAll` (runs on failure too) and is no longer gated
on the health probe: the project is created through mcpd, which can be up when
the gateway probe is not, and deleting a project that was never created is a
no-op.
Adds `pnpm smoke:clean` for the case afterAll cannot cover — a killed process
(CI timeout, Ctrl-C, OOM). Dry-run by default, `--yes` to apply. Only touches
`smoke-*` names and protects the shared `smoke-data` / `smoke-aws-docs`
fixture, which five suites depend on and which must survive a concurrent run.
Verified end to end: created a throwaway smoke project, confirmed the dry run
lists without deleting, then `--yes` removed it and left all 15 real projects
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB