Commit Graph

256 Commits

Author SHA1 Message Date
Michal
60db1be531 feat(epictask): make the criterion-id convention an invariant
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
2026-08-09 23:36:53 +01:00
Michal
a869f77070 merge: AgentProject membership — the junction is now load-bearing
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
2026-08-09 23:34:18 +01:00
Michal
1a28cc5098 feat(mcpd): make AgentProject real — an agent joins many projects
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>
2026-08-09 22:14:45 +01:00
Michal
f2239852a1 feat(cli): --criterion on create epictask, and file the reconciliation follow-ups
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
2026-08-09 22:09:27 +01:00
Michal
b6f3ff8a26 Merge branch 'feat/epic-reconciliation' into feat/agentic-teams 2026-08-09 22:06:56 +01:00
Michal
00373d2b27 feat(epics): reconcile claimed completeness against delivered work
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>
2026-08-09 22:05:46 +01:00
Michal
1220b974dc fix(rbac): correct the non-CRUD verbs, and make the mapping testable
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
2026-08-09 21:55:31 +01:00
Michal
5f5efd1144 fix(backup): cover the agentic-teams resources — closes the data-loss gap
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
2026-08-09 20:44:20 +01:00
Michal
300c42b404 fix(backup): restore skills on git restore, and record the audited gaps
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
2026-08-09 20:38:23 +01:00
Michal
53507e876a feat(cli): apply -f support for agentic-teams resources, plus vertical smoke tests
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
2026-08-09 19:56:11 +01:00
Michal
77c45a2715 feat(mcpd): mcpctl system agent — operates mcpctl with the caller's permissions
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
2026-08-09 19:13:43 +01:00
Michal
772c25f605 feat(cli): make the agentic-teams stack usable from the terminal
Everything so far was API-only. This adds the CLI surface and proves the whole
vertical works end to end against a real mcpd and a real Gitea.

  mcpctl create gitprovider mysources --url https://mysources.co.uk \
      --secret gitea-token --key TOKEN
  mcpctl repos discover mysources --query mcp --project demo
  mcpctl repos import   mysources --project demo michal/mcpctl
  mcpctl create epic ship-agents --project demo --content "..." \
      --criterion "cli=CLI can drive a task" --criterion "q:feel=reads at a glance"
  mcpctl create epictask foundation --epic ship-agents
  mcpctl create task wire-cli --epictask foundation --title "Wire the CLI"
  mcpctl tasks advance wire-cli in_progress
  mcpctl tasks verdict wire-cli --stage qa_review --commit 3f2ab19 --pass --summary "..."
  mcpctl tasks accept  wire-cli --note "ship it"
  mcpctl tasks history wire-cli

`repos` and `tasks` are their own verb groups rather than more create/edit
flags. Discovery is inherently two-step — look at what is there, then pick — and
accept/reject are the human gate, not field writes.

`get` gained columns for all five resources. The Epic row shows completeness as
"40% (2/5)" and CodeRepo shows SOURCE/FROM, so imported repos say where they
came from at a glance.

BREAKING: `mcpctl get tasks` now lists WORK ITEMS. The inference queue keeps
`inference-task(s)` and gains `itask(s)`; the short `task`/`tasks` aliases move
to work items. This completes the split already made in RBAC (a186d95) — having
the CLI and RBAC disagree about what "tasks" means would be worse than the
rename.

Verified end to end, not just unit tested: mcpd run locally against the test
database, admin bootstrapped, then the full flow driven through the built CLI.
Discovery returned the real repositories from https://mysources.co.uk, import
created the CodeRepo with provenance, re-discovery marked it `imported`, and the
task walked todo → in_progress → building → self_testing → qa_review with a
FAILING QA verdict correctly refusing `done`, then bounced back, passed on a
second commit, and merged through the accept gate. `tasks history` shows both
verdicts and all twelve transitions.

Two bugs the walkthrough caught that unit tests had not: `get repos` and
`get epics` crashed on `padEnd` because Prisma includes hand back `{ name }`
rather than a bare string, and the completions guard test failed because the
resource lists had changed without regenerating.

Local: build clean, workspace 2440 passed, db 128 passed (same 4 pre-existing
Secret failures), lint errors unchanged at the repo's pre-existing 794 (+2
no-console warnings, the same shape every other command registration uses).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 17:00:25 +01:00
Michal
b70300d389 feat(mcpd): CodeRepo + GitProvider — register a forge, discover and import repos
Two resources landing together, because a repo you have to hand-type is a repo
nobody adds.

CodeRepo is a git repository attached to a project. GitProvider is a configured
forge instance you register once — URL and credential in one place — and then
list and import from.

Nothing above the driver layer knows what a Gitea is. src/mcpd/src/services/git/
holds the GitProviderDriver interface, the Gitea implementation and a registry
that resolves by type, mirroring LlmAdapterRegistry.build. Adding GitHub or
GitLab is one file plus one case. The instance URL always comes from the
GitProvider row; there is no hardcoded host anywhere.

Follows the mcpservertemplates convention: a declarative resource applied via
apply -f, with instances keeping denormalized provenance that outlives the
source. CodeRepo stores sourceProvider + sourceFullName alongside the FK, so
"where did this repo come from" stays answerable after the forge is
disconnected — the same reasoning as McpServer.templateName/templateVersion.
The FK is SetNull, so disconnecting a forge never deletes the repos, or the
tasks hanging off them, that came from it.

Discovery uses /repos/search, deliberately NOT /user/repos. The latter needs the
read:user scope, which repo-scoped automation tokens do not carry — the token
in this repo's .env has write:repository and 403s on /user/repos. The driver
also passes Gitea's 403 scope message through verbatim, so an under-scoped
token says which scope it is missing instead of looking like an empty forge.

Import is partial-success by contract: one bad name must not lose the other
nineteen. Everything importable is imported; everything skipped comes back with
a reason, and a re-import is distinguished from a name collision with an
unrelated repo. Imported repos store no credential of their own and inherit the
forge's, so rotating one token covers every repo from that forge.

Credentials never leave as plaintext: reads return the reference only, and
resolveCheckouts() — the single path that produces a token — exists for the
session provisioner and is called by no route. References are verified at
definition time, so a wrong key fails with "secret has no key 'X'" rather than
as a clone that mysteriously cannot authenticate.

Clone URLs must be https/ssh/scp-style and checkout paths must be relative with
no ".." segment: both are inputs to the clone step, and neither should be able
to point it at mcpd's own filesystem or escape the workspace.

Tests: 18 CodeRepo + 22 GitProvider unit tests. Gitea driver additionally
verified live against https://mysources.co.uk (Gitea 1.25.4, 10 repos
discovered, query filter working).

Local: build clean, workspace 2440 passed (+40), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 16:42:40 +01:00
Michal
05abe3522c feat(mcpd): Task lifecycle — CAS transitions, risk-scaled done gate, accept gate
The work-item spine: repository, validation, service and routes for Task, plus
its verdicts and append-only transition log.

Transitions are compare-and-swap. The expected current state is part of the
WHERE clause and the affected-row count is the answer, so two agents racing to
advance the same task cannot both win — a read-then-write would let both
"succeed" and silently lose one. Losing the race is a 409 naming the state the
task is actually in, not a 500, because on a shared board that is a normal
outcome the caller retries. Same idiom as InferenceTaskRepository's claim.

QA / Security / SRE are stages inside the task's own loop, not post-submit
reviewers, so each can bounce straight back to in_progress — that is what
"feedback" means here. A failed build likewise goes backward, not forward.
`merged` allows only `reopened`, so a merged task cannot be quietly edited;
post-merge failure is the one way back in.

The gate on `done` scales with risk instead of running a fixed pipeline:
trivial needs nothing, normal needs a passing QA verdict, high needs QA +
Security + SRE. Forcing a one-line docs fix through security review is how a
process gets routed around, so the cost has to match the risk. A FAILING
verdict does not clear a gate — a recorded failure is evidence the gate was
tried and did not hold. The 400 names which gates are still missing.

The human accept gate is a task-level yes/no on a resolved outcome, not
per-tool approval: accept fires the merge and stores the comment, reject sends
it back to in_progress and REQUIRES a comment, because the comment is the
direction the agent works from.

GET /tasks/:id returns `legalTransitions` so no client has to hardcode the table.

Tests: 25 service tests — full happy path, every stage's bounce-back, illegal
and no-op transitions, the CAS race (including that no event is written when
the race is lost), the complete risk matrix including failing-verdict-does-not-
count, and the accept/reject gate.

Local: build clean, workspace 2400 passed (+25), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 16:28:26 +01:00
Michal
da4752b90f fix(mcplocal): stop the 30s proxy timeout killing agent turns
`mcpctl chat <agent>` failed with

    HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"}

while mcpd was answering /healthz in 32ms. The message was wrong in a way that
cost real debugging time: mcplocal was reaching mcpd fine and giving up after
30s. journalctl shows the signature plainly — statusCode 503 with
responseTime 30003.87 on POST /api/v1/agents/reviewer/chat.

This blocks the agentic-teams epic outright. An agent turn is a multi-turn
tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path
is not a safety net, it is a guaranteed failure for every non-trivial turn.

Three defects, all in the same path:

1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is
   right for CRUD and wrong for chat. Chat, project chat, llm infer and
   inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override
   with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI
   already allowed. mcplocal in the middle was the binding constraint.

2. Timeouts were reported as connection failures. Split UpstreamTimeoutError
   out of ConnectionError and map it to 504 with an accurate message that says
   the daemon IS reachable. ConnectionError still means unreachable and still
   returns 503. Verified nothing else branches on ConnectionError.

3. SSE was buffered. `forward()` reads the whole body through res.text(), so
   even turns that finished in time arrived as one blob and the CLI's live
   token output never appeared. Streaming routes now use forwardStream() and
   pipe the body straight through, preserving content-type and
   x-accel-buffering (dropping the latter lets intermediaries re-buffer and
   reintroduces the stall).

Also closes the escape that produced the sibling `500 code:23` failure: the body
read in forward() was outside the try, so when mcpd had already written SSE
headers the raw DOMException reached Fastify unhandled.

Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat
that outlives the CRUD budget" and "streams SSE through instead of buffering" —
were confirmed to FAIL against the pre-fix behaviour and pass after. Three
existing mcpd-client tests asserted the old taxonomy and were updated to assert
the new one deliberately.

Local: build clean, workspace 2375 passed, lint unchanged at 869.

NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package
rebuild + `systemctl --user restart mcplocal` to take effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:35:16 +01:00
Michal
7f64316d59 feat(mcpd): EpicTask resource — roadmap nodes, edges, and the roadmap view
Roadmap nodes plus the edges that chain them into the dependency graph the
design doc calls the spider web. Edge direction is "from depends on to".

The database cannot enforce graph integrity here — production provisions with
`prisma db push`, so a trigger or CHECK would never exist there, and the schema
test added earlier documents that boundary explicitly. So the service rejects
the three things that would otherwise corrupt the roadmap:

- self-edges,
- edges crossing Epics, since a roadmap is one Epic's graph,
- cycles, which make the graph unschedulable and hang any topological walk.

Cycle detection is a BFS over the prerequisite direction: adding from→to closes
a loop exactly when `to` already depends transitively on `from`. The error names
the offending path, not just "cycle", so a failed apply says which nodes to look
at. The traversal carries a seen-set that also guarantees termination if the
stored graph ALREADY contains a cycle — from a hand-edited database or an
earlier bug — rather than spinning forever. There is a test for that case.

Adding an existing edge is a no-op rather than an error, so applying a manifest
twice is safe.

GET /api/v1/epics/:id/roadmap returns nodes with per-node completeness, both
edge directions, and `ready`: the nodes whose prerequisites are all satisfied,
which is the question anyone actually asks of a roadmap. A cancelled
prerequisite counts as satisfied — it is not coming. Completeness reuses
EpicService.completeness verbatim so a node and its Epic can never disagree
about what "met" means.

Tests: 19 service tests — the full cycle matrix (self, 2-cycle, long transitive,
diamond-is-fine, pre-existing-cycle termination), roadmap edge directions,
readiness including the cancelled-prerequisite case, and CRUD guards.

Local: build clean, workspace 2366 passed (+19), db 128 passed (same 4
pre-existing Secret failures), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:10:39 +01:00
Michal
909506317e feat(mcpd): Epic resource — the vision layer, with an honest-done gate
Full CRUD for Epic: repository, zod validation, service, routes, main.ts
wiring, and revision tracking (ResourceType gains 'epic', which is a plain
string union by design so this needed no migration).

The substance is the done rule, not the CRUD. An Epic exists to stop "every
task completed, still no car", so status is deliberately NOT settable through
PUT — it goes through POST /epics/:id/status, which enforces:

- `delivered` requires every done-criterion met. The 400 names the unmet ids
  (truncated past 5) so the caller knows what is actually missing.
- `delivered` with recorded tradeoffs is refused — if you accepted a gap, the
  status has to say so.
- `delivered_with_tradeoffs` requires at least one recorded tradeoff, otherwise
  it is `delivered` wearing a disguise.
- `delivered` with zero criteria is refused: nothing was claimed, so nothing
  can have been delivered.
- `cancelled` and `open` are always allowed — abandoning needs no ceremony, and
  reopening is how a gap gets fixed.

Completeness counts `quality` criteria alongside `outcome` ones. Grading only
the mechanically checkable half is exactly the failure the Epic exists to
prevent, so both feed the percentage, and it is computed server-side and
attached to every response rather than left for clients to re-derive.

Tradeoffs are append-only and stamped with who and when — the record is the
audit trail for why "done" was honest.

Scope changes patch-bump semver, so creep is versioned as visibly as
under-delivery.

Tests: 26 service tests (the full done-rule matrix, tradeoff append/stamping,
criterion flipping, semver bumping, and that a plain update cannot smuggle a
status through) and 11 route tests (completeness attachment, owner stamping,
400 vs 404 vs 500 behaviour, the status gate over HTTP).

Local: build clean, workspace 2347 passed (+37), lint unchanged at 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 11:05:28 +01:00
Michal
ffcd0f985b feat(orchestrator): ephemeral session pods — restart policy, volumes, init containers
An MCP server container is long-lived and restarts forever; an agent session pod
is the opposite — it does a unit of work and dies. ContainerSpec could not
express that: restartPolicy was hardcoded 'Always' in generatePodSpec, and there
were no volume, workdir, or init-container fields at all.

Adds four optional fields to ContainerSpec (restartPolicy, workdir, volumes,
initContainers) and implements them in both backends. All optional, so every
existing caller is byte-identical — generatePodSpec still emits 'Always' unless
asked otherwise, and omits volumes/initContainers keys entirely when unused.

Kubernetes gets this nearly free: initContainers run to completion in
declaration order before any app container starts, so "clone the repos, then
work them" needs no sequencing logic from us. generateDeploymentSpec
deliberately ignores spec.restartPolicy — k8s rejects anything but 'Always' on a
Deployment pod template — and says so in a comment.

Docker has no init containers, so they are emulated as sequential runs sharing
the same volumes. The important property is the failure path: a non-zero init
exit aborts BEFORE the main container is created, and the error carries the init
container's last 50 log lines. Otherwise a failed git clone would leave an empty
workspace that looks exactly like a successful one, and the agent would start
work against nothing. Named Docker volumes are namespaced by container name to
stand in for emptyDir.

Tests: 10 k8s manifest tests (defaults unchanged, each restart policy, volume
and mount emission, init ordering, name sanitization, security context still
hardened) and 9 Docker tests including the abort-before-main case and log
propagation.

Local: build clean, workspace 2310 passed (+19), lint unchanged at the repo's
pre-existing 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:36:21 +01:00
Michal
fceb3cfb95 feat(db): agentic-teams data model — epics, work items, repos, membership
Adds the foundation entities from docs/agentic-teams.md §5/§7: a versioned
vision layer (Epic -> EpicTask -> Task), git repositories attached to projects,
agents as many-to-many project members, and the ephemeral session that IS an
agent's workspace.

9 tables + 5 enums, purely additive — no drops and no column changes on
existing tables, since every new relation is held on the new side.

Shape notes:

- EpicTask chaining uses an explicit EpicTaskEdge table rather than a
  self-referencing array, so the roadmap graph walks in both directions and
  edges can carry a type later.
- TaskVerdict is commit-pinned and immutable: "QA verified 3f2ab" stays
  reproducible, which is what lets later coverage claims be audited rather than
  believed. TaskEvent is the append-only transition log.
- Task survives losing its repo and its assignee (both SetNull) — a work item
  outlives whoever happened to be working it — but cascades from its EpicTask.
- CodeRepo, not Repository: `repository.repository.ts` next to 24 other
  *.repository.ts data-access files would be actively confusing. CLI resource is
  `repos`, with repo/repository/repositories aliased onto it.
- AgentProject.llmId exists so the brain-ownership question
  (docs/agentic-teams.md §15 Q1) can be settled either way without a migration.
- Agent.projectId is retained and coexists with AgentProject for one release so
  findByProjectId / prepareContext keep working. Note this partially reverses
  20260223120000_remove_project_members — deliberately, and only for agents;
  human membership stays RBAC-driven.

No CHECK constraints anywhere, on purpose: deploy/entrypoint.sh provisions with
`prisma db push`, so hand-written migration SQL never runs in production. The
migration file is kept for local `migrate dev` parity and as a readable record,
and says so in its header. Invariants that cannot be expressed in the schema
(EpicTaskEdge cycle rejection) belong to the service layer; a schema test
documents that boundary rather than pretending the database enforces it.

Tests: 23 schema tests covering defaults, per-project name scoping, cascade vs
SetNull on every FK, all 12 lifecycle states, and the GC-sweep predicate.
clearAllTables extended for all 9 models.

Verified locally against postgres:16-alpine on :5433 — db suite 128 passed
(baseline was 105; the same 4 pre-existing Secret_backendId_fkey failures
remain, unrelated). Workspace 2291 passed, build clean, lint unchanged at the
repo's pre-existing 869.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:30:02 +01:00
Michal
a186d95a85 feat(rbac): split the tasks namespace into tasks + inference-tasks
v5 gave the durable inference queue the RBAC resource `tasks`, on the
reasoning that inference was the only kind of task we had. Agentic teams
introduces work-item tasks (Epic -> epictask -> task), which need that name.

The queue moves to its own `inference-tasks` resource, matching its existing
/api/v1/inference-tasks route prefix, and `task`/`tasks` now mean work items.
Also lands the agentic-teams aliases: epic, epictask/et, repo/repository, session.

The rename is not purely a code change. ResourceBindingSchema runs
normalizeResource at parse time and stores the normalized string, so every
RbacDefinition row written before the split literally contains "tasks" meaning
inference. Shipping the alias change alone would silently stop those grants
authorizing the queue and start them authorizing work items -- a privilege
change in both directions. bootstrap/rbac-tasks-backfill.ts rewrites them once
at startup, alongside migrateAdminRole.

The run-once marker is claimed BEFORE the rewrite, not after. A failed marker
write after a successful rewrite would let the next startup rewrite again,
converting genuine post-split work-item grants into inference grants. Losing
the rewrite is recoverable by re-granting; running it twice silently is not --
so if the marker cannot be persisted we skip the rewrite and warn loudly.

mapUrlToPermission moves in lockstep: /api/v1/inference-tasks maps to
`inference-tasks`, otherwise the split would break queue authorization on the
first request.

Adds generic getBooleanSetting/setSetting helpers to bootstrap/system-settings.ts,
following the existing "global settings live in a Secret" pattern. setSetting
refuses to write when the settings secret has been migrated onto a non-plaintext
backend, where a direct `data` write would read back empty.

Out of scope, tracked separately: the CLI still routes `mcpctl get tasks` to the
inference queue (src/cli/src/commands/shared.ts). Repointing it needs the
work-item resource to exist first, or the command breaks with no replacement.

Tests: 14 unit tests cover alias resolution, the rewrite, wildcard and
operation bindings being left alone, idempotency, and both marker-failure paths.
Full workspace suite green (2291 tests). The new smoke test is RED until mcpd
is deployed with this change -- it asserts post-split storage against a live
daemon, and the currently deployed one predates the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 10:21:56 +01:00
Michal
e85250fedf fix(cli): stop the MCP stdio bridge serialising requests
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m23s
CI/CD / publish (pull_request) Has been skipped
The bridge's stdin loop awaited every request before reading the next line, so
it handled exactly one at a time. A single slow call therefore stalled every
later request — and because those requests were never even sent, nothing could
time them out. The client saw silence, not an error.

Observed 2026-08-05: two gitea calls through this bridge sat completely mute
until Claude Code aborted them at its own 1800s idle limit, reporting "sent no
response or progress". The upstream was healthy the whole time — a fresh
session answered the same tools in 0.3s, and the gitea MCP server's own log
showed the calls never reached it. They died queued in the bridge.

JSON-RPC ids exist precisely so responses may return out of order, so nothing
here needed a queue. Requests now dispatch concurrently and are tracked in a
set; stdin close awaits them before the session DELETE, otherwise a concurrent
call races the teardown and dies with a 404.

We still serialise until the session id exists: it arrives on the first
response, and firing later requests without it would open a second upstream
session. A client sends `initialize` first and waits for the reply anyway, so
this costs one round trip rather than throughput.

Also makes the per-request timeout configurable via MCPCTL_MCP_TIMEOUT_MS
(default unchanged at 30s) and names it in the timeout error, so a project with
genuinely long tool calls can raise it instead of hitting a hardcoded wall.

Tests pin both halves: a fast request must overtake a slow one (this fails on
the old serial code — verified by reverting), and a failed request must still
produce a JSON-RPC error carrying the original id rather than nothing.
2026-08-07 15:19:50 +01:00
Michal
34e00af731 feat(gate): drop the reasoning field from selection → ~1.5s (comfortable margin)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m12s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Even "<=8 words" was ignored — the model wrote a full-sentence reasoning, pushing
begin_session to ~7s (thin under the 8s budget). The gate only needs the names, so
request just {"selectedNames":[...]}. Validated live vs vllm-current
(glm-4.6-reap-fast): 1.5s, valid JSON. (reasoning defaults to '' in extractSelection.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 03:07:28 +01:00
Michal
a432e9e19f feat(gate): terse selection prompt so a no-think model answers in ~2s
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m16s
CI/CD / publish (pull_request) Has been skipped
With the fast (no-think) route, gate-selection latency is now proportional to the
ANSWER length (a no-think model runs ~10-13 tok/s), not hidden thinking. A verbose
"reasoning" field pushed a selection to ~9s (over the 8s budget). Request compact
JSON with a <=8-word reasoning → ~2s (vs ~9s prose, or ~1.2s with no reasoning).
Validated live against vllm-fast (glm-4.6-reap-fast): 2.0s, valid selection JSON,
zero reasoning tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:38:08 +01:00
Michal
5225b54901 feat(gate): MCPCTL_GATE_SELECTION_LLM — pin a dedicated fast selection Llm
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m7s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Lets ops route gate prompt-selection at a fast (no-think) server Llm independent
of the project's chat llmProvider — so selection stays ~1-2s while chat keeps its
thinking model. Overrides the project llmProvider for the gate's server-selection
path; unset → prior behavior (use the project's llmProvider). Pairs with the
litellm qwen3-fast no-think alias (kubernetes-deployment) + a `vllm-fast` mcpd Llm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 14:28:44 +01:00
Michal
27480181bf feat(gate): ask reasoning models for a fast, no-think selection response
Prompt selection is mechanical classification — it gains nothing from chain-of-
thought, and a reasoning model (qwen3-thinking) otherwise burns its whole budget
reasoning (~40s, 6k+ chars) and blows the gate's 8s timeout. The gate's server
selection request now includes thinking-suppression hints, forwarded verbatim by
mcpd's passthrough adapter to litellm/vLLM:
  chat_template_kwargs.enable_thinking=false  (Qwen3 hard-off)
  reasoning_effort=low                         (o-series / newer vLLM)
Harmless for models that ignore them; if a backend rejects them the selector
falls back to the local provider. Override via MCPCTL_GATE_SELECT_EXTRA_BODY
(JSON), or '' to disable.

NOT yet validated live — the qwen3-thinking vLLM is crashlooping again (0/1,
HTTP 500), and whether litellm forwards chat_template_kwargs to vLLM is unconfirmed
(the /no_think prompt directive was NOT honored). Validate when the backend
recovers. mcpld gate/selector tests green (75).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:07:27 +01:00
Michal
2729580974 feat(gate): route prompt-selection through mcpd's server Llm (credential tiering)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m3s
CI/CD / lint (pull_request) Successful in 2m6s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / build (pull_request) Successful in 2m10s
CI/CD / smoke (pull_request) Failing after 3m9s
CI/CD / publish (pull_request) Has been skipped
Cloud/server keys belong at the k8s/mcpd level; mcplocal handles only the user's
personal tokens. The gate's prompt-selection used mcplocal's LOCAL provider
registry (heavy=anthropic = the user's OAuth token, which is API-gated and 404s),
so selection always degraded.

Now LlmPromptSelector tries sources in priority order: (1) the project's server
Llm via mcpd's inference proxy (POST /api/v1/llms/:name/infer) — cloud/server
keys stay at k8s — then (2) the local personal-token provider as fallback. First
source that returns valid selection JSON wins. Extracted pickCompletionText
(content ?? reasoning_content) + extractSelection helpers.

Wiring: GatePluginConfig.llmProvider (from the project), threaded via
createDefaultPlugin; handleBeginSession builds serverInfer from ctx.postToMcpd.
Verified live: POST .../llms/vllm-current/infer returns valid selection JSON
(qwen3-thinking, 4000-token budget). + selector tests (server preferred, local
fallback on error, server-only). mcplocal suite green (755).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:43:48 +01:00
Michal
366b505c00 fix(providers): anthropic surfaces HTTP errors too (gate parity with openai)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m0s
CI/CD / publish (pull_request) Has been skipped
The local mcplocal gate resolves heavy=[anthropic]; its OAuth-gated key 404s the
configured opus-4 model, and anthropic.ts (like openai.ts before this series)
JSON.parsed the error body as a completion → empty content → the gate's
misleading "did not contain valid selection JSON". Reject on status >= 400 with
"Anthropic HTTP <status>: <body>" so the true reason (model-not-found / auth)
surfaces as the degradedReason. + parity test.

Note: this makes the error honest; it does not make the local gate SUCCEED — that
needs a working heavy provider (the anthropic OAuth token is fundamentally gated).
The qwen3-thinking gate path (k8s mcplocal) is addressed by the budget+reasoning
fix in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:23:37 +01:00
Michal
c9364a2f2a fix(favourite-index): route favourite/ calls even before tools/list (lazy resolver)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m12s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m31s
CI/CD / publish (pull_request) Has been skipped
The presented→canonical resolver was only built during tools/list, so a
favourite/<tool> call arriving before any list this session fell through to the
namespaced router and 404'd (all/ was fine — it prefix-strips). Real MCP clients
list first, but the smoke test's initialize→call flow exposed it. Extract
buildPresentation() and lazily rebuild the resolver in onToolCallBefore when a
favourite//all/ name isn't resolved yet. + unit test (favourite/ call with no
prior list) and realistic listTools() in the smoke routing case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:19:05 +01:00
Michal
9dbd2f175d fix(gate): stop masking backend errors + rescue thinking-model prompt selection
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m14s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m17s
CI/CD / publish (pull_request) Has been skipped
Two independent, live-reproduced causes of "Smart prompt-selection unavailable
(LLM response did not contain valid selection JSON)" in the gate:

- Cause B (openai.ts request): the response handler JSON.parsed the body with no
  res.statusCode check, so a LiteLLM/vLLM HTTP 500 ("Connection error") parsed as
  an empty completion and surfaced as generic "bad JSON", masking backend-down.
  Now reject on status >= 400 with "OpenAI HTTP <status>: <body>" so the selector
  reports the true degradedReason.
- Cause A (parseResponse + selector budget): a reasoning model (qwen3-thinking)
  spends its whole 1024-token budget in reasoning_content and emits content=null,
  so the {selectedNames} JSON never appears. parseResponse now falls back to
  reasoning_content (incl. provider_specific_fields) when content is empty, and
  the selector budget is raised 1024 -> 4000 (env MCPCTL_GATE_SELECT_MAX_TOKENS)
  so thinking + the JSON answer both fit.

Note: mcpd's chat path got a reasoning_content fallback in PR #67; this is the
separate mcplocal gate-selection provider, which never did. Tests: openai-provider
(500 rejects, reasoning_content capture/preference). mcplocal suite green (749).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:10:08 +01:00
Michal
f614e9bb98 feat(proxy): favourite-index tool presentation (favourite/ + all/ + prefer instruction)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m36s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m16s
CI/CD / publish (pull_request) Has been skipped
Measured winner from the DGX-Spark bake-off (toolsim.py, 145-tool catalog): a
curated favourite/<tool> shortlist + the full all/<server>/<tool> catalog + a
load-bearing "prefer favourite/ first" instruction nearly halved wander (37→20)
and 2.5x'd first-pick (2→5/8) vs a flat catalog. The instruction is load-bearing;
enriching descriptions did not help.

- New mcplocal plugin `favourite-index.ts`: composes AFTER gate (no-ops while
  gated), reshapes the ungated upstream catalog into favourite/ + all/, injects
  the instruction (onInitialize), and rewrites presented names back to canonical
  server/tool in onToolCallBefore so normal routing + content-pipeline still run.
  Gate/agent virtual tools pass through untouched; favourites are upstream-only.
- compose.ts: onInitialize now concatenates plugin instructions (was first-non-null)
  so favindex can contribute its banner alongside the gate's.
- Per-project config `Project.favouriteIndex` {enabled, tools[], maxFavourites};
  surfaced to the proxy via discovery; wired at project-mcp-endpoint when enabled.
- Usage derivation: mcpd tool-usage ranking over tool_call_trace events
  (normalizing presented names → canonical), GET /api/v1/audit/tool-usage, and
  `mcpctl favourites suggest|list`.
- CLI: `create project` gains --favourite/--favourite-index/--max-favourites;
  favouriteIndex round-trips through get -o yaml | apply -f. Completions regenerated.
- Tests: plugin unit (presentation, rewrite routing, gated no-op, collisions),
  compose merge, canonicalizeToolName, buildFavouriteIndex, + a live smoke test.
- Docs: docs/tool-presentation.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:20:30 +01:00
Michal
a1c545d21a fix(mcpd): anthropic adapter supports OAuth (sk-ant-oat) keys via Bearer
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / smoke (pull_request) Failing after 2m48s
CI/CD / publish (pull_request) Has been skipped
Claude subscription / Claude Code keys are OAuth tokens and authenticate with
Authorization: Bearer, not x-api-key. mcpd's server adapter only sent x-api-key,
so registering an OAuth key as a fallback Llm 401'd ("invalid x-api-key").
Mirror the mcplocal client adapter's isOAuth switch so an OAuth key works as a
server-side anthropic fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:43:56 +01:00
Michal
18524c1c57 feat(chat): LLM failover chain + show which model answered
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m14s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / build (pull_request) Successful in 2m30s
CI/CD / smoke (pull_request) Failing after 3m18s
CI/CD / publish (pull_request) Has been skipped
Chat is LLM-essential but not model-specific — instead of failing when the
pinned model is down/drifted, it now fails over across an ordered chain and
reports which model actually answered.

- Ordered fallback: an Llm declares `extraConfig.fallbacks: string[]`; the
  dispatcher builds primary-pool → fallback-pool(s) candidates and tries them
  in order (resolveCandidatesWithFallbacks).
- Fail over on real failures, not just transport: runOneInference now advances
  on a non-2xx status (e.g. a 400 from a drifted model) or an empty/invalid
  completion, not only thrown transport errors. Streaming fails over
  pre-first-chunk (already threw on 4xx).
- Transparency: ChatResult + the SSE `final` frame carry {llm, model,
  failedOver}; the CLI prints `model: <llm> (<model>)` each turn and
  `⚠ failed over → answered by …` when a fallback was used.
- Exhaustion names the last model + upstream body (not "no choice").

Tests: 3 failover unit tests (primary 400 → fallback answers + model reported;
primary answers → failedOver=false; all fail → clear aggregated error).
mcpd 948 + CLI 508 green; tsc + lint clean. docs/reliability.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:39:56 +01:00
Michal
485b8f613f feat(reliability): bound + fail over + surface LLM-optional ops
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / lint (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / smoke (pull_request) Failing after 2m43s
CI/CD / build (pull_request) Failing after 3h11m43s
CI/CD / publish (pull_request) Has been cancelled
mcpctl hung on prompt-reading whenever the LLM misbehaved (thinking model =
minutes; drifted model = silent failure). Root cause: the gate's begin_session
prompt-selection called the LLM with no timeout, its fallback only fired on
error and was silent, and it forced the project's vLLM model onto the anthropic
heavy provider (so selection failed silently every time).

- New withTimeout(run, ms, label): Promise.race + AbortSignal (fetch-based
  providers cancel). CompletionOptions.signal threaded into anthropic/openai.
- Gate begin_session: LLM selection is time-bounded (MCPCTL_GATE_LLM_TIMEOUT_MS,
  8s); on timeout/error it falls back to deterministic tag matching, logs
  [gate] loudly, prepends a ⚠ degraded note to the response, and sets
  degraded/degradedReason on the audit gate_decision.
- Gate selector no longer forces the project vLLM model — uses the heavy
  provider's own model (fixes the always-silent-fail bug).
- Pagination smart-index is time-bounded too (falls back to byte-range pages).
- Chat (LLM-essential) surfaces the upstream status+body (names model+reason)
  instead of "Adapter returned no choice".
- docs/reliability.md documents the principle.

Tests: with-timeout unit tests; gate degradation test (error → visible ⚠ +
deterministic prompts, no hang). mcplocal 737 + mcpd 945 green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:28:34 +01:00
Michal
a5cab5a096 feat(chat): project-scoped chat — mcpctl chat --project <name>
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Chat directly with a Project (no Agent needed): its Prompts become the system
context, its MCP-server tools are callable, its llmProvider/llmModel drive the
LLM, and (opt-in) the model can read secret values. History is saved inside the
project, attributed per user, resumable, and deletable (RBAC-permitting) — "use
it like Claude, scoped to the project".

Backend (reuses the agent-chat orchestrator):
- ChatThread is now agent-XOR-project (schema + migration + CHECK constraint);
  new listThreadsByProject / deleteThread on the repo.
- ChatService: prepareProjectContext (project prompt + Prompts by priority,
  llm from llmProvider with llmModel override, project tools), shared
  runChatLoop/runChatStreamLoop, project thread CRUD with owner enforcement
  (404-not-403 on foreign threads), admin-override delete.
- Gated get_secret virtual tool: offered only with --allow-secrets AND the
  caller's view:secrets; resolves via SecretService, never routes to a server.
- routes/project-chat.ts (chat SSE+non-stream, threads create/list/delete);
  RBAC run:projects:<name>.

CLI:
- `mcpctl chat --project <name>` (+ --allow-secrets), one-shot/REPL/resume.
- REPL /threads, /resume <id>, /delete <id>; project-aware header + /tools.
- `mcpctl get threads --project <name>`, `mcpctl delete thread <id> --project`.
- completions regenerated (--project completes project names).

Tests: 8 new project-chat unit tests; full mcpd (945) + CLI (508) green;
schema validated against Postgres. Docs: docs/chat.md "Project chat" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:34:21 +01:00
Michal
a517443828 fix(pulumi): drop exports map so Pulumi can serialize the provider (v0.0.2)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m1s
CI/CD / typecheck (pull_request) Successful in 2m6s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m9s
CI/CD / publish (pull_request) Has been skipped
Pulumi's closure serializer reaches the dynamic provider's internal submodules
(dist/engine.js, dist/kinds.js) by subpath. An `exports` allowlist with only
"." blocks that: `pulumi preview` failed with "package.json export path for
.../dist/kinds.js not found". Removing `exports` (keeping main/types) lets the
serializer resolve them. Verified end-to-end: a real `pulumi up` from
kubernetes-deployment updated mcpd's qwen3-thinking llm and it probes 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:32:41 +01:00
Michal
f3d92235db feat(pulumi): @mcpctl/pulumi — generic Pulumi provider for mcpctl
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 2m46s
CI/CD / build (pull_request) Successful in 2m25s
CI/CD / publish (pull_request) Has been skipped
New workspace package: a Pulumi dynamic provider (TypeScript, in-process) that
manages mcpctl resources declaratively from any Pulumi program. Motivated by
model drift: when the deployed vLLM/LiteLLM model changes, mcpctl's llm target
must follow, and a Pulumi resource makes that automatic on `pulumi up`.

- Generic core `McpctlResource({ kind, name, spec, mcpd })` round-trips ANY
  mcpctl resource kind; typed `Llm` wrapper for ergonomics.
- Engine talks direct HTTP to mcpd's REST API (ported from cli/api-client.ts),
  mirroring apply's name-keyed upsert (PUT strips immutable name/type) and
  get -o json's read field-stripping. No mcpctl binary needed → CI-friendly.
- CRUD/diff/check with correct replace semantics (name/type/kind/mcpd.url force
  delete-before-replace), 429 retry, secret token in state.
- Validation deferred to mcpd's Zod schemas, so new resource kinds work with no
  provider release.
- CommonJS build so the serialized dynamic provider's module refs are captured.
- 18 Vitest tests (mocked mcpd + Pulumi mocks); build + lint clean; README with
  auth (PAT) setup and consumer example for kubernetes-deployment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:02:22 +01:00
Michal
6ef16f12b6 fix(cli): login over HTTPS + suggest last-used email
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / lint (pull_request) Successful in 2m13s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / smoke (pull_request) Failing after 2m35s
CI/CD / build (pull_request) Successful in 2m25s
CI/CD / publish (pull_request) Has been skipped
auth.ts talked to mcpd over node:http unconditionally with port:url.port,
so an https:// mcpdUrl connected to the ingress on :80, got a 301 redirect
with an empty body (status < 400), and JSON.parse('') crashed login with
"Unexpected end of JSON input". api-client.ts already handled this; the auth
commands did not.

- All 4 auth request fns now pick https/443 and the TLS driver from the URL
  protocol, mirroring api-client.ts.
- parseJsonResponse() guard turns any non-JSON <400 body into a clear
  "Invalid <ctx> response from mcpd" error instead of a raw SyntaxError.
- login now suggests the previously-used email (when a session for the same
  mcpd exists) so pressing Enter reuses it after a session expires.
- Regression tests drive the real default* request fns against a local
  HTTP server (valid JSON, empty body, HTML redirect body, https driver
  selection) and cover the email-suggestion behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:06:29 +01:00
Michal
25da5a3a22 feat(errors): mcpd error-log ring buffer + 'mcpctl errors'
Some checks failed
CI/CD / lint (pull_request) Successful in 1m1s
CI/CD / typecheck (pull_request) Successful in 1m2s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / smoke (pull_request) Failing after 1m50s
CI/CD / build (pull_request) Successful in 5m12s
CI/CD / publish (pull_request) Has been skipped
Operators can now see recent mcpd error/fatal logs without kubectl:
- mcpd tees level>=error pino records into an in-memory ring buffer
  (src/mcpd/src/services/error-log-buffer.ts; wired via pino.multistream in
  server.ts so stdout logging is unchanged). Captures structured errors incl.
  fatal kinds like BACKEND_TOKEN_DEAD.
- GET /api/v1/logs/errors?limit=N (RBAC: 'logs' operation).
- CLI: 'mcpctl errors [-n N]' renders TIME/LEVEL/DETAIL, most-recent-first.

Buffer unit tests (6); full suite 2223 passing. Needs a deploy to go live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:55 +01:00
Michal
467757b966 feat(status): show SecretBackend health in mcpctl status
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m33s
CI/CD / smoke (pull_request) Failing after 1m52s
CI/CD / build (pull_request) Successful in 4m55s
CI/CD / publish (pull_request) Has been skipped
Adds a 'Secrets:' line to mcpctl status (and a secretBackends array to JSON
output) showing each backend healthy (✓) or, when tokenMeta.lastRotationError
is set (e.g. a dead OpenBao token), red ✗ with the error inline. Makes a
recurrence of BACKEND_TOKEN_DEAD visible at a glance instead of only in mcpd
logs. Verified live: 'Secrets: bao* ✓, default ✓'. +3 tests (28 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:13:50 +01:00
Michal
4c7e648771 fix(smoke): read CLI credentials from ~/.mcpctl (active path)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m52s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m49s
CI/CD / publish (pull_request) Has been skipped
passwd smoke read ~/.config/mcpctl/credentials and silently skipped; the CLI
stores creds at ~/.mcpctl/credentials. Now verified live against prod (4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:41:30 +01:00
Michal
2cceeb7093 feat(passwd): mcpctl passwd + RBAC-gated password change
Restores the lost `mcpctl passwd` command and builds the backend it needs.

Backend (mcpd):
- POST /api/v1/users/me/password — self-service change, requires current
  password. Gated by a new `set-own-password` operation.
- PUT /api/v1/users/:id/password — admin reset of another user, gated by
  edit:users (admins have edit:*). Added users name-resolver for CUID→email.
- UserService.setPassword/verifyPassword; UserRepository.update accepts
  passwordHash + findByIdWithHash.

RBAC, no exceptions: self password change is a default, admin-revocable
permission. Every new user gets a `self-<id>` RbacDefinition granting
`set-own-password`, seeded on create + bootstrap, gated by the
`allowSelfPasswordChange` system setting (stored in the mcpctl-system-settings
secret, default ON; admins disable globally or revoke per-user).

CLI: src/cli/src/commands/passwd.ts (self vs admin paths) + completions.

Tests: users-password route tests (8), auth-bootstrap grant assertion,
passwd live smoke test. Full suite 2214 passing; zero new lint errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:55:56 +01:00
Michal
0e952dbf68 merge: integrate v7-rbac-visibility on top of skills feature
Brings together the two unmerged feature lines onto one integration branch:
- skills/review/proposals/revisions (via fix/mcpd-instance-health-and-retry,
  which contains the full skills-1..7 chain + mcpd env/retry/readiness fix)
- v7 visibility scope + ownership for Llms and Agents

Auto-merge resolved schema.prisma, mcpd main.ts, mcplocal config, CLI create,
and completions with no conflicts. Both Prisma migrations coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:31:35 +01:00
Michal
180e50a978 feat(cli): metadata.mcpServers auto-attach in mcpctl skills sync
Closes the third deferred item from PR-5: skills can declare upstream
MCP server dependencies via `metadata.mcpServers` and `mcpctl skills
sync` now attaches them to the active project. Same trust model as
postInstall/hooks: the publisher is responsible, the client just
asks mcpd to attach.

## Behaviour

- For each `{ name, fromTemplate?, project? }` entry:
  - If the project already has the server attached → record as
    `alreadyAttached`, no-op.
  - If the server doesn't exist on mcpd → warn + skip (we don't
    auto-create from template; that's a separate explicit decision
    for the operator). The warning surfaces the suggested template
    so the operator can decide.
  - Otherwise → POST `/api/v1/projects/:id/servers { server: <name> }`.
  - 409 from POST → treat as alreadyAttached (server-side idempotency).
  - 404 from POST → treat as missing (race: server vanished mid-sync).
  - Other errors collected per-server; sync continues.
- A dep with `project:` set to a non-active project is skipped during
  this sync — keeps the active sync from making collateral changes
  to other projects.
- Global skill syncs (no project context) skip mcpServers entirely
  with a warning — there's no project to attach to.

## Files

### Added
- src/cli/src/utils/mcpservers-materialiser.ts (~140 LOC)
- src/cli/tests/utils/mcpservers-materialiser.test.ts (~190 LOC,
  10 tests covering: parse-tolerance, fresh attach, alreadyAttached
  short-circuit, missing-server warn+skip, missing-project errors-
  out, 409→alreadyAttached, 404→missing, cross-project skip,
  per-server error collection, empty-deps no-op)

### Edited
- src/cli/src/commands/skills.ts: applyOne calls
  attachSkillMcpServers after hooks. Tracks per-skill attachments in
  result.mcpServersAttached. Summary line surfaces "N mcpServers
  attached".

## Verification

165 test files / 2193 tests green (up from 2182).

Real-world flow:

```yaml
# skill metadata.yaml
mcpServers:
  - name: my-grafana
    fromTemplate: grafana
    project: monitoring
  - name: my-loki
    fromTemplate: loki
```

```bash
# As operator: ensure the named servers exist on mcpd first
mcpctl create server my-grafana --from-template grafana --env-from-secret grafana-creds
mcpctl create server my-loki --from-template loki

# Now publish the skill that declares them as deps. Sync will attach:
mcpctl skills sync
# → mcpctl: 1 installed, 2 mcpServers attached
mcpctl describe project monitoring     # both servers now attached
```

## What's intentionally NOT in this PR

- Auto-creating servers from `fromTemplate` when they don't exist.
  Provisioning infra from a skill push is a separate decision needing
  explicit operator policy. v1 warns + skips; the warning includes
  the suggested template name so the operator can act manually.
- Detaching a server when a skill drops it from mcpServers. Detach is
  destructive (project loses access) and we can't tell whether the
  detach is intentional vs. accidental drop. PR-7 can revisit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:12:20 +01:00
Michal
7ebc8b22d1 feat(cli): metadata.hooks materialisation in mcpctl skills sync
Closes the second-biggest deferred item from PR-5: skills can declare
PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop /
Notification hooks in metadata.hooks, and `mcpctl skills sync` now
registers them in ~/.claude/settings.json with proper source-tagging.

## Why this needs source-tagging

mcpctl already managed ONE entry in settings.json (the SessionStart
hook for `mcpctl skills sync --quiet`, set up by PR-5 via
`installManagedSessionHook`). That entry uses `_mcpctl_managed: true`
to identify itself for idempotent updates.

Skill-declared hooks need finer scoping: skill A and skill B could
both register PreToolUse hooks, and removing skill A must not touch
skill B's. So entries written by `applyManagedHooks` carry both:

  _mcpctl_managed: true          ← same flag the SessionStart hook uses
  _mcpctl_source: "<skill-name>" ← differentiates per-skill

Removing a skill (orphan cleanup) drops every entry tagged with
that skill's name. User-added entries (no marker) are preserved
verbatim. The earlier session-hook installer keeps working: its
`_mcpctl_managed: true` lacks the source tag, so the hooks-materialiser
ignores it on per-skill operations.

## Behaviour

- Skill installs/updates: applyManagedHooks(skillName, metadata.hooks)
  is called after files are atomically materialised. The function
  reads settings.json, drops this skill's previous entries from every
  declared event (and from any event it previously had entries in but
  no longer declares — so a skill can shrink scope), then re-inserts
  the new tagged set.
- Skill orphan removal: removeManagedHooks(skillName) drops every
  entry owned by the skill. Other skills + user hooks unaffected.
- Failure handling: hooks errors are logged via the warn() callback
  but do not fail the sync. Same shape as postInstall — scoped, not
  fatal.

## Files

### Added
- src/cli/src/utils/hooks-materialiser.ts (~140 LOC)
- src/cli/tests/utils/hooks-materialiser.test.ts (~165 LOC, 11 tests
  covering: write-from-scratch, multi-source coexistence, user-hook
  preservation, update replaces (not duplicates), shrink drops events,
  remove targets only the named source, multiple events independent,
  idempotent, empty/JSONC settings.json tolerance)

### Edited
- src/cli/src/commands/skills.ts: applyOne calls applyManagedHooks
  when metadata.hooks is set; calls removeManagedHooks(skillName) when
  a previously-installed skill no longer declares hooks; orphan
  removal also drops the skill's hooks. New SyncResult field
  `hooksApplied`. Earlier `meta` declaration deduped (it was redeclared
  in the postInstall block).

## Verification

164 test files / 2182 tests green (up from 2171).

End-to-end on a real machine after this PR ships:

```yaml
# skill metadata.yaml
hooks:
  PreToolUse:
    - type: command
      command: "audit-pretool.sh"
  SessionStart:
    - type: command
      command: "claude-greeting.sh"
```

```
mcpctl skills sync
# → mcpctl: 1 installed, 1 hooks applied
jq '.hooks' ~/.claude/settings.json
# → entries tagged with _mcpctl_managed + _mcpctl_source
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:43 +01:00
Michal
d60ad52018 feat(cli): postInstall executor for mcpctl skills sync
Closes the biggest deferred item from PR-5. metadata.postInstall
scripts now actually run when their hash changes, with audit emission
back to mcpd.

Trust model unchanged from the corporate-appliance design: mcpd is
the source of truth, content is reviewed at publish time, the client
just runs. No sandbox, no signing, no consent prompts. The controls
that matter are already on the publishing side (RBAC, audit, reviewer
queue).

What we DO provide is ops hygiene:

- Hard timeout (default 60 s; per-skill override via
  metadata.postInstallTimeoutSec). SIGTERM at the deadline, SIGKILL
  after a 2 s grace.
- Hash pinning. The script's sha256 is recorded in
  ~/.mcpctl/skills-state.json. Re-syncs of an unchanged script are a
  cheap no-op. A re-published "same version, fixed script" still
  triggers re-execution because its hash changed.
- Curated env. PATH/HOME/USER/SHELL inherited; everything else dropped.
  MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT injected so scripts
  have stable context.
- Per-skill install log under ~/.mcpctl/skills/<name>/install.log.
  Bounded at 5 × 256 KB; old entries truncated from the front.
- Audit event back to mcpd (POST /api/v1/audit-events,
  eventKind=skill_postinstall) on every run, including hostname so
  admins can see fleet rollout state. Best-effort — failures are
  warned but never block the sync.
- Path-escape rejection. metadata.postInstall must resolve inside the
  skill bundle; relative paths that try to climb out are refused.
- Auto-chmod 0755 on the script before spawn. Some upstreams ship 0644
  + a shebang and expect a shell to handle it; we always spawn the
  path directly so we need +x.

Failure semantics: on timeout or non-zero exit, the recorded
postInstallHash is NOT updated. Next sync retries. Other skills in
the same sync run continue regardless — postInstall errors are
scoped, not fatal.

## Files

### Added
- src/cli/src/utils/postinstall.ts (~200 LOC)
- src/cli/tests/utils/postinstall.test.ts (~190 LOC, 10 tests covering
  success, env vars, chmod, non-zero exit, timeout via exec sleep,
  path-escape, missing script, log file shape + append-across-runs)

### Edited
- src/cli/src/commands/skills.ts: applyOne now invokes runPostInstall
  + emitPostInstallAudit when metadata.postInstall is set and the
  script hash differs from prior state. New SyncResult fields:
  postInstallsRan, postInstallsSkipped. Summary line surfaces
  "N postInstall ran". --skip-postinstall flag now actually does what
  it says.

## Verification

163 test files / 2171 tests green (up from 2161).

End-to-end on a real machine (after this PR ships and a skill with
metadata.postInstall is published):

```
mcpctl skills sync
# → mcpctl: 1 installed, 1 postInstall ran
cat ~/.mcpctl/skills/<name>/install.log    # see stdout/stderr
mcpctl skills sync                          # idempotent — skipped
```

If the same skill is republished with a fixed script:

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 postInstall ran  (hash changed → rerun)
```

If the script fails (exit 7):

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 errors
mcpctl skills sync   # state.postInstallHash NOT updated → retries
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:05:42 +01:00
Michal
e6cd73543a fix(mcpd): fail-loud on env resolution + retry/backoff + readiness via proxy
Three connected issues with how instances came up + got reported as
healthy when their secret backend was unreachable. The motivating
case: gitea-mcp-server starts when mcpd can't read the
gitea-creds secret from OpenBao, runs with an empty
GITEA_ACCESS_TOKEN, replies fine to tools/list (so liveness passes),
but every authed call fails with "token is required" — and
`mcpctl get instances` cheerfully reports the instance as healthy.

## What changed

### 1. Env resolution failures are now fatal for the start attempt

`src/mcpd/src/services/instance.service.ts`

The previous behaviour swallowed `resolveServerEnv` failures and let
the container start anyway with whatever env survived ("non-fatal —
container may still work if env vars are optional"). That's the bug:
the gitea container started with no token, ran for weeks, and was
reported healthy.

The catch now calls `markInstanceError(instance, "secret resolution
failed: <reason>")` and returns. Optional/missing env vars should be
modelled as `value: ""` entries on the server, not as silent
secret-resolution failures.

### 2. ERROR instances retry with backoff, not blind churn

Adds Kubernetes-style escalation: 30 s × 5 attempts, then 5 min
pauses thereafter. Retry state lives on `McpInstance.metadata` (no
schema migration) — `attemptCount`, `lastAttemptAt`, `nextRetryAt`,
`error`.

The reconciler no longer tears down ERROR instances and creates
fresh replacements (which would reset attemptCount and effectively
loop at 30 s forever). Instead:

- ERROR rows whose `nextRetryAt` is in the future are LEFT ALONE
  and counted against the replica budget — preventing tight create-
  fail-create churn while a previous attempt is in its backoff window.
- ERROR rows whose `nextRetryAt` has elapsed are retried IN-PLACE
  via a new `retryInstance` method, which preserves attemptCount on
  the same row so the schedule actually escalates.

The work has been factored into `startOne` (creates + initial attempt)
+ `attemptStart` (env + container) + `retryInstance` (re-attempt the
same row) + `markInstanceError` (write retry metadata).

### 3. STDIO readiness probe goes through mcpProxyService

`src/mcpd/src/services/health-probe.service.ts`

The legacy `probeStdio` (a `docker exec node -e '... spawn(packageName)
...'` invocation) only worked for packageName-based servers. Image-
based STDIO servers like gitea-mcp-server fell through with "No
packageName or command for STDIO server" and were reported unhealthy
for the WRONG reason — they have no packageName because they are an
image, not because anything's wrong.

New `probeReadinessViaProxy`: sends `tools/call` through the live
running container via `mcpProxyService.execute`. Same code path as
production traffic, so probe failures match real failures. Picks up:

- JSON-RPC errors (e.g. "token is required" when env is empty).
- Tool-level errors expressed as `result.isError: true`.
- Connection failures wrapped as exceptions.
- Hard timeouts via the deadline race.

After this PR, configuring `gitea` with
`healthCheck: { tool: get_me, intervalSeconds: 60 }` makes
`mcpctl get instances` report it as `unhealthy` whenever the auth
token is missing or wrong — which is honest.

The dead `probeStdio` (~120 LOC) is removed; HTTP/SSE bespoke probe
paths are kept for now (they work and the diff stays minimal).

## Tests

`src/mcpd/tests/instance-service.test.ts`:
- Replaces "cleans up ERROR instances and creates replacements" with
  "retries ERROR instances in-place when their backoff has elapsed".
- Adds "leaves ERROR instances alone while their nextRetryAt is in
  the future" and "escalates the backoff: attemptCount + nextRetryAt
  persist on retry failures".

`src/mcpd/tests/services/health-probe.test.ts`:
- Swaps STDIO probe mocks from `orchestrator.execInContainer` →
  `mcpProxyService.execute`.
- Adds "marks unhealthy when proxy returns a JSON-RPC error
  (e.g. broken-secret auth failure)" — explicitly the gitea case.
- Adds "marks unhealthy when proxy returns a tool-level error in
  result.isError" — covers servers that report tool failures as
  isError instead of as JSON-RPC errors.
- Renames "handles exec timeout" → "handles probe timeout" and
  exercises the deadline race rather than an exec rejection.

Full suite: 162 test files / 2161 tests green (+4 new).

## Manual verification step (post-deploy)

```bash
mcpctl edit server gitea
# → add healthCheck:
#     tool: get_me
#     intervalSeconds: 60
#     timeoutSeconds: 10
#     failureThreshold: 3
```

If OpenBao is still down: gitea instance enters ERROR with
attemptCount + nextRetryAt visible in `mcpctl describe instance`.
Otherwise: gitea env resolves at next start, probe passes, instance
is honestly healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:55:23 +01:00
Michal
e8c3803fac feat(web): bold redesign — Tailwind v4 + shadcn-style primitives + Skills/Proposals/Revisions UI
Phase 6 of the Skills + Revisions + Proposals work. The web UI gets a
new design language and first-class affordances for everything the
backend now supports.

## Visual direction

- Tailwind v4 with custom @theme block (oklch tokens). Dark-mode-only
  (internal tool — light mode doubles QA surface).
- Inter for UI, JetBrains Mono for code/IDs (loaded via Google Fonts;
  trivial to swap for self-hosted geist later — the fallback stack
  reads identically).
- Sidebar layout (always-visible at desktop widths) replacing the
  previous top-bar nav. Pending-proposals badge polls every 30 s so
  reviewers see a queue building without refreshing.
- Lucide icons throughout.
- Spacing and radii on Tailwind defaults.

Existing inline-styled pages (Projects, Agents, AgentDetail,
ProjectPrompts, PersonalityDetail, Login) continue to work unchanged
inside the new Layout — Tailwind doesn't conflict with their inline
styles. A follow-up can migrate them incrementally.

## What's added

### Build infra (src/web/)
- package.json: tailwindcss@^4 + @tailwindcss/vite, lucide-react,
  class-variance-authority, clsx, tailwind-merge, diff, geist (held
  for future self-hosting).
- vite.config.ts: registers the @tailwindcss/vite plugin.
- src/index.css: Tailwind import + @theme tokens + @layer base.
- src/main.tsx: imports index.css.
- src/lib/utils.ts: shadcn-style cn() helper.

### shadcn-style primitives (src/components/ui/)
Hand-written rather than generated via `npx shadcn` so the repo doesn't
depend on a CLI tool that needs interactive runtime:

- button.tsx — variants: primary / secondary / ghost / danger / link;
  sizes: sm / md / lg / icon.
- card.tsx — Card + Header/Title/Description/Content/Footer subparts.
- badge.tsx — variants: default / info / success / warning / danger /
  outline.
- input.tsx — Input + Textarea + Label.
- tabs.tsx — no-dep accessible Tabs (no Radix needed for our use).
- separator.tsx — h/v separator with role=separator.

### Diff component (src/components/Diff.tsx)
Wraps the `diff` package (already added in PR-2) for inline unified-
diff display with color-coded add/remove rows. Used by both the
proposal review page and the skill revision-history tab.

### New pages (src/pages/)
- Dashboard.tsx — at-a-glance home. Counts for skills, prompts,
  projects, agents, proposals; pending-proposals call-out card if any.
- Skills.tsx — list view, separated into Global vs Project/Agent-
  scoped sections.
- SkillDetail.tsx — name + semver + description; tabs for SKILL.md /
  Files / Metadata / History. History tab shows revisions with
  click-to-diff against the live body.
- Proposals.tsx — queue with Pending/Approved/Rejected tabs. Pending
  count is highlighted in amber.
- ProposalDetail.tsx — full body, diff against current resource (or
  "would create new" if it doesn't exist), approve button + reject-
  with-required-note flow.

### usePolling hook (src/hooks/)
Tiny polling-with-cancellation hook used by Layout and Proposals.

### Layout rewrite (src/components/Layout.tsx)
Sidebar with nav items: Dashboard, Projects, Agents, Skills,
Proposals. Lucide icons. Active-route highlighting via NavLink.
Pending-proposals warning badge on the Proposals item.

### Routes (src/App.tsx)
New routes: /dashboard, /skills, /skills/:name, /proposals,
/proposals/:id. Default redirects to /dashboard.

### API types (src/api.ts)
Type defs for Skill, VisibleSkill, Proposal, Revision (with the
shapes the new pages consume).

## Tests

Existing 7 web tests still pass (Login + api). New page-level tests
deferred — the new pages are mostly compositions of primitives and
fetch hooks that round-trip to the backend; the backend tests already
cover what they call. PR-7 polish can add render-and-click tests if
coverage drift surfaces.

## Verification

- `pnpm --filter @mcpctl/web build` clean, no warnings.
- `pnpm test:run` whole monorepo: 162 test files / 2157 tests green.
- Visual smoke deferred — needs a running mcpd to populate the
  fixtures. Manual smoke tested locally is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:54:55 +01:00
Michal
58e8e956ce feat(cli+mcpd): mcpctl skills sync + config claude extension
Phase 5 of the Skills + Revisions + Proposals work. Skills are now
materialised onto disk under ~/.claude/skills/<name>/, with
hash-pinned diff against mcpd, atomic per-skill install, and
preservation of locally-modified files. `mcpctl config claude --project X`
now wires the full pickup chain: writes .mcpctl-project marker, runs
the initial sync, installs the SessionStart hook so subsequent Claude
invocations stay in sync transparently.

## Sync algorithm

1. Resolve project: `--project` flag overrides; else walk up from cwd
   looking for `.mcpctl-project`; else fall back to globals-only.
2. GET /api/v1/projects/:name/skills/visible (or
   /api/v1/skills?scope=global without a project). Server returns
   id + name + semver + scope + contentHash + metadata — no body, no
   files. The contentHash is sha256 of the canonicalised body, computed
   server-side; any reordering of keys produces the same hash, so it's
   a stable diff key.
3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/
   on purpose — Claude Code reads that tree and we don't want to
   pollute it with our bookkeeping).
4. Diff:
     - server skill not in state → INSTALL
     - server skill, state contentHash matches → SKIP (cheap path)
     - server skill, state contentHash differs → UPDATE (fetch full body)
     - state skill not in server → orphan, REMOVE (preserve if locally
       modified, unless --force)
5. Atomic per-skill install: write to <targetDir>.mcpctl-staging-<pid>/,
   rename existing tree to .mcpctl-trash-<pid>, swap staging in,
   rmtree the trash. A concurrent reader (Claude Code starting up)
   never sees a partial tree.
6. State file updated with new versions, per-file SHA-256, install
   path. saveState is atomic (temp + rename).

## Failure semantics

- `--quiet` mode (used by SessionStart hook): exit 0 on network /
  timeout / mcpd error. Fail-open is non-negotiable here — we never
  want a hung mcpd to block Claude Code starting up.
- Auth failure: exit 1, clear "run mcpctl login" message.
- Disk error during state save: exit 2.
- Per-skill errors are collected in the result and reported as a
  count; one bad skill doesn't stop the others.

Network fetches run with concurrency 5. The server-side
`/visible` endpoint is metadata-only so the cheap path (everything
unchanged) needs exactly one HTTP roundtrip total.

## Files added

### CLI utilities (src/cli/src/utils/)
- skills-state.ts — load/save state, per-file sha256, edit detection.
- project-marker.ts — walk-up to find `.mcpctl-project`, bounded by
  user home so we never search above $HOME.
- sessionhook.ts — install/remove a SessionStart hook entry tagged
  with `_mcpctl_managed: true`. Idempotent. Defensive against
  missing/empty/JSONC settings.json.
- skills-disk.ts — atomic install via staging-dir rename swap,
  symmetric atomic delete via trash-dir rename. Path-escape attempts
  in files{} are rejected.

### CLI command (src/cli/src/commands/)
- skills.ts — `mcpctl skills sync` Commander wrapper + the
  `runSkillsSync(opts, deps)` library function (also called from
  `mcpctl config claude --project`). Supports `--dry-run`, `--force`,
  `--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved
  (postInstall execution lands in a follow-up PR, not this one).

### Wiring
- index.ts: registers `mcpctl skills` after `mcpctl review`.
- config.ts: `mcpctl config claude --project X` now writes the
  `.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls
  `installManagedSessionHook('mcpctl skills sync --quiet')`. New flag
  `--skip-skills` opts out (used by tests; useful for CI).

## Server-side change

- src/mcpd/src/services/skill.service.ts: getVisibleSkills now
  computes contentHash on the fly from the canonical body shape the
  client will reconstruct. Cheap (sha256 of ~few KB per skill); no
  schema migration needed since hash is derived not stored.

## Tests

Four new utility test files (31 tests) under src/cli/tests/utils/:
- sessionhook.test.ts — creation, idempotency, command updates,
  preservation of user hooks, removal, empty/JSONC tolerance.
- skills-disk.test.ts — atomic write, replacement without leftovers,
  path-escape rejection, atomic delete, listing ignores
  staging/trash artifacts.
- skills-state.test.ts — sha256 determinism, state round-trip,
  schema-version drift handling, edit detection.
- project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty
  marker, write+read round-trip.

The existing `mcpctl config claude` test (claude.test.ts) was updated
to pass `--skip-skills` so it stays focused on .mcp.json generation;
the new sync flow is covered by the utility tests.

Full suite: 162 test files / 2157 tests green (up from 158 / 2127).

## Deferred to a follow-up

- `metadata.hooks` materialisation into `~/.claude/settings.json` —
  the data path exists, sync receives it; PR-7 or a focused follow-up
  will write the `_mcpctl_managed: true` entries for declarative
  hooks.
- `metadata.mcpServers` auto-attach via mcpd API — likewise.
- `metadata.postInstall` script execution — the most substantive
  deferred piece. Current sync logs a TODO and skips. The corporate
  trust model (publisher-side rigor, not client-side defence) means
  this is straightforward to add once we wire the curated env +
  timeout + audit emission. Orthogonal to file sync, easier to ship
  separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:26:35 +01:00
Michal
db57bb5856 feat(mcpd+mcplocal+cli): propose-learnings system skill, propose_skill MCP tool, mcpctl review
Phase 4 of the Skills + Revisions + Proposals work. Closes the reflexive
loop: Claude sessions can now propose back content (prompts or skills)
that maintainers triage via a CLI queue. The system documents itself
to Claude through the same mechanism it documents to humans.

## What's added

### propose-learnings global skill (mcpd bootstrap)
- src/mcpd/src/bootstrap/system-skills.ts — idempotent upsert, mirrors
  system-project.ts. Single skill seeded today: `propose-learnings`,
  ~430 words, explains when to engage with propose_prompt vs
  propose_skill, what makes a good proposal, what NOT to propose, and
  the review→approve flow. Priority 9, global scope.
- main.ts: `bootstrapSystemSkills(prisma)` called right after
  `bootstrapSystemProject`.

### gate-encouragement-propose system prompt
- system-project.ts gains a new gate prompt (priority 10, alongside the
  other gate-* prompts) that nudges Claude to call propose_prompt when
  it discovers a project-specific lesson. Pairs with the propose-learnings
  skill — the prompt is the trigger, the skill is the manual.

### propose_skill MCP tool (mcplocal)
- proxymodel/plugins/gate.ts: new virtual tool registered alongside
  propose_prompt. Posts to /api/v1/proposals (the new endpoint from
  PR-2) with resourceType='skill'. Tool description steers Claude
  toward propose_prompt for project-specific knowledge and reserves
  propose_skill for cross-cutting cases. propose_prompt's tool
  description is also expanded to point at the propose-learnings skill
  for guidance — the bare "creates a pending request" copy was bland
  enough that nothing in Claude's prior would actually make it engage.

### mcpctl review CLI
- New top-level command in src/cli/src/commands/review.ts.
  Subcommands:
    mcpctl review pending       List pending proposals
    mcpctl review next          Show oldest pending
    mcpctl review show <id>     Full detail
    mcpctl review approve <id>  POST /proposals/:id/approve
    mcpctl review reject <id> --reason "..."
    mcpctl review diff <id>     Side-by-side current vs proposed
- Wired into src/cli/src/index.ts. Registered after createApproveCommand
  to keep the existing project-ops `mcpctl approve promptrequest`
  command working (legacy) while the new review surface is the
  preferred path.

## Tests touched

- bootstrap-system-project.test.ts already counts via
  getSystemPromptNames() length, so it picked up the new prompt
  automatically; only the priority assertion needed nothing — the
  new prompt starts with `gate-` so the existing `gate-* → priority 10`
  invariant validates it.
- system-prompt-validation.test.ts: bumped expected length from 11→12
  and added a `toContain('gate-encouragement-propose')` assertion.

Full suite: 158 test files / 2127 tests green.

## What's NOT in this PR

- A SkillService mock-based test for the proposal approval handler —
  the PromptService approval handler is structurally identical and
  already covered; the database-backed integration is exercised in
  PR-2's tests.
- Changes to mcplocal's existing handleProposePrompt URL — it still
  POSTs to the legacy /api/v1/projects/.../promptrequests endpoint,
  which works because PR-2 left that route in place. PR-7 will
  cut mcplocal over to /api/v1/proposals along with the
  PromptRequest table rename + drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:13:33 +01:00
Michal
20a541a5d6 feat(mcpd): Skill resource end-to-end (CRUD + backup + revision integration)
Phase 3 of the Skills + Revisions + Proposals work. Skills get the same
inline-content + revision-history shape as prompts, with the addition of
`files` (multi-file bundles, materialised by `mcpctl skills sync` in PR-5)
and a typed `metadata` Json (hooks, mcpServers, postInstall, …).

## What's added

### Validation (src/mcpd/src/validation/skill.schema.ts)
Typed metadata schema with a closed list of recognised hook events
(PreToolUse, PostToolUse, SessionStart, Stop, SubagentStop, Notification),
typed `mcpServers` dependency declarations (name + fromTemplate + optional
project), and `postInstall` / `preUninstall` paths into the bundle's
`files{}`. `.passthrough()` so unknown fields survive — forward-compat
for follow-on additions.

### Repository (src/mcpd/src/repositories/skill.repository.ts)
Mirrors PromptRepository exactly. Same `?? ''` workaround for nullable-FK
compound-key lookups.

### Service (src/mcpd/src/services/skill.service.ts)
Mirrors PromptService for create / update / delete / restore / upsert,
including:
- Auto-bump patch on content/files/metadata change.
- Revision recording (best-effort — failures don't block the save).
- 'skill' approval handler registered with ResourceProposalService so
  proposalService.approve dispatches to skills the same way it
  dispatches to prompts.
- `getVisibleSkills(projectId)` returns id + name + semver + scope +
  metadata for `mcpctl skills sync` (PR-5) to diff against on-disk state.

### Routes (src/mcpd/src/routes/skills.ts)
- GET /api/v1/skills (filters: ?project= ?projectId= ?agent= ?scope=global)
- GET /api/v1/skills/:id
- POST /api/v1/skills
- PUT /api/v1/skills/:id
- DELETE /api/v1/skills/:id
- GET /api/v1/projects/:name/skills
- GET /api/v1/projects/:name/skills/visible — sync diffing
- GET /api/v1/agents/:name/skills
- POST /api/v1/skills/:id/restore-revision { revisionId, note? }

### main.ts
SkillRepository + SkillService instantiated; revision/proposal services
wired in. `skills` segment added to the RBAC permission map (uses the
existing `prompts` permission for now — same trust shape) and to
`kindFromSegment` so the git-backup hook captures skill mutations.

### Backup integration
- yaml-serializer.ts: `BackupKind` adds 'skill'; APPLY_ORDER bumps to 9
  with skill last (it depends on projects/agents). `parseResourcePath`
  recognises the `skills/` directory.
- git-backup.service.ts: `serializeResource` adds the `case 'skill'`
  branch alongside prompts. The git-sync loop now round-trips skills
  on every change.
- (Bundle backup-service.ts is NOT updated in this PR — deferred to PR-7
  alongside the cutover. The git-based backup IS wired, which is the
  primary persistence path.)

### CLI
- `mcpctl create skill <name>` with --content / --content-file,
  --description, --priority, --semver, --metadata-file (YAML/JSON),
  --files-dir (walks a directory tree into `files{}`, UTF-8 only;
  null bytes rejected).
- shared.ts adds `skill` / `skills` / `sk` aliases.

### apply.ts
Not updated — `mcpctl apply -f skill.yaml` is deferred to PR-7. The
existing CRUD endpoints + `mcpctl create skill` cover the bootstrap
need; bulk-apply will arrive with the `propose-learnings` seed and
docs.

## Tests

158 test files / 2127 tests green across the workspace. The DB-level
schema tests for Skill landed in PR-1; the new service-level integration
is exercised through main.ts wiring + the existing prompt revision tests
(skill follows the same code path through proposal service approval).

A `describe('Skill service mocks')` test file deliberately not added —
the PromptService mock-based tests already cover the revision/approval
handler shape, and the skill handler is structurally identical (same
upsert + record-revision + link-currentRevisionId pattern). PR-7 will
add an integration test that walks the full propose → review → approve
flow for both resource types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:48:40 +01:00