Compare commits

...

350 Commits

Author SHA1 Message Date
Michal
ed54aa1464 fix(smoke): reconcile assertions with ungated-global + ambient clock
Some checks failed
CI/CD / lint (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / typecheck (pull_request) Successful in 3m12s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m51s
CI/CD / publish (pull_request) Has been skipped
Three smoke tests predated PR #137 (ungated-global pre-gate servers) and
the ambient-context clock, and were never exercised against a prod that
had calcmcp seeded — the 5415060 fix was cut over via kubectl, bypassing
the suite. They failed on today's a783869 deploy:

- proxy-pipeline: a fresh gated session legitimately sees begin_session
  plus the ungated-global project's tools. Now discovers the gate-exempt
  server set and asserts nothing ELSE leaks pre-gate.
- tool-drilldown: get_current_time is a router-dispatched virtual tool
  (ambient clock) that never paginates; added to the exempt set.
- backup: command is optional for dockerImage STDIO servers (entrypoint
  images — calcmcp, dav-michal, jmap-michal run with none). The real
  contract is fidelity: every live server WITH a command must keep it
  through backup. Asserted that instead.
- audit: 'Gated (only begin_session)' label said something the >=1 check
  never verified; renamed.

Full suite against live a783869: 32 files, 181/181 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-09 16:06:24 +01:00
a783869e7c Merge pull request 'agentic-teams: epics, tasks, git forges, agent authority' (#139) from feat/agentic-teams into main
Some checks failed
CI/CD / lint (push) Successful in 1m16s
CI/CD / test (push) Successful in 1m30s
CI/CD / typecheck (push) Successful in 3m15s
CI/CD / smoke (push) Failing after 2m1s
CI/CD / build (push) Successful in 2m16s
CI/CD / publish (push) Has been skipped
2026-10-09 14:47:33 +00:00
Michal
527983d87e fix(db-tests): seed a SecretBackend for Secret model tests
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m36s
CI/CD / typecheck (pull_request) Successful in 3m13s
CI/CD / smoke (pull_request) Failing after 2m4s
CI/CD / build (pull_request) Successful in 4m43s
CI/CD / publish (pull_request) Has been skipped
The four Secret tests in models.test.ts created rows via raw
prisma.secret.create without a backendId, so the schema default ''
violated Secret_backendId_fkey (that default only exists so 'db push'
can add the column to pre-existing rows; real writes always carry a
valid FK). Seed a plaintext backend per test, the same shape
bootstrapSecretBackends creates, and clear SecretBackend rows in
clearAllTables so nothing leaks between tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-09 15:40:11 +01:00
Michal
24f202cb79 Merge origin/main into feat/agentic-teams
Conflict in src/mcpd/src/main.ts: both sides touched the import block.
Kept this branch's mapUrlToPermission plus main's bootstrapUngatedGlobal;
dropped main's SET_OWN_PASSWORD_OPERATION import — this branch moved that
usage into middleware/url-permission.ts, which imports it itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-09 15:36:32 +01:00
5415060de6 Merge pull request 'fix(ungated-global): create settings secret with a valid backend FK' (#138) from fix/ungated-global-secret-fk into main
Some checks failed
CI/CD / lint (push) Successful in 1m18s
CI/CD / test (push) Successful in 1m23s
CI/CD / typecheck (push) Successful in 3m15s
CI/CD / smoke (push) Failing after 1m56s
CI/CD / build (push) Successful in 5m7s
CI/CD / publish (push) Has been skipped
2026-10-03 00:58:57 +00:00
Michal
fe00f42bce fix(ungated-global): create settings secret with a valid backend FK
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Failing after 27s
CI/CD / lint (pull_request) Successful in 2m50s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
bootstrapUngatedGlobal wrote its seed marker into the mcpctl-system-settings
Secret, creating the row with the default backendId '' when it did not exist.
The Secret_backendId_fkey constraint is enforced at insert time, so on a DB
where that secret was absent, mcpd crash-looped on startup (P2003).

Fix: run the seed after bootstrapSecretBackends (so the default plaintext
backend exists) and set backendId to the resolved default on create. If no
backend exists at all, skip writing the marker rather than crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 01:57:37 +01:00
7bf9d49a02 Merge pull request 'ungated-global: system project whose servers ride every project, pre-gate' (#137) from feat/ungated-global into main
Some checks failed
CI/CD / lint (push) Successful in 1m13s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m6s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m11s
CI/CD / publish (push) Has been skipped
2026-10-03 00:46:08 +00:00
Michal
7b70c91c41 ungated-global: system project whose servers ride every project, pre-gate
Some checks failed
CI/CD / lint (pull_request) Successful in 1m12s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m22s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m15s
CI/CD / publish (pull_request) Has been skipped
New seed-once system project `ungated-global`: every server attached to it
is merged into every project's MCP endpoint in front of the gate (listed
and callable before begin_session), and its project prompt is prepended to
every project's instructions.

- mcpd: bootstrapUngatedGlobal seeds the project once-ever (marker key
  `ungatedGlobalSeeded` in mcpctl-system-settings), with default server
  `calcmcp` (patched CalcMCP fork, mysources.co.uk/michal/calcmcp:1.1.0)
  and a "never do math yourself, always use calcmcp" prompt. Deletions are
  durable: `mcpctl delete server calcmcp -p ungated-global` stays deleted.
- mcpd: GET /projects/:id/servers appends global servers flagged
  `ungatedGlobal: true` (direct attachment wins, no self-merge);
  GET /projects/:id/instructions prepends the global prompt and lists the
  global servers.
- mcplocal: discovery surfaces the flagged names; the gate plugin exempts
  them — gated tools/list returns begin_session + exempt tools, calls to
  them pass through without ungating, and initialize instructions list
  them under "Available immediately (no begin_session required)".
- templates/calcmcp.yaml so a deleted default can be re-created by hand.
- docs/ungated-global.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 01:45:44 +01:00
23c8218811 Merge pull request 'templates/ttrss: image 1.0.1 (Sec-Fetch fix + isError)' (#136) from fix/ttrss-template-101 into main
Some checks failed
CI/CD / lint (push) Successful in 1m11s
CI/CD / test (push) Successful in 1m40s
CI/CD / typecheck (push) Successful in 2m56s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m10s
CI/CD / publish (push) Has been skipped
2026-10-02 22:56:26 +00:00
Michal
b2aaa9d18d templates/ttrss: image 1.0.1 (node:http Sec-Fetch fix + isError)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / lint (pull_request) Successful in 2m51s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m11s
CI/CD / publish (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 23:56:13 +01:00
45b875ae2c Merge pull request 'ambient context: give every project's model a clock' (#135) from feat/ambient-context into main
Some checks failed
CI/CD / lint (push) Successful in 1m16s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m16s
CI/CD / smoke (push) Failing after 1m57s
CI/CD / build (push) Successful in 2m18s
CI/CD / publish (push) Has been skipped
2026-10-01 23:33:38 +00:00
Michal
979e9720c0 ambient context: give every project's model a clock
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m25s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m9s
CI/CD / publish (pull_request) Has been skipped
Models consuming projects have no idea what day it is. Three always-on
layers from mcplocal plus one from mcpd:

- initialize instructions and the begin_session briefing carry
  '[context] now: <date/time> — project: <name>' (frozen at session
  start by design);
- a get_current_time virtual tool (ungated catalogs only, same pattern
  as read_prompts);
- every tools/call result gets a trailing '[context] now: …' content
  item at the endpoint send site — the only chokepoint that also covers
  virtual tools, gate intercepts and failure results, and the layer
  that keeps a days-old chat session current;
- mcpd agent/project chat system blocks append the same line each turn.

Time renders as UTC plus an optional local timezone from
MCPLOCAL_AMBIENT_TZ (IANA name; both services read it; invalid or unset
means UTC only). ~15 tokens per stamp, minute precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-02 00:33:20 +01:00
f9ef4ebcb9 Merge pull request 'templates: jmap, dav, karakeep, ttrss for personal-service projects' (#134) from feat/personal-service-templates into main
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m49s
CI/CD / typecheck (push) Successful in 3m13s
CI/CD / build (push) Successful in 2m36s
CI/CD / smoke (push) Failing after 3m51s
CI/CD / publish (push) Has been skipped
2026-10-01 22:59:23 +00:00
Michal
1180bbdc1d templates/dav: baked stdio image instead of npx
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / lint (pull_request) Successful in 2m44s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / build (pull_request) Successful in 2m46s
CI/CD / smoke (pull_request) Failing after 3m48s
CI/CD / publish (pull_request) Has been skipped
dav-mcp declares tsdav/tsdav-utils as GitHub-URL deps — npx on the
git-less node-runner dies with 'spawn git ENOENT' (observed: dav-michal
CrashLoopBackOff on first deploy). Upstream's ghcr image PID-1s the HTTP
server, so mysources.co.uk/michal/dav-mcp bakes the install and execs
src/server-stdio.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-01 23:47:12 +01:00
Michal
709068ce68 templates/jmap: drafts3 image + JMAP_REBASE_SESSION_URLS
Some checks failed
CI/CD / lint (pull_request) Successful in 1m14s
CI/CD / test (pull_request) Successful in 1m28s
CI/CD / typecheck (pull_request) Successful in 2m53s
CI/CD / smoke (pull_request) Failing after 2m5s
CI/CD / build (pull_request) Successful in 4m43s
CI/CD / publish (pull_request) Has been skipped
Stalwart's session advertises publicUrl-absolute endpoints; over the
ClusterIP those hairpin through Envoy and 403. Verified in-cluster:
apiUrl came back as https://smtp.ad.itaz.eu/jmap/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-30 01:42:05 +01:00
Michal
c1f0117b80 templates: jmap, dav, karakeep, ttrss for personal-service projects
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m20s
CI/CD / lint (pull_request) Successful in 2m38s
CI/CD / test (pull_request) Successful in 1m28s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / smoke (pull_request) Failing after 3m16s
CI/CD / publish (pull_request) Has been skipped
jmap and ttrss are custom images (mysources.co.uk/michal/{jmap-mcp,ttrss-mcp});
dav-mcp and @karakeep/mcp run on the node runner. Health checks are real
backend calls. Applied live 2026-09-30; these seeds keep the next mcpd
image rebuild from reverting them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-30 01:35:43 +01:00
0515f99e17 Merge pull request 'fix(mcplocal): rebind upstream auth token on every project refresh' (#133) from fix/stale-upstream-token into main
Some checks failed
CI/CD / lint (push) Successful in 1m11s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 2m46s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m10s
CI/CD / publish (push) Has been skipped
2026-09-22 22:46:02 +00:00
Michal
6fbd9c19fc fix(mcplocal): rebind upstream auth token on every project refresh
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m24s
CI/CD / publish (pull_request) Has been skipped
A cached project router outlives the caller's bearer token. syncUpstreams
only created a McpdUpstream (which binds the token at construction) for
server names not already registered, so after a token rotation every
pre-rotation upstream kept posting the old bearer: six attached, healthy
servers 401'd on tools/list forever — no error anywhere the client could
see — while the two servers attached after the rotation worked.

- discovery.ts: rebuild every upstream on every sync, so it always carries
  the current caller token and the current server id (also fixes a stale
  serverId after delete+recreate under the same name).
- router.ts: record per-server discovery failures with their messages and
  expose them (getToolDiscoveryFailures).
- gate.ts: a partial catalog is now loud — initialize instructions and the
  begin_session response name every attached server that contributed no
  tools and the error that silenced it.
- tests: regression tests for token rebinding and serverId refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 23:44:52 +01:00
0076fa0d37 Merge pull request 'feat(servers): per-server memory ceiling, because 512Mi OOMKills silently' (#132) from feat/server-memory-limit into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m17s
CI/CD / lint (push) Successful in 2m22s
CI/CD / test (push) Successful in 1m22s
CI/CD / smoke (push) Failing after 1m56s
CI/CD / build (push) Successful in 3m59s
CI/CD / publish (push) Has been skipped
2026-09-16 23:24:32 +00:00
Michal
859150770f feat(servers): per-server memory ceiling, because 512Mi OOMKills silently
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m16s
CI/CD / lint (pull_request) Successful in 2m16s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / build (pull_request) Successful in 2m10s
CI/CD / smoke (pull_request) Failing after 3m15s
CI/CD / publish (pull_request) Has been skipped
Server pods have always had a hardcoded 512Mi limit. That is right for a
server that proxies an API and fatal for one that drives a browser: the
`docs` server (docs-mcp-server, which scrapes with headless Chromium)
idles at ~228Mi and crosses 512Mi within seconds of its first scrape.

An OOMKill is the quietest failure we have. The kernel kills it, the pod
restarts, the readiness probe passes, and the instance reads `healthy`
again — while the six scrape jobs whose queue lived in memory are gone
and the index has 17 pages in it. Nothing is logged, because the process
never got to say anything.

memoryLimitMb is declared per server, in MiB, and converted to bytes in
the container spec. NULL keeps DEFAULT_MEMORY_LIMIT, so every existing
server is bit-for-bit unchanged — a bigger default would have cost real
memory on every node for servers that do not need it.

  mcpctl create server docs --memory-limit-mb 2048 --force

Tests assert the two places a new column dies quietly: the repository's
field-by-field mapping (a column not mapped there returns "patched" and
changes nothing) and the spec built in instance.service. docs/reliability
gains the OOMKill section — the `lastState.terminated.reason` check is
what turns "it restarted again" into an answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPjtnE6Gd343oRNtMU9Bcd
2026-09-17 00:24:18 +01:00
3e534854f4 Merge pull request 'fix(templates): search templates stop probing with searches; add firecrawl' (#131) from fix/search-templates into main
Some checks failed
CI/CD / lint (push) Successful in 1m10s
CI/CD / test (push) Successful in 1m22s
CI/CD / typecheck (push) Successful in 2m50s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 2m7s
CI/CD / publish (push) Has been skipped
2026-09-15 21:18:28 +00:00
Michal
61e52403a3 fix(templates): search templates stop probing with searches; add firecrawl
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / lint (pull_request) Successful in 2m56s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m19s
CI/CD / publish (pull_request) Has been skipped
Readiness probes ran real searches every interval: searxng's
searxng_web_search probe fanned a query out to every engine 1,440 times a
day, billing API-key engines (braveapi, kagi), and duckduckgo's `search`
probe scraped DuckDuckGo from the same IP agents search from.

- searxng: probe searxng_instance_info, which reads /config only
- duckduckgo: probe fetch_content on example.com every 300s
- firecrawl (new): firecrawl-mcp against a self-hosted or cloud
  Firecrawl, for reading pages as main-content markdown; probe
  firecrawl_scrape on example.com every 300s

Every probe still names a readiness tool, as templates.test.ts requires;
each was run against the real package before being written down.
docs/web-search.md now pairs searxng (search) with firecrawl (reading)
and says why duckduckgo does not hold up under agent traffic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPjtnE6Gd343oRNtMU9Bcd
2026-09-15 22:16:17 +01:00
Michal
70bc7f1598 Merge origin/main into feat/agentic-teams
124 commits of main against 33 of agentic-teams. 22 files overlapped, 7
conflicted. Three needed real reconciliation rather than a side-pick:

**The `volumes` collision.** Both branches independently added a `volumes`
field to ContainerSpec meaning different things: main's is persistent storage
keyed by a server-derived `claimName` that deliberately outlives an instance;
mine is emptyDir scratch shared between an init container and the main
container. Worse, each emitted the pod-level `volumes` list from its own
helper, so spreading both would have silently kept only the last — a server
with a PVC would have lost it the moment a session scratch volume appeared.

Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's
keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now
owns the whole pod-level list and emits both kinds; `buildPodExtras` is init
containers only. fsGroup/securityContext stays tied to the PVC case, because
an emptyDir is created writable by the container's own user and chowning it
is work with no purpose. Five tests pin the coexistence so a later change
cannot drop one kind again.

**The extracted permission mapper.** I moved `mapUrlToPermission` out of
main.ts into middleware/url-permission.ts so it could be tested; main kept it
inline and kept editing it. Kept the extraction and ported main's one change
across — the `/api/v1/audit/events/purge` mapping onto the existing
`audit-purge` operation. Verified rule-by-rule that the extracted version is a
superset: all 15 of main's rules plus the 5 agentic-teams ones.

**The NUL byte in create.ts.** Both branches fixed the same defect
independently, mine as `''` and main's as `'\0'`. Took main's.

The rest were one-liners: .gitignore comment wording, and an add/add on
proxy-long-running.test.ts where main's copy carries a progressive-delivery
test mine lacked — took main's in full.

Not changed, but worth flagging: main's generateDeploymentSpec hardcodes
`automountServiceAccountToken: false` while generatePodSpec honours
`spec.automountServiceAccountToken`. That asymmetry is main's own and predates
this merge, so it is left exactly as-is rather than altered inside a merge
commit.

Verified: 3138 tests pass (237 files), build clean, prisma schema valid.
Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file
touched during conflict resolution was checked and introduces no new errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-09-06 23:40:04 +01:00
ad00e49262 Merge pull request #130: smoke _resultId regex
Some checks failed
CI/CD / lint (push) Successful in 2m17s
CI/CD / typecheck (push) Successful in 1m24s
CI/CD / test (push) Successful in 3m14s
CI/CD / smoke (push) Failing after 2m40s
CI/CD / build (push) Successful in 2m24s
CI/CD / publish (push) Has been skipped
2026-09-05 17:04:09 +00:00
Michal
22d8e13390 fix(smoke): match the _resultId format the pipeline actually emits
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m19s
CI/CD / test (pull_request) Successful in 1m41s
CI/CD / smoke (pull_request) Failing after 2m6s
CI/CD / build (pull_request) Successful in 2m22s
CI/CD / publish (pull_request) Has been skipped
The drill-down checks matched /_resultId:\s*(\S+)/ — the legacy paginator's
JSON spelling. The content pipeline writes _resultId="pm-abc", so the regex
found nothing: "Has _resultId for drill-down" reported a false failure on
every run, and the drill-down tests that used the same regex to extract an id
skipped themselves as "not large enough for section-split". Both were
invisible in a green suite.

One shared extractResultId() in the smoke harness now accepts either
spelling and returns the bare id, so the ad-hoc character strip on the
captured group goes away too. proxy-pipeline's check block reports 21/21
instead of 20 with a phantom failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQr5Z9gYrqBQXTGBBuemZ2
2026-09-05 18:03:56 +01:00
b5d0234d0b Merge pull request #129: drill-down required params
Some checks failed
CI/CD / typecheck (push) Successful in 1m22s
CI/CD / lint (push) Successful in 2m13s
CI/CD / test (push) Has started running
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-09-05 16:59:46 +00:00
Michal
0e12637271 fix(mcplocal): stop upstream required params from blocking a drill-down
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m27s
CI/CD / lint (pull_request) Successful in 2m22s
CI/CD / test (pull_request) Successful in 1m31s
CI/CD / smoke (pull_request) Failing after 2m41s
CI/CD / build (pull_request) Successful in 2m21s
CI/CD / publish (pull_request) Has been skipped
A paginated tool result tells the model to call the same tool again with
only _resultId/_section. The advertised schema still carried the upstream's
own `required` list, so a client that validates arguments rejected exactly
that call — "Received tool input did not match expected schema" — and the
model concluded pagination was broken and gave up
(websearch/fetch_content requires `url`; verified live on llm-model-tester).

`required` is ANDed with everything else in a schema, so no property
declaration could rescue it. It now becomes an alternation: either the
upstream's requirements (a fresh call) or `_resultId` (a re-read, which is
answered from cache and never reaches the upstream). Validators that
understand anyOf enforce that; naive ones that only read top-level
`required` now find none and accept both. Schemas that already carry a
combinator get the alternation appended to `allOf`, and a schema with no
required params is left untouched.

Covered by unit tests on a required-bearing upstream schema plus a smoke
assertion that no advertised tool keeps a drill-down-blocking `required`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQr5Z9gYrqBQXTGBBuemZ2
2026-09-05 17:58:57 +01:00
33d7007af5 Merge pull request #128: learn sampling-param rejection
Some checks failed
CI/CD / typecheck (push) Successful in 1m20s
CI/CD / lint (push) Successful in 2m32s
CI/CD / test (push) Successful in 1m26s
CI/CD / smoke (push) Failing after 2m3s
CI/CD / build (push) Successful in 4m34s
CI/CD / publish (push) Has been skipped
2026-08-25 23:12:08 +00:00
bc7eb5a0ad fix(mcplocal): learn which models reject sampling params, don't hardcode them
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m34s
CI/CD / test (pull_request) Successful in 1m28s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / smoke (pull_request) Failing after 3m6s
CI/CD / publish (pull_request) Has been skipped
Auto-following to the newest Opus moved the failure rather than removing it.
The deploy's own smoke output showed it: 404 "model not found" became

  HTTP 400: `temperature` is deprecated for this model.

Anthropic removed temperature/top_p/top_k on the current generation (Opus 5,
Sonnet 5, Opus 4.7/4.8, Fable 5), and the adapter sends temperature: 0
unconditionally. So the gate's prompt-selection was still degrading on every
call -- just with a different status code.

A list of which models accept sampling would rot exactly the way the pinned
model ids did, which is the whole thing this branch is trying to stop. So the
provider learns it instead: the first 400 naming a sampling parameter drops it
and retries, and remembers the model so every later call omits it up front.
One wasted call, once, rather than a hardcoded table to maintain.

The match is deliberately narrow -- a 400 must actually name temperature/top_p/
top_k. An unrelated 400 (missing max_tokens, bad schema) propagates untouched;
there is a test for that, because a broad match here would silently swallow
real request errors and retry them pointlessly.

Verified live: claude-opus-latest -> claude-opus-5, first attempt 400 on
temperature, retry succeeds past it. The retry then hit HTTP 429 -- the
personal OAuth token's rate limit, which is the pre-existing credential-tiering
issue, not this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-26 00:11:50 +01:00
cd20d8b980 Merge pull request #127: bounded, legible MCP failures
Some checks failed
CI/CD / typecheck (push) Successful in 1m24s
CI/CD / lint (push) Successful in 2m32s
CI/CD / test (push) Successful in 1m32s
CI/CD / smoke (push) Has started running
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled
2026-08-25 23:03:50 +00:00
47d7c779d0 feat: per-server tool-call timeout, on the server resource
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m25s
CI/CD / lint (pull_request) Successful in 2m39s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / smoke (pull_request) Failing after 3m6s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / publish (pull_request) Has been skipped
Completes the plan's last item. The deadline shipped with a global default;
this makes it overridable per server, where the knowledge actually lives -- a
server with genuinely slow tools declares its own budget instead of forcing the
global up for everyone.

Source of truth is the server resource, following healthCheck exactly:
Prisma column + migration (NULL keeps today's behaviour, so no existing server
changes), zod validation on create and update, the repository, an
`--tool-call-timeout` flag mirroring `--health-check-timeout`, and the apply
schema so `apply -f` accepts what `get server -o yaml` emits.

That round-trip needed care: get emits `toolCallTimeoutSeconds: null` for every
server without an override, so the apply schema is nullable, not merely
optional -- otherwise the very first `get -o yaml | apply -f` on an untouched
server would have failed validation.

Bounded at one hour. A deadline exists so a wedged call answers instead of
hanging; a value beyond an hour is indistinguishable from having none.

It reaches mcplocal through server discovery rather than the project-scoped
serverOverrides map, and is applied on EVERY sync rather than only at first
registration -- raising a server's timeout should take effect at the next
refresh, not require the upstream to be dropped and rebuilt.

The endpoint sees the wire name (`docmost_search`), so it decodes to the
canonical `server/tool` before resolving. The override is keyed by server, so
every tool on that server inherits it without being enumerated, and the
failure message quotes the deadline actually applied rather than the global.

Smoke tests (tests/smoke/bounded-failures.smoke.test.ts) cover the two
production faults against the live proxy: a stale session recovers instead of
stranding the client on an uncorrelatable 404, every request is answered, and
`mcpctl trace` responds for an unknown code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-26 00:03:06 +01:00
08b451b7aa feat(mcplocal): Anthropic models auto-follow the newest in their family
`claude-opus-4-20250514` was pinned as the heavy provider and had been
returning 404 on every gate ranking and every pagination title. The only
visible symptom was a fallback that looked like an ordinary one -- it surfaced
here because the degradation notice added earlier in this branch finally
printed the reason.

Checked against GET /v1/models: BOTH pins were dead. The fast tier's
claude-haiku-3-5-20241022 is gone too, so that tier had been silently 404ing
as well.

The provider's listModels() asserted "Anthropic doesn't have a models listing
endpoint" and returned four hardcoded dated ids. That endpoint does exist and
answers fine with the OAuth token this deployment uses; the hardcoded list was
simply stale, and a test pinned it in place.

So: `claude-<family>-latest` (or a bare `opus` / `sonnet` / `haiku` / `fable`)
now resolves against the live list. Exact ids pass through untouched, so
pinning still works when someone wants it.

Newest is decided by `created_at`, never by parsing the version out of the id.
That is not incidental: `claude-opus-4-5` sorts ABOVE `claude-opus-5` as a
string, and "4-5" parses as a larger minor than "5". There is a test for
exactly that trap.

Resolution is cached (12h, MCPCTL_ANTHROPIC_MODEL_TTL_MS) so it is not a
per-call network hop, and shared across instances since the model list is
account-wide. When the endpoint is unreachable it falls back to a pinned
known-good id per family and says so on stderr -- the map going stale can then
only cost availability, never correctness.

The constructor default was `claude-sonnet-4-20250514`, also retired; it now
tracks the family too.

Local config: heavy -> claude-opus-latest, fast -> claude-haiku-latest.
Verified live: opus-latest -> claude-opus-5, haiku-latest ->
claude-haiku-4-5-20251001, sonnet-latest -> claude-sonnet-5, and an exact id
passes through.

Also found while there: both anthropic entries were named "anthropic", and the
registry keys by name, so the second silently OVERWROTE the first and one
tier's model was discarded entirely. Renamed to anthropic-fast /
anthropic-heavy so NamedProvider keeps them distinct and the tier split is
real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:55:44 +01:00
14eb2623bc fix(cli): trace's 'slowest' names a step, not the aggregate
tool_call_trace and pipeline_execution are aggregates OF the stages, so letting
them compete always named the total and told you nothing. Verified live: now
reports 'slowest: paginate (252ms)' rather than 'tool_call_trace (509ms)'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:48:07 +01:00
89db09a12d feat(cli): mcpctl trace <code>, and bound the table it reads
The trace codes are now emitted in every deadline message and degradation
notice, so they need somewhere to go.

`mcpctl trace <code>` queries the audit events mcpd already stores and renders
the request as a waterfall: each step with its offset, duration, byte delta,
and any ⚠ degradation or ✗ error, then a summary naming the slowest step. No
new endpoint was needed -- GET /api/v1/audit/events?correlationId= already
existed and correlationId is already an indexed column; it just had nothing
writing meaningful values into it until this branch, and nothing reading it.

--strict exits non-zero when the trace contains an error or a degraded step, so
it composes into scripts. An empty result explains itself rather than printing
nothing: batches flush up to 5s late, old requests predate trace codes, and the
codes exclude I/L/O/U so a mistyped one is worth calling out.

Retention: AuditEvent had no prune at all. That was defensible while the table
was write-only; it is not now that a command reads it. Mirrors the AuditLog
convention exactly -- POST /api/v1/audit/events/purge, triggered rather than
scheduled, guarded by the existing audit-purge RBAC operation so it cannot be
granted by accident separately from the log purge. 30 days by default rather
than AuditLog's 90: these are several rows per MCP call, not a record of
administrative mutations.

Note for the plan's sake: I had assumed AuditLog retention was a scheduled job
registered in main.ts. It is not -- it is a manual endpoint. Mirroring what the
codebase actually does beat inventing a scheduler that exists for neither table.

completions are generated, so `trace` is registered in PROJECT_SCOPED_COMMANDS
and both shells regenerated; the freshness test passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:44:36 +01:00
b6e270ee48 feat(mcplocal): every request answers, even when the pipeline wedges
The LLM budgets bound the failure we actually hit. This is the backstop for the
ones they cannot see: a wedged plugin hook, a virtual-tool handler, an upstream
path without its own timeout.

transport.onmessage IS the whole request pipeline, and it writes to a socket
that has already been hijacked from Fastify. The SDK does not await it, so a
throw became an unhandled rejection and a hang became silence -- in both cases
the client got no response at all and waited out its own timeout. onmessage is
now structured so that reaching transport.send() is unconditional: the route
call is raced against a deadline, and the notification flush and the send each
carry their own catch, so a failure while flushing can no longer cost the client
its response.

transport.onerror was never assigned, so SDK-level transport errors were
swallowed entirely. It is now.

Shape of the answer is deliberate. A tools/call comes back as a SUCCESSFUL
result with isError and readable text naming the trace code -- the same shape
router.ts already uses for an expired _resultId, because a transport error tends
to surface as a hard failure while a tool error is something the model reads and
acts on. It also says the upstream may still be running, which is true and
matters. Other methods get a JSON-RPC error (-32001).

MCPLOCAL_TOOLCALL_DEADLINE_MS defaults to TOOLCALL_TIMEOUT_MS + 30s rather than
120s. The watchdog arms earlier in the request than mcpd's fetch does, so at
equal values it would always fire first, masking mcpd's specific
UpstreamTimeoutError with a generic message and cutting off pagination and the
rest of the post-processing. There is a test asserting the two stay ordered.

The pause queue is exempt, but not bypassed. It blocks until a human operator
releases, edits or drops a response, so the deadline SUSPENDS -- clock stopped,
then re-armed with whatever was left. A test asserts the re-arm, because an
exemption that forgot to re-arm would silently make every paused request
immortal.

End-to-end: an upstream that never settles now yields a response in under a
second carrying ⚠, the deadline, and `mcpctl trace <code>`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:41:09 +01:00
0eca4148e3 fix(mcplocal): recreate a stale session instead of 404ing outside the envelope
Second of the two faults that both presented as a 1800s hang.

mcplocal keeps MCP sessions in memory only, so any restart -- a deploy, an RPM
install, scripts/release.sh -- invalidates every connected client's session id.
The response was a bare 404 {"error":"Session not found"}: 3.5ms server-side,
but it lands OUTSIDE the JSON-RPC envelope with no id, so the client cannot
correlate it to its request and simply waits out its own timeout. Every mcpctl
tool call in that session became a 30-minute stall while the server was
perfectly healthy and answering other traffic in milliseconds.

Now the session is recreated, keeping the id the client already holds -- a
re-handshake would change it and invalidate everything the client has cached.
The SDK assigns sessionId/_initialized only while handling an `initialize`
(webStandardStreamableHttp.js:419-420), so adoptSession() replays exactly those
two assignments. It reaches into SDK internals, so it fails LOUDLY: the guard
throws with a pointer to itself, and session-adopt.test.ts is the canary that
goes red at `pnpm test` rather than in production on the next SDK bump.

Recreation is not silent. Doing it quietly would hand an agent an ungated tool
list with no signal that its gate state had vanished, so the first tools/call
result carries the established "⚠ Session state unavailable (...)" notice
telling it to call begin_session again.

onsessioninitialized does NOT fire on the adopt path, so its body is factored
into registerSession() and called explicitly. That is the subtle part: skipping
it would have produced recreated sessions with null userName on every audit
event, silently.

Also fixed, and it is the CAUSE rather than the symptom: MCP clients close a
session with DELETE + Content-Type: application/json and an empty body, which
Fastify's default parser rejected with FST_ERR_CTP_EMPTY_JSON_BODY before the
route handler ran. sessions.delete() therefore never happened and every
"closed" session leaked -- manufacturing the stale-session condition this
commit handles. Fixing recreation without fixing teardown would have shipped
the workaround and kept the cause.

project-mcp-endpoint.test.ts's "returns 404 for unknown session ID" inverts to
"recreates an unknown session instead of 404ing it", and asserts the id comes
back unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:36:27 +01:00
c06c34f0a2 feat(mcplocal): a trace code that actually reaches the audit trail
The plumbing for request tracing was all present and almost entirely inert.
correlationId was generated at the endpoint and threaded into traffic events,
but:

  - emitTrace() dropped it, so every durable tool_call_trace row had
    correlationId = null and could not be joined to anything;
  - ExecuteOptions.correlationId existed but its only production caller never
    passed it, making every stage_execution and pipeline_execution row
    unjoinable too -- dead code that looked like a feature;
  - plugin events (all three gate emits) had no access to it at all;
  - and the value itself was `<session-uuid>:<jsonrpc-id>`, which nobody can
    retype from a screenshot.

Also fixed: onToolCallBefore intercepts returned without emitTrace, so every
tool call made while a session was gated produced no trace whatsoever.

The trace code now IS the correlationId. Nothing parses the old format --
checked across mcpd's audit repository and route, mcplocal's router and traffic
capture, and the CLI console -- sessionId is its own audit column and the
JSON-RPC id is in the traffic body, so nothing is lost. AuditEvent.correlationId
is already an indexed Postgres column, so lookup works with no migration.
8 chars of Crockford base32 without I/L/O/U: readable aloud, typeable from a
screenshot, 40 bits (a collision shows two traces, it corrupts nothing).

Getting it to the plugins and the executor needed AsyncLocalStorage, not a
parameter. Both are constructed per SESSION, not per request -- processContent
is a closure with no RouteContext -- so threading an argument would have touched
PluginSessionContext, every plugin, ExecuteOptions and StageContext. ALS also
stays correct with two requests in flight on one session, which a mutable field
on the session-scoped context would not; there is a test for exactly that.

PluginContextImpl.emitAuditEvent auto-fills it the way it already auto-fills
sessionId, so this covers every gate event and every plugin written later
without a per-callsite edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:32:10 +01:00
c2a419b4f7 fix(mcplocal): one LLM budget per stage, so a loop cannot multiply it
A per-call timeout bounds one call. It does not bound a stage that calls the
LLM in a loop -- and summarize-tree does worse than loop: buildTree recurses to
maxDepth (3 by default) and iterates per section at every level, then
groupSections iterates again. Hundreds of sequential calls are reachable, so a
10s per-call cap is a multiplier, not a bound.

StageBudget is one wall-clock budget shared across everything a single stage
invocation does. Once it is gone the stage takes its deterministic path --
first-line excerpts, numbered pages -- and says so. The executor builds one per
stage from config.budgetMs ?? MCPCTL_STAGE_LLM_BUDGET_MS (30s) and disposes it
in a finally, so a long-lived mcplocal never accumulates timers.

Two details that matter more than they look:

  - A warm cache is never budget-gated. cachedSummarize and generatePageTitles
    used ctx.cache.getOrCompute, which makes the budget check impossible to
    place correctly; split into get/set so a cached summary -- which costs
    nothing -- is still returned when the budget is spent.
  - "No LLM configured" is NOT a degradation. It is a deliberate choice, and
    numbered pages are the expected output there, so it gets no warning. Only
    failures, timeouts and exhausted budgets do. Crying wolf on a working
    configuration would train people to ignore the notice.

summarize-tree's single-block path previously had no try/catch at all, so a
failure escaped the stage entirely and executor.ts discarded the whole stage's
work. It now degrades like the others.

Degradation is reported three ways, all from util/degrade.ts: the reason in the
content behind the established "⚠ <feature> unavailable (<reason>)" prefix, the
count of affected sections (the recursion can hit one exhausted budget dozens of
times, so the user needs the reason once plus what it cost), and
degraded/degradedReason on the stage_execution audit event -- previously a
degradation was invisible in the trace.

docs/reliability.md now names ONE helper rather than a list of compliant call
sites. That list is exactly how this drifted: the doc named the gate and
llm/pagination.ts, and the newer stages never joined it.

Tests: the 20-section recursion completes in ~300ms against a 300ms budget;
a warm cache still serves real titles with the budget exhausted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:29:45 +01:00
2c90f5971d fix(mcplocal): no LLM call can hang a tool call forever
docs/reliability.md has stated the rule since it was written: "LLM-optional
operations must be time-bounded, fall back deterministically, and report the
degradation". The gate and llm/pagination.ts obey it. The newer proxymodel
stages never adopted it -- stages/paginate.ts:72 awaited ctx.llm.complete()
with no timeout and no signal, and its try/catch caught errors, not hangs.

So when a provider hung rather than erroring, the promise never settled, the
tool call never returned, mcplocal never wrote to the already-hijacked socket,
and the client waited out its own 1800s timeout. In the journal that is three
POST /projects/docmost/mcp requests accepted and never answered -- the only
three such requests in 9,600 across every project, each followed immediately
by [llm-adapter] "trying next" and [paginate] "Smart page titles failed".

It read as a Docmost transport fault for two sessions. It was neither: Docmost
answers /search in 203ms, and through mcplocal on a fresh session in 461ms.
docmost_search returns ~14KB and crosses the pagination threshold; update_page
returns a small ack and does not. One tool paginated, one did not, and only the
paginating one could hang.

Bound it centrally in the adapter rather than at each call site, so a stage
that passes no options is still bounded and a stage written next year inherits
the guarantee:

  - LLMCompleteOptions gains optional budgetMs/perCallTimeoutMs/signal. Optional
    matters -- seven inline stubs implement LLMProvider structurally and a
    required member would break every one.
  - LLMProviderAdapter.complete() runs one budget with a deadline across the
    whole failover chain, each attempt capped at min(perCall, remaining). A
    per-provider timeout would have made the worst case N x timeout; wrapping
    the method would have killed failover mid-chain.
  - Every attempt now gets a real AbortSignal. anthropic and openai (which back
    vllm) honour it; deepseek/ollama/gemini ignore it, and withTimeout's race
    unblocks the caller anyway -- exactly what its doc comment anticipated.
  - A caller abort stops failover: if nobody is waiting, don't burn the chain.

util/degrade.ts generalises the gate's bounded -> fallback -> loud log ->
degradedReason pattern into bounded(), which cannot throw, so the deterministic
fallback is unconditional rather than something a catch block must remember.
gate.ts is refactored onto it; plugin-gate and router-gate pass UNMODIFIED (61
tests), which is the proof the generalisation is faithful.

anySignal() rather than AbortSignal.any: that landed in Node 20.3 and
package.json declares >=20.0.0.

proxymodel-llm-adapter.test.ts now asserts signal: expect.any(AbortSignal) --
every provider attempt being cancellable is the guarantee, so it is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-25 23:23:16 +01:00
a87c4faf21 Merge pull request 'test(mcplocal): smoke asserts the wire-form tool-name contract' (#126) from test/wire-safe-smoke into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m23s
CI/CD / lint (push) Successful in 2m49s
CI/CD / test (push) Successful in 1m28s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 4m32s
CI/CD / publish (push) Has been skipped
2026-08-25 21:06:29 +00:00
Michal
b4ad95ca66 test(mcplocal): smoke asserts the wire-form tool-name contract
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / lint (pull_request) Successful in 2m43s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / build (pull_request) Successful in 2m41s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped
The smoke suite talks to the live mcplocal through the HTTP boundary, which
serves wire names since #124 — assertions expecting `favourite/`, `all/` and
`smoke-aws-docs/` prefixes now check the underscore wire form (config pins
stay canonical). These two files were the release-gate failures after the
bbd3188 rollout; 166/166 green against the live fleet after the update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 22:06:18 +01:00
bbd31883e8 Merge pull request 'fix(mcplocal): wire-form tool names in gate and favourite-index prose' (#125) from fix/wire-safe-prose into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m25s
CI/CD / lint (push) Successful in 2m40s
CI/CD / test (push) Successful in 1m30s
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-25 20:59:00 +00:00
Michal
c014fcdd82 fix(mcplocal): name tools in wire form in gate and favourite-index prose
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m24s
CI/CD / lint (pull_request) Successful in 2m34s
CI/CD / test (pull_request) Successful in 1m44s
CI/CD / smoke (pull_request) Failing after 3m18s
CI/CD / build (pull_request) Successful in 2m30s
CI/CD / publish (pull_request) Has been skipped
Follow-up to #124: the boundary WireNameCodec serves underscore-joined
names, but the gate plugin's tool inventories (initialize instructions and
begin_session response) still listed canonical `server/tool`, and the
favourite-index instruction told the model to prefer `favourite/<tool>` —
prose naming functions the model cannot call. Inventories now run through
sanitizeWireName and the instruction describes the favourite_/all_ prefixes.

Cosmetic for routing (slash names still pass through the codec) but
load-bearing for tool selection: models copy names out of prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 21:58:52 +01:00
7fbb827aa5 Merge pull request 'fix(mcplocal): serve OpenAI-safe tool names on the wire' (#124) from fix/wire-safe-tool-names into main
Some checks failed
CI/CD / lint (push) Successful in 1m20s
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / test (push) Successful in 1m28s
CI/CD / build (push) Successful in 2m28s
CI/CD / smoke (push) Failing after 3m1s
CI/CD / publish (push) Has been skipped
2026-08-25 19:23:19 +00:00
Michal
eb0e97e76e test(mcplocal): end-to-end wire-name coverage on the project endpoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
Drives /projects/:name/mcp over the real Streamable HTTP transport with a
fake websearch upstream: tools/list must serve `websearch_fetch_content`
(every name matching the OpenAI function-name charset), calling that wire
name must reach the upstream as bare `fetch_content`, and a legacy client
echoing the canonical `websearch/fetch_content` must still route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:23:09 +01:00
Michal
065ce02a60 fix(mcplocal): serve OpenAI-safe tool names on the wire
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m40s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m3s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
The proxy namespaces tools as `server/tool` (and favourite-index presents
`favourite/<tool>` / `all/<server>/<tool>`). A `/` is not a valid character
in an OpenAI-style function name, so hosts that forward MCP tool names
verbatim as LLM function names depend on the model faithfully echoing an
illegal name. LibreChat did exactly that: deepseek-v4-flash intermittently
dropped the `websearch/` prefix, LibreChat's registry lookup failed, and it
reported "This tool's MCP server is temporarily unavailable" while nothing
was down — the calls never reached mcplocal at all (confirmed against the
AuditEvent table, 2026-08-25). Claude Code and the pi extension only dodge
this because they sanitize names client-side.

Fix at the HTTP boundary only: a WireNameCodec rewrites tools/list responses
to wire-safe names (`/` -> `_`, exact-match reverse map, deterministic
suffix on collision) and maps tools/call names back before routing. Wired
into both /mcp and /projects/:name/mcp. Everything inside the proxy —
routing maps, plugins, favourites config, audit events — keeps canonical
names, and unknown inbound names (legacy clients echoing slash names,
virtual tools) pass through unchanged, so existing clients keep working.

Codecs are keyed per project and outlive the router cache TTL so a client
can call a tool it listed minutes earlier; after a restart the client's
initialize-time tools/list repopulates the map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:17:33 +01:00
9c5d0d1861 Merge pull request 'fix(gitea): pin rebuilt image by digest, match upstream CMD form' (#123) from fix/gitea-mcp-digest-pin into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m18s
CI/CD / lint (push) Successful in 2m30s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 2m1s
CI/CD / build (push) Successful in 4m27s
CI/CD / publish (push) Has been skipped
2026-08-21 16:07:31 +00:00
Michal
c16d7964c9 fix(gitea): pin the rebuilt image by digest and match upstream's CMD form
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m39s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Two corrections to the shell-bearing rebuild.

**Pin by digest.** It copied from `:latest`, so a rebuild silently ships
whatever upstream has moved to. When a probe started failing right after a
rebuild I could not tell a version change from a broken build, and burned
time on the wrong one — the binary's own `--version` prints 1.1.0 while
the image label says 1.6.0, so that was a red herring too. Now pinned to
sha256:dda8d56e…, which IS the running 1.6.0.

**CMD, not ENTRYPOINT.** Upstream sets `Cmd: ["/app/gitea-mcp"]` with no
entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
Cmd but only APPENDS to an ENTRYPOINT — so the ENTRYPOINT form would have
changed how the binary is invoked for any server that sets a command.
Matching upstream's shape keeps the non-injected path byte-identical.

gitea also needs `entrypoint` on its server row: its `command` is [], so
there is nothing for the injector wrapper to wrap without it. Set to
["/usr/local/bin/gitea-mcp"] via apply -f (patch cannot express an array).

Verified live: gitea RUNNING/healthy on secretDelivery: injector, with
vault-agent-init present and the command wrapped as
  ["/bin/sh","-c",". /vault/secrets/gitea-creds; exec \"$0\" \"$@\"",
   "/usr/local/bin/gitea-mcp"]
`get_me` — which needs read:user, the scope that started this whole
session — returns the real account. Plaintext credentials across all
mcpctl server pod specs: 4 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 17:07:23 +01:00
7716e424f9 Merge pull request 'feat(gitea): rebuild gitea-mcp on a shell-bearing base' (#122) from feat/gitea-mcp-shell-base into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m26s
CI/CD / typecheck (push) Successful in 2m48s
CI/CD / smoke (push) Failing after 3m7s
CI/CD / build (push) Successful in 2m12s
CI/CD / publish (push) Has been skipped
2026-08-21 10:16:28 +00:00
Michal
f097c0f4d5 feat(gitea): rebuild gitea-mcp on a shell-bearing base
Some checks failed
CI/CD / lint (pull_request) Successful in 1m20s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / typecheck (pull_request) Successful in 2m52s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / publish (pull_request) Has been skipped
Upstream docker.gitea.com/gitea-mcp-server is distroless — `Cmd` is
["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by
exec'ing every candidate against the running pod).

That is fine until the server wants secretDelivery: injector. The OpenBao
agent renders secrets to a FILE, so mcpd wraps the container command as
`sh -c '. /vault/secrets/<name>; exec "$0" "$@"'`, which needs a shell.
gitea was the only server in the fleet blocked on this, and so the only
one whose token had to stay inline in its pod spec.

Copying one static Go binary onto debian:stable-slim is cheaper than
building and maintaining a static envexec shim, and follows the precedent
in deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party
MCP servers when it needs to change how they run.

ca-certificates is required rather than incidental: the binary talks HTTPS
to mysources.co.uk and the distroless base shipped a trust store we are
leaving behind. Verified in the built image: shell present, binary runs,
ca-certificates.crt present.

ENTRYPOINT is kept so the plain (non-injected) path behaves exactly like
upstream; mcpd replaces it with the sourcing wrapper only when the server
opts in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:16:23 +01:00
c79bdab51b Merge pull request 'fix(secrets): wrap an image server's own command, not just entrypoint' (#121) from fix/injector-image-server-argv into main
Some checks failed
CI/CD / lint (push) Has been cancelled
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-21 10:14:42 +00:00
Michal
913c0fbdc6 fix(secrets): wrap an image server's own command, not just entrypoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m25s
CI/CD / test (pull_request) Successful in 1m32s
CI/CD / typecheck (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m34s
CI/CD / publish (pull_request) Has been skipped
Migrating docmost and my-home-assistant, both rendered their secret and
neither picked it up. The pod spec showed why:

  command: (empty)
  args:    ["node","build/index.js"]
  server.entrypoint: (unset)

wrapCommand consulted only `server.entrypoint` for non-package servers, so
with `entrypoint` unset it returned undefined, the wrapper was skipped
entirely, and the container ran its normal command — which never sourced
/vault/secrets/<name>. The failure is silent by construction: the agent
init container succeeds, the file is there, and the server simply starts
with empty credentials.

An explicit `command` on an image server is already a complete command
line — mcpd's exec mode would run exactly it — so it should be wrapped
verbatim. `entrypoint` is only needed when there is no command at all and
the image's own ENTRYPOINT would take over.

Now branches on the three real shapes: package server (prepend the runner
entrypoint mcpd owns), image + command (use verbatim), image only (require
the declared entrypoint).

Seven tests, one per shape plus the two undefined cases. Reintroducing the
old logic fails two of them — checked before keeping.

Both servers were rolled back to secretDelivery: env and are healthy; they
can migrate once this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:14:39 +01:00
beb57baf58 Merge pull request 'fix(secrets): injector-delivered servers must attach, not exec' (#120) from feat/injector-attach-mode into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m19s
CI/CD / lint (push) Successful in 2m24s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m40s
CI/CD / publish (push) Has been skipped
2026-08-21 01:05:53 +00:00
Michal
0a83f71648 fix(secrets): injector-delivered servers must attach, not exec
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m41s
CI/CD / test (pull_request) Successful in 1m28s
CI/CD / build (pull_request) Successful in 2m18s
CI/CD / smoke (pull_request) Failing after 3m3s
CI/CD / publish (pull_request) Has been skipped
With injected delivery the credentials exist ONLY in PID 1's environment:
the container command is a shell that sources /vault/secrets/<name> and
execs the real server, so the values live in that process and nowhere
else — not in the pod spec, which is the whole point.

mcpd picks its STDIO mode from the server's shape: an explicit command or
a packageName selects `exec`, a bare dockerImage selects `attach`. `exec`
spawns a NEW process inside the container, which never sourced the file
and therefore starts with empty credentials. The server comes up, answers
tools/list, and fails every authenticated call — precisely the silent
empty-token failure this feature exists to prevent.

Seen as `Readiness check (list_datasources) failed: process exited 1` on a
pod whose PID 1 demonstrably held the token (verified via /proc/1/environ:
GRAFANA_URL set, token 46 chars, nothing in the pod spec).

So secretDelivery: injector now forces attach regardless of server shape.
Safe because wrapCommandForInjector execs rather than forks, so PID 1 IS
the server. It also means no bouncer or HTTP shim is needed — mcpd's
PersistentStdio already holds one long-lived connection; it was simply
pointed at the wrong process.

Note this inverts an assumption I had earlier: image-entrypoint servers
were already on the attach path and would have worked; it is the
package-based ones that were broken. gitea remains the sole exception, and
for the unrelated reason that it is distroless and has no shell to source
the file.

Extracts the decision as a pure `chooseStdioMode()` so it can be tested —
it is subtle and fails silently. Six cases pinned, including that env
delivery still execs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 02:05:51 +01:00
f25616f720 Merge pull request 'fix(secrets): injector wrapper must replace the entrypoint' (#119) from fix/injector-entrypoint into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / lint (push) Successful in 2m35s
CI/CD / test (push) Successful in 1m28s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m39s
CI/CD / publish (push) Has been skipped
2026-08-21 00:28:33 +00:00
Michal
ec35e1cc36 fix(secrets): the injector wrapper must replace the entrypoint, not extend it
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m35s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / publish (pull_request) Has been skipped
Caught migrating a real server: the pod crashlooped with
`.: cannot open /vault/secrets/grafana-creds`, and the reason was in the
generated spec:

  args: ["/bin/sh","-c",". /vault/secrets/grafana-creds; exec \"$0\" \"$@\"",
         "@leval/mcp-grafana"]

mcpd deliberately maps ContainerSpec.command -> k8s `args` so a package
server keeps its runner image's ENTRYPOINT (`npx -y`, `uvx`). Putting the
sourcing wrapper there meant the pod actually ran
`npx -y /bin/sh -c '...'` — npx trying to resolve a package called
/bin/sh. The agent had rendered the file correctly; nothing ever sourced it.

Adds `ContainerSpec.entrypoint`, which maps to k8s `command` and so
REPLACES the image entrypoint, and has wrapCommand fold that entrypoint
into the argv it returns (`npx -y` / `uvx` for package servers, the
server's own `entrypoint` field for dockerImage servers — already required
at validation for exactly this reason).

Two tests pin it: a wrapped server emits `command` and no `args`; an
unwrapped one still emits `args` and no `command`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 01:28:21 +01:00
13421008c7 Merge pull request 'fix(servers): persist secretDelivery and entrypoint' (#118) from fix/server-repo-field-mapping into main
Some checks failed
CI/CD / lint (push) Successful in 1m15s
CI/CD / test (push) Successful in 1m32s
CI/CD / typecheck (push) Successful in 2m48s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 3m31s
CI/CD / publish (push) Has been skipped
2026-08-20 22:41:12 +00:00
Michal
ef9ba6fb8d fix(servers): persist secretDelivery and entrypoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m29s
CI/CD / typecheck (pull_request) Successful in 1m18s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / smoke (pull_request) Failing after 1m59s
CI/CD / build (pull_request) Successful in 6m42s
CI/CD / publish (pull_request) Has been skipped
`mcpctl patch server my-grafana secretDelivery=injector` printed
"patched server 'my-grafana'" and changed nothing. The repository maps
update/create fields explicitly, one by one, so a new column silently
does nothing until it is added there — and the silence is total: the API
returns 200, the CLI reports success, and `get -o yaml` still shows the
old value.

Caught by trying to migrate a real server, not by any test.

Adds the two fields to both create and update, plus tests that assert the
mapping directly. Those tests fail against the unfixed repository (3 of 4)
— verified before keeping them.

This class of bug will recur: the mapping is manual and nothing links a
schema column to it. The tests at least make the next omission loud for
these two fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 23:40:59 +01:00
fef26a9f81 Merge pull request 'feat(secrets): opt-in injected secret delivery, scoped per server' (#117) from feat/per-server-identity-tests into main
Some checks failed
CI/CD / lint (push) Successful in 1m13s
CI/CD / typecheck (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / test (push) Has been cancelled
2026-08-20 22:33:56 +00:00
Michal
0793285105 chore: ignore .claude/worktrees
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / typecheck (pull_request) Successful in 2m54s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 6m22s
CI/CD / publish (pull_request) Has been skipped
Agent worktrees are scratch checkouts. Without this a plain `git add -A`
sweeps them in as embedded git repositories, which clone as empty
directories for everyone else.
2026-08-20 23:33:26 +01:00
Michal
370fd0a034 feat(secrets): opt-in injected secret delivery, scoped per server
Completes the path that stops mcpd writing secret VALUES into MCP server
pod specs. With `secretDelivery: injector`, the pod fetches its own
secrets from OpenBao through the agent injector, under a ServiceAccount
and role scoped to just that server's secrets — so the value never enters
etcd, and gitea-mcp cannot read the Grafana token.

Opt-in per server, defaulting to `env`. Every existing server is
bit-for-bit unchanged, and migrating is one reversible decision at a time
rather than a flag day.

The two invariants under most risk, both tested:

- **Opted-out servers produce an identical manifest.** No annotations, no
  serviceAccountName, automountServiceAccountToken still false.

- **Opted-in servers still fail LOUDLY on a bad ref.** Once mcpd stops
  reading a server's secrets, the check e6cd735 added no longer fires for
  it, and a typo'd secretRef would degrade into a vault-agent-init
  crashloop that mcpd reports as a generic pod failure — the same class of
  bug that had gitea-mcp running for weeks on an empty token while
  reporting healthy. `validateServerEnvRefs` resolves every ref and throws
  the value away, purely to keep that error. After the value cache it is a
  cache hit and costs nothing.

Shell quoting is the other silent-failure trap and is treated as part of
the contract: the agent renders `export NAME='value'` and the container
command sources it, so a value containing a space, `$`, a quote or a
newline would truncate and yield an empty token. `shellSingleQuote` is
tested by executing a real /bin/sh over nine adversarial values including
`'; export PWNED=1; '` — and those tests fail against naive quoting,
confirmed before keeping them.

`sh -c <script> arg0 arg1 …` preserves argv via $0/$@, and `exec` keeps
PID 1 as the real process, which matters because mcpd attaches to PID 1's
stdin/stdout for STDIO servers.

Docker/Podman declare `capabilities.secretRefs: false` and fall back to
inline resolution, so local development is untouched. Deleting a server
revokes its identity, after its pods are gone and best-effort — a role no
pod can authenticate as grants nothing, and failing the delete over it
would strand the row.

Per the CLI rules, `secretDelivery`/`entrypoint` are `create` flags,
round-trip through apply -f, and show in `describe server` — which now
also flags servers still inlining secrets into their pod spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 23:33:20 +01:00
Michal
f8427959e5 test(secrets): cover per-server identity containment
ServerIdentityService shipped without tests. Containment is the whole
point of the design, so it needs assertions rather than trust: a shared
role would let third-party MCP images (gitea-mcp, ha-mcp) read every
secret under secret/mcpctl/*, which is worse than the pod-spec exposure
it replaces.

Eight cases, all about what a server must NOT get: the grant covers only
the secrets that server declares; a secret referenced twice is one grant,
not two; inline env values never widen it; another server's secrets never
appear. Plus the ordering invariant (ServiceAccount before the role that
binds it — the reverse lets a pod start, fail to log in and crashloop
while the role is still being written), and that a backend which cannot
scope identities REFUSES rather than silently succeeding, which would
leave a pod believing it held access it never got.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 23:13:49 +01:00
85e53e8d1e Merge pull request 'fix(secrets): list via GET ?list=true — the LIST verb dies at the proxy' (#116) from fix/openbao-list-verb into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / lint (push) Successful in 2m31s
CI/CD / test (push) Successful in 1m31s
CI/CD / smoke (push) Failing after 2m3s
CI/CD / build (push) Successful in 4m29s
CI/CD / publish (push) Has been skipped
2026-08-20 22:03:40 +00:00
Michal
7b5136491f fix(secrets): list via GET ?list=true — the LIST verb dies at the proxy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m44s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Found by the readiness probe added in the previous commit, on its first
run against production: `mcpctl status` reported

  Secrets:    bao-k8s* ✗ auth failed: OpenBao list: HTTP 400 Bad Request

That is a real bug, not a probe artifact. `bao-k8s` is configured with
the public ingress URL, and Cilium's ingress Envoy rejects the
non-standard LIST HTTP method outright. Verified live from the mcpd pod:

  LIST   https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/            -> 400 Bad Request
  GET    https://bao.ad.itaz.eu/v1/secret/metadata/mcpctl/?list=true  -> 403 permission denied (bogus token, i.e. reached bao)
  LIST   http://openbao.openbao.svc:8200/... (ClusterIP, no Envoy)    -> 403 permission denied

So the verb was fine against bao and fatal through the ingress. OpenBao
accepts both forms and documents the GET form for exactly this reason.

This was never noticed because nothing called list() in production —
it backs `mcpctl migrate secrets`, which would have failed with an opaque
HTTP 400 against any ingress-fronted backend.

Also adds the per-server scoping primitives Phase 2 needs, with tests:
buildServerSecretPolicyHcl (no wildcards, read-only, stable under
reordering), buildServerProvisioningPolicyHcl (prefix-confined so mcpd
cannot grant itself more than it holds), ensureKubernetesAuthRole /
delete helpers, the driver-level ensureServerIdentity/removeServerIdentity
capability, and ServerIdentityService.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:54:42 +01:00
eb3e558a44 Merge pull request 'feat(secrets): survive OpenBao outages and report real backend health' (#115) from feat/openbao-resilience into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m24s
CI/CD / lint (push) Successful in 2m31s
CI/CD / test (push) Successful in 1m32s
CI/CD / build (push) Successful in 2m28s
CI/CD / smoke (push) Failing after 3m6s
CI/CD / publish (push) Has been skipped
2026-08-20 21:37:57 +00:00
Michal
545e7745da test(secrets): cover the rotator loop's boot-time dead-token detection
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m25s
CI/CD / lint (pull_request) Successful in 2m34s
CI/CD / test (pull_request) Successful in 1m32s
CI/CD / smoke (pull_request) Failing after 3m3s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
SecretBackendRotatorLoop had zero tests, despite being the detector added
in e51b924 specifically so a re-initialised OpenBao surfaces the moment
mcpd boots rather than 24h later when the scheduled rotation finally
fires. The class already injects setTimeout/clearTimeout and a logger, so
this needed no production change.

Nine cases, weighted to what actually breaks: the boot health check runs
per rotatable backend; a dead token emits kind BACKEND_TOKEN_DEAD through
the injected logger (the point of the earlier console.error removal — a
bare console call never reaches ErrorLogBuffer, so `mcpctl errors` could
not see it); a throwing health check does not abort start(); overdue
backends rotate immediately and still get scheduled; the 60s floor holds
across 50 adversarial-jitter draws; and stop() both clears timers and
trips the `stopped` guard against rescheduling, which had never been
exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:17:24 +01:00
Michal
fe9987cefd test(secrets): smoke-cover backend health honesty + cache correctness
Two of these four assertions currently FAIL against the live cluster, which
is the point: mcpd there still renders `Secrets: bao-k8s* ✓` from the
rotation field, and its JSON status carries no live/ready pair. Per the
project rule the fix is to deploy, not to relax the assertion.

The status check specifically rejects a bare "name ✓" with no qualifier —
that string is the old rendering and proves the probe was never consulted.

Deliberately does not take the real OpenBao down. Simulating an outage
against shared infrastructure to satisfy a test would be worse than the
bug it covers; the outage paths are unit-tested with an injected clock.

Docs: adds a Reliability section (request hardening, the stale-while-error
table, the cold-cache gap and why persisting values is not the answer,
live-vs-ready) and replaces the stale "Kubernetes ServiceAccount auth is
not shipped yet" note — it shipped in 5152066, and it is what the live
backend has used since June.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:16:04 +01:00
Michal
0fbfc72d68 feat(secrets): report real backend health instead of a hard-coded tick
`mcpctl status` derived its Secrets verdict entirely from
`tokenMeta.lastRotationError`. The rotator writes that field only for
`auth: 'token'` backends (SecretBackendRotator.isRotatable), so a
`kubernetes`-auth backend never wrote it and the line rendered a green
tick unconditionally — including with OpenBao sealed, unreachable, or
answering 403 to every read. The one signal we had was structurally
incapable of going red for the backend we actually run.

New `GET /api/v1/secretbackends/:id/health` reports two signals, kept
separate on purpose:

  live   — reachable at all?          (unauthenticated sys/health)
  ready  — can we read through it?    (uses our credentials)

live-but-not-ready is the exact shape of a re-initialised OpenBao handing
back valid-looking tokens that grant nothing; collapsing both into one
boolean is what hid that for four days. Needs no RBAC mapping — it falls
through to the generic `secretbackends` resource, so a GET is
`view:secretbackends`.

`mcpctl status` now renders four states — reachable / degraded (serving N
cached secrets) / unreachable / auth failed — with rotation error demoted
to a trailing clause rather than the verdict. A failed probe renders
"? unknown", never green: not knowing is not health. JSON output carries
the same probe, so scripts stop being told every k8s-auth backend is fine.

Also adds a boot-time cache warm. The stale-while-error cache can only
absorb an outage for secrets it has already seen, so a cold mcpd during a
backend outage still fails; resolving each running server's refs once at
startup closes that for the common case. Best-effort and deliberately
partial — if the backend is also down at boot this is a no-op and
instances fail loudly, which is correct. Persisting last-known-good to
Postgres or disk would just be plaintext-at-rest again.

Tests: 15 new. The five status assertions were confirmed to fail against
the old rotation-only logic before being kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:14:12 +01:00
Michal
bd3e1134c9 feat(secrets): survive OpenBao outages instead of cascading them
mcpd re-read the secret backend on every use — server env resolution, LLM
api keys, chat, git providers, code repos, webhooks — with no value cache,
no request timeout, and a retry that only fired on HTTP 403. A few seconds
of OpenBao unavailability therefore turned into minutes of degraded
service: instances that restarted during the blip failed env resolution,
got marked ERROR, and entered the 30s x5 then 5min backoff.

Three changes, in dependency order:

1. Typed errors. `SecretNotFoundError` (definitive) vs
   `SecretBackendUnavailableError` (transport). The distinction has to be
   typed rather than string-matched — a mis-classified "not found" would
   resurrect deleted secrets, and a mis-classified auth failure would
   paper over revoked grants, which is how an upstream re-init once broke
   every secret write for four days (e51b924).

2. Driver resilience. Every request now carries an AbortSignal timeout
   (there was none, so an unreachable backend hung its caller) and retries
   5xx/429/network with full-jitter backoff — 503 is what a sealed
   OpenBao returns and used to be an immediate hard failure. The 403
   purge-and-retry stays single-shot and outside the retry budget: it is a
   credential refresh, not a backend-unavailable condition, and looping on
   it would hide a genuinely revoked grant.

   `healthCheck()` no longer routes through the authenticated path, so an
   expired role stops reporting as "OpenBao is down"; it maps OpenBao's
   status codes (sealed/standby/uninitialised) instead. New `authCheck()`
   covers the readiness half via list(), which exercises the capability we
   actually depend on — unlike lookup-self, which only proves the token
   exists.

3. CachingSecretBackendDriver. Fresh reads inside the TTL never touch the
   network; past it we always try the backend, and on a transport failure
   serve the last known-good value instead of throwing. That is what stops
   the ERROR storm. Deleted secrets evict and rethrow — serving those
   stale would resurrect a revoked credential, strictly worse than an
   outage — and non-transport errors rethrow untouched. plaintext is not
   wrapped: its read() is an identity function over the row handed in.

A cold cache during an outage still fails, loudly and by design (e6cd735).

Also routes BACKEND_TOKEN_DEAD / BACKEND_ROTATION_FAILED through pino
rather than bare console.error. They bypassed the multistream feeding
ErrorLogBuffer, so the one failure `mcpctl errors` exists to surface was
the one it never showed.

Tests: 20 new. The two load-bearing guards (never serve a deleted secret
stale; never serve stale for a non-transport error) were confirmed to fail
against deliberately broken code before being kept. The 403 purge-retry
path had no coverage at all until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-20 22:08:10 +01:00
Michal
553834dbb1 docs(epics): document linking a task to its pull request
Accept only merges if the task carries a prNumber, and nothing said how a
human sets one. It goes through the generic patch command; without this the
merge gate reads as unreachable from the CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-16 00:16:07 +01:00
Michal
6d38953a96 docs(agentic-teams): document the new resources, and make the webhook reachable
Five resources shipped with no documentation and one documented lie:
docs/inference-tasks.md still told people `mcpctl get tasks` lists the
inference queue. Work items own that name now.

Adds docs/epics.md (the Epic -> EpicTask -> Task hierarchy, the state machine,
risk-scaled review gates, the accept-merges gate, and reconciliation),
docs/gitproviders.md (forge registration, discovery/import, merge-on-accept,
post-merge reopen) and docs/agent-authority.md (the three run modes, and
plainly which two are not implemented).

Two things found while writing them, both fixed rather than filed:

- `webhookSecretKey` was read by the webhook route but writable from NOWHERE
  -- absent from the create schema, update schema, service, CLI and apply
  spec. Since the endpoint fails closed on a null key, post-merge CI failures
  could never reopen their task; the board would keep claiming `merged` for
  work that broke the build. Adds --webhook-secret-key, the schema fields and
  the apply key, with tests covering persist / omit / explicit-null /
  reject-empty. Omission deliberately does not clear the column, so an
  unrelated edit cannot silently disarm the webhook.

- src/cli/src/commands/create.ts contained a literal NUL byte inside a string
  literal (a binary-file check written with the raw character rather than an escape). Identical behaviour, but it made the whole file read as
  binary, so `grep -I` skipped it entirely and 1120 lines of CLI were
  invisible to every text search. Replaced with the escape.

The Epic write path (criteria/status/tradeoffs) is API-only -- documented as
such rather than papered over, and filed as task 180.

Full suite 2612 green; lint at baseline 794/77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-16 00:14:58 +01:00
Michal
2a85447fac feat(rbac): bound what an agent may DO by whoever invoked it
Closes 177. An agent carries a project's prompts and its MCP tools, and the only
check on a chat turn was run:agents:<name> - so anyone who could talk to an
agent inherited tool access to every project that agent had joined. Membership
was the capability. AgentProject widened it from one pinned project to many.

The rule now: knowledge travels with the agent, action is bounded by the
authority of the run. Prompts and skills still load regardless of who is asking -
a QA reviewer that forgets the project's conventions when a different person
asks is not a reviewer. Every MCP tool call is gated.

Authority is resolved once per run (services/run-authority.ts) and is per-RUN,
not per-agent: the same agent answers a human under that human's authority and,
once a scheduler exists, runs unattended under its own. `caller` intersects the
agent's memberships with the caller's run:projects - reusing the exact grant the
project-chat route already demands, rather than inventing a parallel permission.
`agent` takes every membership without consulting a user. Both are implemented;
`service-account` throws 501 rather than honouring a forgeable x-service-account
header, and task 179 records what it needs first.

Gated in BOTH listTools and callTool, deliberately: withholding a tool from the
list is presentation, not enforcement - a model can invent a tool name it was
never shown, and callTool is what would run it.

The accepted consequence is stated plainly rather than papered over: a user can
ask about a project they cannot reach and the agent will answer from its
knowledge. The system block tells the model when it is holding such context so a
persona can apply it without reciting it. That is guidance to a model, not a
boundary - the boundary is that the tools are absent.

Two sibling holes found while exploring and closed here:

- Thread hijack. resolveThreadId on the agent path had no owner check, so anyone
  with run:agents:<name> could append to another user's thread by id and read
  its history back. The project path already did this correctly; mirrored, 404
  not 403 so ids cannot be probed.
- McpToken project escape. `project` travels in the request BODY, so the
  URL-based token scope check in main.ts never saw it - a token bound to project
  A could drive an agent into B's tools. The binding is now a ceiling.

Tests: 19 authority tests (intersection, fail-closed with no user and with no
RBAC wired, token ceiling, agent authority bypassing RBAC entirely, the 501) and
4 through the real chat path asserting prompts load while tools do not.

Three existing assertions updated for the new dispatcher signature - a
deliberate interface change, not tests bent to fit.

Local: build clean, workspace 2608 passed (+16), 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-15 23:54:53 +01:00
Michal
5fb1154190 Merge remote-tracking branch 'origin/main' into fix/stdio-restart-recovery
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m2s
CI/CD / smoke (push) Failing after 1m55s
CI/CD / build (push) Successful in 4m51s
CI/CD / publish (push) Has been skipped
2026-08-15 23:32:46 +01:00
Michal
b022f322f0 fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):

- the exec/attach websocket died without ending the stdout PassThrough
  (client-node installs no onclose), so PersistentStdioClient — whose only
  death signal was stdout 'end' — kept believing it was connected and every
  request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
  nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
  neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
  dueForRetry, and the retry recreated the pod under the SAME name — an
  uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
  replacement.

The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
  docker interactive path mirrors its own one-shot handlers), funneling into
  an identity-guarded teardown in PersistentStdioClient that also listens
  for stream 'close'/'error' — a late event from a previous session cannot
  clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
  with retry metadata cleared), tracks restartCount in instance metadata to
  catch restarts between polls, MERGES retry metadata instead of clobbering
  it, and evicts the cached stdio client through a setter-injected hook
  whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
  replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
  so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
  threshold, and shutdown finally calls closeAll().

Closes #114

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
Michal
f0ba6e8096 feat(git): reopen a task when CI fails on the commit its merge produced
Closes 151 — the last loop in the lifecycle. A task reaches `merged`, CI then
fails on the merge commit, and the same task comes back as `reopened` instead of
the breakage going unnoticed because the board already said done.

Two properties make this safe rather than a liability.

**It fails closed.** The endpoint is unauthenticated by nature — a forge calls
it — so it accepts nothing unless the provider has a configured webhook signing
secret AND the HMAC verifies. No secret, no effect. The signature is computed
over the RAW body: re-serializing a parsed object changes the bytes, which is
the classic way this check silently stops working, and there is a test for it.
A 404 for an unknown provider is deliberately vague, because confirming which
provider names exist is free reconnaissance on a public endpoint.

**It matches precisely.** Accept now records `merge_commit_sha` — the commit the
merge actually produced, which is what CI runs against — so a failing status
finds exactly the task that caused it. The alternative, reopening "whatever
merged most recently", would reopen innocent work. A failure on a commit mcpctl
did not merge is somebody else's business and is ignored, as is a task that has
already moved on from `merged`.

Capturing the merge commit is best-effort by design: the merge already
succeeded, so failing to re-read the sha must not fail the accept — it only
costs the webhook its precise match later.

The route is skipped by the RBAC hook, since the forge has no mcpctl identity.
That is only safe because of the fail-closed rule above, and both the skip and
the reason are asserted in tests.

Tests: 12 covering every refusal path (no secret, unsigned, wrong key, signature
over different bytes), the vague 404, the RBAC skip, and the reopen matrix.

Local: build clean, workspace 2592 passed (+12), db 128 passed (same 4
pre-existing), 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-15 23:10:49 +01:00
Michal
c66502e590 fix(mcplocal): fetch project instructions with the caller's token (#113)
Some checks failed
CI/CD / typecheck (push) Successful in 1m23s
CI/CD / test (push) Successful in 1m26s
CI/CD / lint (push) Successful in 2m55s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m53s
CI/CD / publish (push) Has been skipped
The instructions fetch was the one downstream call in getOrCreateRouter
still using mcpdClient — whose token is an empty string in HTTP mode — so
mcpd answered 401, the catch swallowed it, and every session initialized
with no instructions while nothing looked broken. requestClient exists
precisely for this; use it.

Closes #113

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 21:43:00 +01:00
Michal
740ce31469 fix(mcplocal): give proxied tools/call a 120s budget, not the 30s default
McpdUpstream.send routed every non-list method through the default-timeout
client, so any tool that legitimately runs past 30s — web_url_read through
a browser solver, a large PDF extraction, a slow retail site — died as
"mcpd proxy error: mcpd did not respond within 30000ms" while mcpd was
still working on it. LibreChat agents hit this constantly on real pages.

New TOOLCALL_TIMEOUT_MS (default 120s, env MCPLOCAL_TOOLCALL_TIMEOUT_MS)
sits between DISCOVERY_TIMEOUT_MS (list calls must stay short so a dead
upstream can't stall session init) and LONG_RUNNING_TIMEOUT_MS (chat and
inference, minutes). withTimeout preserves the caller token and headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 21:43:00 +01:00
Michal
ac9423e9c8 feat(git): merge the PR when a task is accepted
Closes 148/149 — the accept gate now lands code instead of just moving a row.

Driver gains createPullRequest / getPullRequest / mergePullRequest, verified
against the real Gitea API shapes rather than guessed: MergePullRequestOption
requires a capitalised `Do`, and supports `head_commit_id`.

That `head_commit_id` is the point. A human approved a SPECIFIC commit; if
someone pushed after that, merging anyway lands code nobody reviewed. So the
reviewed sha travels with the request and is checked twice — once by us for a
clear MergeStaleError naming both commits, and again by the forge, which closes
the race between our read and the merge. When it trips, the message says what
happened and what to do: re-review the current head, then accept again.

Accept merges BEFORE it transitions. If the forge refuses — conflicts, failing
checks, a moved branch — the task stays in awaiting_review and no event is
written. Reporting `merged` while main does not have the code is the single
worst thing this gate could do, and there is a test asserting the transition
never happens on a failed merge.

Failure modes are answers, not stack traces: no PR attached is approval-only and
succeeds; a repo with no forge connection explains that mcpctl cannot merge it;
an already-merged PR is treated as success so a retry after a timeout is safe.

owner/repo comes from `sourceFullName` when discovery recorded it, falling back
to parsing https, ssh:// and scp-style clone URLs, and returning null rather
than guessing when it cannot tell.

The merger is injected and optional, so an mcpd without a git provider still
runs and accept degrades to approval-only — which is also what the help text
now says, restored after the earlier commit that removed a claim we could not
back up.

Tests: 7 driver PR tests (Do field, head pinning, stale refusal, idempotent
re-merge, 405 → refusal), 10 merger tests incl. URL parsing, 7 accept-gate tests.

Local: build clean, workspace 2580 passed (+23), 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-15 00:05:31 +01:00
Michal
821c45b2ec fix(cli): stop claiming that accept merges anything
`mcpctl tasks accept --help` said "fires the merge". Nothing merges. There is
no createPullRequest or mergePullRequest anywhere in the codebase — the
git-provider client is task 148 and is not built.

Accept records human approval and moves the task to `merged`. Until 148/149
land, `merged` means "a human approved this", not "this is in main", and the
help text and service comments now say so. A user who accepted a task expecting
a PR to land would have been silently wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:57:46 +01:00
b6983f036d merge: stop the deploy script running smoke twice (#112)
Some checks failed
CI/CD / lint (push) Successful in 1m11s
CI/CD / test (push) Successful in 1m24s
CI/CD / typecheck (push) Successful in 3m7s
CI/CD / smoke (push) Failing after 1m57s
CI/CD / build (push) Successful in 2m18s
CI/CD / publish (push) Has been skipped
2026-08-14 22:45:30 +00:00
Michal
21aadf6d82 fix(deploy): stop running the smoke suite twice and crying wolf
Some checks failed
CI/CD / lint (pull_request) Successful in 1m14s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / typecheck (pull_request) Successful in 3m5s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m9s
CI/CD / publish (pull_request) Has been skipped
Step 7 called release.sh — which restarts mcplocal and runs the smoke suite
against the binary it just installed — and then restarted mcplocal and ran the
whole suite again. Two full suites inside a minute trips mcpd's rate limiter,
so the second run came back with six 429s and printed

    SMOKE TESTS FAILED — system may be unhealthy. Consider rollback

over a deploy that was fine. Seen for real on 35d506d: the first suite was
162/162 green, the second failed only on `mcpd returned 429: Rate limit
exceeded`, and a clean re-run afterwards was 162/162 again.

A deploy script that recommends rolling back a healthy release is worse than
one that says nothing. One run, one verdict — release.sh's exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU
2026-08-14 23:45:16 +01:00
35d506df77 merge: paginated-result contract usable by any MCP client (#111)
Some checks failed
CI/CD / lint (push) Successful in 1m16s
CI/CD / test (push) Successful in 1m29s
CI/CD / typecheck (push) Successful in 3m4s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m18s
CI/CD / publish (push) Has been skipped
2026-08-14 22:28:00 +00:00
Michal
03350856ea fix(mcplocal): make the paginated-result contract usable by any MCP client
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / typecheck (pull_request) Successful in 3m2s
CI/CD / smoke (pull_request) Failing after 2m5s
CI/CD / build (pull_request) Successful in 5m1s
CI/CD / publish (pull_request) Has been skipped
UniFi tools were unusable from non-Claude agents. A tool result over 2000
chars is replaced with a table of contents and re-read by calling the tool
again with _resultId/_section, but those params were never declared on the
tool's inputSchema — and unifi-network and my-grafana ship
`additionalProperties: false`, so for a client that validates arguments the
drill-down call was illegal and the data unreachable. The stub's own wording
made it worse: "Use section parameter" names a param that does not exist;
sending it fell through to the upstream, re-paginated, and minted a fresh
_resultId. An unbounded loop.

UniFi took the blame because it is the one server whose results always trip
the threshold: get_devices was 11,718 chars, get_clients 136,696 across 19
pages with a 92-char page-1. Grafana and Gitea return compact results.

- content-pipeline gains onToolsList, declaring _resultId/_section on every
  tool it can paginate (gate tools excluded — they are intercepted before the
  pipeline runs). additionalProperties stays false: a property listed in
  `properties` is already legal under it, so declaring is enough and the
  upstream keeps its typo protection.
- createDefaultPlugin wired only the gate's onToolsList, so the pipeline's
  would have been dropped on the floor. Both now chain, gate first.
- _resultId without _section re-shows the table of contents instead of
  forwarding an unknown argument to a strict upstream.
- The stub names the tool, the live _resultId and a real section id.
- Nested MCP envelopes are collapsed. A server fronting another MCP server
  returns the inner result wrapped in its own content/structuredContent pair,
  so the payload arrives two or three times over. A layer is peeled only when
  it carries nothing the inner value lacks, which keeps it lossless. Live
  against the sre project: get_devices 11,718 -> 5,210 chars and no longer
  paginates at all; get_clients 136,696 -> 62,358 across 8 pages instead of 19.
- deploy/mcplocal.service shipped MCPLOCAL_MCPD_URL=http://10.0.0.194:3100,
  which is dead — every working machine had a hand-written drop-in. Points at
  the k8s ingress now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU
2026-08-14 23:27:36 +01:00
Michal
8c359902c7 Merge 'feat(servers): persistent volumes + self-hosted web search and docs templates' into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m27s
CI/CD / typecheck (push) Successful in 2m59s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 4m43s
CI/CD / publish (push) Has been skipped
Servers can declare persistent volumes. The backing store is keyed on the
server (mcpctl-<server>-<name>), not the instance, so data survives the
instance recreation that any server edit triggers — a Kubernetes PVC ensured
before the pod is created and never deleted with it, or a Docker named volume.

Three templates, all self-hosted and none needing an API key:
  - duckduckgo — web search with no backing service at all
  - searxng    — better results, needs a SearXNG engine
  - docs-mcp   — open-source Context7/Ref alternative, uses the new volume

Verified against the cluster: PVC bound 20Gi RWO longhorn, and a sentinel plus
the SQLite index survived a full instance destroy/recreate onto a new pod.

Includes two fixes it took to get there, both latent beforehand:
  - client-node v1 puts the HTTP status on ApiException.code, not .statusCode,
    so every expected 404/409 was being rethrown — that also broke
    removeContainer against an already-deleted pod
  - pods with volumes need fsGroup, or a fresh root:root claim is unwritable to
    any image that drops privileges
2026-08-12 23:12:05 +01:00
Michal
8a31121e30 Merge remote-tracking branch 'origin/main' into feat/web-search-templates
# Conflicts:
#	completions/mcpctl.bash
#	completions/mcpctl.fish
#	src/cli/src/commands/create.ts
#	src/db/src/seed/index.ts
2026-08-12 23:11:54 +01:00
db38de7e09 Merge pull request 'fix(cli): don't brick the chat REPL when the first turn fails upstream' (#110) from fix/chat-repl-thread-brick into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m19s
CI/CD / lint (push) Successful in 2m46s
CI/CD / test (push) Successful in 1m26s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m36s
CI/CD / publish (push) Has been skipped
2026-08-10 21:55:10 +00:00
Michal
ac5dee906e fix(cli): don't brick the chat REPL when the first turn fails upstream
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m20s
CI/CD / lint (pull_request) Successful in 2m48s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / build (pull_request) Successful in 2m40s
CI/CD / smoke (pull_request) Failing after 3m46s
CI/CD / publish (pull_request) Has been skipped
Observed live: turn 1 died with an anthropic 429 before the stream's `final`
frame, streamOnce resolved '' as the thread id, the REPL stored it, and every
later message sent `threadId: ""` — rejected by mcpd's z.string().min(1) with
HTTP 400. Permanently stuck: no turn could succeed again, so no `final` frame
could ever repair the id.

Two independent layers:
- streamOnce now resolves `string | undefined` — undefined when no `final`
  frame arrived — and the REPL keeps its previous thread state on undefined
  instead of overwriting it. One-shot mode skips the `(thread: ...)` footer
  when there is none to report.
- chatBody refuses to serialize an empty threadId at all, so even a leaked ''
  can never reach the wire.

Regression cover in chat-thread-brick.test.ts (7 tests), including the full
REPL chain: failed turn 1 → turn 2 body carries no threadId key. The
assertions are the direct inverse of the old behavior, so they fail pre-fix
by construction.

CLI suite 726 passed, lint clean, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
2026-08-10 22:54:53 +01:00
d4c33baf03 Merge pull request 'fix(mcplocal): stream chat SSE through the proxy instead of buffering it' (#109) from fix/chat-sse-streaming into main
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m27s
CI/CD / typecheck (push) Successful in 3m4s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 2m21s
CI/CD / publish (push) Has been skipped
2026-08-10 21:28:00 +00:00
Michal
bbd2195c64 test(mcplocal): prove chat SSE streams live through the proxy, not buffered
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / lint (pull_request) Successful in 2m55s
CI/CD / smoke (pull_request) Failing after 1m59s
CI/CD / build (pull_request) Successful in 4m33s
CI/CD / publish (pull_request) Has been skipped
`mcpctl chat reviewer` showed nothing until the turn finished, then dumped
the whole answer at once. mcpd streams token deltas and the CLI renders
them incrementally — the sole buffering point was mcplocal's catch-all
/api/v1/* proxy reading the whole SSE body via res.text() before replying.
The previous commit (cherry-picked from feat/agentic-teams) pipes the body
through instead; this one adds the cover that was missing:

- proxy-long-running.test.ts: a progressive-delivery test in which the
  stand-in mcpd withholds its final frame until the client has observed the
  first delta through the proxy. A buffering proxy cannot satisfy that
  ordering — verified: the test fails in 3s (no hang) against a res.text()
  proxy and passes against the piped one. The existing SSE test used
  inject(), which collects the whole body and so passes either way.

- agent-chat.smoke.test.ts: a live smoke that posts through mcplocal on
  localhost:3200 (the path `mcpctl chat` actually takes — every other chat
  smoke uses --direct and bypasses the proxy entirely) and asserts delta
  frames arrive spread across the generation window, not in one burst at
  stream end. Uses its own agent: the shared smoke agent pins replies to a
  single token, too short to tell live streaming from a buffer dump.

Also settles the strict-boolean-expressions lint on the auth-header check
the streaming split touched.

Local: workspace 2546 passed, proxy suite 10/10, smoke file loads + self-skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
2026-08-10 22:27:21 +01:00
Michal
5a8185d7c9 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-10 22:19:56 +01:00
ae5a6203f8 Merge PR #108: fail the release when smoke tests fail
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m26s
CI/CD / typecheck (push) Successful in 3m7s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 2m15s
CI/CD / publish (push) Has been skipped
2026-08-10 16:16:05 +00:00
Michal
822c1bb047 build: fail the release when smoke tests fail, and fix the SSE test that hung
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m6s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 4m40s
CI/CD / publish (pull_request) Has been skipped
release.sh printed `WARNING: Smoke tests failed!` and exited 0. That is how four
broken readiness probes shipped unnoticed on 2026-08-10 — the warning scrolled
past in the build log and the release reported success.

It now exits 1, with `MCPCTL_ALLOW_SMOKE_FAILURE=1` as the escape hatch. The
message is explicit that smoke runs LAST, against the installed binary: the
package is already published and installed, so the failure reports fleet
breakage rather than preventing a bad artifact.

Turning the gate on required fixing a latent hang first, or every release would
have blocked on it. `security.test.ts > /inspect SSE endpoint …` waited for a
response body that by design never ends, so it could only settle via the
socket's *inactivity* timeout — and /inspect relays every project's MCP traffic,
so during a full smoke run it is never idle. Run alone it passed and looked
flaky; run with the suite it failed every time.

httpRequest gains `headersOnly`, which resolves on the response headers and
hangs up. The assertion only ever needed the status line.

Verified: full smoke suite 158/158 (was 157/158 with this test timing out); the
gate block lifted verbatim from release.sh exits 1 with a stubbed failing smoke
run, and exits 0 reaching subsequent code under MCPCTL_ALLOW_SMOKE_FAILURE=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 17:15:40 +01:00
cd94e855aa Merge PR #107: smoke-aws-docs readiness probe
Some checks failed
CI/CD / typecheck (push) Successful in 1m18s
CI/CD / lint (push) Successful in 2m46s
CI/CD / test (push) Successful in 1m25s
CI/CD / smoke (push) Failing after 1m56s
CI/CD / build (push) Successful in 4m52s
CI/CD / publish (push) Has been skipped
2026-08-10 16:00:08 +00:00
Michal
b8cedd6262 test(smoke): give the smoke-aws-docs fixture a readiness probe
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / typecheck (pull_request) Successful in 1m18s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / build (pull_request) Successful in 2m15s
CI/CD / smoke (pull_request) Failing after 3m23s
CI/CD / publish (pull_request) Has been skipped
health-readiness.smoke.test.ts asserts no RUNNING server lacks a
healthCheck.tool. The smoke suite's own shared fixture had none, so the suite
reported its own scaffolding as a fleet regression — and because the fixture is
in the PROTECTED set of clean-smoke-resources.ts, it is long-lived and failed
that assertion on every run.

Mirrors the production aws-docs probe (same package): search_documentation with
a phrase, 300s interval since the call leaves the cluster.

Verified live: with this applied, "every RUNNING server has a readiness probe
configured" passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 16:59:41 +01:00
13f1ff28eb Merge PR #106: statusline project resolution + stale-build gate
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m5s
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
2026-08-10 15:52:50 +00:00
Michal
96e27c8716 build: extend the main-sync gate to the image build and the k8s deploy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m59s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
Same hazard as the package build, with the cluster on the receiving end: a
branch behind main builds images missing whatever landed there, and deploy-k8s.sh
pins that sha in Pulumi — making the stale build the cluster's source of truth.

build-mcpd.sh gets its own call because it is run standalone as well as from
deploy-k8s.sh, so neither can rely on the other having checked.

`--dry-run` is exempt. It builds and cuts over nothing, and blocking a read-only
inspection is exactly what teaches people to export MCPCTL_ALLOW_BEHIND_MAIN=1
permanently — which would disable the gate for the real deploys too.

The failure text is now artifact-agnostic ("produce an artifact" / "shipping
it"), since one helper now speaks for packages, images and deploys.

Verified against a synthetic ref one commit ahead: build-mcpd.sh exits 1 before
any docker work, and deploy-k8s.sh exits 1 before the test gate, the pg_dump,
the image build and pulumi. Neither the working tree nor HEAD was moved to test
this — the ref was built with git commit-tree and deleted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 16:41:26 +01:00
Michal
dd29f98f82 build: refuse to package from a branch that is behind main
Everyone branches off main and builds from their own branch. A branch that is
behind main still builds and installs perfectly — it just packages a binary
missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the
good one with it. Nothing reports an error; the release succeeds and the feature
simply vanishes from the installed CLI.

That is what happened today: a build from a stale checkout replaced
/usr/bin/mcpctl with one that has no `statusline` command at all, months after
the status line landed on main (`mcpctl statusline` -> "unknown command").

`check-main-sync.sh` fetches main, compares, and fails before any work happens,
listing the commits the branch is missing and the merge that fixes it. Sourced
by build-rpm.sh and build-deb.sh — both are run standalone, so neither can rely
on the other having checked.

A hard failure rather than a warning: a warning scrolls past in a build log, and
the whole point is to stop before the artifact exists.
MCPCTL_ALLOW_BEHIND_MAIN=1 is the escape hatch for a deliberate old-tree build;
MCPCTL_BASE_BRANCH retargets the comparison. Offline it degrades to the last
fetched origin/main, then a local main, saying which it used; outside a git
checkout it skips.

Verified: passes on this branch (up to date with main); against a synthetic ref
one commit ahead, build-rpm.sh aborts with exit 1 before ensure_build_deps and
before any compilation; the escape hatch bypasses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 16:38:19 +01:00
Michal
b07287baf2 fix(k8s): set fsGroup on pods with volumes
A freshly provisioned PVC mounts root:root, so any image that drops privileges
cannot write to it. docs-mcp-server runs as uid 1000 and died on first start
with SQLITE_CANTOPEN; its own Dockerfile says to chown the volume 1000:1000.

Pods with volumes now carry securityContext.fsGroup, defaulting to 1000 and
overridable per volume. fsGroupChangePolicy is OnRootMismatch so Kubernetes
does not walk and re-chown the whole volume on every start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-10 12:31:31 +01:00
Michal
bb4b0b910f fix(k8s): read ApiException.code when classifying client-node errors
@kubernetes/client-node v1 raises ApiException, which carries the HTTP status
on `.code`. Every check in the orchestrator read only `.statusCode` — the
pre-1.0 HttpError field — so `status` came back undefined and each "expected"
404/409 was rethrown instead of handled.

Found in production: the first start of a server with a volume read a
not-yet-existing PVC, got a 404 that should have meant "create it", and failed
the instance instead. The same latent bug sat in removeContainer, where a
missing pod failed the delete rather than being treated as already gone.

httpStatusOf() checks .code, .statusCode and .response.statusCode, and falls
back to parsing the rendered "HTTP-Code: NNN" message for wrappers that keep
none of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-10 12:20:56 +01:00
Michal
2a7bba11ea fix(claude): stop pre-migration .mcp.json residue outranking a project switch
`mcpctl config claude --project X` writes user scope and never rewrites a
checkout's `.mcp.json`. The status line, however, preferred that file
unconditionally — so a legacy project-named entry an older mcpctl left behind
(`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project
for good, and every switch looked like it had done nothing.

Reproduced live: with user scope on `sre`, `mcpctl statusline --directory
~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project
Claude Code also had in `disabledMcpServers` for that directory, so the line
named a server that was not even mounted.

Rank the sources by how deliberate each one is instead: a canonical `mcpctl`
pin, then user scope, then legacy residue, then the marker. A pin is a decision
and still wins; residue is not and no longer does. At every step, skip a server
Claude Code has switched off for that directory.

`config claude` now also warns when the working directory's `.mcp.json`
contradicts the switch, naming the file — the two scopes are merged rather than
chosen between, so nothing else would tell you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k
2026-08-10 12:15:46 +01:00
Michal
2a305f7045 feat(cli): describe formatters for the agentic-teams resources
Closes 167. The five new resources fell through to the generic key-value dump,
against the project rule that describe shows clean sections and tables. More to
the point, two things built earlier had no way to reach a human at all: the
roadmap endpoint had no client, and the Epic reconciliation was only visible as
raw JSON.

`describe epic` puts the delivery mismatch directly under the completeness
percentage it contradicts, because reading either one alone is the bug. Rendered
against a live mcpd:

    Completeness:  [....................] 0%  (0/3)
    ...
    Delivery:      MISMATCH between what is claimed and what shipped
      Tasks:                1 merged, 0 outstanding, 0 cancelled
      Delivered, not claimed:
        - wheels  (1 merged, 0 outstanding)
      No roadmap node owns it:
        - engine, feel

plus the criteria checklist, accepted tradeoffs, and the roadmap with per-node
completeness and READY flags.

`describe task` derives the gates a task still owes from its risk and recorded
verdicts ("awaiting security_review, sre_review" vs "all passed"), and shows the
legal next transitions, PR linkage, verdicts and full history — so the answer to
"why won't this move" is on screen rather than requiring a failed attempt.

`describe repo` / `describe gitprovider` show the credential REFERENCE only.
The resolved value exists solely on the session-provision path and must never
reach a describe.

The roadmap, verdict and event fetches are all best-effort: an older mcpd 404s
and the resource still renders, rather than the whole describe failing over an
enrichment.

One bug found by looking at the output rather than the types: the Git section
opened on `!== undefined`, but JSON hands back null, so every task without a PR
printed an empty `Git:` header.

Local: build clean, workspace 2557 passed, lint unchanged at 794 errors.
Verified live against a local mcpd for epic, epictask and task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-10 12:11:24 +01:00
2b87cfdbf1 Merge pull request 'fix(pi-ext): stop importing @earendil-works/pi-ai at runtime' (#105) from fix/pi-ext-module-resolution into main
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Failing after 13m0s
CI/CD / typecheck (push) Failing after 13m49s
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-09 23:15:30 +00:00
Michal
be7fabd467 fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 10m42s
CI/CD / build (pull_request) Failing after 13m32s
CI/CD / publish (pull_request) Has been skipped
The extension failed to load outright on older pi installs:

  Failed to load extension ".../mcpctl-pi.ts":
  Cannot find module '@earendil-works/pi-ai'

pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.

The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.

Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.

Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
e4e2e063f1 Merge 'fix(health): a passing tools/list is live, not healthy' into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m9s
CI/CD / build (push) Successful in 2m25s
CI/CD / smoke (push) Failing after 3m22s
CI/CD / publish (push) Has been skipped
2026-08-09 23:01:01 +00:00
Michal
a158e49ec2 fix(templates): make the shipped templates match reality
Some checks failed
CI/CD / lint (pull_request) Successful in 1m12s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / typecheck (pull_request) Successful in 2m50s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 4m49s
CI/CD / publish (pull_request) Has been skipped
The templates are what `create server --from-template` builds from and what
mcpd seeds on start, so drift there ships broken servers. Nothing ever read
these files in a test, and they had rotted badly.

- grafana: GRAFANA_URL now defaults to the in-cluster ClusterIP and the
  description spells out why the public hostname is wrong — reaching a
  co-located Grafana over its ingress hairpins through the per-host Envoy L7
  policy, which drops the caller's identity and returns a bare `Access denied`
  403 with a perfectly valid token. That cost a day of looking at the token.
- unifi-network: was wrong on every field that mattered. `runtime: python`
  for an npm package, an env contract (UNIFI_HOST/USERNAME/PASSWORD) the
  package doesn't read, and no probe. Now UNIFI_TARGETS with the
  classic-vs-unifi_os distinction and the :8443 egress caveat written down.
- docmost, gitea: both carried "health check disabled" comments citing a
  limitation of the old docker-exec probe, which readiness-via-proxy removed.
  Both probes verified against the live servers. gitea uses search_repos, not
  get_me, because get_me needs a `read:user` scope a repo-scoped token lacks.
- filesystem: packageName was `@anthropic/filesystem-mcp`, which 404s on npm —
  the template could never have installed. Points at the real package.
- terraform: deleted. `@anthropic/terraform-mcp` 404s too and there is no
  npm-published replacement to point it at.
- node-red: deleted, the service is gone.

Two supporting fixes:
- The seeder declared no `runtime` field and never wrote the column, so a
  template asking for the python runner silently seeded as null and got node.
- A new templates test reads every shipped file: schema-valid, a runner the
  orchestrator knows, some way to actually start, unique env names, and a
  readiness probe (without one an instance can only ever report `live`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
2026-08-09 23:53:22 +01:00
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
4515eb4517 fix: don't commit agent worktrees
`git add -A` swept .claude/worktrees/ into the previous commit as embedded git
repositories. They are transient checkouts created for parallel agent runs and
have no business in the tree — removed from the index and gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:34:28 +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
732ca98ccc docs(reliability): record the three shapes a failing readiness probe takes
Some checks failed
CI/CD / lint (pull_request) Successful in 1m12s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 3m9s
CI/CD / smoke (pull_request) Failing after 1m58s
CI/CD / build (pull_request) Successful in 4m44s
CI/CD / publish (pull_request) Has been skipped
Turning readiness probes on took the fleet from 8/8 healthy to three real
failures in under a minute, and all three were network shape rather than code:
an egress port (UniFi :8443), an ingress hairpin through the Envoy L7 policy
(Grafana 403 `Access denied` with a token that worked from a laptop), and a
Tailscale address a pod can never reach (Node-RED, since retired).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
2026-08-09 22:54:33 +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
fc524583b1 chore(completions): regenerate after the epictask --criterion flag
The guard test caught it, which is its job — the generator derives flags from
the commander program, so any new option makes the committed files stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:09:56 +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
d9535bc51f chore(taskmaster): file 174 — scope project-only commands out of root --help
The tasks/repos command groups added with the CLI work sit at the root of
`mcpctl --help`, but both only function inside a project context. Beyond the
clutter, it teaches the wrong data model: a task reads as a peer of servers and
secrets rather than a leaf of the epic tree.

Convention recorded as the `scope-commands-to-their-context` prompt in the
mcpctl-development project. Note `attach-server`/`detach-server` are already
hidden, so the pattern exists and was simply not applied consistently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:38 +01:00
Michal
b2547429ca fix(health): a passing tools/list is live, not healthy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / typecheck (pull_request) Successful in 3m15s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 5m5s
CI/CD / publish (pull_request) Has been skipped
`mcpctl get instances` showed all eight servers healthy while the UniFi one
had never once reached its controller. The default probe is `tools/list`,
which MCP servers answer from a static in-process table — no credentials, no
upstream, ~3ms. It cannot fail for any reason the user cares about, so it was
reporting `healthy` for every process that managed to start.

Split the two passes:

  healthy  — readiness: `tools/call` on `healthCheck.tool`. The upstream
             answered, so the server can actually do its job.
  live     — liveness: `tools/list` only. Process up, upstream unverified.

`live` is now the default for any server without a `healthCheck.tool`. It is
not a warning; it is an admission that nothing is watching that server. Probe
events name which probe ran and which tool ("Readiness check (list_sites)
passed"), so the events log distinguishes the two after the fact.

Also:
- `healthCheck.tool` is optional now, so the timings can be tuned without
  inventing a readiness probe.
- `create server --health-check-tool/-args/-interval/-timeout/
  -failure-threshold`, per the rule that everything applyable is a create
  flag. Merges over a `--from-template` healthCheck rather than replacing it.
- `describe instance` explains a `live` verdict instead of leaving it cryptic.
- create.ts held a raw NUL byte in a string literal, which made grep treat the
  whole file as binary and silently skip it. Escaped as `\0`.

Verified against the live fleet: with readiness probes configured, my-grafana
went unhealthy (Grafana API 403) and my-node-red degraded (connect timeout to
a Tailscale address) — both had read healthy for months.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
2026-08-09 21:23:33 +01:00
Michal
ff0e71da05 Merge 'fix(opencode): guard state parsing, lint the .tsx, correct an overstated doc claim' into main
Some checks failed
CI/CD / lint (push) Successful in 1m17s
CI/CD / test (push) Successful in 1m28s
CI/CD / typecheck (push) Successful in 2m58s
CI/CD / build (push) Successful in 2m21s
CI/CD / smoke (push) Failing after 3m8s
CI/CD / publish (push) Has been skipped
2026-08-09 20:47:45 +01:00
Michal
cbd3b95d97 fix(opencode): guard state parsing, lint the .tsx, correct an overstated doc claim
Three findings from a cross-branch review of the competing opencode
implementations, all of which are fair.

1. `readState` type-guards the parsed JSON now. A bare try/catch does not
   cover it: `JSON.parse('null')` succeeds and returns null, so the catch never
   fires and the next `state.project` throws a TypeError that takes the plugin
   down. Verified the crash before fixing; a test pins the guard in the embedded
   copies. Credit to the competing 'opencode-mine' branch, which had this right.

2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the
   300-line TUI plugin — the largest file in the addon — was linted by nothing.
   It was typechecked, which is why this went unnoticed. Confirmed the rules
   actually fire on it rather than the file being silently skipped. The
   'abhishek' branch was the only entry that got this right.

3. docs/opencode-extension.md overstated the security argument. "The token would
   sit in a 0644 opencode.json" is not a point against a `type: local` stdio
   bridge, which needs no token at all because it reads your own credentials.
   That reason is a consequence of having picked the HTTP gateway, not a
   justification for it. The docs now lead with the real reason — live
   re-pointing without a restart — and state the trade honestly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 20:47:45 +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
a7094c01c6 Merge 'fix(install): rebuild when any packaged input changes' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m20s
CI/CD / lint (push) Successful in 2m43s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 1m57s
CI/CD / build (push) Successful in 2m14s
CI/CD / publish (push) Has been skipped
2026-08-09 20:07:55 +01:00
Michal
292506ec98 fix(install): rebuild when any packaged input changes, not just src/*.ts
installlocal.sh decided the package was up to date by looking only for
`src/**/*.ts` newer than it. nfpm.yaml also packages stack/claude-vllm, the
shell completions and the systemd unit — none of which are TypeScript — so
editing any of those installed a stale package that reported success. Caught by
claude-vllm's new --init being absent from /usr/bin after a clean install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 20:07:55 +01:00
Michal
c79477d7fe Merge 'feat(claude-vllm): own config file instead of reading opencode's' into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / typecheck (push) Has started running
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-09 20:07:07 +01:00
Michal
80fa8a3c7e feat(claude-vllm): own config file instead of reading opencode's
claude-vllm read `~/.config/opencode/opencode.jsonc` directly. Same shape is
useful; sharing the actual file is not — a credential rotation in opencode would
silently change what Claude Code authenticates with, and it couples two tools'
configs for no reason.

It now has its own `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc`, shaped like
opencode's (`provider.<name>.options.{baseURL,apiKey}` plus a `models` map), and
takes priority. The pi and prime-agent homes stay as a fallback so the command
works before any config exists; reading opencode's file is dropped.

No key is stored in the tool. `apiKey` may be a literal in the 0600 file,
`${ENV_VAR}`, or a bare env var NAME (the form pi's models.json already uses),
so the secret can live in the environment instead of on disk. `--init` reads it
from stdin when `--api-key` is omitted — keeping it out of shell history and out
of the process table, where an argument is visible to every user via `ps`.
`--list` prints at most a 10-character prefix.

`--init` records the context window for every model it can see, not just the
active one: recording only the default meant `--model something-else` silently
fell back to Claude Code's assumed 200k on a 393k model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 20:07:07 +01:00
Michal
888b72b26c Merge 'feat(claude): register the MCP server in user scope by default' into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / typecheck (push) Successful in 1m20s
CI/CD / test (push) Successful in 1m26s
CI/CD / smoke (push) Has started running
CI/CD / build (push) Has started running
CI/CD / publish (push) Has been cancelled
One `mcpctl` entry in ~/.claude.json applying to every directory and window,
instead of a per-directory .mcp.json you had to re-create in each checkout.
2026-08-09 19:59:48 +01:00
Michal
b7c0de2bf0 feat(claude): register the MCP server in user scope by default
`config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in
every checkout you opened — and in a repo that commits `.mcp.json` (this one
does) it dirtied the working tree. Every other integration is already global:
pi, prime-agent and opencode each have one active project, wired once.

Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in
every directory and window. That is now the default. `--scope project`, or an
explicit `-o/--output`, keeps the old per-directory file for a repo that wants
its own pinned project. `--inspect` stays project-scope — it is a debugging
server you turn on for one checkout.

Details worth knowing:

  - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is
    set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside
    it. Verified against a live Claude Code run with an isolated config dir.
  - `.claude.json` also holds onboarding state, caches and a per-project map
    that Claude Code rewrites while running, so this merges into the document
    and writes through a temp file + rename.
  - User scope writes no `.mcpctl-project` marker: it scopes nothing to a
    directory, and a marker beside `.claude.json` would sit in $HOME and scope
    every repo under it.
  - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that
    pinned itself wins), then user scope, then the marker.

Scope selection reads Commander's option source rather than process.argv —
argv is the test runner's command line when the command is driven in-process,
which the suite caught immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00
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
834aa704ed Merge pull request 'feat: bring claude, pi and prime-agent to the opencode standard (+ claude-vllm)' (#101) from feat/agent-parity into main
Some checks failed
CI/CD / lint (push) Successful in 2m56s
CI/CD / test (push) Successful in 1m28s
CI/CD / typecheck (push) Successful in 3m27s
CI/CD / smoke (push) Has started running
CI/CD / build (push) Has started running
CI/CD / publish (push) Has been cancelled
Adds claude-vllm (the homelab gateway already speaks the Anthropic Messages API,
so there is no bridge to build), one constant `mcpctl` MCP entry for Claude Code
instead of one per project, a Claude Code status line and /mcpctl switcher, and
the prime-agent switcher extracted to typechecked source.

Also fixes a pre-existing bug where the test suite wrote into the developer's
real ~/.claude — the origin of the untagged duplicate skills-sync hook.
2026-08-09 19:48:49 +01:00
Michal
a9fcd83ed8 refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m37s
CI/CD / publish (pull_request) Has been skipped
The prime-agent switcher existed only as a 275-line string literal inside
prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap
that let a wrong ctx.ui.select() option shape ship in the pi extension. It now
lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig
checking it against the real @earendil-works/pi-coding-agent types, eslint
coverage and an embed-freshness test, matching pi and opencode.

The extraction was verified byte-identical before any edit, so the behaviour
shipped today is exactly what was captured. Linting it then found six problems
in code nothing had ever checked: object-truthiness null guards, a nullable
string conditional and a missing return type. All behaviour-preserving to fix,
but exactly the class of thing that ships silently when nothing is looking.

Also:

  - `config pi` gains --dry-run, the last agent without it.
  - The SessionStart hook installer now drops untagged duplicates of its own
    exact command — rows left behind before the marker existed, or by a suite
    that used to write into a real ~/.claude. Invisible in the UI; they just run
    the sync twice per session. A hook the user wrote is never touched, even one
    calling `mcpctl skills sync` with different flags.
  - docs/claude-integration.md and docs/prime-agent-extension.md, the two
    integrations that had no page.

prime-agent deliberately keeps its per-project MCP entry name rather than the
constant `mcpctl` claude and opencode now use: its switcher already unmounts the
previous project, so it never accumulates entries, and re-keying auth.json from
mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a
migration. Documented as its own change rather than folded in here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:20:36 +01:00
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
b3a062ce28 feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude
Claude Code had neither of the things opencode, pi and prime-agent all have: a
visible active project, and a way to change it from inside a session. It has no
plugin API that can draw a widget or open a picker, but it does run a command
for its status line and it does load slash commands — which is enough for both.

  - `mcpctl statusline` prints the active project (from .mcp.json, falling back
    to a .mcpctl-project marker) and is wired into settings.json. It reads the
    directory out of the JSON Claude Code pipes in, so it follows /cwd rather
    than reporting wherever the binary was launched. Prints nothing when no
    project is active: an empty line beats "none" on every unrelated repo.
  - `/mcpctl [project]` switches and reminds you to reconnect from /mcp.
    allowed-tools is scoped to the four exact mcpctl invocations it needs.

Three things found by running it rather than reasoning about it:

  - Claude Code REWRITES settings.json against its own schema and strips
    unknown keys from `statusLine` — our `_mcpctl_managed` marker came back
    gone, so ownership is now determined by the command string. (Hooks keep
    their marker; statusLine does not.) A composed line like
    `my-prompt && mcpctl statusline` is deliberately not claimed.
  - Every `!`-prefixed block in a slash command is permission-checked against
    allowed-tools. Omitting `statusline` failed the whole command before the
    model saw anything. A test now asserts every pre-executed command is
    covered.
  - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn
    that auth may not work; claude-vllm now sets only the former and clears an
    inherited API key.

Also fixes a pre-existing test-isolation bug this work would have made worse:
`config claude` wrote into the developer's real ~/.claude when the suite ran,
which is how an untagged duplicate of the skills-sync SessionStart hook got
there. Both the hook installer and the new UI installers now honour
CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first,
isolation second), `config claude` gains --claude-dir for parity with --pi-dir
and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical.

Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost`
switches and the line updates to `mcpctl:docmost` in the same session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:06:06 +01:00
Michal
d7055a0953 fix(claude): one constant mcpctl MCP entry instead of one per project
`config claude` named the `.mcp.json` entry after the project, and the file is
merged rather than rewritten — so configuring a second project left the first
one mounted alongside it. Every project you had ever configured stayed
connected, with duplicate tool names and nothing saying which was active.

The entry is now always `mcpctl`, and switching rewrites what sits behind that
name. Claude Code can reconnect an existing MCP server from inside a session, so
a switch lands without restarting the app, and the tool prefix stays stable
across switches. Entries an older CLI wrote are retired on the next run —
recognised by the pairing that makes retiring them safe: our command, named
after the very project it bridges to. A hand-configured server is never touched.

The shaping lives in config/claude-mcp.ts as pure functions so the merge,
migration and active-project detection are unit-tested rather than inferred from
a command's side effects.

Also brings two parity gaps in line with `config opencode` / `config prime-agent`:
--dry-run, and --skip-marker for when the caller must not re-scope the directory
it runs in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:51:31 +01:00
Michal
c5ea39e959 feat(claude): claude-vllm — run Claude Code against the homelab LLM gateway
The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already
serves the Anthropic Messages API on /v1/messages — verified with a real
completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is
the whole integration.

`claude-vllm` exists only to stop you pasting four exports each time. It reuses
what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then
opencode's config, first hit wins — taking the base URL and the credential from
the same source so one gateway's URL is never paired with another's key.

Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss:

  - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the
    background and summarisation calls ask the gateway for a real Haiku it does
    not serve and every one 404s;
  - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow,
    because Claude Code assumes 200k for models it has no table for — and
    deepseek-v4-* is 393k, so it would auto-compact at half capacity.

On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves
/api/v1/llms (management) and its adapters call out to providers for gating,
prompt selection and agent chat; nothing serves /v1/messages. Routing Claude
through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps
LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own
(per-project gating of model calls, prompt audit, budgets), which is a mcpd
endpoint rather than a wrapper script.

Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against
deepseek on the homelab, with the unknown-model context warning gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:39:36 +01:00
Michal
a1f22f46ef Merge 'feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m23s
CI/CD / lint (push) Successful in 2m58s
CI/CD / build (push) Successful in 2m21s
CI/CD / smoke (push) Failing after 3m22s
CI/CD / publish (push) Has been skipped
Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills
sync target, so mcpctl projects can be switched from opencode's TUI and the
active one is visible in the prompt footer.

Unlike the claude and prime-agent integrations, nothing is written into the
host's config: opencode exposes an HTTP API for its own MCP registry, so the
project is mounted through the running app — the bearer token stays in a 0600
state file, and switching takes effect on the next turn with no restart.

Includes the follow-up pass taken from the sibling opencode branches: a
<leader>m keybind, an explicit disconnect before re-pointing the mount, and a
non-wrapping footer label.

Verified end-to-end against opencode 1.18.15 from an installed RPM build.
2026-08-09 18:24:32 +01:00
Michal
b0233918ff feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.

A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).

Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.

Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.

Templates added, all self-hosted and none needing an API key:
  - duckduckgo — no backing service at all
  - searxng    — needs a SearXNG engine (compose profile in stack/)
  - docs-mcp   — open-source Context7/Ref alternative, uses the new volume

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
Michal
99f881dd67 feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator
Three improvements taken from reading the sibling opencode branches
(feat/opencode-extension-abhishek in particular):

- `<leader>m` opens the project picker. Switching is the repeated action and
  typing `/mcpctl` every time is friction; the other two commands stay
  palette-only.

- A switch disconnects before re-adding. `mcp.add` under the same name does
  re-point the tools on its own, but leaves it to opencode whether the previous
  client is closed — and an abandoned one keeps its `mcp-session-id` alive on
  mcplocal, which is exactly what holds a gated project open. Best-effort, so a
  first mount still works.

- The footer label renders `wrapMode="none" truncate`. The home prompt row is
  narrow enough that the default wrap broke `mcpctl:homeautomation` across two
  lines mid-word; clipping the tail of a long name reads far better.

Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is
now one line, and a disconnect-then-add switch still lands — the model called
`mcpctl_begin_session` and listed the new project's tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:02:52 +01:00
Michal
be2a5cb189 feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator
Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills
sync target, so an mcpctl project can be switched from inside opencode's TUI
and the active one is visible at a glance.

Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into
the host's config. opencode exposes an HTTP API for its own MCP registry
(`POST /mcp`), so the project is mounted through the running app:

  - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a
    mode-0644 opencode.json users paste into bug reports;
  - switching projects takes effect on the next turn, with no restart.

Inside opencode:
  /mcpctl         filterable project picker; switches live
  /mcpctl-status  active project, mount state, gateway URL
  /mcpctl-skills  re-sync this project's skills
  plus a `mcpctl:<project>` indicator in the prompt footer, next to the model
  name and one line above the token counter.

Design notes:
  - the MCP server is registered under a constant name, so tools keep a stable
    `mcpctl_*` prefix and opencode's per-request tool resolution shows the new
    project's tools by itself — no "your old tool names are dead" message to
    the model, unlike the pi extension;
  - an unchanged mount is never re-registered: mcp.add rebuilds the connection
    and mcplocal binds a gated project's unlocked state to that connection's
    mcp-session-id, so re-adding would re-lock a project begin_session had just
    opened;
  - the server plugin does not mount during setup — setup runs before the
    server accepts connections and mcp.add calls back into it, which hangs
    opencode on a blank screen before the TUI draws;
  - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token
    minting, state and skills stay in one place;
  - no usable credential aborts non-zero with the state file untouched, so a
    failed switch leaves the previous project working rather than swapping it
    for a mount that 401s.

`skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG
aware) with the same shared-tree semantics as pi and prime-agent. The
credential plumbing shared with `config prime-agent` is lifted to one place and
parameterised by agent rather than copied.

The plugin sources are embedded in the CLI (generated, freshness-tested) so an
installed binary with no source tree can provision them, and are typechecked
against the real @opencode-ai/plugin types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-08 21:03:53 +01:00
Michal
2513da33c3 Merge pull request 'feat(pi): native pi integration — extension, config pi, skills sync --agent pi' (#96) from feat/pi-extension into main
Some checks failed
CI/CD / lint (push) Successful in 1m6s
CI/CD / test (push) Successful in 1m22s
CI/CD / typecheck (push) Successful in 2m39s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 2m16s
CI/CD / publish (push) Has been skipped
Includes the review fixes (dead /mcpctl menu, skills target, tool-name
collisions, typecheck + lint coverage for src/pi-ext), the project-picker
filter, the post-switch tool-context message, the Agent Skills frontmatter
fix, and the smoke-test cleanup.
2026-08-08 20:16:48 +01:00
Michal
a8a1045824 Merge 'fix(smoke): actually clean up smoke-test resources' into feat/pi-extension
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m37s
CI/CD / publish (pull_request) Has been skipped
2026-08-08 20:16:21 +01:00
Michal
79e04ed89f Merge 'refactor(prime-agent): tray status only' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m6s
CI/CD / test (push) Successful in 1m34s
CI/CD / build (push) Successful in 2m19s
CI/CD / smoke (push) Failing after 3m13s
CI/CD / publish (push) Has been skipped
2026-08-08 19:59:25 +01:00
Michal
3fa41e4d46 Merge main into feat/pi-extension
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m28s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 4m45s
CI/CD / publish (pull_request) Has been skipped
2026-08-08 19:59:25 +01:00
Michal
90c49bcb22 refactor(prime-agent): drop the widget fallback, keep the tray status
With prime-agent-extension-status.patch in place the tray renders
ctx.ui.setStatus() next to the model name, which is what a status line should
be. The widget was a workaround for its absence and was never a substitute:
widgetContainerBelow sits in the fullscreen *scroll* list, not the dock, so it
scrolled away with the transcript, and with both set the project name appeared
twice.

The startup retries stay: resetExtensionUI() clears extension statuses just as
it cleared widgets, so the value set during session_start is still wiped before
it can be seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 19:59:24 +01:00
Michal
cee27084ac Merge 'fix(prime-agent): indicator re-publish after widget reset' into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m20s
CI/CD / build (push) Successful in 2m18s
CI/CD / smoke (push) Failing after 2m44s
CI/CD / publish (push) Has been skipped
2026-08-08 19:52:08 +01:00
Michal
0a29c2fd7f fix(prime-agent): re-publish the indicator after prime-agent clears extension widgets
Verified with a probe extension rather than by reading the bundle: session_start
fires with hasUI=true, ctx.ui.setWidget exists and the call returns without
throwing — and the widget still never appeared.

Cause is prime-agent wiping it immediately afterwards. resetExtensionUI() ->
clearExtensionWidgets() runs from onBeforeSessionInvalidate and from the
connection-state-snapshot handler, both of which land after session_start, so
the indicator was set and cleared before it could be seen. Nothing re-set it
until a turn, which is why a fresh session with no messages showed nothing.

Re-publishes at 1s/3s/6s after session_start to land past that reset.
setWidget is idempotent, so a redundant retry costs one re-render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 19:52:07 +01:00
Michal
a90dd091f0 fix(smoke): actually clean up smoke-test resources
341 of 356 projects on the shared mcpd were smoke-test leftovers, plus 11
never-expiring `smoke-*` mcptokens sitting on the real `mcpctl-development`
and `sre` projects. Enough to make the project picker unusable.

Two causes, both silent:

- `delete project X --force` — `--force` is valid on `create project` but NOT
  on `delete`, so every cleanup call exited non-zero and deleted nothing.
  project-llm-ref's afterAll looked correct and had never worked, which is why
  there were 84 each of smoke-proj-{ok,orphan,none}-*.
- mcptoken.smoke had no afterAll at all; its cleanup was an `it()` at the end
  of the file, so it was skipped whenever an earlier assertion failed.

Cleanup now lives in `afterAll` (runs on failure too) and is no longer gated
on the health probe: the project is created through mcpd, which can be up when
the gateway probe is not, and deleting a project that was never created is a
no-op.

Adds `pnpm smoke:clean` for the case afterAll cannot cover — a killed process
(CI timeout, Ctrl-C, OOM). Dry-run by default, `--yes` to apply. Only touches
`smoke-*` names and protects the shared `smoke-data` / `smoke-aws-docs`
fixture, which five suites depend on and which must survive a concurrent run.

Verified end to end: created a throwaway smoke project, confirmed the dry run
lists without deleting, then `--yes` removed it and left all 15 real projects
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:49:42 +01:00
Michal
e638f81456 Merge main into feat/pi-extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m8s
CI/CD / typecheck (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / build (pull_request) Successful in 2m27s
CI/CD / smoke (pull_request) Failing after 3m30s
CI/CD / publish (pull_request) Has been skipped
2026-08-08 18:38:55 +01:00
Michal
3061435399 Merge 'fix(prime-agent): project indicator on turn_start' into main
Some checks failed
CI/CD / lint (push) Successful in 1m3s
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / test (push) Successful in 3m26s
CI/CD / smoke (push) Failing after 3m23s
CI/CD / build (push) Successful in 2m11s
CI/CD / publish (push) Has been skipped
2026-08-08 18:38:53 +01:00
Michal
a1893a7578 fix(prime-agent): publish the project indicator on turn_start, not only session_start
prime-agent emits session_start from exactly one place — reload() — and never
at startup. A session_start-only handler therefore never ran on a fresh
session, and the '/mcpctl -> already on X' path returns without reloading, so
the indicator stayed blank exactly when it was most wanted.

Also guard against the no-op UI context: the runtime hands extensions
noOpUIContext until the TUI binds the real one, and every setter on it
silently discards. Publishing under it would cache a label that never
rendered.

Now published on session_start (reload), turn_start (earliest reliable point
on a fresh session), and on entering /mcpctl. Repeat calls are a no-op unless
the label changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:38:52 +01:00
Michal
bc83d36a68 fix(pi): cap the project picker at 20 rows so it does not fill the screen
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m16s
CI/CD / lint (pull_request) Successful in 2m41s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / smoke (pull_request) Failing after 3m46s
CI/CD / publish (pull_request) Has been skipped
pi's ExtensionSelectorComponent.updateList() renders every option with no
windowing, so 50 rows scrolled the transcript away. prime-agent's selector
windows to ~20 itself and shows a true (20/356) counter; matching that height
makes both hosts behave the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:34:10 +01:00
Michal
e6db9cac83 Merge main into feat/pi-extension (prime-agent switcher UX) 2026-08-08 18:33:31 +01:00
Michal
64503e8924 Merge pull request 'feat(prime-agent): filterable project picker + visible active-project indicator' (#97) from feat/prime-agent-switcher-ux into main
Some checks failed
CI/CD / lint (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 2m21s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Has started running
CI/CD / build (push) Has started running
CI/CD / publish (push) Has been cancelled
2026-08-08 18:33:30 +01:00
Michal
ba05b2865c feat(prime-agent): filterable project picker + a visible active-project indicator
Two gaps the pi switcher had already closed.

**The indicator was invisible.** `ctx.ui.setStatus` was the wrong channel:
prime-agent stores extension statuses (`FooterDataProvider.setExtensionStatus`)
but nothing ever reads them back — there is no `getExtensionStatuses()` call
site anywhere in the app, so the value was recorded and never rendered. pi's
`footer.js` does render them, which is why the same code showed a status line
there and nothing here. The active project is now published as a *widget*
(`extensionWidgetsBelow` → `renderWidgets()`), which prime-agent does render.
`setStatus` is still called so pi keeps its footer entry.

**No search.** With 356 projects the picker was a wall of
`smoke-mcptoken-*`. Above 20 projects it now asks for a filter first;
space-separated terms must all match, case-insensitively, against name or
description, so `home auto` finds `homeautomation`. Active project sorts
first, then alphabetical.

Deliberately no client-side truncation here, unlike the pi picker: pi's
`ExtensionSelectorComponent.updateList()` renders every option, so a long list
fills the screen and has to be capped. prime-agent's selector windows the list
itself and shows a true `(20/356)` counter — capping would replace an accurate
total with a misleading one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:33:23 +01:00
Michal
8b006263de fix(skills): write the frontmatter every Agent Skills host requires
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m15s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Failing after 3m8s
CI/CD / publish (pull_request) Has been skipped
mcpd keeps `name` and `description` as columns, and a skill's `content` is
often just the body — `propose-learnings` starts straight at its `#` heading.
The sync wrote `full.content` verbatim, so the SKILL.md landed with no YAML
frontmatter and the host rejected it. Observed live:

  [Skill warning]
    ~/.prime/agent/skills/propose-learnings/SKILL.md
      description is required

Claude Code and pi require the same keys, so this affected every target; it
only surfaced now because prime-agent prints the warning at startup.

`ensureSkillFrontmatter` synthesises the block from the columns when it is
absent, and fills in only a missing `name`/`description` when the author
already supplied a header — an existing complete header is never rewritten.
Values are emitted as JSON strings (valid YAML double-quoted scalars) because
descriptions routinely contain `:` and `#`, which break a bare scalar.

Also: `--force` now re-fetches skills whose server content is unchanged.
Without it a skill already on disk could never be repaired by a client-side
fix — the content hash still matched, so it was skipped forever, which is
exactly what happened on the first attempt to repair the file above.

Three existing assertions compared SKILL.md to the raw server content; they
now assert the body survives rather than exact bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:16:22 +01:00
Michal
47809a8942 fix(pi): correct the model's tool context after a project switch
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m13s
CI/CD / lint (pull_request) Successful in 2m30s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 2m59s
CI/CD / publish (pull_request) Has been skipped
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.

Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".

Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.

The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.

`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
Michal
24a3b8cc0a feat(pi): filter the project picker instead of scrolling hundreds of rows
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m45s
CI/CD / smoke (pull_request) Failing after 3m35s
CI/CD / build (pull_request) Successful in 2m7s
CI/CD / publish (pull_request) Has been skipped
pi's selector is a plain arrow-key list: `ExtensionUIDialogOptions` has no
search field and `ExtensionSelectorComponent` ignores typed characters, so
filtering has to happen before the list is handed over. With 356 projects —
most of them `smoke-proj-none-*` leftovers — arrowing to the one you want is
hopeless.

Above 20 projects the picker now asks for a filter first. Terms are
space-separated and all must match as case-insensitive substrings, so
`home auto` finds `homeautomation`. Blank shows everything, Esc cancels.

The active project sorts first (most likely pick), then alphabetical. A
result set over 50 is capped, and the title says what was dropped — a
silently truncated list reads as "that's all of them".

The ordering and matching are extracted into an exported `filterProjects` so
they are unit-tested rather than eyeballed through a TUI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:51:36 +01:00
Michal
739e679d1a fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / typecheck (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m21s
CI/CD / publish (pull_request) Has been skipped
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.

Fixes:

- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
  chosen string; it was called with `{value,label}` objects, so the menu
  rendered five `[object Object]` rows and `choice === "status"` never
  matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
  `Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
  integration whose stated purpose is to not depend on ~/.claude — and said
  so in its own success message. It also ran execSync with `stdio: "inherit"`,
  painting raw output over pi's TUI, and interpolated the project name into a
  shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
  the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
  `docs-search`). The colliding tool was silently never registered but still
  reported active, so its calls were forwarded to the first tool. Names are
  now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
  parsing strips `//` comments, a no-op run destroyed them.

Guards, so this class of bug can't return:

- `src/pi-ext/tsconfig.json` checks the extension against the real published
  pi types (dev dependency, not a shim — a shim drifting from the published
  API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
  editing the sources without regenerating silently shipped old code.

Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
Michal
f1d84b0952 fix(config pi): embed extension sources so installed binary is self-contained
- add scripts/generate-pi-extension.ts to embed src/pi-ext sources as
  string constants (mirrors prime-agent's embedded switcher pattern).
- config pi now writes the embedded sources by default, so it works from
  an /usr/bin install with no source tree (--extension-dir overrides for
  dev). Verified installed binary writes files byte-identical to source.
- add installEmbeddedExtension test.
2026-08-08 17:03:03 +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
f90e23d000 Merge remote-tracking branch 'origin/main' into feat/pi-extension
# Conflicts:
#	README.md
#	src/cli/src/commands/config.ts
#	src/cli/src/commands/skills.ts
2026-08-08 16:35:51 +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
28f1a411fd feat(pi): add native pi integration — extension, config pi, skills --agent pi
- src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored
  mcp-http client) that talks JSON-RPC directly to mcplocal and registers
  project MCP tools as native pi tools. No MCP client, no ~/.claude.
- Persistent per-project session so gated projects ungate on begin_session.
- /mcpctl command: status, switch project (GUI), refresh tools, sync skills.
- mcpctl config pi: installs extension, wires pi settings, persists active
  project, syncs skills into ~/.pi/agent/skills.
- skills sync: add --agent pi (target install root).
- docs + tests.
2026-08-08 16:23:02 +01:00
Michal
3f93f0798f Merge pull request 'feat(cli): show the active mcpctl project in prime-agent's footer' (#96) from feat/mcpctl-switcher-status into main
Some checks failed
CI/CD / lint (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 2m18s
CI/CD / test (push) Successful in 1m18s
CI/CD / build (push) Successful in 2m13s
CI/CD / smoke (push) Failing after 3m24s
CI/CD / publish (push) Has been skipped
2026-08-08 12:18:22 +01:00
Michal
ce7df10e06 feat(cli): show the active mcpctl project in prime-agent's footer
`/mcpctl` could switch projects but there was no way to see which one was
active without running a command. prime-agent exposes the same status-bar
channel the model name uses (`ctx.ui.setStatus`), so the switcher now
publishes `mcpctl:<project>` there.

Wired to `session_start`, which fires on startup *and* on every reload —
including the reload the switch itself triggers — so the footer tracks
settings.json without extra bookkeeping. Cleared when no project is mounted.

Also fixes `notify(..., 'success')`: the API only accepts info|warning|error.
Not a live bug (prime-agent falls through to the same showStatus path as
'info') but it fails a typecheck of the extension against the real
ExtensionAPI, which is how it was found.

The extension ships as a JSON-escaped string and is never compiled by our
build, so it was typechecked out-of-tree against
@earendil-works/pi-coding-agent's published types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:18:21 +01:00
Michal
25c1bffdd0 Merge pull request 'fix(cli): harden config prime-agent sync, auth + /mcpctl switcher' (#95) from feat/config-prime-agent into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m11s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Failing after 1m53s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-08-08 12:02:08 +01:00
Michal
170dc06496 fix(cli): close third review — token collision, migration, ownership
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / lint (pull_request) Successful in 2m13s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m9s
CI/CD / smoke (pull_request) Failing after 2m44s
CI/CD / publish (pull_request) Has been skipped
Round 2 fixed the first review but introduced regressions of its own, all
of which only bite against state written by the previously installed build.

`config prime-agent`:
- Mint each credential under a unique `prime-agent-<stamp>` name again.
  `McpToken` is unique on (name, projectId) and revoke is a soft delete, so
  round 2's fixed `prime-agent` name could only ever be minted once per
  project — and the revoke-first ordering destroyed the working credential
  before discovering the mint would fail.
- Provision the credential BEFORE touching settings.json. Registering the
  new project unmounts the previously active one, so a failed mint must not
  be able to leave prime-agent with no working project at all. The command
  now aborts with settings.json untouched.
- Retire only the token this auth.json actually held, once its replacement
  is stored. Sweeping every `prime-agent*` token for the project would
  revoke the credential another install (or a custom --output run) is
  using; anything else that looks orphaned is reported, not deleted.
- Validate a pre-existing credential instead of trusting its presence: a
  revoked or expired token used to short-circuit provisioning and leave
  prime-agent broken while the command reported success. Matched by
  tokenPrefix against the project's active tokens, so the secret is never
  sent. Fails open when the API can't be consulted.
- Actually write auth.json 0600. `writeFile`'s mode is ignored for an
  existing file and prime-agent creates auth.json itself at 0644, so chmod
  after writing.
- Recognise the untagged mcpServers entries older CLIs wrote (canonical
  proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts
  them instead of leaving two gateways live. Hand-configured servers have
  no such credential and are still preserved. Same rule in the `/mcpctl`
  switcher's active-project lookup.
- Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the
  extension runs from whatever directory prime-agent was started in, and
  was silently re-scoping that repo's `.mcpctl-project`.

`skills sync --agent prime-agent`:
- Record ownership from the skill's own scope, not the syncing project's.
  Globals were being pinned to whichever project happened to sync them,
  after which every other project refused to update them forever.
- Never adopt legacy, ownership-less state into the current scope. Round 2
  did, which deleted the other project's skills on the first sync after
  upgrading. Such entries are attributed to the project that last wrote the
  state file, and left alone when that isn't the project syncing now.
- Close the overwrite-guard bypass: a sync with no project, or a global
  landing on a project-owned name, could still clobber and re-own a
  tracked skill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
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
fa7055ac5e fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 3m7s
CI/CD / smoke (pull_request) Failing after 3m13s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / publish (pull_request) Has been skipped
Addresses the second round of `config prime-agent` review (10 findings).

auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
  rewrite, so one syntax error can no longer destroy the provider API key and
  every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
  of the default umask — bearer tokens are no longer world-readable on first
  creation.

state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
  for globals; legacy undefined adopted to current scope) instead of comparing
  null against undefined, so global-only syncs and pre-PR state can no longer
  leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
  warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
  (prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
  this also makes the earlier dropped-attach concern explicit rather than silent.

single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
  other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
  while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
  active project, fixing the false short-circuit / no-op switch.

config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
  no longer silently reverts on the next sync), no-ops when it matches, and
  still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
  extension checks child exit status, so it no longer reports a successful
  switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
  so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
  fresh one (no more never-expiring token litter / lost-credential duplication).

Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +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
eb1642ab1a fix(cli): harden config prime-agent sync + install /mcpctl switcher extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / test (pull_request) Successful in 3m9s
CI/CD / build (pull_request) Successful in 2m16s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.

Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
  JSON instead of swallowing it and rewriting the file (which destroyed every
  non-mcpServers setting). A project's mcpServers entry is merged (keeping
  user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
  / hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
  via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
  a thin wrapper). Under the prime-agent target it: preserves untracked
  pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
  records per-project ownership so configuring a second project never deletes
  the first project's skills, skips Claude-only hooks/postInstall, and keeps
  the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
  auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
  (b) writes the .mcpctl-project marker only when none exists up-tree and never
  from $HOME, and (c) propagates the skills sync exit code so auth failures are
  reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
  silently running the Claude sync.

New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.

Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
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
d56a074733 Merge pull request 'feat(cli): add mcpctl config prime-agent — proxy MCP + skills sync for prime-agent' (#93) from feat/config-prime-agent into main
Some checks failed
CI/CD / lint (push) Successful in 1m6s
CI/CD / typecheck (push) Successful in 2m9s
CI/CD / test (push) Successful in 1m21s
CI/CD / smoke (push) Failing after 1m53s
CI/CD / build (push) Successful in 4m16s
CI/CD / publish (push) Has been skipped
Reviewed-on: #93
2026-08-08 09:19:32 +00:00
Michal
797df2299d docs(taskmaster): PRD + task graph for agentic teams foundation
Converts docs/agentic-teams.md §16 steps 1-3 into a trackable PRD and 48
appended tasks (IDs 115-162) in the master tag. Steps 4-8 land as 5 low
priority placeholders wired onto the spine, pending the §15 open questions.

The PRD is grounded in the current codebase rather than the design doc's
indicative sketches, which changed the shape of the work:

- RBAC `tasks` is already owned by the inference queue, and bindings are
  normalized at parse time, so existing RbacDefinition rows literally hold
  "tasks". Taking the name for work items needs a one-shot guarded backfill
  to `inference-tasks` (task 116).
- deploy/entrypoint.sh uses `prisma db push`, so migration SQL never runs in
  production. Invariants are service-layer, not CHECK constraints.
- Ephemeral pods do not exist: restartPolicy is hardcoded Always and
  ContainerSpec has no volume/workdir fields (tasks 140-142).
- `exec` is cheap: KubernetesOrchestrator already implements exec/attach over
  k8s.Exec/k8s.Attach; only an API + CLI surface is missing (tasks 144-145).
- No git provider client exists in product code (task 148).
- ChatService caps its tool loop at 12 iterations, so the AgentRuntime runs a
  headless coding agent in-pod rather than extending chat (tasks 146-147).

Names the git-repo entity CodeRepo (CLI `repos`) to avoid colliding with the
repository/ data-access layer; AgentProject carries an optional llmId so the
brain-ownership question can be settled either way without a migration.

Generated task_*.md files are left untracked, matching how this repo has
treated them since the first commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 09:58:20 +01:00
Michal
582f6f185b feat(cli): add mcpctl config prime-agent — proxy MCP + skills sync for prime-agent
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m14s
CI/CD / publish (pull_request) Has been skipped
Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl
proxy MCP gateway over HTTP instead of stdio):

`mcpctl config prime-agent --project X`:
- registers the proxy MCP gateway in ~/.prime/agent/settings.json as
  mcpServers.X = { type: "http", url: <gateway>/projects/X/mcp }, merging with
  any existing servers (e.g. the bundled `sre` project) and preserving all other
  settings
- writes a .mcpctl-project marker so later syncs resolve the project
- syncs the project's skills into ~/.prime/agent/skills/<name>/ as markdown
  skills (prime-agent auto-discovers them at session start)

New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later.

- src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers
- src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses
  installSkillAtomic + skills-state; skips Claude-only hooks/postInstall)
- completes config.ts/skills.ts wiring; regenerated shell completions
- tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts
2026-08-08 09:34:15 +01:00
Michal
d24c30761b docs: keep superseded design-round doc history
Supporting documents folded into agentic-teams.md (the canonical unified
design record), retained as historical context:

- agentic-teams-review.md (round 2)
- vision-fidelity.md (round 3 diagnosis + research)
- epic-task-artifact-model.md (Epic/epictask/task + context philosophy)
- session-summary-vision-fidelity.md (session narrative)

All superseded by docs/agentic-teams.md, which remains the source of truth.
2026-08-08 09:03:58 +01:00
Michal
5e1c6b3681 docs: unified agentic-teams design document
Consolidates agentic-teams (rounds 1-2), vision-fidelity, Epic/artifact
model, and session summary into one canonical design record.

Supersedes: agentic-teams-review.md, vision-fidelity.md,
epic-task-artifact-model.md, session-summary-vision-fidelity.md
2026-08-08 09:03:35 +01:00
2c8419eddb Merge pull request 'fix(cli): stop the MCP stdio bridge serialising requests' (#92) from fix/mcp-bridge-head-of-line into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m16s
CI/CD / test (push) Successful in 1m21s
CI/CD / smoke (push) Failing after 1m55s
CI/CD / build (push) Successful in 4m18s
CI/CD / publish (push) Has been skipped
One slow call stalled every later request because the stdin loop awaited each in turn; queued requests were never sent so nothing could time them out and the client saw silence. Dispatch is now concurrent. Regression test verified to fail on the old code.
2026-08-07 14:20:27 +00: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
cf85e36ede Merge pull request 'feat(gate): drop reasoning field from selection (~1.5s)' (#90) from feat/gate-drop-reasoning into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m10s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-25 02:07:30 +00: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
2f9e7be762 feat(gate): terse selection prompt (~2s) (#89)
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m8s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-24 20:38:32 +00: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
3b96b0399e feat(gate): fast no-think selection + dedicated selection Llm (#88)
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m5s
CI/CD / publish (push) Has been skipped
2026-07-24 13:29:58 +00: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
86942b1e91 feat(gate): route prompt-selection through mcpd server Llm (#87)
Some checks failed
CI/CD / typecheck (push) Successful in 1m2s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m18s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m8s
CI/CD / publish (push) Has been skipped
2026-07-24 11:52:03 +00: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
e75d2ba296 feat(proxy): favourite-index tool presentation + gate provider hardening (#86)
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m11s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m17s
CI/CD / publish (push) Has been skipped
2026-07-23 21:28:22 +00: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
574fc63bb1 docs(reliability): fallback needs a real API key (#85)
Some checks failed
CI/CD / typecheck (push) Successful in 1m13s
CI/CD / lint (push) Successful in 2m10s
CI/CD / test (push) Successful in 1m29s
CI/CD / build (push) Successful in 2m24s
CI/CD / smoke (push) Failing after 3m15s
CI/CD / publish (push) Has been skipped
2026-07-22 22:41:37 +00:00
Michal
88025d0ef5 docs(reliability): fallback target needs a real sk-ant-api03 key, not an OAuth token
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m14s
CI/CD / lint (pull_request) Successful in 2m15s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / publish (pull_request) Has been skipped
Record the empirical finding: a Claude subscription OAuth token is gated by
Anthropic (404/429 as a raw API) and can't serve as the server-side fallback,
even though mcpd's adapter now sends it via Bearer. The anthropic-fallback is
wired-but-inert until mcpd Secret anthropic-key holds a real console API key.
Also note the chain is now Pulumi-owned so pulumi up won't wipe it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:41:30 +01:00
e95aeeb3a2 fix(mcpd): anthropic adapter OAuth support (#84)
Some checks failed
CI/CD / lint (push) Successful in 1m5s
CI/CD / typecheck (push) Successful in 2m6s
CI/CD / test (push) Successful in 1m16s
CI/CD / smoke (push) Failing after 1m52s
CI/CD / build (push) Successful in 4m15s
CI/CD / publish (push) Has been skipped
2026-07-21 22:46:26 +00: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
efbe99832b Merge pull request 'feat(chat): LLM failover chain + show which model answered' (#83) from feat/llm-failover-chain into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m9s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-07-21 22:40:29 +00: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
3465596635 Merge pull request 'feat(reliability): bound + fail over + surface LLM-optional ops' (#82) from feat/llm-resilience into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m11s
CI/CD / lint (push) Successful in 2m15s
CI/CD / test (push) Successful in 1m33s
CI/CD / smoke (push) Failing after 2m44s
CI/CD / build (push) Successful in 12m9s
CI/CD / publish (push) Has been skipped
2026-07-19 15:29:00 +00: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
d839d3c6cf Merge pull request 'feat(chat): project-scoped chat — mcpctl chat --project <name>' (#81) from feat/project-chat into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m1s
CI/CD / test (push) Successful in 1m17s
CI/CD / lint (push) Failing after 7m5s
CI/CD / smoke (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / publish (push) Has been skipped
2026-07-18 09:34:47 +00: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
ae66ad8430 Merge pull request 'fix(pulumi): drop exports map so Pulumi can serialize the provider (v0.0.2)' (#80) from fix/pulumi-provider-exports into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m5s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Failing after 1m52s
CI/CD / build (push) Successful in 4m20s
CI/CD / publish (push) Has been skipped
2026-07-17 21:32:49 +00: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
8b6dbdd368 Merge pull request 'feat(pulumi): @mcpctl/pulumi — generic Pulumi provider for mcpctl' (#79) from feat/pulumi-provider into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m8s
CI/CD / test (push) Successful in 1m19s
CI/CD / build (push) Successful in 2m27s
CI/CD / smoke (push) Failing after 2m43s
CI/CD / publish (push) Has been skipped
2026-07-17 21:02:44 +00: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
e2db8d9003 Merge pull request 'fix(cli): login over HTTPS + suggest last-used email' (#78) from fix/login-https-and-email-suggest into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m2s
CI/CD / lint (push) Successful in 2m1s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Failing after 2m38s
CI/CD / build (push) Successful in 2m24s
CI/CD / publish (push) Has been skipped
2026-07-17 20:07:00 +00: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
76470f4f8c Merge pull request 'mcpd error-log view: 'mcpctl errors'' (#77) from feat/mcpd-error-log into main
Some checks failed
CI/CD / lint (push) Successful in 1m4s
CI/CD / test (push) Successful in 1m22s
CI/CD / typecheck (push) Failing after 7m0s
CI/CD / smoke (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / publish (push) Has been skipped
2026-06-16 22:31:55 +00:00
ab00fc6296 Merge pull request 'OpenBao provisioning script (recurrence fix)' (#76) from feat/openbao-provisioning into main
Some checks failed
CI/CD / lint (push) Successful in 1m2s
CI/CD / typecheck (push) Successful in 1m1s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m48s
CI/CD / build (push) Successful in 2m31s
CI/CD / publish (push) Has been skipped
2026-06-16 22:31:54 +00: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
7e8568777e feat(ops): idempotent OpenBao provisioning script
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m30s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 4m46s
CI/CD / publish (pull_request) Has been skipped
scripts/provision-openbao.sh recreates the KV mount + app-mcpd ACL policy +
periodic app-mcpd-role that mcpd's secret backend needs. These were hand-made
and uncaptured, so an OpenBao re-init silently dropped the policy (root cause
of the recurring BACKEND_TOKEN_DEAD / 403-on-secret-write). Now reproducible:
run after any OpenBao (re)init; --seed also mints a token, writes bao-creds,
and restarts mcpd. Mirrors src/shared/src/vault/policy.ts. Idempotent + --dry-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:21:08 +01:00
9f4b8c9149 Merge pull request 'Integrate skills + v7-visibility + mcpctl passwd (deployed)' (#75) from integration/skills-v7-passwd into main
Some checks failed
CI/CD / lint (push) Successful in 1m2s
CI/CD / typecheck (push) Successful in 1m1s
CI/CD / test (push) Successful in 1m18s
CI/CD / build (push) Successful in 2m33s
CI/CD / smoke (push) Failing after 3m37s
CI/CD / publish (push) Has been cancelled
2026-06-16 22:16:47 +00: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
ad2ba12b5b feat(deploy): versioned, reversible Pulumi-driven k8s deploy script
scripts/deploy-k8s.sh replaces fulldeploy.sh's rollout-restart-:latest pattern
(which bypassed Pulumi and left no rollback target). It:
- gates on pnpm test:run
- captures the current prod images as immutable rollback tags (skopeo) + records digests
- pg_dumps the prod DB before the destructive-capable `prisma db push`
- builds/pushes mcpd+mcplocal tagged with the git short-sha
- pins the sha in ../kubernetes-deployment/Pulumi.homelab.yaml and runs
  `pulumi up --target` the mcpd/mcplocal Deployments only (avoids the SOGo
  docker-image resource that needs a local docker daemon)
- waits for rollout + /healthz, installs the CLI RPM, runs smoke tests
- prints an exact rollback recipe on post-cutover failure

--dry-run validated: tests/pg_dump/targeted preview run read-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:26:04 +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
56735a5290 docs: skills + revisions + proposals reference, plus cheatsheet update
Phase 7 of the Skills + Revisions + Proposals work — documentation
pass for the surface added in PR-1 through PR-6. Reference material
only; no code changes.

## What's added

- `docs/skills.md` — skill model, scoping rules, CLI surface, the
  `mcpctl config claude --project` setup flow, metadata schema (with
  the deferred-execution note for hooks/mcpServers/postInstall), the
  on-disk state file shape, atomic install mechanics, failure
  semantics, and what's deferred.
- `docs/revisions.md` — ResourceRevision model, semver auto-bump
  rules, contentHash diff key (cross-resource sync), CLI for history
  / diff / restore, RBAC, audit emission, storage growth note.
- `docs/proposals.md` — ResourceProposal model, the reviewer flow
  (CLI + web UI), atomic-approval mechanics, the propose_prompt /
  propose_skill MCP tools, the propose-learnings global skill that
  steers Claude toward engaging with them, and the deferred legacy
  PromptRequest cutover.

## What's edited

- Top-level `CLAUDE.md` — resource cheatsheet adds `skill`, `proposal`,
  `revision` with cross-references to the new docs. The legacy
  `promptrequest` entry stays (still on the legacy code path) but
  notes that new work should use `proposal`.

## What's NOT in this PR

- The PromptRequest → ResourceProposal cutover migration. Both run
  side-by-side today; the focused cutover PR will rename + backfill +
  drop. Keeping that out of PR-7 means review can stay on docs.
- Bundle-backup / `mcpctl apply -f` skill support (deferred from PR-3).
- `metadata.hooks` / `metadata.mcpServers` / `metadata.postInstall`
  execution (deferred from PR-5).
- Existing-page UI migration to Tailwind (deferred from PR-6 — old
  inline-styled pages coexist fine inside the new Layout).

These are tracked as future PRs; each is its own focused change.

## Verification

`pnpm test:run` whole monorepo: 162 test files / 2157 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:58:04 +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
Michal
1ec286bb14 feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration
Phase 2 of the Skills + Revisions + Proposals work. Stands up the generic
revision/proposal layer and wires Prompt into it. Skills will plug into the
same infrastructure in PR-3 with no further service changes required.

This PR is intentionally additive: PromptRequest table and routes are
unchanged. The /api/v1/proposals API runs side-by-side with the legacy
/api/v1/promptrequests API. The PromptRequest cutover (rename + backfill +
mcplocal rewire) is deferred to a later PR so this one stays reviewable.

## What's added

### Repositories (src/mcpd/src/repositories/)
- resource-revision.repository.ts — append-only revision log keyed by
  (resourceType, resourceId). Soft FK; no relations declared. Supports
  history listing, semver lookup, and contentHash cross-resource search.
- resource-proposal.repository.ts — generic propose queue. Status lifecycle
  pending → approved | rejected. Mirrors Prompt's `?? ''` workaround for
  nullable-FK compound lookups.

### Services (src/mcpd/src/services/)
- resource-revision.service.ts — record() inserts a revision with a stable
  sha256 contentHash computed from canonicalised JSON (key-sorted at every
  level so reordered objects produce the same hash). Caller passes a
  pre-computed semver; service does NOT decide bump policy.
- resource-proposal.service.ts — propose / approve / reject / list, with a
  per-resourceType handler registry. PromptService registers the 'prompt'
  handler at construction; the SkillService will register 'skill' in PR-3.
  approve() runs in a Prisma $transaction so the resource update + revision
  insert + proposal status flip are atomic.

### Pure utility (src/mcpd/src/utils/semver.ts)
- bumpSemver(current, kind) for major / minor / patch
- compareSemver(a, b) — numeric, not lex (10 > 9)
- isValidSemver(s)
- Invalid input falls back to '0.1.0' rather than throwing — keeps the
  audit-write path from blowing up the prompt update if a row's semver
  ever drifts out of MAJOR.MINOR.PATCH shape.

### Routes (src/mcpd/src/routes/)
- revisions.ts — GET /api/v1/revisions?resourceType=&resourceId=,
  GET /api/v1/revisions/:id, GET /api/v1/revisions/:id/diff?against=<id|live>
  (unified-format diff via the `diff` package), and POST
  /api/v1/prompts/:id/restore-revision { revisionId, note? }.
- proposals.ts — GET / POST /api/v1/proposals,
  GET /api/v1/proposals/:id, PUT for body updates, POST .../approve and
  POST .../reject, plus DELETE.

## What's changed

- PromptService.create / update now record a ResourceRevision when the
  revision service is wired. Update auto-bumps patch on content change;
  authors can override via `--bump major|minor|patch` or `--semver X.Y.Z`
  on the CLI (forwarded into the PUT body). Best-effort: revision write
  failures are swallowed so the prompt save still succeeds (revision is
  audit, not source of truth).
- PromptService.setProposalService registers a 'prompt' approval handler
  with the proposal service. Approval runs in a Prisma transaction:
  upsert prompt → record revision → update currentRevisionId → flip
  proposal status. semver bumps to 0.1.0 on first approval, patch
  thereafter.
- New CLI flags on `mcpctl edit prompt`: --bump, --semver, --note. They're
  prompt-only (validated client-side); other resources reject them.
- Aliases in shared.ts: `proposal`/`prop` → proposals,
  `revision`/`rev` → revisions.
- diff dependency added to mcpd.

## Tests

- src/mcpd/tests/utils/semver.test.ts — covers bump/compare/validate
  including numeric (not lex) semver compare and invalid-input fallback.
- prompt-service.test.ts updated: makePrompt fixture now sets semver +
  agentId + currentRevisionId; updatePrompt assertion expects the
  auto-bumped patch in the same update call.
- prompt-routes.test.ts updated symmetrically.

## RBAC

`proposals` and `revisions` URL segments map to the existing `prompts`
permission for now. PR-7 may split if a "reviewer" role becomes useful.

## Verification

Full suite: 158 test files / 2127 tests green.
`pnpm build` clean across all 6 workspace packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:38:35 +01:00
Michal
fbe68fa693 feat(db): schema for ResourceRevision, ResourceProposal, Skill
Phase 1 of the Skills + Revisions + Proposals work. Purely additive — no
existing rows are touched, no tables renamed, no columns dropped.

New tables:
- ResourceRevision — append-only audit + diff log keyed by
  (resourceType, resourceId). Both Prompt and Skill produce revisions on
  every change. Soft FK so revisions outlive the resources they describe.
  Indexed for history viewer (latest-first), semver lookup, and
  cross-resource sync diff via contentHash.
- ResourceProposal — generic propose/approve/reject queue. Drop-in
  replacement for the prompt-only PromptRequest. Created empty here;
  PR-2 will rename PromptRequest → _PromptRequest_legacy and backfill.
- Skill — new resource type that mirrors Prompt for everything CRUD-
  shaped. Adds `files` Json (multi-file bundles, materialised onto disk
  by `mcpctl skills sync` in PR-5) and `metadata` Json (typed app-layer
  in PR-3: hooks, mcpServers, postInstall, …).

New columns on Prompt:
- semver (semver string, default '0.1.0') — auto-bumped patch on save
  by PromptService.update once PR-2 wires it. Distinct from `version`,
  which stays as the optimistic-concurrency counter.
- currentRevisionId — soft pointer to the latest ResourceRevision row.

DB tests cover scope rules (project XOR agent XOR neither), name
uniqueness across both compound keys, cascade-on-delete, soft-FK
survival of deletion, and JSON column persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:18:21 +01:00
f8aa6c2f0d feat: mcpctl provider {enable,disable} — persistent on/off switch (#74)
Some checks failed
CI/CD / lint (push) Successful in 1m0s
CI/CD / typecheck (push) Successful in 1m1s
CI/CD / test (push) Successful in 4m7s
CI/CD / smoke (push) Failing after 6m4s
CI/CD / build (push) Successful in 5m27s
CI/CD / publish (push) Has been skipped
2026-05-03 14:57:21 +00:00
Michal
d04adb5623 feat(cli+mcplocal): persistent provider disable/enable
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m11s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 52s
CI/CD / build (pull_request) Successful in 3m59s
CI/CD / publish (pull_request) Has been skipped
Adds two new subcommands on top of v7's provider lifecycle CLI:

  mcpctl provider disable vllm-local   # release GPU + survive restart
  mcpctl provider enable  vllm-local   # clear the flag, ready to chat

Use case: vLLM keeps crashing on engine init. `down` works for "now"
but the next chat triggers a restart; `disable` writes
`disabled: true` into the provider's entry in ~/.mcpctl/config.json
and short-circuits complete()/ensureRunning() until you re-enable.

Implementation:
- LlmProviderEntry / LlmProviderFileEntry: new optional `disabled` field
- ManagedVllmProvider: setDisabled(bool), isDisabled(), gate in
  complete()/ensureRunning(), expose `disabled` in getStatus()
- mcplocal HTTP: POST /llm/providers/:name/{disable,enable} write the
  config file and apply the change live; /start returns 409 when the
  target is disabled instead of silently failing
- Boot: createSingleProvider honors `entry.disabled` so a known-bad
  vLLM doesn't auto-start on the first chat after mcplocal restart
- CLI: `disable` / `enable` subcommands on `mcpctl provider`; status
  output now shows `(disabled)` next to the state

`enable` is live — provider stays in the registry while disabled, so
flipping the flag back is enough; no mcplocal restart needed.

Tests: cli 437/437, mcplocal 731/731.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:57:01 +01:00
fe27947f80 feat: mcpctl provider <name> {up,down,status} for managed LLMs (#73)
Some checks failed
CI/CD / typecheck (push) Successful in 1m10s
CI/CD / test (push) Successful in 1m11s
CI/CD / lint (push) Successful in 2m47s
CI/CD / build (push) Successful in 2m57s
CI/CD / smoke (push) Failing after 6m22s
CI/CD / publish (push) Has been skipped
2026-05-03 14:40:57 +00:00
Michal
356cbe87b5 feat(cli+mcplocal): mcpctl provider <name> {up,down,status} for managed LLMs
Some checks failed
CI/CD / typecheck (pull_request) Successful in 57s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m1s
CI/CD / smoke (pull_request) Failing after 1m47s
CI/CD / build (pull_request) Successful in 5m58s
CI/CD / publish (pull_request) Has been skipped
Adds lifecycle control for managed local LLM providers (vllm-managed)
without the nuclear option of restarting mcplocal. Practical use:

  mcpctl provider vllm-local down    # release GPU memory now
  mcpctl provider vllm-local up      # warm up before the next chat
  mcpctl provider vllm-local status  # see state, pid, uptime

mcplocal exposes three new endpoints:

  GET  /llm/providers/:name/status   → returns lifecycle state for
                                       managed providers, { managed: false }
                                       for unmanaged (anthropic, openai, …)
  POST /llm/providers/:name/start    → calls warmup() (202 + initial state)
  POST /llm/providers/:name/stop     → calls dispose() (200 + post-stop state)

Stop and start return 400 for non-managed providers — stopping an API-key
provider is meaningless. The CLI surfaces the error verbatim.

Restarting mcplocal would also free the GPU but drops the SSE connection
to mcpd and forces every virtual Llm to re-publish; this is the targeted,
non-disruptive escape hatch.

The completions test gained a `topLevelMarkers` filter so a sub-command
named `status` (under `provider`) doesn't trip the existing "non-project
commands must guard with __mcpctl_has_project" rule.

Tests: cli 437/437, mcplocal 731/731.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:58:46 +01:00
Michal
2b2444a2c5 docs+smoke(v7): visibility section in virtual-llms.md + register/list smoke
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m42s
CI/CD / build (pull_request) Successful in 5m37s
CI/CD / publish (pull_request) Has been skipped
Wraps up v7 Stage 3:

- docs/virtual-llms.md gains a "Visibility scope (v7)" section that
  explains public-vs-private semantics, who skips the filter (owner +
  `*` admin), how to grant single-row exceptions via name-scoped RBAC,
  per-row override syntax in mcplocal config, the `--visibility` flag
  on `mcpctl create llm`/`create agent`, and YAML round-trip behavior.
- New smoke (virtual-llm-visibility.smoke.test.ts) publishes one
  public + one private virtual Llm via the registrar against the
  live mcpd and asserts the GET /llms response carries visibility
  + a non-empty ownerId for both, and that GET /llms/<name> returns
  the private row to its owner without 404. Cross-user filtering is
  covered by mcpd's visibility-filter unit tests; smoke proves the
  fields make the round-trip end-to-end.

Will pass once mcpd is rebuilt + deployed via fulldeploy.sh on this
branch (current main is v6, doesn't yet serialize visibility).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:08:03 +01:00
Michal
2c98a21323 feat(mcpd+cli+mcplocal): wire visibility filter through routes, CLI, registrar (v7 Stage 2)
Stage 1 added the schema + service predicate. This stage threads the
filter through every surface that lists or fetches Llms/Agents:

- mcpd routes: viewerFromRequest helper builds a Viewer from the
  request's RBAC scope. List endpoints rely on the existing
  preSerialization hook (now two-phase: name-scope first, visibility
  second). get-by-id/get-by-name routes pass the viewer to the service
  which 404s on hidden rows.
- RBAC: AllowedScope gains `isAdmin` to distinguish a `*` cross-resource
  grant (admins skip visibility) from a plain `view:llms` grant
  (wildcard for RBAC, but visibility still applies). FastifyRequest
  augmentation updated.
- VirtualLlmService.register accepts ownerId and stamps it on freshly
  created virtual rows; defaults visibility to 'private' on first
  create, leaves existing rows untouched on sticky reconnect.
- AgentService.registerVirtualAgents mirrors the same defaults.
- mcplocal: LlmProviderFileEntry / AgentFileEntry / RegistrarPublishedX
  carry visibility through to the register payload (default 'private').
- CLI: VISIBILITY column on `mcpctl get llm` and `mcpctl get agent`,
  `--visibility` flag on `mcpctl create llm` / `create agent`. YAML
  round-trip works because visibility passes through stripInternalFields
  unchanged (ownerId is already stripped). Completions regenerated.

Tests: mcpd 908/908, mcplocal 731/731, cli 437/437.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:03:58 +01:00
Michal
21f8bede2e feat(mcpd+db): visibility scope + ownership for Llms and Agents (v7 Stage 1)
Adds the schema + service-layer machinery for per-user RBAC scoping
of virtual Llms and Agents. Without this, anyone with `view:llms`
sees every other user's published model — fine for a single-user
homelab, wrong for org use where workstation-published models or
paid keys aren't meant to be broadcast.

Schema:
  - Llm: new `ownerId String?` + `visibility String @default("public")`.
    NULL ownerId on legacy rows is treated as public for back-compat.
  - Agent: `visibility String @default("public")` (Agent already has
    `ownerId`, required).
  - Composite index `(visibility, ownerId)` on both tables for the
    list-filter hot path.
  - Migration backfills both columns to 'public' so pre-v7 setups
    behave identically post-deploy.

Service layer:
  - New `Viewer` / `AgentViewer` shape: `{ userId, wildcard, allowedNames }`.
    The route layer computes this from `request.userId` +
    `RbacService.getAllowedScope` and passes it down. NULL viewer =
    skip the filter (internal callers — cron sweeps, audit, tests).
  - `isLlmVisibleTo` / `isAgentVisibleTo` pure predicates encode the
    decision tree:
      visibility=public → visible (RBAC layer above already passed)
      viewer=null OR wildcard → visible
      ownerId === viewer.userId → visible
      row.name in viewer.allowedNames → visible
      else → hidden
  - LlmService.list/getById/getByName + AgentService equivalents
    accept an optional Viewer arg and apply the predicate. Get-style
    methods 404 (not 403) on hidden rows so name enumeration via
    differential status is impossible.

Repositories: CreateInput/UpdateInput types gained `ownerId`/
`visibility` (Llm) and `visibility` (Agent). Update is in place;
ownerId is set-once at create time.

Tests:
  - 13 unit tests on the predicate covering every branch (null
    viewer, public, wildcard, owner, name-scoped grant, foreign
    private, legacy null-ownerId).
  - mcpd 908/908 (was 893; +15 across the merge windows + this PR).

Stage 2 (next): route plumbing — every list/get endpoint needs to
build the Viewer from the request and pass it through. mcplocal
virtuals default to visibility=private on register. CLI adds a
VISIBILITY column and a --visibility flag. yaml round-trip preserves
the field.
2026-04-29 00:46:06 +01:00
3071bcee8e feat: v6 polish — per-publisher namespacing + auto-create project (#71)
Some checks failed
CI/CD / typecheck (push) Successful in 57s
CI/CD / test (push) Successful in 1m13s
CI/CD / lint (push) Successful in 2m51s
CI/CD / smoke (push) Failing after 1m45s
CI/CD / build (push) Successful in 5m47s
CI/CD / publish (push) Has been skipped
2026-04-28 23:33:39 +00:00
46697f4f63 feat: v5 durable inference task queue (#70)
Some checks failed
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / lint (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-28 23:33:36 +00:00
Michal
ee18c5107e feat(mcpd): auto-create project on virtual-agent register (v6 Stage 2)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / lint (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m41s
CI/CD / publish (pull_request) Has been skipped
Closes the v3-deferred "project must already exist" gap. When a
virtual agent declares `project: "my-team"` and no such project
exists, mcpd creates it idempotently with the publishing user as
owner (instead of throwing 404 from registerVirtualAgents).

ProjectService gains `ensureByName(name, ownerId, opts)` — find
the project or create it with sensible defaults (description carries
an audit note pointing at the registrar; proxyModel/gated take
their schema defaults). First publisher to land on a name owns the
row; subsequent publishers reuse the existing one.

AgentService.registerVirtualAgents calls ensureByName instead of
resolveAndGet, so the same agent register payload works regardless
of whether the project pre-existed or not.

Tests: 2 new tests (auto-creates a missing project on first publish;
reuses an existing project without re-creating). Mock projects
factory rebuilt to track _created names + maintain id→name reverse
lookup so the agent's toView returns the correct project name
(prior mock hardcoded 'mcpctl-dev').

Existing 13 virtual-agent tests + 870 mcpd suite green.
2026-04-28 15:54:27 +01:00
Michal
c346b93789 feat(mcplocal): per-publisher namespacing for virtual Llms/Agents (v6 Stage 1)
Two mcplocals sharing the same config template (`vllm-local-qwen3`)
no longer collide on mcpd's cluster-wide unique-name constraint.
Each publisher can append a suffix derived from hostname (or any
other stable per-host identifier) so the wire-side names become
distinct (`vllm-local-qwen3-alice`, `vllm-local-qwen3-bob`).
Pair with an explicit `poolName` (v4) and the rows still appear as
one logical pool — agents pinned to any member load-balance across
both.

Config (`~/.mcpctl/config.json`):

  {
    "publisher": { "suffix": "auto" }   // → os.hostname() sanitized
                  // or { "suffix": "alice" } for explicit override
  }

Or via env: `MCPCTL_PUBLISHER_SUFFIX=alice` (operations override).

Resolution order: env var → config.publisher.suffix → empty
(legacy behavior, no mangling). Sanitization lowercases, replaces
non-`[a-z0-9-]` runs with `-`, strips leading/trailing dashes —
the result must satisfy mcpd's name validation, otherwise the
register POST would 422.

Wire shape: RegistrarPublishedProvider gets an optional
`publishName` field. When set, the wire payload's `name` is
`publishName` (suffixed); when not, today's `provider.name`.
Inbound infer/wake task lookups match `publishName ?? provider.name`
so the local registry stays addressable by its original name —
SSE frames carrying the suffixed wire name still find their
provider.

Agents are forwarded with their own suffixed name AND a
`llmName` rewritten through the same per-local→wire map so the
agent rows pin to the suffixed Llm wire name (otherwise
registerVirtualAgents would 404).

Tests: 8 new tests covering applyPublisherSuffix (empty, normal,
length limit, exact-100) and loadPublisherSuffix (env override,
absent, sanitization, dash stripping). Existing registrar tests
untouched — no suffix means no behavior change.
2026-04-28 15:54:06 +01:00
Michal
7320b50dac feat(cli+docs+smoke): inference-task CLI + GC ticker + smoke + docs (v5 Stage 4)
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / typecheck (pull_request) Successful in 2m46s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Failing after 7m0s
CI/CD / publish (pull_request) Has been skipped
CLI surface for the durable queue:

- `mcpctl get tasks` — table view (ID, STATUS, POOL, LLM, MODEL,
  STREAM, AGE, WORKER). Aliases `task`, `tasks`, `inference-task`,
  `inference-tasks` all normalize to the canonical plural so URL
  construction works uniformly. RESOURCE_ALIASES + completions
  generator updated.
- `mcpctl chat-llm <name> --async -m <msg>` — enqueue and exit. stdout
  is just the task id (pipeable into `xargs mcpctl get task`); stderr
  carries human-readable status. REPL mode is rejected for --async
  (fire-and-forget doesn't make sense without -m).

GC ticker in mcpd: 5-min interval. Pending tasks past 1 h queue
timeout flip to error with a clear message; terminal tasks past 7 d
retention get deleted. Both queries are index-backed.

Crash fix uncovered by the smoke: when the async route doesn't await
ref.done, a later cancel/error rejected the in-flight Promise as
unhandled and crashed mcpd. The route now attaches a no-op `.catch`
so the legacy `done` semantic still works for sync callers (chat,
direct infer) without taking out the process for async ones. The
EnqueueInferOptions also gained an explicit `ownerId` field so the
async API can stamp the authenticated user on the row instead of
inheriting 'system' from the constructor's resolveOwner — without
this, every GET/DELETE from the original caller would 404 due to
foreign-owner mismatch.

Smoke (tests/smoke/inference-task.smoke.test.ts):

  1. POST /inference-tasks while no worker bound → row=pending.
  2. Bring a registrar online → bindSession drain claims and
     dispatches → worker complete()s → row=completed → GET returns
     the assistant body.
  3. Stop worker, enqueue, DELETE → row=cancelled, persisted.

docs/inference-tasks.md (new): full data model, lifecycle diagram,
async API reference, CLI examples, RBAC table, GC defaults, and the
v5 limitations / v6 roadmap. Cross-linked from virtual-llms.md and
agents.md.

Tests + smoke: mcpd 893/893, mcplocal 723/723, cli 437/437, full
smoke 146/146 (was 144, +2 new task smoke). Live mcpd verified via
manual curl: enqueue → cancel → re-fetch — no crash, owner scoping
returns 404 on foreign ids, GC ticker logs at info when it sweeps.

v5 complete: durable queue (Stage 1) + VirtualLlmService rewire
(Stage 2) + async API & RBAC (Stage 3) + CLI/GC/smoke/docs (Stage 4).
2026-04-28 15:25:09 +01:00
Michal
1dcfdc8b05 feat(mcpd): async inference task API + tasks RBAC resource (v5 Stage 3)
Exposes the durable queue (Stage 1+2) as a first-class API so callers
can enqueue work, get a task id immediately, and poll/stream/cancel
without holding open the original HTTP connection.

New endpoints (`/api/v1/inference-tasks`):

  POST   /                  → enqueue, return task id (201 + row).
                              failFast:false — task stays pending if no
                              worker is up; future bindSession drains.
                              Rejects 400 for public Llms (the existing
                              /llms/<name>/infer is the right tool there)
                              and 404 for missing Llms.
  GET    /                  → list owner's tasks. Optional ?status,
                              ?poolName, ?agentId, ?limit query.
                              Owner-scoped at the route layer; cross-
                              user listing requires resource-wide grant.
  GET    /:id               → poll one task. 404 (not 403) on a
                              foreign-owner id to prevent enumeration.
  DELETE /:id               → cancel a non-terminal task. Already-
                              terminal rows return 200 + current shape
                              (no-op). 404 on foreign owner.
  GET    /:id/stream        → SSE feed of `chunk` and `terminal` events.
                              Re-fetches the row at subscribe time so
                              already-completed tasks emit one terminal
                              event and close immediately.

RBAC:

- New `tasks` resource added to RBAC_RESOURCES + the URL→permission
  map in main.ts. Default action mapping: GET=view, POST=create,
  DELETE=delete. The route layer enforces owner-scoping ON TOP of
  the hook (404 on foreign owner) — without this, anyone with
  `view:tasks` could list/peek every user's queued work.
- Singular alias `task` and the multi-word `inference-task` /
  `inference-tasks` all normalize to `tasks` so users can write
  `mcpctl create rbac-binding --resource task --role view ...` or
  any of the variants and have it map correctly.

Tests: 9 new route tests covering the wire shapes, owner scoping
(matching/foreign), public-Llm rejection, missing-Llm 404, list
filter, and cancel semantics (pending→cancelled, terminal→no-op).
mcpd 893/893 (was 884, +9). Live smoke: POST against a public Llm
returns the documented 400, POST against missing returns 404, GET
list returns [] cleanly.

Stage 4 (next): CLI surface (`mcpctl get tasks`, `--async` flag on
chat-llm), GC ticker, smoke test (enqueue → connect worker →
drain), docs.
2026-04-28 15:06:31 +01:00
Michal
7b18bb6d6b feat(mcpd): VirtualLlmService rewires through durable queue (v5 Stage 2)
The in-memory `tasksById` map for inference tasks is gone. Every
inference call lands as a row in `InferenceTask`; the result POST
updates the row + emits a wakeup; the in-flight HTTP handler unblocks
on the wake. mcpd surviving a restart no longer drops in-flight tasks,
and a worker disconnecting mid-task no longer fails the caller — the
row reverts to pending and a sibling worker on the same pool drains it.

Wake tasks (publisher control messages, not inference) keep their own
small in-memory map (`wakeTasks`). They're millisecond-scoped and
don't benefit from durability — a missed wake on restart just means
the next infer fires a fresh wake.

Behavioral changes worth flagging:

- Worker disconnect mid-task: WAS reject ref.done with "publisher
  disconnected"; NOW revert claimed/running rows to pending. Original
  caller's ref.done keeps waiting up to INFER_AWAIT_TIMEOUT_MS (10
  min); whichever worker delivers the result fulfills it.

- bindSession drains pending tasks for the session's pool keys. So
  tasks queued while no worker was up automatically get dispatched
  when one shows up. The drain matches by *effective pool key*
  (poolName ?? name) — tasks queued against vllm-alice get drained
  by any session whose owned Llms share alice's pool.

- New `failFast: true` option on enqueueInferTask (default: false).
  Existing callers that NEED fast-fail get it explicitly:
    - Direct `/api/v1/llms/<name>/infer` route: caller pinned a
      specific Llm and wants 503 immediately if the publisher is
      offline; queueing for an unknown future worker would surprise.
    - chat.service pool failover loop: it iterates pool candidates
      and needs each candidate's transport failure to surface fast.
      Without failFast, a downed pool member would absorb the call
      into the queue and the loop would wait 10 min before trying
      the next.
  The async API route (Stage 3) leaves failFast=false — that's the
  whole point of the durable queue path.

- VirtualLlmService now requires an InferenceTaskService dep at
  construction. Older test wirings that didn't pass it get a clear
  "InferenceTaskService not wired" error from enqueueInferTask
  rather than a confusing in-memory stub.

Tests:

- 12 existing virtual-llm-service tests updated for the new
  semantics: "rejects when no session" → "queues durably"; "rejects
  when row inactive" → "still queues (pool may have a sibling)";
  "unbindSession rejects in-flight tasks" → "reverts to pending".
  Wake-task probing now uses `wakeTasks` instead of `tasksById`.

- 3 new v5-specific tests: drain-on-bind matches by effective pool
  key (not just name); enqueue without a session keeps the row
  pending; completeTask via the result-route updates the DB and
  emits the wakeup that resolves ref.done.

- chat-service-virtual-llm + llm-infer-route assertions updated to
  expect the new {failFast: true} option arg.

mcpd 884/884 (was 881; +3 v5 cases). mcplocal 723/723. Full smoke
suite 144/144 against the deployed queue-backed mcpd.

Stage 3 (next): expose the durable queue via async API endpoints.
POST /api/v1/inference-tasks (enqueue with failFast=false), GET
/api/v1/inference-tasks/:id (poll), GET /api/v1/inference-tasks/:id/stream
(SSE), DELETE /api/v1/inference-tasks/:id (cancel). New `tasks` RBAC
resource.
2026-04-28 02:33:26 +01:00
Michal
ed21ad1b5a feat(mcpd+db): durable InferenceTask queue + state machine (v5 Stage 1)
The persistence + signaling layer for v5. No integration with the
existing in-flight inference path yet — that's Stage 2. This commit
just lands the durable queue underneath, with a state machine that
mcpd's HTTP handlers, the worker result-POST route, and the GC sweep
will all build on.

Schema (src/db/prisma/schema.prisma + migration):

- New `InferenceTask` model + `InferenceTaskStatus` enum
  (pending|claimed|running|completed|error|cancelled).
- Routing fields stored at enqueue time so a later rename of
  `Llm.poolName` doesn't reroute already-queued work: `poolName`
  (effective pool key), `llmName` (pinned target), `model`, `tier`.
- Worker tracking: `claimedBy` (providerSessionId) + `claimedAt`,
  cleared on revert.
- Bodies as `Json`: requestBody (always set), responseBody (set at
  completion). Streaming chunks are NOT persisted — too expensive at
  delta granularity. The final assembled body lands once per task.
- Lifecycle timestamps: createdAt, claimedAt, streamStartedAt,
  completedAt. Plus ownerId (RBAC + audit) and agentId (null for
  direct chat-llm calls).
- Indexes for the hot paths: (status, poolName) for the dispatcher's
  drain query, claimedBy for the disconnect revert, completedAt for
  the GC retention sweep, owner/agent for the async API listing.

Repository (src/mcpd/src/repositories/inference-task.repository.ts):

- CRUD + state transitions as conditional CAS via `updateMany`. Two
  workers racing to claim the same row both run the UPDATE; whichever
  the DB serializes first sees affected=1 and gets the row, the loser
  sees 0 and falls through to the next candidate. No application-
  level locking required.
- findPendingForPools(poolNames[]) for the worker drain on bind.
- findHeldBy(claimedBy) for the unbindSession revert.
- findStalePending + findExpiredTerminal for the GC sweep.

Service (src/mcpd/src/services/inference-task.service.ts):

- Owns the in-process EventEmitter that wakes blocked HTTP handlers
  when a worker POSTs results. The DB row is the source of truth for
  *state*; the EventEmitter just signals "go re-read row X" so we
  don't have to poll. Single-instance assumption for v5; pg
  LISTEN/NOTIFY is the v6 swap when scaling horizontally — no schema
  change needed, just replace the emitter wakeup.
- waitFor(taskId, timeoutMs) returns { done, chunks }: the terminal
  promise + an async iterator of streaming deltas. Throws on cancel
  (clear message) or error (worker's errorMessage propagates) or
  timeout. Polls the row once at subscribe time so an already-
  terminal task resolves immediately without waiting for an event
  that's never coming.
- gcSweep flips stale pending rows to error (with a clear message
  about the timeout) and deletes terminal rows past retention.
  Defaults: 1h pending timeout, 7d terminal retention; both
  configurable.

Tests:
- 6 db-level schema tests (defaults, json roundtrip, drain query
  shape, claimedBy filter, GC predicate, agentId nullable).
- 13 service tests covering enqueue, the CAS race on tryClaim,
  complete/fail/cancel, idempotent terminal transitions, revertHeldBy
  on disconnect, and the full waitFor signal lifecycle (immediate
  resolve, wake on event, chunk streaming, cancel/error/timeout
  paths). Plus a gcSweep test with a fixed clock.

mcpd 881/881 (was 868; +13). db pool-schema 14/14, +6 new
inference-task-schema. Pre-existing failures in models.test.ts
(Secret FK fixture issue, also fails on main HEAD) are unrelated.

Stage 2 (next): VirtualLlmService rewires through this — remove the
in-memory pendingTasks map; enqueue creates a row, dispatch picks an
active session, the result-route updates the row + emits the wakeup.
Worker disconnect reverts; worker bind drains.
2026-04-28 02:14:45 +01:00
256e117021 Merge pull request 'feat: v4 LB pools by shared poolName' (#69) from feat/llm-pool-by-name into main
Some checks failed
CI/CD / lint (push) Successful in 55s
CI/CD / test (push) Successful in 1m10s
CI/CD / typecheck (push) Successful in 2m45s
CI/CD / smoke (push) Failing after 1m43s
CI/CD / build (push) Successful in 5m54s
CI/CD / publish (push) Has been skipped
Reviewed-on: #69
2026-04-28 01:02:45 +00:00
Michal
137711fdf6 feat(docs+smoke): LB pool live smoke + virtual-llms.md pool semantics (v4 Stage 3)
Some checks failed
CI/CD / lint (pull_request) Successful in 53s
CI/CD / test (pull_request) Successful in 1m8s
CI/CD / typecheck (pull_request) Successful in 2m53s
CI/CD / smoke (pull_request) Failing after 1m47s
CI/CD / build (pull_request) Successful in 6m20s
CI/CD / publish (pull_request) Has been skipped
Smoke (tests/smoke/llm-pool.smoke.test.ts): two in-process registrars
publish virtual Llms with distinct names but a shared poolName, then:

  1. /api/v1/llms/<name>/members surfaces both with the correct
     effective pool key, size, activeCount, and per-member kind/status.
  2. Chat through an agent pinned to one pool member dispatches across
     the pool — verified by running 12 calls and asserting at least
     one response from each backend (the random-shuffle selection
     would have to hit only-A or only-B in 12 fair coin flips, ~1/2048).
  3. Failover: stop one publisher, the surviving member still serves
     chat. /members shows the stopped row as inactive immediately
     (unbindSession runs synchronously on SSE close).

docs/virtual-llms.md gets a full "LB pools (v4)" section with the
two-field schema model, dispatcher selection + failover semantics,
public + virtual declaration examples, list/describe rendering, the
"pin to specific instance" escape hatch, and an API surface entry
for /members. docs/agents.md cross-link extended.

Tests: full smoke 144/144 (was 141, +3 for the new pool smoke).
Stages 1-3 ship the complete v4 — public and virtual Llms can both
join pools, agents transparently load-balance across them, yaml
round-trip preserves poolName, and the existing single-Llm world
keeps working byte-identically when poolName is null.
2026-04-27 23:22:15 +01:00
Michal
e21f96080d feat(mcpd+cli+mcplocal): /llms/<name>/members + POOL column + --pool-name (v4 Stage 2)
Surfaces the v4 pool model end-to-end:

- mcpd: GET /api/v1/llms/:name/members returns the effective pool the
  named anchor belongs to, plus aggregate stats (size, activeCount,
  explicit vs implicit pool key). RBAC inherits from `view:llms` —
  same as the single-Llm route. Members are full LlmView shapes so
  callers don't need a second roundtrip to render the pool block.

- mcpd: VirtualLlmService.register accepts an optional `poolName` on
  RegisterProviderInput; the route's `coerceProviderInput` validates
  the same character set as CreateLlmSchema.poolName. Backwards
  compatible — older mcplocals that don't send the field continue to
  publish solo Llms.

- CLI `get llm` table: new POOL column right after NAME. Solo rows
  show "-" so the "no pool / pool of 1" case is unambiguous (per
  user direction "make sure we see it, prominently visible and
  impossible to mistake").

- CLI `describe llm`: fetches /members and renders a Pool block at
  the top of the detail view when the row is in an explicit pool OR
  when its implicit pool has size > 1. Each member line shows
  kind/status; the anchor row gets "← this row". Block is suppressed
  for solo rows so describe stays compact in the common case.

- CLI `create llm --pool-name <name>` flag and apply schema both
  accept the new field. Yaml round-trip preserves it: get -o yaml
  emits `poolName: <name>`, apply -f re-imports it without diff.
  Verified end-to-end against the live mcpd.

- mcplocal: LlmProviderFileEntry gains optional `poolName`; main.ts
  and registrar.ts thread it through into the register payload. Use
  case for distributed inference: each user's mcplocal picks a
  unique `name` (e.g. `vllm-<host>-qwen3`) but a shared `poolName`
  (e.g. `user-vllm-qwen3-thinking`); agents see one logical pool
  that auto-grows as workers come online.

- Shell completions: regenerated from source via the existing
  scripts/generate-completions.ts. `--pool-name` now suggests in
  fish + bash for `mcpctl create llm`.

Tests: +3 new mcpd route tests for /members (explicit pool, solo
pool of 1, missing-anchor 404). All suites green:
  mcpd 868/868 (was 865, +3),
  mcplocal 723/723,
  cli 437/437.

Stage 3 (next): live smoke against 2 publishers sharing a pool name +
docs.
2026-04-27 23:18:53 +01:00
Michal
7949e1393d feat(mcpd+db): Llm.poolName + chat dispatcher pool failover (v4 Stage 1)
Adds LB-pool-by-shared-name without introducing a new resource. The
existing `Llm.name` stays globally unique; a new optional `poolName`
column declares membership in a pool. Multiple Llms sharing a non-null
`poolName` stack into one load-balanced pool that the chat dispatcher
expands at request time.

Effective pool key = `poolName ?? name`. Solo rows (poolName=null) are
addressable as a "pool of 1" via their own name, so existing single-Llm
agents and YAMLs keep working unchanged. A solo row whose name happens
to match an explicit poolName joins the same pool — by design — so an
operator can transparently promote an existing Llm to pool seed.

Dispatcher (chat.service): prepareContext now resolves a randomly-
shuffled list of viable pool candidates (status != inactive) once per
turn. runOneInference and streamInference iterate the list on
transport-level failure (network, virtual publisher disconnect) until
one succeeds or the list is exhausted. Streaming failover only covers
"failed before first chunk" — once we've yielded text, we're committed
to that backend. Auth/4xx errors surfaced as result.status are NOT
retried; siblings with the same key/model would fail identically.

When the agent's pinned Llm is itself inactive but a sibling pool
member is up, dispatch transparently uses the sibling — that's the
whole point. When every member is inactive, prepareContext throws a
clear "No active Llm in pool '<key>' (pinned: <name>)" error rather
than letting the dispatcher's "exhausted" branch surface it.

Tests:
- 5 new chat-service tests for pool dispatch / failover / pinned-down /
  all-inactive (chat-service.test.ts).
- 7 new db schema tests for the column, the unique-name invariant, the
  fallback-to-name semantics, and the solo-name-joins-explicit-pool
  edge case (llm-pool-schema.test.ts).
- mcpd 865/865 (was 860; +5), db pool-schema 7/7, no regressions.

Stage 2 (next): HTTP route /api/v1/llms/<name>/members + aggregate pool
stats on the existing single-Llm route, CLI POOL column + describe
block + --pool-name flag, yaml round-trip.
2026-04-27 22:02:41 +01:00
c0b4dc89f3 Merge pull request 'chore: fulldeploy uses bao-backed pulumi wrapper for drift check' (#68) from chore/fulldeploy-pulumi-wrapper into main
Some checks failed
CI/CD / lint (push) Successful in 54s
CI/CD / test (push) Successful in 1m8s
CI/CD / typecheck (push) Successful in 2m23s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / build (push) Successful in 5m46s
CI/CD / publish (push) Has been skipped
Reviewed-on: #68
2026-04-27 20:21:33 +00:00
Michal
7f49294b36 chore(fulldeploy): use kubernetes-deployment/scripts/pulumi.sh wrapper
Some checks failed
CI/CD / lint (pull_request) Successful in 2m22s
CI/CD / typecheck (pull_request) Successful in 2m57s
CI/CD / test (pull_request) Failing after 4m36s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
The pre-flight drift check now calls the bao-backed pulumi wrapper
that landed with the litellm key persistence work, so deploys no
longer need PULUMI_CONFIG_PASSPHRASE in .env or shell env. The
passphrase is fetched from OpenBao at runtime by the wrapper and
exec-passed to pulumi only — never touches the parent shell's
state.

Falls back to a clear warning if the wrapper isn't present (older
clone of kubernetes-deployment) instead of pretending to skip the
check silently.
2026-04-27 19:14:36 +01:00
f5bdeea8e7 Merge pull request 'feat: virtual agents v3 (Stages 1-3) + real fixes for chat/adapter/CLI thread format' (#67) from feat/virtual-agent-v3 into main
Some checks failed
CI/CD / typecheck (push) Successful in 55s
CI/CD / test (push) Successful in 1m10s
CI/CD / lint (push) Successful in 2m32s
CI/CD / smoke (push) Failing after 1m44s
CI/CD / build (push) Successful in 5m4s
CI/CD / publish (push) Has been skipped
Reviewed-on: #67
2026-04-27 18:06:59 +00:00
Michal
1998b733b2 feat(cli+docs): mcpctl get agent KIND/STATUS columns + virtual-agent smoke + docs (v3 Stage 4)
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m10s
CI/CD / typecheck (pull_request) Successful in 2m30s
CI/CD / build (pull_request) Successful in 2m36s
CI/CD / smoke (pull_request) Failing after 5m56s
CI/CD / publish (pull_request) Has been skipped
CLI: `mcpctl get agent` table view gains KIND and STATUS columns
mirroring the `get llm` shape from v1. Public agents render as
`public/active` (the AgentRow defaults) and virtual ones surface their
true lifecycle state, so `mcpctl get agent` becomes a single-pane view
for both manually-created and mcplocal-published personas.

Smoke: tests/smoke/virtual-agent.smoke.test.ts mirrors virtual-llm's
in-process registrar pattern — publishes a fake provider + agent in
one round-trip, confirms mcpd surfaces the agent kind=virtual /
status=active under /api/v1/agents, then disconnects and verifies the
paired Llm-and-Agent both flip to inactive (deletion is GC-driven, not
disconnect-driven, so the rows must still exist post-stop). Heartbeat-
stale and 4 h sweep paths are covered by the unit suite to keep smoke
duration in check.

Docs: docs/virtual-llms.md gets a "Virtual agents (v3)" section with a
config sample, lifecycle notes, listing example, and the cluster-wide
name-uniqueness caveat. The API surface block now mentions the new
`agents[]` field on _provider-register, the join-by-session heartbeat
behavior, and the `GET /api/v1/agents` lifecycle fields. docs/agents.md
gains a one-paragraph note pointing to the v3 publishing path.

Tests: full smoke suite 141/141 (was 139, +2 new), unit suites
unchanged (mcpd 860/860, mcplocal 723/723).
2026-04-27 18:47:03 +01:00
Michal
610808b9e7 fix(chat): real fixes for thinking-model + URL conventions, not test tweaks
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Successful in 1m7s
CI/CD / typecheck (pull_request) Successful in 2m37s
CI/CD / smoke (pull_request) Failing after 1m43s
CI/CD / build (pull_request) Successful in 5m42s
CI/CD / publish (pull_request) Has been skipped
Five real bugs surfaced by the agent-chat smoke against live
qwen3-thinking. None of these are fixed by changing the test — the
test was right to fail.

1. openai-passthrough adapter doubled `/v1` in the request URL. The
   adapter hard-codes `/v1/chat/completions` after the configured base,
   but every OpenAI-compat provider documents its base URL with a
   trailing `/v1` (api.openai.com/v1, llm.example.com/v1, …). Users
   pasting that conventional shape produced
   `https://x/v1/v1/chat/completions` → 404. endpointUrl now strips a
   trailing `/v1` so both forms canonicalize. `/v1beta` (Anthropic-style)
   is preserved.

2. Non-streaming chat returned an empty assistant when thinking models
   (qwen3-thinking, deepseek-reasoner, OpenAI o1) emitted only
   `reasoning_content` with `content: null`. extractChoice now also
   pulls reasoning (every spelling the streaming parser already knows
   about), and a new pickAssistantText helper falls back to it when
   content is empty. A `[response truncated by max_tokens]` marker is
   appended when finish_reason is `length`, so users see the cut-off
   instead of guessing why the answer is short. Symmetric streaming
   fix: the chatStream loop accumulates reasoning and yields ONE
   synthesized `text` frame at the end when content stayed empty,
   keeping the CLI's stdout (which only prints `text` deltas) in sync
   with the persisted thread message.

3. `mcpctl get agent X -o yaml` emitted `kind: public` (the v3
   lifecycle field) instead of `kind: agent` (apply envelope), so
   round-tripping through `apply -f` failed. Same fix shape as the v1
   Llm strip in toApplyDocs — drop kind/status/lastHeartbeatAt/
   inactiveSince/providerSessionId for the agents resource too.

4. Non-streaming `mcpctl chat` printed `thread:<cuid>` (no space) on
   stderr; streaming printed `(thread: <cuid>)` (with space). Tests
   and any other regex watching for one form missed the other.
   Standardize on `thread: <cuid>` (single space) in both paths.

5. agent-chat.smoke's `run()` used `execSync`, which discards stderr on
   success — making any `expect(stderr).toMatch(...)` assertion
   structurally impossible to satisfy in the happy path. Switch to
   `spawnSync` so stderr is actually captured. Includes a small
   shell-style argv splitter so the existing call sites with quoted
   multi-word values (`--system-prompt "..."`) keep working.

Tests: +6 new mcpd unit tests (4 chat-service for the reasoning
fallback / truncation marker / content-preference / streaming synth;
2 llm-adapters for the URL strip + /v1beta preservation). Full mcpd
+ mcplocal + smoke green: 860/860 + 723/723 + 139/139.
2026-04-27 18:39:01 +01:00
Michal
58bc277242 feat(mcpd+mcplocal): register-agents endpoint + mcplocal agents block (v3 Stage 3)
Extends the existing `_provider-register` payload with an optional `agents`
array so a single round-trip atomically publishes both virtual Llms and
their pinned virtual Agents. v1/v2 publishers (providers-only) keep
working unchanged — the agents path is gated on the route receiving an
AgentService instance, otherwise it logs a warning and ignores the array.

mcplocal config gains a top-level `agents` block (loadLocalAgents)
mirroring the providers shape. The registrar reads it, builds
RegistrarPublishedAgent entries against the published provider names,
and folds them into the same register POST. mcpd routes the agents
through AgentService.registerVirtualAgents(sessionId, ..., ownerId),
which was added in Stage 2.

No CLI changes here — `mcpctl chat <virtual-agent>` already works once
chat.service has the kind=virtual branch (Stage 1) and the agents are
present in the Agent table. CLI columns + smoke land in Stage 4.
2026-04-27 18:38:37 +01:00
Michal
c7b1bd8e2c feat(mcpd): AgentService virtual methods + GC cascade (v3 Stage 2)
State machine for kind=virtual Agent rows. Mirrors what
VirtualLlmService did for Llms in v1, then wires both lifecycles
together so disconnect/heartbeat/GC cascade through both at once.

AgentRepository:
- create/update accept the new lifecycle fields (kind, providerSessionId,
  status, lastHeartbeatAt, inactiveSince).
- Adds findBySessionId, findByLlmId, findStaleVirtuals, findExpiredInactives.

AgentService — new virtual-agent methods:
- registerVirtualAgents(sessionId, inputs, ownerId) — sticky upsert.
  New names insert as kind=virtual/status=active. Existing virtuals
  owned by the same session reactivate; existing inactive virtuals
  from a foreign session can be adopted (sticky reconnect). Refuses
  to overwrite a public agent or a foreign session's still-active
  virtual (HTTP 409). Pinned LLM is resolved via LlmService — caller
  posts Llms first.
- heartbeatVirtualAgents(sessionId) — bumps owned agents on a session
  heartbeat; revives inactive rows.
- markVirtualAgentsInactiveBySession(sessionId) — disconnect cascade.
- deleteVirtualAgentsForLlm(llmId) — defensive cascade for the GC's
  Llm-delete step (Agent.llmId is Restrict).
- gcSweepVirtualAgents() — same shape as VirtualLlmService.gcSweep
  (90s heartbeat-stale → inactive, 4h inactive → delete).

VirtualLlmService:
- Optional AgentService dependency. heartbeat() now also bumps owned
  agents; unbindSession() flips them inactive. gcSweep() runs the
  agent sweep FIRST (so any agent that would block an Llm delete via
  Restrict is already gone), and adds a defensive
  deleteVirtualAgentsForLlm step right before each Llm delete in case
  an agent's heartbeat lagged its Llm's just enough to escape this
  round's 4h cutoff.

main.ts:
- VirtualLlmService construction moves below AgentService so it can
  receive the cascade dependency.

Tests: 13 new in virtual-agent-service.test.ts cover all the register
variants (insert, sticky reconnect, adopt-inactive-foreign, refuse
public-overwrite, refuse foreign-session-active), heartbeat-revive,
disconnect-cascade, deleteVirtualAgentsForLlm scope, GC sweep flip
+ delete + idempotence, and three VirtualLlmService cascade scenarios
(unbindSession, gcSweep deleting agent before Llm, defensive cascade
when agent's heartbeat lagged).

mcpd suite: 854/854 (was 841 + 13 new). Workspace unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:07:23 +01:00
Michal
9afd24a3aa feat(db+mcpd): Agent lifecycle + chat.service kind=virtual branch (v3 Stage 1)
Two pieces of v3 plumbing — schema + the latent v1 chat.service bug.

Schema (db):
- Agent gains kind/providerSessionId/lastHeartbeatAt/status/inactiveSince
  mirroring Llm's v1 lifecycle. Reuses LlmKind / LlmStatus enums; no
  new types. Existing rows backfill kind=public/status=active so v1
  CRUD is unaffected.
- @@index([kind, status]) for the GC sweep, @@index([providerSessionId])
  for disconnect-cascade lookups.
- 4 new prisma-level tests cover defaults, persisting virtual fields,
  the (kind, status) GC index, and providerSessionId lookups.
  Total agent-schema tests: 20/20.

chat.service (mcpd) — fixes the v1 latent bug:
- LlmView's kind is now plumbed through prepareContext as ctx.llmKind.
- Two new private helpers, runOneInference / streamInference, branch
  on ctx.llmKind: 'public' goes through the existing adapter
  registry, 'virtual' relays through VirtualLlmService.enqueueInferTask
  (mirrors the route-handler branch from v1 Stage 3).
- Streaming bridges VirtualLlmService's onChunk callback API to an
  async iterator via a small queue + wake pattern.
- ChatService gains an optional virtualLlms constructor parameter;
  main.ts wires it in. Older test wirings without it raise a clear
  "virtualLlms dispatcher not wired" error when the row is virtual,
  rather than silently falling through to the public path against an
  empty URL.

This unblocks any Agent (public OR future v3-virtual) pinned to a
kind=virtual Llm. Pre-this-stage, those agents 502'd against the
empty url field.

Tests: 4 new chat-service-virtual-llm.test.ts cover the relay path
non-streaming, streaming, missing-dispatcher error, and rejection
surfacing. mcpd suite: 841/841 (was 833, +8 across stages 1+v3-Stage-1).
Workspace: 2054/2054 across 153 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:07:23 +01:00
9374a2652b perf: vitest threads pool + Dockerfile pnpm cache mount (#66)
Some checks failed
CI/CD / lint (push) Successful in 58s
CI/CD / test (push) Successful in 1m11s
CI/CD / typecheck (push) Successful in 2m35s
CI/CD / smoke (push) Failing after 1m43s
CI/CD / build (push) Successful in 2m21s
CI/CD / publish (push) Has been skipped
2026-04-27 16:07:05 +00:00
Michal
18245be0c1 perf: vitest threads pool + Dockerfile pnpm cache mount
Some checks failed
CI/CD / typecheck (pull_request) Successful in 56s
CI/CD / test (pull_request) Successful in 1m9s
CI/CD / lint (pull_request) Successful in 2m40s
CI/CD / smoke (pull_request) Failing after 1m43s
CI/CD / build (pull_request) Failing after 7m6s
CI/CD / publish (pull_request) Has been skipped
Two tuning knobs that were leaving most of the host idle:

1) vitest.config.ts pool=threads with maxThreads ≈ cores/2.
   Default left this 64-core workstation at ~10% CPU during
   \`pnpm test:run\`. Threads pool uses the box: same 152-file/2050-test
   suite now runs at ~700% CPU instead of ~150%. Wall time gain is
   modest (workload is dominated by a handful of slow individual files
   that one thread must run serially), but the parallel headroom is
   there for when the suite grows. Cap = max(2, cores/2) keeps laptops
   reasonable; override with \`VITEST_MAX_THREADS=N\` in the env.

2) Dockerfile.mcpd uses BuildKit cache mounts on both pnpm install
   steps. Adds \`# syntax=docker/dockerfile:1.6\` and a
   \`--mount=type=cache,target=/root/.local/share/pnpm/store\` so
   pnpm's content-addressed store survives across image rebuilds.
   Cold rebuilds where the lockfile changed are unaffected; warm
   rebuilds where only source changed drop the install step from
   ~60s to <5s. fulldeploy.sh's mcpd image rebuild gets that back
   minus the docker push hash mismatch.

Test parity: 2050/2050 across 152 files; per-package mcpd 837/837.
Both unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:06:39 +01:00
45c7737ee1 feat: virtual LLMs v2 (wake-on-demand) (#65)
Some checks failed
CI/CD / lint (push) Successful in 54s
CI/CD / test (push) Successful in 1m12s
CI/CD / typecheck (push) Successful in 2m42s
CI/CD / smoke (push) Failing after 1m43s
CI/CD / build (push) Successful in 2m33s
CI/CD / publish (push) Has been skipped
2026-04-27 14:20:59 +00:00
Michal
e0cfe0ba4d feat: virtual-LLM v2 smoke + docs (v2 Stage 3)
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m8s
CI/CD / typecheck (pull_request) Successful in 2m43s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 5m28s
CI/CD / publish (pull_request) Has been skipped
Closes v2 (wake-on-demand). Same shape as v1's stage 6: smoke
exercises the live-cluster path, docs lose the "v2 reserved" caveat
and gain a full wake-recipe section.

Smoke (virtual-llm.smoke.test.ts):
- New "wake-on-demand" describe block runs alongside the v1 tests.
- Spins a tiny in-process HTTP "wake controller"; the published
  provider's isAvailable() returns false until the wake POST flips
  the bool. Asserts:
    1. Provider publishes as kind=virtual / status=hibernating.
    2. First inference triggers the wake recipe, the recipe POSTs
       to the controller, the provider becomes available, mcpd
       relays the inference, and the row settles to active.
- Cleans up the row + wake server in afterAll.

Docs (docs/virtual-llms.md):
- Lifecycle table updates the `hibernating` description from
  "reserved for v2" to the actual v2 semantics.
- New "Wake-on-demand (v2)" section: configuration shapes for both
  recipe types (http + command), the wake-then-infer flow diagram,
  concurrent-infer dedup, failure semantics.
- Roadmap drops v2; v3-v5 still listed.

Workspace: 2050/2050 (smoke runs separately; the new SSE-based wake
test runs only against a live cluster, not under \`pnpm test:run\`).

v2 closes. v3 = virtual agents, v4 = LB pool by model, v5 = queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:20:18 +01:00
Michal
db839afc57 feat(mcpd): wake-before-infer for hibernating virtual LLMs (v2 Stage 2)
Second half of v2. mcpd now dispatches a \`wake\` task on the SSE
control channel when an inference request hits a row whose
status=hibernating, waits for the publisher to confirm readiness,
then proceeds with the infer task. Concurrent infers for the same
hibernating Llm share a single wake task — \`wakeInFlight\` map
dedupes by Llm name.

State machine in enqueueInferTask:
  active        → push infer task immediately (existing path).
  inactive      → 503, publisher offline (existing path).
  hibernating   → ensureAwake() → push infer task (new in v2).

ensureAwake/runWake (private):
- Allocates a fresh taskId on the existing PendingTask plumbing.
- Pushes \`{ kind: "wake", taskId, llmName }\` on the SSE handle.
- Awaits the publisher's result POST. On 2xx, flips the row to
  active + bumps lastHeartbeatAt, so all queued + future infers
  hit the active path. On non-2xx or service.failTask, the row
  stays hibernating (next request retries).

Tests: 4 new in virtual-llm-service.test.ts cover happy path
(wake → infer in order), concurrent dedup (3 parallel infers, 1
wake task), wake failure surfaces to all queued infers and leaves
the row hibernating, inactive ≠ hibernating (still rejects with 503,
no wake attempt). 22/22 service tests, 2050/2050 workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:18:24 +01:00
Michal
af0fabd84f feat(mcplocal+mcpd): wake-recipe config + wake-task execution (v2 Stage 1)
First half of v2 — mcplocal can now declare a hibernating backend and
respond to a `wake` task by running a configured recipe. v2 Stage 2
will wire mcpd to dispatch the wake task before relaying inference.

Config (LlmProviderFileEntry):
- New \`wake\` block on a published provider:
    wake:
      type: http        # or: command
      url: ...           # http only
      method: POST       # http only, default POST
      headers: {...}     # http only
      body: ...          # http only
      command: ...       # command only
      args: [...]        # command only
      maxWaitSeconds: 60 # how long to poll isAvailable() after wake fires

Registrar (mcplocal):
- At publish time, providers with a wake recipe whose isAvailable()
  returns false report initialStatus=hibernating to mcpd. Without a
  wake recipe (legacy v1) or when already up, status stays active.
- handleWakeTask: runs the recipe (HTTP request OR child-process
  spawn), then polls isAvailable() up to maxWaitSeconds, sending a
  heartbeat each loop so mcpd's GC sweep doesn't time us out
  mid-boot. Reports { ok, ms } on success or { error } on
  timeout/recipe failure via the existing _provider-task/:id/result.
- Replaces the v1 stub that rejected wake tasks with "not implemented".

mcpd VirtualLlmService:
- RegisterProviderInput gains optional initialStatus ('active' |
  'hibernating'). The register/upsert path uses it for both new and
  reconnecting rows. Defaults to 'active' so v1 publishers still
  work unchanged.
- Provider-register route's coercer accepts the new field.

Tests: 3 new in registrar.test.ts cover initialStatus selection
(hibernating when wake configured + unavailable, active otherwise,
active when no wake even if unavailable). 8/8 registrar tests, 833/833
mcpd unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:15:46 +01:00
700d1683c2 fix(cli): strip virtual-LLM lifecycle fields from llm apply-doc YAML (#64)
Some checks failed
CI/CD / lint (push) Successful in 56s
CI/CD / test (push) Successful in 1m11s
CI/CD / typecheck (push) Successful in 2m49s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / build (push) Successful in 3m10s
CI/CD / publish (push) Has been skipped
2026-04-27 13:47:18 +00:00
Michal
2a44f60785 fix(cli): strip virtual-LLM lifecycle fields from llm apply-doc YAML
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m12s
CI/CD / typecheck (pull_request) Successful in 2m59s
CI/CD / smoke (pull_request) Failing after 1m44s
CI/CD / build (pull_request) Successful in 6m35s
CI/CD / publish (pull_request) Has been skipped
The smoke test \`llm.smoke > round-trips yaml output → apply -f\` failed
after v1 of the virtual-LLM feature: \`mcpctl get llm <name> -o yaml\`
output now starts with \`kind: public\` (the new schema column) instead
of \`kind: llm\` (the apply-doc envelope), because toApplyDocs spread
the cleaned item AFTER setting the kind, so the cleaned item's \`kind\`
overwrote.

Fix: in toApplyDocs, when serialising the \`llms\` resource, drop the
new lifecycle fields (kind, status, lastHeartbeatAt, inactiveSince,
providerSessionId) before merging. They collide with the apply-doc
envelope and aren't apply-able anyway — they're derived runtime state
owned by VirtualLlmService. Public-LLM round-trip is now byte-clean
(those fields default to public/active anyway). Virtual rows are
created by the registrar, not via apply -f, so dropping them on
output is the right call.

CLI suite: 437/437. Smoke will re-run against the live mcpd via
scripts/release.sh after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:47:00 +01:00
65b6b265d9 feat: virtual LLMs v1 (registration skeleton) (#63)
Some checks failed
CI/CD / lint (push) Successful in 55s
CI/CD / test (push) Successful in 1m12s
CI/CD / typecheck (push) Successful in 2m13s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / build (push) Successful in 4m50s
CI/CD / publish (push) Has been skipped
2026-04-27 13:38:50 +00:00
Michal
866f6abc88 feat: virtual-LLM smoke test + docs (v1 Stage 6)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 53s
CI/CD / test (pull_request) Successful in 1m8s
CI/CD / lint (pull_request) Successful in 2m6s
CI/CD / smoke (pull_request) Failing after 1m39s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / publish (pull_request) Has been skipped
Final stage of v1.

Smoke (mcplocal/tests/smoke/virtual-llm.smoke.test.ts):
- Spins an in-process LlmProvider that returns canned content.
- Runs the registrar against the live mcpd in fulldeploy.
- Asserts: row appears with kind=virtual / status=active, infer
  through /api/v1/llms/<name>/infer comes back through the SSE
  relay with the provider's content + finish_reason, and a 503
  appears immediately after registrar.stop() (publisher offline).
- Times out / cleanup paths idempotent so re-runs against the same
  cluster don't litter rows. The 90-s heartbeat-stale flip and 4-h
  GC are unit-tested — too slow for smoke.

Docs:
- New docs/virtual-llms.md: when to use this vs creating a regular
  Llm row, how to opt-in via publish: true, the lifecycle table,
  the inference-relay sequence, the v1 streaming caveat, the v2-v5
  roadmap, and the full /api/v1/llms/_provider-* surface.
- agents.md cross-links virtual-llms.md alongside personalities/chat.
- README's Agents section gains a "Virtual LLMs" subsection.

Workspace suite: 2043/2043 (smoke files run separately). v1 closes.

Stage roadmap (each its own future PR):
  v2 wake-on-demand · v3 virtual agents · v4 LB pool · v5 task queue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:28:43 +01:00
Michal
7e6b0cab44 feat(cli): mcpctl chat-llm + KIND/STATUS columns (v1 Stage 5)
Closes the loop on user-facing surface:

  $ mcpctl get llm
  NAME             KIND     STATUS    TYPE     MODEL                       TIER  KEY  ID
  qwen3-thinking   public   active    openai   qwen3-thinking              fast  ...  ...
  vllm-local       virtual  active    openai   Qwen/Qwen2.5-7B-Instruct    fast  -    ...

  $ mcpctl chat-llm vllm-local
  ────────────────────────────────────────
  LLM: vllm-local  openai → Qwen/Qwen2.5-7B-Instruct-AWQ
  Kind: virtual    Status: active
  ────────────────────────────────────────
  > hello?
  Hi! …

New: chat-llm command (commands/chat-llm.ts)
- Stateless chat with any mcpd-registered LLM. No threads, no tools,
  no project prompts. POSTs to /api/v1/llms/<name>/infer; mcpd's
  kind=virtual branch handles relay-through-mcplocal transparently,
  so the same CLI command works for both public and virtual LLMs.
- Reuses installStatusBar / formatStats / recordDelta / styleStats /
  PhaseStats from chat.ts (now exported) so the bottom-row tokens-per-
  second ticker behaves identically to mcpctl chat.
- Flags: --message (one-shot), --system, --temperature, --max-tokens,
  --no-stream. Streaming uses OpenAI chat.completion.chunk SSE.
- REPL mode keeps a per-session history array so multi-turn flows
  feel natural; each turn is an independent inference call.

Updated: get.ts
- LlmRow gains optional kind/status fields.
- llmColumns layout: NAME, KIND, STATUS, TYPE, MODEL, TIER, KEY, ID.
  Defaults gracefully when older mcpd responses don't return them.

Updated: chat.ts
- Re-exports the helpers chat-llm.ts needs (PhaseStats, newPhase,
  recordDelta, formatStats, styleStats, styleThinking, STDERR_IS_TTY,
  StatusBar, installStatusBar). No behavior change.

Completions: chat-llm picks up the standard option enumeration
automatically; bash gets a special-case for first-arg LLM-name
completion via _mcpctl_resource_names "llms".

CLI suite: 437/437 (was 430, +7 from auto-discovered test cases in
the regenerated completions golden). Workspace: 2043/2043 across
152 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:25:38 +01:00
Michal
97174f450f feat(mcplocal): virtual-LLM registrar (v1 Stage 4)
The mcplocal counterpart to mcpd's VirtualLlmService. After this stage,
flipping \`publish: true\` on a provider in ~/.mcpctl/config.json makes
the provider show up in mcpctl get llm with kind=virtual the next time
mcplocal restarts; running an inference against it relays through this
client back to the local LlmProvider.

Config:
- LlmProviderFileEntry gains optional \`publish: boolean\` (default false,
  so existing setups don't change).

Registrar (new file: providers/registrar.ts):
- start(): if any provider is opted-in, POSTs to
  /api/v1/llms/_provider-register with the publishable set, persists
  the returned providerSessionId to ~/.mcpctl/provider-session for
  sticky reconnects, then opens the SSE control channel and starts a
  30-s heartbeat ticker.
- SSE listener parses event/data lines from text/event-stream frames.
  task frames trigger handleInferTask: convert OpenAI body to
  CompletionOptions, call provider.complete(), POST the result back as
  either { status, body } (non-streaming) or two chunk POSTs
  (streaming: one delta + a [DONE] marker).
- Disconnect → exponential backoff reconnect from 5 s up to 60 s. On
  successful reconnect the persisted sessionId revives the same Llm
  rows in mcpd (mcpd flips them back to active on heartbeat).
- stop() destroys the SSE socket and clears the timer; cleanly handed
  off from main.ts's existing shutdown handler.

Wired into mcplocal main.ts via maybeStartVirtualLlmRegistrar:
- Filters opted-in providers, looks up their LlmProvider instances in
  the registry.
- Reads ~/.mcpctl/credentials for mcpdUrl + bearer; absence is a
  best-effort skip (logs a warning, returns null) — never a boot
  blocker.

v1 caveat documented in the file header: LlmProvider returns a
finalized CompletionResult, not a token stream, so streaming requests
get a single delta chunk + [DONE]. Real per-token streaming is a v2
concern.

Tests: 5 new in tests/registrar.test.ts using a tiny in-process HTTP
server. Cover: no-op when nothing opted-in, register POST + sticky
sessionId persistence, sticky reconnect from disk, heartbeat ticker
fires at the configured interval, register HTTP error surfaces.

Workspace suite: 2043/2043 across 152 files (was 2006/149, +5
new tests + the new file gets discovered).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:54 +01:00
Michal
192a3831df feat(mcpd): virtual-LLM routes + GC ticker (v1 Stage 3)
End-to-end backend wiring. After this stage, an mcplocal client can
register a provider, hold the SSE channel open, heartbeat, and have
its inference requests fanned through the relay — all without
touching the agent layer or the public-LLM path.

Routes (new file: routes/virtual-llms.ts):
  POST /api/v1/llms/_provider-register    → returns { providerSessionId, llms[] }
  GET  /api/v1/llms/_provider-stream      → SSE channel keyed by
                                            x-mcpctl-provider-session header.
                                            Emits `event: hello` on open,
                                            `event: task` on inference fan-out,
                                            `: ping` every 20 s for proxies.
  POST /api/v1/llms/_provider-heartbeat   → bumps lastHeartbeatAt
  POST /api/v1/llms/_provider-task/:id/result
                                          → mcplocal pushes result back;
                                            body shape is one of:
                                              { error: 'msg' }
                                              { chunk: { data, done? } }
                                              { status, body }

LlmService:
- LlmView gains kind/status/lastHeartbeatAt/inactiveSince so route
  handlers + the upcoming `mcpctl get llm` columns can branch on
  kind without re-fetching the row.

llm-infer.ts:
- Detects llm.kind === 'virtual' and delegates to
  VirtualLlmService.enqueueInferTask. Streaming + non-streaming both
  supported; on 503 (publisher offline) the existing audit hook still
  fires with the right status code.
- Adds optional `virtualLlms: VirtualLlmService` to LlmInferDeps;
  absence in test fixtures returns a 500 with a clear "server
  misconfiguration" message rather than silently falling through to
  the public path against an empty URL.

main.ts:
- Constructs VirtualLlmService(llmRepo).
- Passes it to registerLlmInferRoutes.
- Calls registerVirtualLlmRoutes(app, virtualLlmService).
- 60-s GC ticker started after app.listen; clears on graceful
  shutdown alongside the existing reconcile timer.

Tests: 11 new virtual-LLM route assertions (validation paths,
service plumbing for register/heartbeat/task-result) + 3 new
infer-route assertions (kind=virtual non-streaming relay, 503 path,
500 when virtualLlms dep missing). mcpd suite: 833/833 (was 819,
+14). Typecheck clean.

The full SSE handshake is exercised by the smoke test in Stage 6;
under app.inject the keep-alive blocks until close so unit-level
SSE testing isn't worth the complexity here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:15:18 +01:00
Michal
2215922618 feat(mcpd): VirtualLlmService + repo lifecycle helpers (v1 Stage 2)
The state machine for kind=virtual Llm rows. Wires the schema added
in Stage 1 into something that can register, heartbeat, time out,
and relay inference tasks. The HTTP routes (Stage 3) plug into this.

Repository (extends ILlmRepository):
- create/update accept kind/providerSessionId/lastHeartbeatAt/status/
  inactiveSince/type so VirtualLlmService can drive the lifecycle.
- findBySessionId(sessionId) — the reconnect lookup.
- findStaleVirtuals(cutoff) — heartbeat-stale rows for the GC sweep.
- findExpiredInactives(cutoff) — 4h-expired rows for deletion.

VirtualLlmService:
- register(): sticky-id-aware upsert. New names insert as kind=virtual/
  status=active. Existing virtual rows from the same session reactivate
  in place; existing inactive virtuals from a foreign session can be
  adopted (sticky reconnect). Refuses to overwrite a public row or a
  foreign session's still-active virtual.
- heartbeat(): bumps lastHeartbeatAt for every row owned by the
  session; revives inactive rows.
- bindSession()/unbindSession(): in-memory map of sessionId → SSE
  handle. Disconnect immediately flips owned rows to inactive AND
  rejects any in-flight tasks for that session.
- enqueueInferTask(): pushes an `infer` task frame to the SSE handle,
  returns a PendingTaskRef whose `done` resolves when the publisher
  POSTs the result back. Streaming variant exposes onChunk(cb).
- completeTask/pushTaskChunk/failTask: route-side hooks called from
  the result POST handler (lands in Stage 3).
- gcSweep(): flips heartbeat-stale active virtuals to inactive (90s
  cutoff), deletes inactives past 4h. Idempotent.

Lifecycle constants live in this file (HEARTBEAT_TIMEOUT_MS=90s,
INACTIVE_RETENTION_MS=4h) so future stages can tune in one place.

18 new mocked-repo tests cover: register variants (insert, sticky
reconnect, refuse public-overwrite, refuse foreign-session, adopt
inactive-foreign), heartbeat-revive, unbind cascade, enqueue happy
path + 503 paths (no session, inactive, public-Llm), complete/fail/
streaming chunk fan-out, GC sweep flip + delete + idempotence.

mcpd suite: 819/819 (was 801, +18). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:05:19 +01:00
Michal
1acd8b58bc feat(db): Llm.kind discriminator + virtual-provider lifecycle (v1 Stage 1)
First step of the virtual-LLM feature. A virtual Llm row is one that
gets *registered by an mcplocal client* rather than created via
\`mcpctl create llm\`. Its inference is relayed back through an SSE
control channel to the publishing session (mcpd routes added in
Stage 3). The lifecycle fields below let mcpd reap stale rows when
the publisher goes away.

Schema additions:
- enum LlmKind (public | virtual). Default public.
- enum LlmStatus (active | inactive | hibernating). Default active.
  hibernating is reserved for v2 wake-on-demand.
- Llm.kind, providerSessionId, lastHeartbeatAt, status, inactiveSince.
- @@index([kind, status]) for the GC sweep.
- @@index([providerSessionId]) for the reconnect lookup.

All existing rows backfill with kind=public/status=active so v1 is
purely additive — public LLMs ignore the lifecycle columns entirely.

7 new prisma-level assertions in tests/llm-virtual-schema.test.ts
cover: defaults, persisting kind=virtual + lifecycle together, the
active→inactive flip, hibernating value, enum rejection, the
(kind,status) GC index, the providerSessionId reconnect index.

mcpd suite still 801/801 (regenerated client) and typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:44 +01:00
e65a396d3e fix(cli): status probe accepts reasoning_content for thinking models (#62)
Some checks failed
CI/CD / typecheck (push) Successful in 56s
CI/CD / test (push) Successful in 1m10s
CI/CD / lint (push) Successful in 2m40s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / build (push) Successful in 5m5s
CI/CD / publish (push) Has been skipped
2026-04-27 11:10:15 +00:00
Michal
a84214dad1 fix(cli): status probe accepts reasoning_content for thinking models
Some checks failed
CI/CD / typecheck (pull_request) Successful in 56s
CI/CD / lint (pull_request) Successful in 3m6s
CI/CD / test (pull_request) Successful in 1m9s
CI/CD / build (pull_request) Successful in 2m39s
CI/CD / smoke (pull_request) Failing after 3m58s
CI/CD / publish (pull_request) Has been skipped
Live deploy showed qwen3-thinking failing the probe with "empty
content": at max_tokens=8 the model spent its entire budget on the
reasoning trace and never emitted a final \`content\` block.

Fix:
- Bump max_tokens to 64. Still caps latency at ~1-2 sec on cheap
  models but gives reasoning models enough headroom.
- If \`message.content\` is empty but \`reasoning_content\` is non-empty,
  count it as alive and prefix the preview with "[thinking]" so the
  user knows the model didn't actually answer "hi" but is responsive.
- Replace the prompt with the terser "Reply with just: hi" — closer
  to what a thinking model can short-circuit on.

Tests: existing 25 pass; the failure-path test still asserts on the
"empty content" path because reasoning_content is empty there too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:09:42 +01:00
54e56f7b71 feat(cli): live "say hi" probe for server LLMs in mcpctl status (#61)
Some checks failed
CI/CD / lint (push) Successful in 57s
CI/CD / typecheck (push) Successful in 57s
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-27 11:02:26 +00:00
Michal
e4af16477c feat(cli): live "say hi" probe for server LLMs in mcpctl status
Some checks failed
CI/CD / lint (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m13s
CI/CD / typecheck (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 1m46s
CI/CD / build (pull_request) Successful in 3m24s
CI/CD / publish (pull_request) Has been skipped
Status was showing the server-side LLM list but not whether each one
actually serves inference. This adds a per-LLM probe that POSTs a
tiny prompt to /api/v1/llms/<name>/infer:

  messages: [{ role: 'user', content: "Say exactly the word 'hi' and nothing else." }]
  max_tokens: 8, temperature: 0

Each registered LLM gets a one-line health line:

  Server LLMs: 2 registered (probing live "say hi"...)
    fast   qwen3-thinking  ✓ "hi" 312ms
              openai → qwen3-thinking  http://litellm.../v1  key:litellm/API_KEY
    heavy  sonnet  ✗ upstream auth failed: 401
              anthropic → claude-sonnet-4-5  provider default  no key

Probes run in parallel so a single slow LLM doesn't gate the others;
each has its own 15-second timeout. JSON/YAML output gains a
\`health: { ok, ms, say?, error? }\` field per server LLM so dashboards
get the same liveness signal.

Tests: 25/25 (was 24, +1 new for the failure-path render). Workspace
suite: 2006/2006 across 149 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:02:00 +01:00
de96af7bf6 feat(cli)+fix(mcpd): server-side LLM status + SPA fallback 500 (#60)
Some checks failed
CI/CD / lint (push) Successful in 55s
CI/CD / test (push) Successful in 1m9s
CI/CD / typecheck (push) Failing after 7m9s
CI/CD / smoke (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / publish (push) Has been skipped
2026-04-27 10:28:10 +00:00
Michal
0db37e92a4 feat(cli)+fix(mcpd): server-side LLM status + SPA fallback 500
Some checks failed
CI/CD / typecheck (pull_request) Successful in 58s
CI/CD / test (pull_request) Successful in 1m9s
CI/CD / lint (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 1m39s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / publish (pull_request) Has been skipped
Two related fixes:

1. \`mcpctl status\` now lists mcpd-managed Llm rows (the ones created via
   \`mcpctl create llm\`) under a new "Server LLMs:" section, grouped by
   tier with type, model, upstream URL, and key reference. JSON/YAML
   output gains a \`serverLlms\` array.

   Bearer token (from \`mcpctl auth login\` / saved credentials) is
   passed through; if mcpd is unreachable or returns non-200 the
   section is silently omitted (the existing mcpd connectivity line
   already conveys that). 6 new tests cover happy path, empty list,
   token plumbing, and JSON shape.

2. SPA fallback at \`/ui/<deeplink>\` was returning 500 because we
   registered \`@fastify/static\` with \`decorateReply: false\` and then
   called \`reply.sendFile\`. Read index.html once at startup and serve
   it with \`reply.send(html)\` instead — also dodges a per-request
   stat call. Drop \`decorateReply: false\` so future code can use
   reply.sendFile if it ever needs to.

Full suite: 2005/2005 across 149 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:27:45 +01:00
899f2c750c fix(test): vitest 4 projects + src/web jsdom env (#59)
Some checks failed
CI/CD / lint (push) Successful in 55s
CI/CD / test (push) Successful in 1m10s
CI/CD / typecheck (push) Successful in 2m37s
CI/CD / smoke (push) Failing after 1m41s
CI/CD / build (push) Successful in 2m38s
CI/CD / publish (push) Has been skipped
2026-04-26 20:31:47 +00:00
Michal
bf0a60bc0a fix(test): switch workspace runner to vitest 4 \projects\ field
Some checks failed
CI/CD / typecheck (pull_request) Successful in 57s
CI/CD / test (pull_request) Successful in 1m7s
CI/CD / lint (pull_request) Successful in 2m43s
CI/CD / smoke (pull_request) Failing after 1m45s
CI/CD / build (pull_request) Successful in 5m43s
CI/CD / publish (pull_request) Has been skipped
The workspace-level \`pnpm test:run\` (which fulldeploy.sh runs as a
gate) was failing with \`localStorage is not defined\` on the new
src/web tests. Two intertwined causes:

1. vitest 4 deprecated \`vitest.workspace.ts\`. The file was being
   silently ignored, so per-package configs (cli, mcpd, mcplocal)
   weren't being honored under workspace mode either — the root
   config was being used for all of them.

2. With the root config in charge, src/web/tests ran with the default
   Node environment, no \`localStorage\` global, so the api wrapper's
   test setup blew up.

Fix:
- Move workspace projects into the root \`vitest.config.ts\` under the
  new \`projects\` array (the vitest 4 replacement).
- Add a proper \`src/web/vitest.config.ts\` (vitest 4 doesn't auto-pick
  up vite.config.ts as a test config in workspace mode, even though
  per-package \`pnpm --filter\` does).
- Exclude \`src/web/tests/**\` from the root-level include so we don't
  double-run them under the wrong env.

After: \`pnpm test:run\` runs 1999/1999 across 149 files (was 1992/1996
with 4 web failures). Per-package runs unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:31:27 +01:00
c0ba0a9040 feat: web prompt editor + agent personalities (#58)
Some checks failed
CI/CD / typecheck (push) Successful in 56s
CI/CD / test (push) Failing after 1m10s
CI/CD / lint (push) Successful in 2m34s
CI/CD / smoke (push) Has been skipped
CI/CD / build (push) Has been skipped
CI/CD / publish (push) Has been skipped
2026-04-26 20:21:53 +00:00
Michal
4cbf58d212 feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6)
Some checks failed
CI/CD / lint (pull_request) Successful in 54s
CI/CD / test (pull_request) Failing after 1m8s
CI/CD / typecheck (pull_request) Successful in 2m35s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image
bundles the build artifact, a smoke test exercises the personality
HTTP surface end-to-end, and the user-facing docs spell out the
mental model.

mcpd:
- Add @fastify/static dep.
- New routes/web-ui.ts: registers /ui/* against a static bundle. Looks
  for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the
  Docker image path), then a dev-tree fallback. Logs and skips
  cleanly if missing — API-only deploys keep working.
- SPA fallback: any /ui/<path> that doesn't match a file falls through
  to index.html so direct hits to react-router URLs work.
- /ui/* falls through to `kind: skip` in mapUrlToPermission, so the
  static assets are served unauthenticated. Each API call from the
  SPA still carries the bearer token.

Deploy:
- Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder
  stage and copies dist/ to /usr/share/mcpd/web in the runtime image.

Smoke (personality.smoke.test.ts):
- Live mcpd flow: create secret/llm/agent/personality, attach an
  agent-direct prompt, verify the binding listing, reject double-
  attach (409) + foreign-agent prompt (400), set defaultPersonality
  by name, detach + delete cleanup.

Docs:
- New docs/personalities.md: VLAN-on-ethernet model, system-block
  ordering table, three prompt scopes, CLI walkthrough, web UI
  walkthrough, full API surface, RBAC notes.
- agents.md and chat.md cross-link.
- README's Agents section gains a Personalities subsection.

Test count after Stage 6:
  mcpd:   801/801      cli:  430/430
  web:    7/7          db:   58/62 (4 pre-existing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:48:43 +01:00
Michal
0010cc18b7 feat(web): browser-based prompt + personality editor (Stage 5)
New workspace package @mcpctl/web — a Vite + React 19 SPA that talks
to mcpd's existing HTTP API. Bundles to a static dist/ which Stage 6
will bake into the RPM and serve from mcpd at /ui via @fastify/static.

Pages:
  /ui/projects                       list projects
  /ui/projects/:name/prompts         CRUD project prompts (Monaco editor)
  /ui/agents                         list agents
  /ui/agents/:name                   tabs: Direct prompts | Personalities
  /ui/personalities/:id              bind/unbind prompts to a personality

Auth: paste a session token (mcpctl auth login) or PAT (mcpctl_pat_*)
once on a login screen, kept in localStorage; logout clears it.

API client: 60-line fetch wrapper, attaches the bearer header from
storage, throws an ApiError with status + parsed body on non-2xx.
A 200-line useFetch hook provides loading/error/data without a
state-management library — we are not building Notion.

UX:
  - Dark terminal-adjacent theme so the page feels like the CLI.
  - Monaco @monaco-editor/react for prompt content (markdown mode,
    word-wrap, search, multi-cursor).
  - Personality detail's "attach prompt" picker filters in-scope
    candidates: agent-direct + same-project + globals.

Dev loop:  pnpm --filter @mcpctl/web dev   (vite at :5173, proxies
  /api to https://mcpctl.ad.itaz.eu — override with MCPCTL_API_URL).
Build:     pnpm --filter @mcpctl/web build → src/web/dist/.

Tests: 7 vitest cases covering the bearer header / 4xx body / 204
no-content path on the api wrapper, and the login storage round-trip
+ help toggle. Production build green: 269 KB JS / 84 KB gzipped.
Typecheck clean (TS strict + exactOptionalPropertyTypes carried over).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:41:57 +01:00
Michal
9050918a83 feat(cli): personality flag + create/get/edit/delete personalities (Stage 4)
End-to-end CLI surface for the personality overlay:

  mcpctl create personality grumpy --agent reviewer --description "be terse"
  mcpctl create prompt tone --agent reviewer --content "Be very terse."
  mcpctl get personalities
  mcpctl get personalities --agent reviewer
  mcpctl edit personality <id>
  mcpctl delete personality grumpy --agent reviewer
  mcpctl chat reviewer --personality grumpy

Chat banner gains a "Personality:" line that shows either the active
flag value or the agent's `defaultPersonality` (when no flag given),
so the user knows which overlay is in effect before sending a message.

`--personality` is stripped from `/save` (it's a per-turn override,
not a `defaultParams` field — the agent's defaultPersonality lives on
its own column and is set via PUT /agents).

Backend (small additions to land Stage 4 cleanly):
- `GET /api/v1/personalities[?agent=name]` so `mcpctl get
  personalities` doesn't require an agent filter.
- PersonalityService.listAll() aggregates across agents.

Completions: regenerated fish + bash. `personalities` added as a
canonical resource with `personality` alias; edit-resource list
extended; the per-resource argument completers pick up the new
type automatically.

CLI suite: 430/430. mcpd: 801/801. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:32:48 +01:00
Michal
faef1e732d feat(mcpd): personality routes + chat system block overlay (Stage 3)
End-to-end backend wiring for the agents-feature evolution. After
this stage you can curl all the endpoints; CLI + Web UI follow.

Routes (new):
  GET    /api/v1/agents/:agentName/personalities
  POST   /api/v1/agents/:agentName/personalities
  GET    /api/v1/personalities/:id
  PUT    /api/v1/personalities/:id
  DELETE /api/v1/personalities/:id
  GET    /api/v1/personalities/:id/prompts
  POST   /api/v1/personalities/:id/prompts
  DELETE /api/v1/personalities/:id/prompts/:promptId
  GET    /api/v1/agents/:agentName/prompts            (agent-direct)

Routes (extended):
  POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>`
  POST /api/v1/agents/:name/chat accepts `personality: <name>`

RBAC: `personalities` segment maps to the `agents` resource so
view/edit/create/delete on the parent agent governs personality access.
No new RBAC roles — piggybacking keeps the surface flat.

System block (chat.service.ts):
  agent.systemPrompt
  + agent-direct prompts (Prompt.agentId === agent.id, priority desc)
  + project prompts        (existing behavior, priority desc)
  + personality prompts    (PersonalityPrompt[chosen], priority desc)
  + systemAppend

Personality is selected by request body `personality: <name>`, falling
back to `agent.defaultPersonalityId` if unset. A typo'd flag throws
404 rather than silently dropping back to no overlay — failing loudly
on misconfiguration is the only way users learn it didn't apply.

Backwards-compatible by construction: when no agent-direct prompts
exist and no personality is selected, the resulting block is byte-
identical to the old layout (verified by a regression test).

Tests: 5 new chat-service.test cases cover ordering, default-
personality fallback, missing-personality 404, and the regression
guard. mcpd suite: 801/801 (was 796). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
Michal
6b5bd78cfa feat(mcpd): personality + prompt-by-agent repos and services (Stage 2)
Wires the schema landed in Stage 1 into the service layer. No HTTP
routes yet — Stage 3 will register `/api/v1/...` endpoints and update
chat.service to read agent-direct + personality prompts when building
the system block.

Repositories:
- PersonalityRepository: CRUD + listPrompts/attach/detach bindings.
- PromptRepository: findByAgent + findByNameAndAgent; create/update
  accept the new agentId column. findGlobal now also filters
  agentId=null so agent-direct prompts don't leak into global lists.
- AgentRepository: defaultPersonalityId on create + connect/disconnect
  in update.

Services:
- PersonalityService: CRUD scoped per agent, plus attach/detach with
  scope enforcement — a prompt may bind only if it's agent-direct on
  the same agent, in the agent's project, or global. Foreign-project
  / foreign-agent attachments are rejected with 400.
- PromptService: createPrompt / upsertByName accept agentId and
  resolve `agent: <name>`, with XOR-with-project guard. Adds
  listPromptsForAgent.
- AgentService: defaultPersonality (by name on the agent's own
  personality set) round-trips through update + AgentView.

Validation:
- prompt.schema.ts: refine() rejects projectId+agentId together.
- personality.schema.ts: new Create/Update/AttachPrompt schemas.
- agent.schema.ts: defaultPersonality { name } | null on update.

Tests: 12 PersonalityService + 7 PromptService agent-scope tests
covering happy paths, XOR/scope enforcement, double-attach guard,
detach-not-bound. mcpd suite: 796/796 (was 777). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:20:51 +01:00
Michal
f60f00f1fd feat(db): add personalities + agent-direct prompts schema (Stage 1)
A Personality is a named overlay on top of an Agent — same agent,
same LLM, but a different bundle of prompts injected into the system
block at chat time. VLAN-on-ethernet semantics: ethernet still works
without VLAN; with a VLAN tag, frames are segmented but still ethernet.

Schema additions:
- Prompt.agentId (nullable FK + index, cascade on delete) so prompts
  can attach directly to an agent without going through a project.
- Personality { id, name, description, agentId, priority } with
  unique (name, agentId).
- PersonalityPrompt join table with per-binding priority override.
- Agent.defaultPersonalityId (SetNull on delete) so an agent can pick
  one personality as the default when no --personality flag is passed.

Backwards-compatible by construction: every new column is nullable;
existing rows are valid as-is; the chat.service systemBlock changes
land in Stage 3.

8 new prisma-level assertions in agent-schema.test.ts cover unique
constraints, cascade behavior, the SetNull on defaultPersonalityId,
and shared-prompt-across-personalities. All 16 db tests pass; mcpd
typecheck + 777 mcpd unit tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:12:22 +01:00
9389ffff3c feat(agents+chat): agents feature + live chat UX (#57)
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m6s
CI/CD / typecheck (push) Successful in 2m17s
CI/CD / smoke (push) Failing after 1m38s
CI/CD / build (push) Successful in 2m35s
CI/CD / publish (push) Has been skipped
2026-04-26 17:53:27 +00:00
Michal
21f406037a feat(chat): print agent + system prompt banner at chat start
Some checks failed
CI/CD / typecheck (pull_request) Successful in 53s
CI/CD / test (pull_request) Successful in 1m5s
CI/CD / lint (pull_request) Successful in 2m29s
CI/CD / smoke (pull_request) Failing after 1m39s
CI/CD / build (pull_request) Successful in 5m30s
CI/CD / publish (pull_request) Has been skipped
When you launch \`mcpctl chat <agent>\` it's not always obvious which
agent, LLM, project, or system prompt you're actually wired to,
especially when --system / --system-append flags are layered on top
of the agent's defaults. The session would just start at \`> \` with
no confirmation of the configuration.

Now both REPL and one-shot modes print a banner to stderr listing:
  - agent name + description
  - LLM + project (if attached)
  - effective system prompt (or --system override) and any
    --system-append addendum, indented for readability
  - active sampling overrides (temperature, top_p, etc.)

Goes through stderr so \`mcpctl chat ... -m "hi" 2>/dev/null\` keeps
piping clean. Best-effort: a metadata fetch failure logs and lets
the chat proceed rather than blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:37:06 +01:00
Michal
ae54210a52 fix(chat): pin live tokens/sec ticker to a bottom-row status bar
The previous ticker used cursor save/restore (\x1b[s / \x1b[u) to draw
a stats line one row below the cursor. Save/restore is unreliable when
content scrolls or wraps — the saved row drifts off the visible area
and the restore lands inside content lines, smearing the ticker into
mid-word positions:

  Here are the available tools you can
  ⏵ 7w · 56.5 w/s · 0.1s | thinking 41 use with Docmost:6s

Replace it with a DECSTBM scroll region. Lock the bottom row, scroll
rows 1..N-1 for content, redraw the locked row in place every 250 ms.
This is how htop / tig / mosh status pin their footers — content and
status physically can't overlap.

Lifecycle: install once per chat-session (REPL or one-shot), tear down
on close / Ctrl-D / /quit / SIGINT / SIGTERM / uncaughtException. Pipes
and small terminals (<5 rows) get a no-op StatusBar so output stays
clean. Resize re-emits the scroll region with the new height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:49:26 +01:00
Michal
cc9822d38b feat(chat): live tokens/sec ticker + final stats footer
While streaming, the REPL now shows a live word/sec counter on a status
line one row below the cursor — refreshes every 250ms via ANSI cursor
save+restore so it floats with the content as the response grows.
After each response, a dim stats footer prints on stderr:

  (47w · 12.3 w/s · 3.9s | thinking 234w · 38 w/s · 6.2s)

The ticker is stderr-only and only emits when stderr is a TTY — pipes
to a file stay clean for grepping/redirect. Words are whitespace-
separated tokens (good enough across English/code/Markdown without a
tokenizer dependency; CJK under-counts but the rate is still
directional).

Both phases tracked separately:
  - thinking: reasoning_content from qwen3-thinking / deepseek-reasoner
    / o1, where the model's scratchpad is the long part
  - content: the actual assistant answer

Final stats also added to the --no-stream path: total HTTP duration
and word count, since we don't get per-token timing there.

CLI suite still 430/430.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:15:26 +01:00
Michal
7cfa449465 feat(chat): surface reasoning_content as thinking chunks; fix --no-stream timeout
Reasoning models (qwen3-thinking, deepseek-reasoner, OpenAI o1 family) emit
their scratchpad as `delta.reasoning_content` (or `delta.reasoning`,
or `delta.provider_specific_fields.reasoning_content` when LiteLLM passes
through from vLLM) — separate from `delta.content`. Before this commit
mcpd's parseStreamingChunk only watched `content`, so the model's 30-90s
reasoning phase looked like dead air to the REPL: streaming connection
open, no chunks, no progress. Caught during the agents-feature shakedown
when qwen3-thinking sat silent for 90s on a docmost__list_pages call.

mcpd
====
chat.service.ts
  - parseStreamingChunk extracts a `reasoningDelta` from the chunk body,
    accepting all four spellings (reasoning_content / reasoning /
    provider_specific_fields.{reasoning_content,reasoning}). Future
    providers can add their own field names by extending the
    fallback chain.
  - chatStream yields `{ type: 'thinking', delta }` chunks as reasoning
    arrives, alongside the existing `{ type: 'text', delta }` for content.
  - Reasoning is intentionally NOT persisted to the thread. It's the
    model's scratchpad, not part of the conversation. Subsequent turns
    don't see it.
  - Adds 'thinking' to the ChatStreamChunk.type union.

CLI
===
chat.ts
  - streamOnce handles 'thinking' chunks: writes them dim+italic to
    stderr (ANSI 2;3m) so the model's reasoning visually flows like a
    quote block while the final answer streams to stdout. Plain text
    when stderr isn't a TTY (pipe to file → no escape codes leak).
  - chatRequestNonStream replaces the shared ApiClient.post() for the
    --no-stream path. ApiClient defaults to a 10s timeout, way too tight
    for any chat that calls a tool: LLM round + tool dispatch + LLM
    summary easily exceeds 10s. The new helper uses the same 600s timeout
    the streaming path has been using all along.

Tests:
  chat-service.test.ts (+2):
    - reasoning_content deltas surface as `thinking` chunks (not text);
      reasoning is NOT persisted to the assistant turn's content.
    - LiteLLM's provider_specific_fields.reasoning_content shape parses
      identically to the vendor-native shape.

mcpd 777/777, cli 430/430.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:04:01 +01:00
Michal
cc225eb70f feat(llm): probe upstream auth at registration time
mcpd now runs a cheap auth probe whenever an Llm is created (or its
apiKeyRef/url is updated). Catches misconfigured tokens / wrong URLs at
registration with a 422 + structured error message, instead of silently
500-ing on first chat with a generic "fetch failed". Caught in the wild
today: the homelab Pulumi config exposed `MCPCTL_GATEWAY_TOKEN` (which
is mcpctl_pat_-prefixed, intended for LiteLLM→mcplocal direction) where
LiteLLM expects `LITELLM_MASTER_KEY` (sk-prefixed). The probe makes
this immediate.

Probe shape (LlmAdapter.verifyAuth):
  - OpenAI passthrough → GET <url>/v1/models. Cheap, idempotent, gated
    by the same auth as chat/completions.
  - Anthropic → POST /v1/messages with max_tokens:1, "ping". Anthropic
    has no list-models endpoint; this is the cheapest auth-exercising
    call.
  - Returns one of:
      { ok: true }
      { ok: false, reason: "auth", status, body }    — 401/403, fail hard
      { ok: false, reason: "unreachable", error }    — network, warn-only
      { ok: false, reason: "unexpected", status, body } — non-auth 4xx, warn-only

Behavior:
  - LlmService.create()/update() runs the probe after resolveApiKey.
    Throws LlmAuthVerificationError on `auth`, logs warn for
    unreachable/unexpected, swallows for offline registration.
  - Probe is skipped when there's no apiKeyRef (nothing to verify) or
    when the caller passes skipAuthCheck=true.
  - update() probes only when apiKeyRef OR url changes — pure
    description/tier updates don't trigger upstream calls.
  - Routes catch LlmAuthVerificationError and return 422 with
    `{ error, status }`. The CLI surfaces the message verbatim via
    ApiError.

Opt-out:
  - CLI: `mcpctl create llm ... --skip-auth-check` for offline
    registration before the upstream is reachable.
  - HTTP: side-channel body field `_skipAuthCheck: true` (stripped
    before validation, never persisted on the row).

Side fix in same commit (caught while testing): src/cli/src/index.ts
read `program.opts()` BEFORE `program.parse()`, so `--direct` was a
no-op for ApiClient — every command went to mcplocal regardless. Some
commands accidentally still worked because mcplocal forwards plain
`/api/v1/*` to mcpd, but flows that need direct SSE streaming (e.g.
`mcpctl chat`) couldn't reach mcpd. Fixed by peeking at process.argv
directly for the two global flags before Commander's parse runs.

Tests:
  - llm-adapters.test.ts (+8): OpenAI 200/401/403/404/network, Anthropic
    200/401/400 (typo'd model = unexpected, NOT auth — registration
    shouldn't block on bad model names that surface at chat time).
  - llm-service.test.ts (+6): create-throws-on-auth-fail (no row
    written), warn-only on unreachable/unexpected, skipAuthCheck
    bypass, no-key skip, update-only-probes-on-auth-affecting-change.

mcpd 775/775, mcplocal 715/715, cli 430/430.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:51:55 +01:00
Michal
1f0be8a5c1 fix(agents): close gaps from /gstack-review
P1 — thread reads now enforce ownership
========================================
chat.service.ts / routes/agent-chat.ts
  GET /api/v1/threads/:id/messages was previously RBAC-mapped to
  view:agents (no resourceName scope) with the route comment promising
  "service-level owner check enforces fine-grained access" — but the
  service didn't actually check. Any caller with view:agents could read
  another user's thread by guessing/learning the threadId. CUIDs are
  hard to brute-force but they leak: SSE `final` chunks, agents-plugin
  `_meta.threadId`, and several response bodies surface them. Now
  ChatService.listMessages(threadId, ownerId) loads the thread, returns
  404 (not 403, to avoid id-enumeration via differential status codes)
  if ownerId doesn't match. Regression test in chat-service.test.ts
  covers Alice/Bob isolation + nonexistent-thread same-shape 404.

P2 — AgentChatRequestSchema strict mode
========================================
validation/agent.schema.ts
  `.merge()` does NOT inherit `.strict()` from AgentChatParamsSchema.
  Typo'd fields (e.g. `temprature`) silently fell through and the agent
  silently used the default — debuggable only by reading the LLM call
  payload. Re-applied `.strict()` on the merged schema.

P2 — per-agent maxIterations override + clamp
==============================================
chat.service.ts
  Loop cap was a hard-coded module constant (12), wrong for both
  research-style agents (need higher) and cheap-probe agents (could opt
  lower). Now reads `agent.extras.maxIterations`, clamps 1..50, falls
  back to 12 default. The clamp is the soft-DoS guard: a hostile agent
  definition with `maxIterations:1000000` can't burn unbounded LLM calls
  per request. Both chat() and chatStream() use ctx.maxIterations now.
  Regression test covers low-cap override (rejects with `exceeded 2`)
  and hostile-value clamp (rejects with `exceeded 50`).

P3 — SSE write to closed socket
================================
routes/agent-chat.ts
  When the upstream adapter throws after some chunks were already
  written AND the client disconnected, the catch block tried to flush
  more chunks to a closed socket. Without an `on('error')` handler
  Node emits unhandled error events; once Pino is wired to alerts
  this'd page on every disconnect-mid-stream. writeSseChunk now
  checks `reply.raw.destroyed || writableEnded` before write.

P3 — BACKEND_TOKEN_DEAD preserves original stack
=================================================
services/secret-backend-rotator.service.ts
  When wrapping mintRoleToken/lookupSelf failures as
  BACKEND_TOKEN_DEAD, the new Error() discarded the original throw —
  hard to tell whether the inner failure was a network blip vs an
  OpenBao API mismatch vs DNS. Now uses `new Error(msg, { cause: err })`
  so the inner stack survives.

P3 — .gitignore .claude/scheduled_tasks.lock
=============================================
This persisted state file was leaking into every `git status`.

Tests
=====
mcpd 761/761 (+2 regression tests). mcplocal 715/715. cli 430/430.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:53:19 +01:00
Michal
2e266e318a fix(mcplocal): lower default token introspection TTL in serve.ts too
Followup to e51b924. The middleware default in token-auth.ts is 5s, but
serve.ts wraps the construction with its own env-fallback default of
30000ms — so when MCPLOCAL_TOKEN_POSITIVE_TTL_MS isn't set in the
environment, serve.ts always wins and revoked tokens still propagate
slowly. Lowered serve.ts to 5s for symmetry; operators wanting a longer
window can set the env var explicitly.

Caught by mcptoken.smoke continuing to fail after the previous redeploy:
verified the token-auth.js shipped with `?? 5_000`, but the wrapper was
overriding it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:41:22 +01:00
Michal
e51b92473f fix(smoke,rotator,auth): repair smoke env + close failure modes that
caused 27 post-deploy smoke failures

This commit lands the durable side of the post-deploy investigation:
genuine bugs that let the upstream OpenBao re-init silently break every
secret write for 4 days, plus test-code bugs that masked the same
breakage in the smoke output.

mcpd — fail loud on dead OpenBao tokens
=======================================
secret-backend-rotator.service.ts
  When `mintRoleToken` or `lookupSelf` returns 403/401, classify it as
  BACKEND_TOKEN_DEAD (likely cause: upstream OpenBao re-init invalidated
  every pre-existing token), wrap the thrown error with explicit
  remediation (mint via root + `mcpctl create secret <name> --data
  <key>=<token> --force`), persist the same message to
  tokenMeta.lastRotationError, and emit a structured `level:fatal`
  console.error so it shows up in `kubectl logs deploy/mcpd` with grep-
  friendly `kind:BACKEND_TOKEN_DEAD`. Adds a `healthCheck(backendId)`
  method that runs lookup-self without minting — so the boot-time loop
  can detect the dead-token state immediately, not 24 hours later.

secret-backend-rotator-loop.ts
  Boot-time health check: in `start()`, for every rotatable backend, call
  `rotator.healthCheck(b.id)` and on failure log a structured fatal entry.
  This converts the prior silent failure mode (24h wait until scheduled
  rotation reveals the dead token, with secret writes failing under it
  the entire time) into "mcpd boots, immediately sees the dead token,
  alerts loudly". Existing isOverdue path is unchanged.

mcpd — Prisma userId crash on /me
=================================
routes/auth.ts
  GET /api/v1/auth/me used `request.userId!` which lied: an authenticated
  McpToken bearer satisfies the auth middleware but has no associated
  User row, so userId stayed undefined and `findUnique({ id: undefined })`
  threw PrismaClientValidationError. Now returns 401 with a clear
  "service-account/token-bound principal cannot be queried via /me"
  message instead of bubbling a 500.

mcplocal — token revocation propagation
=======================================
http/token-auth.ts
  Lowered default introspection positiveTtl from 30s → 5s. mcpd's
  introspection endpoint is a single DB lookup; the cache only protects
  against burst restart storms, not steady-state load. The 30s window
  let revoked tokens keep working for the full window after revocation
  (caught by mcptoken.smoke's negative-cache assertion). Aligns with the
  existing 5s negativeTtl and the test's `wait 7s after revoke` expectation.

smoke tests — read URL the same way the CLI does
================================================
mcp-client.ts
  Adds `loadMcpdAuth()`: URL from `~/.mcpctl/config.json`, token from
  `~/.mcpctl/credentials`. Critically, the URL does NOT come from
  credentials. credentials.mcpdUrl carries a stale field for legacy
  reasons and goes out of sync (left over from old `mcpctl login
  --mcpd-url localhost:3xxx` invocations) — tests reading it ended up
  hitting whatever URL the user last logged into rather than the URL
  the CLI is actually using right now. audit/security/system-prompts
  smoke now use loadMcpdAuth(), eliminating ~10 cascade failures.
  Also: switch httpRequest to https.request when scheme is https
  (matching audit/security/system-prompts/mcp-client/agent helpers).
  Bumps default callTool timeout from 30s → 60s; many tools that fetch
  external resources routinely run 10-30s.

agent.smoke.test.ts
  - readToken read from `credentials.json`; the file is `credentials`
    (no extension). Caused 401 on POST /threads.
  - `mcpctl get <resource> <name> -o json` returns an array, not a bare
    object. Round-trip yaml test now indexes [0] before reading
    description.

secretbackend.smoke.test.ts
  Two genuine assertion-drift fixes (env was right, test was stale):
  - "lists at least one secretbackend": stop hard-coding the default
    backend type as 'plaintext'; the invariant is "exactly one default
    exists". The seeded plaintext is the bootstrap default but operators
    routinely promote a remote backend (openbao etc.) once it's healthy.
  - "refuses to delete the seeded default": widen the regex from
    /default|in use|cannot delete/ to also accept "referenced" — the
    exact wording has shifted to "is still referenced by N secret(s);
    migrate them first".

audit.test.ts / system-prompts.test.ts / security.test.ts
  Switch http.request → https.request when URL is https (each had its
  own copy of the helper). Drop the now-orphan loadMcpdCredentials in
  favour of loadMcpdAuth from mcp-client.ts.

Tests
=====
mcpd 759/759, mcplocal 715/715 unit suites still green. Smoke (live):
  Run 1 (pre-commit, post bao-token rotation):  27 → 12 failures.
  Run 2 (after fixes-batch, pre-redeploy):      12 →  2 failures.
The remaining 2 (mcptoken cache TTL, proxy-pipeline timeout) are what
the durable code changes here address; verify after the next redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:35:13 +01:00
Michal
8b56f09f25 feat(agents): smoke tests + README + docs (Stage 6, final)
Closes the agents feature.

Smoke tests (run via `pnpm test:smoke` against a live mcpd at
$MCPD_URL, default https://mcpctl.ad.itaz.eu):

* tests/smoke/agent.smoke.test.ts — full CRUD round-trip:
  create secret + Llm + agent with sampling defaults; `get agents`
  surfaces it; `get agent foo -o yaml | apply -f` round-trips
  identically; create + list a thread via the HTTP API; agent delete
  leaves Llm + secret intact (Restrict + SetNull as designed). Self-
  skips with a warning when /healthz is unreachable.

* tests/smoke/agent-chat.smoke.test.ts — gated on
  MCPCTL_SMOKE_LLM_URL + MCPCTL_SMOKE_LLM_KEY. Provisions secret +
  Llm + agent against a real upstream, runs `mcpctl chat -m … --no-
  stream` (asserts a reply lands), then runs the streaming default
  (asserts text on stdout + `(thread: …)` on stderr). The fast path
  for verifying the in-cluster qwen3-thinking deployment:

      MCPCTL_SMOKE_LLM_URL=http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \
      MCPCTL_SMOKE_LLM_MODEL=qwen3-thinking \
      MCPCTL_SMOKE_LLM_KEY=$(pulumi config get --stack homelab \
        secrets:litellmMcpctlGatewayToken) \
        pnpm test:smoke

Docs:

* README.md — new "Agents" section under Resources with the
  qwen3-thinking quickstart and links to docs/agents.md and
  docs/chat.md. Adds llm + agent rows to the resources table.

* docs/agents.md (new) — full reference: data model, chat-parameter
  table, HTTP API, RBAC mapping, tool-use loop semantics, yaml
  round-trip shorthand, the kubernetes-deployment wiring recipe,
  and a troubleshooting section (namespace collision, llm-in-use,
  pending-row recovery, Anthropic-tool limitation).

* docs/chat.md (new) — user-facing `mcpctl chat` walkthrough:
  modes, per-call flags, slash-commands, threads, and a
  troubleshooting section.

* CLAUDE.md — adds a "Resource types" cheatsheet with one-line
  pointers to each, including the new `agent` row that links to
  the docs.

All suites still green: mcpd 759/759, mcplocal 715/715, cli 430/430.
Smoke tests typecheck and self-skip when no live mcpd is reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:08:37 +01:00
Michal
727e7d628c feat(agents): mcpctl chat REPL + agent CRUD + completions (Stage 5)
This is the moment the user can actually talk to an agent end-to-end:

  mcpctl create llm qwen3-thinking --type openai --model qwen3-thinking \
    --url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \
    --api-key-ref litellm-key/API_KEY
  mcpctl create agent reviewer --llm qwen3-thinking --project mcpctl-dev \
    --description "I review security design — ask me after each major change."
  mcpctl chat reviewer

Pieces:

* src/cli/src/commands/chat.ts (new) — REPL + one-shot. Streams the SSE
  endpoint and prints text deltas to stdout as they arrive; tool_call /
  tool_result events go to stderr in dim-style brackets so the chat
  output stays clean. LiteLLM-style flags (--temperature / --top-p /
  --top-k / --max-tokens / --seed / --stop / --allow-tool / --extra)
  layer over agent.defaultParams. In-REPL slash-commands: /set KEY VAL,
  /system <text>, /tools (list project's MCP servers), /clear (new
  thread), /save (PATCH agent.defaultParams = current overrides),
  /quit.

* src/cli/src/commands/create.ts — `create agent` mirroring the llm
  pattern. Every yaml-applyable field has a corresponding flag (memory
  rule); --default-temperature / --default-top-p / --default-top-k /
  --default-max-tokens / --default-seed / --default-stop /
  --default-extra / --default-params-file all populate agent.defaultParams.

* src/cli/src/commands/apply.ts — AgentSpecSchema accepts both `llm:
  qwen3-thinking` shorthand and `llm: { name: ... }` long form; runs
  after llms in the apply order so apiKey/llm references resolve. Round-
  trips with `get agent foo -o yaml | apply -f -` (memory rule).

* src/cli/src/commands/get.ts — agentColumns (NAME, LLM, PROJECT,
  DESCRIPTION, ID); RESOURCE_KIND mapping for yaml export.

* src/cli/src/commands/shared.ts — `agent`/`agents`/`thread`/`threads`
  added to RESOURCE_ALIASES.

* src/cli/src/index.ts — wires createChatCommand into the program; passes
  the resolved baseUrl + token so chat can stream SSE without going
  through ApiClient (which only does buffered request/response).

* completions/mcpctl.{fish,bash} regenerated. scripts/generate-completions.ts
  knows about agents (canonical + aliases) and emits a special-case
  `chat)` block that completes the first arg with `mcpctl get agents`
  names. tests/completions.test.ts: +9 new assertions covering agents in
  the resource list, chat in the commands list, --llm flag for create
  agent, agent-name completion for chat, etc.

CLI suite: 430/430 (was 421). Completions --check is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:02:38 +01:00
Michal
285be11dd5 feat(agents): mcplocal agents plugin + composePlugins helper (Stage 4)
When a Claude (or any other MCP client) connects to a project's mcplocal
endpoint, every Agent attached to that project now appears in the
session's tools/list as a virtual MCP server named `agent-<agentName>`
with one tool `chat`. Calling that tool POSTs to the Stage 3 chat
endpoint and returns the assistant's reply as MCP content. The tool's
description is the agent's own description, so connecting clients see
prose like "I review security design — ask me after each major change."
This is what makes one agent reachable from another's MCP session.

Plumbing:
  * src/mcplocal/src/proxymodel/plugins/agents.ts (new) — the plugin.
    onSessionCreate fetches /api/v1/projects/:p/agents via mcpd, then
    registers a VirtualServer per agent. The chat tool's inputSchema
    mirrors the LiteLLM-style override surface (temperature, top_p,
    top_k, max_tokens, stop, seed, tools_allowlist, extra) plus
    threadId for follow-ups. Namespace collision with an existing
    upstream MCP server named `agent-<x>` is detected and skipped with
    a `ctx.log.warn` line — better to surface the conflict than to
    silently shadow real tool entries in the virtualTools map.
  * src/mcplocal/src/proxymodel/plugins/compose.ts (new) — generic
    N-plugin composition helper. Lifecycle hooks fan out in order;
    transform hooks (onToolsList, onResourcesList, onPromptsList,
    onToolCallAfter) pipeline; intercept hooks (onToolCallBefore,
    onResourceRead, onPromptGet, onInitialize) short-circuit on the
    first non-null. Generalizes what createDefaultPlugin does for
    two fixed parents.
  * src/mcplocal/src/http/project-mcp-endpoint.ts — every project
    session now uses composePlugins([defaultPlugin, agentsPlugin]) so
    agents show up no matter which proxymodel the project is on.
  * Plugin context: added getFromMcpd(path) alongside postToMcpd. The
    existing postToMcpd was hard-coded to POST; the agents plugin
    needs GET to discover. Wired through plugin.ts → plugin-context.ts
    → router.ts.

Tests:
  plugin-agents.test.ts (8) — registers per agent, falls back to a
    generic description, skips on namespace collision, no-ops with
    zero agents, logs and continues on mcpd error, chat handler
    POSTs correct body and returns content array, isError surfacing
    on mcpd error, onSessionDestroy unregisters everything.
  plugin-compose.test.ts (6) — single-plugin pass-through, empty
    rejection, lifecycle ordering, intercept short-circuit, list
    pipeline, no-op composition stays minimal.

mcplocal suite: 715/715. mcpd suite still 759/759.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:51:44 +01:00
Michal
03ae4e15f7 feat(agents): mcpd routes + RBAC + tool dispatcher (Stage 3)
Wires the Stage 2 services into HTTP. New routes:

  GET    /api/v1/agents                — list
  GET    /api/v1/agents/:idOrName       — describe
  POST   /api/v1/agents                 — create
  PUT    /api/v1/agents/:idOrName       — update
  DELETE /api/v1/agents/:idOrName       — delete
  GET    /api/v1/projects/:p/agents     — project-scoped list (mcplocal disco)
  POST   /api/v1/agents/:name/chat      — chat (non-streaming or SSE stream)
  POST   /api/v1/agents/:name/threads   — create thread explicitly
  GET    /api/v1/agents/:name/threads   — list threads
  GET    /api/v1/threads/:id/messages   — replay history

The chat endpoint reuses the SSE pattern from llm-infer.ts (same headers
incl. X-Accel-Buffering:no, same `data: …\n\n` framing, same `[DONE]`
terminator). Each ChatService chunk is one frame. Non-streaming returns
{threadId, assistant, turnIndex} as JSON.

RBAC mapping in main.ts:mapUrlToPermission:
  - /agents/:name/{chat,threads*}  → run:agents:<name>
  - /threads/:id/*                 → view:agents (service-level owner check
    handles fine-grained access since the URL doesn't carry the agent name)
  - /agents and /agents/:idOrName  → default {GET:view, POST:create,
    PUT:edit, DELETE:delete} on resource 'agents'.
'agents' added to nameResolvers so RBAC's CUID→name lookup works.

ChatToolDispatcherImpl bridges ChatService to McpProxyService: it lists a
project's MCP servers, fans out tools/list calls to each, namespaces tool
names as `<server>__<tool>`, and routes tools/call back to the right
serverId on dispatch. tools/list errors on a single server are logged and
that server's tools are dropped from the turn's tool surface — one bad
server doesn't poison the whole list.

Tests:
  agent-routes.test.ts (15) — full HTTP CRUD round-trip, 404/409 paths,
    project-scoped list, non-streaming + SSE chat, thread create/list,
    /threads/:id/messages replay, body-required 400.
  chat-tool-dispatcher.test.ts (7) — empty list when no project / no
    servers, namespacing + inputSchema forwarding, partial-failure
    skipping with audit log, callTool dispatch shape, missing-server
    rejection, JSON-RPC error surfacing.

All 22 new green; mcpd suite now 759/759 (was 737).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:45:15 +01:00
Michal
eda8e79712 feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2)
Layers the persistence-side logic on top of the Stage 1 schema. AgentService
mirrors LlmService's CRUD shape with name-resolved llm/project references and
yaml round-trip support; ChatService is the orchestrator that drives one chat
turn end-to-end: build the merged system block (agent.systemPrompt + project
Prompts ordered by priority desc + per-call systemAppend), persist the user
turn, run the adapter, dispatch any tool_calls through an injected
ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop
until the model returns terminal text.

Per-call params resolve LiteLLM-style: request body → agent.defaultParams →
adapter default. The escape hatch `extra` is forwarded as-is so each adapter
can cherry-pick provider-specific knobs (Anthropic metadata, vLLM
repetition_penalty, etc.) without code changes here.

Persistence is non-transactional across the loop because tool calls can take
minutes; long-held DB transactions would starve other writers. Instead each
in-flight assistant turn is written `pending` and flipped to `complete` only
after its tool results land. On failure or max-iter overrun, every `pending`
row in the thread is flipped to `error` so the trail is auditable.

Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at
dispatch time; `tools_allowlist` filters before the model sees the list.

Tests:
  agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict
    on duplicate, llm switch, project detach, listByProject filtering,
    upsertByName branch coverage.
  chat-service.test.ts (9) — plain text turn, full text→tool→text loop with
    toolCallId linkage, max-iter cap leaves zero pending, adapter-throws
    leaves zero pending, body→defaultParams merge, `extra` passthrough,
    project-Prompt priority ordering in the system block, tool-without-
    project rejection, tools_allowlist filtering.

All 16 green; full mcpd suite still 737/737.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
Michal
3726a65f53 feat(agents): add Agent + ChatThread + ChatMessage schema (Stage 1)
Introduces the persistence layer for the upcoming Agent feature: an LLM
persona pinned to a specific Llm, optionally attached to a Project, with
persisted chat threads/messages so conversations survive REPL exits.

Constraint shape:
- Agent.llm uses ON DELETE RESTRICT — deleting an Llm in active use fails.
- Agent.project uses ON DELETE SET NULL — agents survive project deletion.
- ChatThread → ChatMessage cascade so deleting an agent purges its history.
- ChatMessage @@unique([threadId, turnIndex]) gives append ordering even
  under racing writers (services retry on collision).

LiteLLM-style per-call overrides will live in Agent.defaultParams (Json);
the loose extras Json field is reserved for future LoRA/tool-allowlist work.

Pinned vitest fileParallelism=false in @mcpctl/db: all suites share the
same Postgres, and adding a second suite exposed FK contention between a
clearAllTables in one file and a create in another. Per-test isolation
still comes from beforeEach.

Tests: 8/8 green in src/db/tests/agent-schema.test.ts (defaults, name
uniqueness, llm-in-use Restrict, project-delete SetNull, agent-delete
cascade, duplicate (threadId, turnIndex) blocked, tool-call payload
round-trip, lastTurnAt DESC ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:29:55 +01:00
Michal
6ac79de8a4 feat(secrets): one-shot startup backfill for keyNames on existing rows
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m8s
CI/CD / typecheck (push) Successful in 2m20s
CI/CD / build (push) Successful in 2m49s
CI/CD / smoke (push) Failing after 3m16s
CI/CD / publish (push) Has been skipped
Lazy backfill in SecretService.getById covers per-row retries, but list
views still show 'KEYS: -' until each row is described. New
backfillSecretKeyNames bootstrap runs once at startup, finds Secrets
where keyNames=[] AND data={} (i.e. backend-stored, pre-existing rows),
calls resolveData to learn the keys, persists. Sequential to be kind to
the upstream backend on cold start. Idempotent + non-fatal.
2026-04-24 01:01:40 +01:00
Michal
9a808877b5 feat(secrets): track key names so list/describe work for backend-stored secrets
Some checks failed
CI/CD / lint (push) Successful in 53s
CI/CD / test (push) Successful in 1m6s
CI/CD / typecheck (push) Successful in 2m11s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled
Post-migration, every Secret on a non-plaintext backend had an empty `data`
column (values live in the backend; only externalRef on the row). The CLI's
\`get secrets\` showed \`KEYS: -\` and \`describe secret\` showed \`(empty)\` for
all 9 migrated secrets — useless without --show-values.

Fix: dedicated \`keyNames Json\` column on Secret that stores the sorted key
list independently from the values. Populated on every write path, lazily
backfilled on first read for pre-existing rows that pre-date the column.
Schema default \`[]\` keeps prisma db push self-healing on rolling upgrades.

- src/db/prisma/schema.prisma: add Secret.keyNames Json @default("[]")
- src/mcpd/src/repositories/secret.repository.ts: pipe keyNames through create
  + update
- src/mcpd/src/services/secret.service.ts:
  - create/update populate keyNames = sorted Object.keys(data)
  - getById lazy-backfills empty keyNames (cheap: derives from data for
    plaintext, single backend read for openbao)
- src/mcpd/src/services/secret-migrate.service.ts: migrate writes keyNames
  alongside the new backendId so freshly-migrated rows are populated without
  a follow-up read
- src/cli/src/commands/get.ts: KEYS column reads keyNames first, falls back
  to Object.keys(data) for older rows
- src/cli/src/commands/describe.ts: shows the Data section keys whenever
  keyNames OR data has entries (so backend-stored secrets render their key
  list); --show-values still resolves through the backend

After deploy, the 9 already-migrated secrets backfill their keyNames on the
next describe-by-id, with no operator action needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:57:06 +01:00
Michal
b1bccee50d test(describe): mock the ?reveal=true path on --show-values
Some checks failed
CI/CD / lint (push) Successful in 54s
CI/CD / test (push) Successful in 1m7s
CI/CD / typecheck (push) Successful in 2m19s
CI/CD / smoke (push) Failing after 5m9s
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled
Follow-up to faccbb5: the describe-secret test for --show-values used the
old fetchResource shape, so it broke after the route now goes through
client.get directly with ?reveal=true.
2026-04-24 00:49:22 +01:00
Michal
faccbb58e7 fix(secrets): describe --show-values resolves through the backend driver
Some checks failed
CI/CD / lint (push) Successful in 55s
CI/CD / test (push) Failing after 1m5s
CI/CD / typecheck (push) Has started running
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
Post-migration, every Secret on a non-plaintext backend has empty `Secret.data`
(the actual value lives in the backend; only externalRef is on the row).
`describe secret --show-values` was reading the raw row, so the user saw
"Data: (empty)" for every migrated secret.

- Route GET /api/v1/secrets/:id accepts ?reveal=true; when set, resolves the
  value via SecretService.resolveData() so the response carries the actual
  data dispatched through the right driver.
- CLI --show-values flips that query param. Without --show-values the route
  returns the raw row exactly as before (no leak risk).

Caught running the wizard end-to-end on the live cluster after the
ClusterMesh fix on the kubernetes-deployment side made bao reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:46:54 +01:00
Michal
bf312850b5 fix(openbao): include response body in error messages
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / typecheck (push) Successful in 51s
CI/CD / test (push) Successful in 1m4s
CI/CD / smoke (push) Failing after 1m36s
CI/CD / build (push) Successful in 2m19s
CI/CD / publish (push) Has been skipped
Debugging the wizard migration flow, every OpenBao error was just
`HTTP 403` with no context. The response body often carries the actual
reason (missing capability, specific path, namespace mismatch), so
surfacing it makes operator debugging a one-step task. Added a shared
bodyText() helper that trims huge HTML error pages to 400 chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:03 +01:00
Michal
72e49f719f fix(mcpd): skip bootstrap tokens on migrate + back-fill ops on existing admins
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / typecheck (push) Successful in 1m45s
CI/CD / test (push) Successful in 1m2s
CI/CD / build (push) Successful in 2m9s
CI/CD / smoke (push) Has started running
CI/CD / publish (push) Has been cancelled
Two production issues caught running the wizard end-to-end:

1. `mcpctl migrate secrets --from default --to bao` listed `bao-creds` as a
   candidate — the very token that lets mcpd reach bao. Moving it would
   brick the auth chain (destination backend needs its own bootstrap token
   to read its own bootstrap token). Fix: SecretMigrateService now calls
   backends.list() and filters out any Secret whose name matches ANY
   SecretBackend's `config.tokenSecretRef.name`. dryRun mirrors the same
   filter so candidates match reality. `--names` explicitly bypasses the
   filter for operators who really mean it.

2. Initial rotation in the wizard 403'd because the global RBAC hook
   demands the `rotate-secretbackend` operation, which wasn't in
   bootstrap-admin — migrateAdminRole only added ops when processing a
   legacy `role: admin` entry, so already-migrated admin rows missed every
   new op added after their initial migration. Fix: migrateAdminRole now
   also runs a back-fill pass on rows that look admin-equivalent (have both
   `edit:*` and `run:*`), appending any missing op from ADMIN_OPS. Writes
   only when something actually changed, so restarts stay quiet. Same path
   also retroactively grants `migrate-secrets` which had the same problem
   yesterday.

Tests: 4 new migrate-service cases (bootstrap filter on/off, dryRun parity,
--names bypass). Full suite 1889/1889.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:56:00 +01:00
Michal
56a4ff7f17 chore: regenerate completions after --setup-token rename
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 2m2s
CI/CD / smoke (push) Failing after 1m36s
CI/CD / build (push) Successful in 4m53s
CI/CD / publish (push) Has been skipped
2026-04-20 17:28:05 +01:00
Michal
1c5301289c refactor(wizard): rename --admin-token → --setup-token
Some checks failed
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / lint (push) Has been cancelled
Any token with policy-write + auth/token admin works; root is a convenient
default but a scoped service account is fine too. The previous naming
misrepresented the permission floor as root-only.

- flag: --admin-token → --setup-token
- wizard field: adminToken → setupToken
- prompt label: "OpenBao admin / root token" → "OpenBao setup token (needs
  policy write + auth/token admin perms; root is fine)"
- file doc + one comment reworded
- tests updated for the new label
- regression test (token-absent-from-stdout) kept unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:27:09 +01:00
ba4129a1e4 Merge pull request 'feat(openbao): wizard + daily token rotation' (#56) from feat/openbao-wizard into main
Some checks failed
CI/CD / lint (push) Successful in 51s
CI/CD / test (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 1m56s
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
2026-04-20 16:22:50 +00:00
Michal
dd4246878d feat(openbao): wizard-provisioning + daily token rotation
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m2s
CI/CD / smoke (pull_request) Failing after 1m36s
CI/CD / build (pull_request) Successful in 4m13s
CI/CD / publish (pull_request) Has been skipped
One-command setup replaces the 6-step manual flow — `mcpctl create
secretbackend bao --type openbao --wizard` takes the OpenBao admin token
once, provisions a narrow policy + token role, mints the first periodic
token, stores it on mcpd, verifies end-to-end, and prints the migration
command. The admin token is NEVER persisted.

The stored credential auto-rotates daily: mcpd mints a successor via the
token role (self-rotation capability is part of the policy it was issued
with), verifies the successor, writes it over the backing Secret, then
revokes the predecessor by accessor. TTL 720h means a week of rotation
failures still leaves 20+ days of runway.

Shared:
- New `@mcpctl/shared/vault` — pure HTTP wrappers (verifyHealth,
  ensureKvV2, writePolicy, ensureTokenRole, mintRoleToken, revokeAccessor,
  lookupSelf, testWriteReadDelete) and policy HCL builder.

mcpd:
- `tokenMeta Json @default("{}")` on SecretBackend. Self-healing schema
  migration — empty default lets `prisma db push` add the column cleanly.
- SecretBackendRotator.rotateOne: mint → verify → persist → revoke-old →
  update tokenMeta. Failures surface via `lastRotationError` on the row;
  the old token keeps working.
- SecretBackendRotatorLoop: on startup rotates overdue backends, schedules
  per-backend timers with ±10min jitter. Stops cleanly on shutdown.
- New `POST /api/v1/secretbackends/:id/rotate` (operation
  `rotate-secretbackend` — added to bootstrap-admin's auto-migrated ops
  alongside migrate-secrets, which was previously missing too).

CLI:
- `--wizard` on `create secretbackend` delegates to the interactive flow.
  All prompts can be pre-answered via flags (--url, --admin-token,
  --mount, --path-prefix, --policy-name, --token-role,
  --no-promote-default) for CI.
- `mcpctl rotate secretbackend <name>` — convenience verb; hits the new
  rotate endpoint.
- `describe secretbackend` renders a Token health section (healthy /
  STALE / WARNING / ERROR) with generated/renewal/expiry timestamps and
  last rotation error. Only shown when tokenMeta.rotatable is true — the
  existing k8s-auth + static-token backends don't surface it.

Tests: 15 vault-client unit tests (shared), 8 rotator unit tests (mcpd),
3 wizard flow tests (cli, including a regression test that the admin
token never appears in stdout). Full suite 1885/1885 (+32). Completions
regenerated for the new flags.

Out of scope (explicit): kubernetes-auth wizard, Vault Enterprise
namespaces in the wizard path, rotation for non-wizard static-token
backends. See plan file for details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:20:37 +01:00
Michal
515206685b feat(openbao): kubernetes ServiceAccount auth — no static token in DB
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m5s
CI/CD / typecheck (push) Successful in 2m8s
CI/CD / smoke (push) Failing after 3m38s
CI/CD / build (push) Successful in 4m15s
CI/CD / publish (push) Has been skipped
Why: requiring a static OpenBao root token to live (even once-bootstrap) on
the plaintext backend is the weakest link in the chain. With the bao-side
Kubernetes auth method enabled, mcpd's pod can authenticate using its own
projected SA token, exchange it for a short-lived Vault client token, and
keep the database free of any vault credentials at all.

Driver changes (src/mcpd/src/services/secret-backends/openbao.ts):
- New `OpenBaoConfig.auth = 'token' | 'kubernetes'`. Defaults to 'token' so
  existing rows keep working. Both shapes share url + mount + pathPrefix +
  namespace; auth-specific fields are mutually exclusive in the config schema.
- Kubernetes auth flow: read JWT from /var/run/secrets/.../token, POST to
  /v1/auth/<authMount>/login {role, jwt}, cache the returned client_token
  for `lease_duration - 60s` (grace window), then re-login.
- One-shot 403-retry: if a request comes back 403 (revoked / clock skew),
  purge cache and retry the original request once with a fresh login.
- Reads + writes go through the same getToken() path so token-auth is
  unchanged for existing deployments.

CLI (src/cli/src/commands/create.ts):
- `mcpctl create secretbackend bao --type openbao --auth kubernetes \
     --url https://bao.example:8200 --role mcpctl`
- Optional `--auth-mount` (default 'kubernetes') + `--sa-token-path` (default
  the standard projected-token path) for non-default deployments.
- Token-auth path unchanged: `--auth token --token-secret SECRET/KEY`
  (or omit `--auth` since 'token' is the default).

Validation (factory.ts) gates on the auth strategy: each path enforces its
own required fields and produces a clear error if misconfigured.

Tests: 6 new k8s-auth unit cases (login wire shape, lease-based caching,
custom authMount, 403-on-login, missing-role rejection, missing-tokenSecretRef
rejection). Full suite 1859/1859. Completions regenerated for the new flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:23:05 +01:00
Michal
a21220b6f6 fix(deploy): self-healing pre-migrate bootstrap for SecretBackend rollout
Some checks failed
CI/CD / typecheck (push) Successful in 51s
CI/CD / lint (push) Successful in 1m42s
CI/CD / test (push) Successful in 1m6s
CI/CD / smoke (push) Failing after 3m41s
CI/CD / build (push) Successful in 4m31s
CI/CD / publish (push) Has been skipped
Why: clusters upgrading from the pre-SecretBackend schema crash-loop on the
first rollout. `prisma db push` applies the Phase 0 migration as three
sequential steps — add Secret.backendId column (default ''), create
SecretBackend table, add FK — and the FK fails because empty-string values
reference no row in the empty SecretBackend table. This happened on the live
cluster today; I fixed it by hand with psql. This PR makes the fix
automatic so a fresh cluster or anyone replaying the migration doesn't hit
the same trap.

- New `src/db/src/scripts/pre-migrate-bootstrap.ts` — idempotent node script.
  Checks if SecretBackend table exists; if so, ensures a default row exists
  (insert on conflict noop), then backfills any Secret.backendId = '' to
  point at it. Uses Prisma raw queries so it runs against a partially-
  migrated schema.

- `deploy/entrypoint.sh` now catches a failed first push, runs the
  bootstrap, and retries. Fresh installs and fully-migrated clusters take
  the happy path (one push, no bootstrap needed). Pre-Phase-0 upgrades take
  the healing path (push fails → bootstrap seeds → retry succeeds).

- The bootstrap is deliberately non-fatal — even on unexpected errors it
  logs and exits 0 so the retry still runs. If that retry also fails, the
  push error surfaces normally and the pod crash-loops visibly rather than
  silently starting in a half-migrated state.

Verified the idempotent path logically: on the already-bootstrapped cluster
(1 backend row, 0 empty-backendId Secrets), the script's UPDATE matches
zero rows and the INSERT hits ON CONFLICT DO NOTHING — pure no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:59:07 +01:00
Michal
d5236171cc fix(smoke): use json output for llm apiKeyRef assertion
Some checks failed
CI/CD / lint (push) Successful in 51s
CI/CD / typecheck (push) Successful in 1m42s
CI/CD / test (push) Successful in 1m5s
CI/CD / smoke (push) Has started running
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled
The table KEY column truncates at ~34 chars so `secret://<name>/<key>` wasn't
appearing verbatim in stdout — the assertion was correct but brittle against
presentation choices. Switched to `-o json` where the ref round-trips as a
structured object, which is what actually matters.

Caught by the live-cluster smoke run right after Phase 0-4 rolled out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:55:39 +01:00
Michal
860033d3de fix(db): make Secret.backendId default to empty string for rollout migration
Some checks failed
CI/CD / typecheck (push) Successful in 53s
CI/CD / lint (push) Successful in 1m44s
CI/CD / test (push) Successful in 1m5s
CI/CD / smoke (push) Failing after 3m43s
CI/CD / build (push) Failing after 6m52s
CI/CD / publish (push) Has been skipped
Why: `prisma db push` refused to add the required `backendId` column on
clusters with pre-existing Secret rows — it can't assign NOT NULL without a
default, and the cluster DB had 9 live rows. The mcpd pod crash-looped
during the Phase 0 rollout because of this.

Empty-string default lets the schema apply cleanly; `bootstrapSecretBackends`
(which runs on every startup) then rewrites those empty values to the
seeded `default` plaintext backend's id. New writes via SecretService always
carry a real FK immediately, so the empty-string state only exists during
the one-shot migration window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:45:08 +01:00
e27a0e695e Merge pull request 'feat(project): Project.llmProvider as Llm reference' (#55) from feat/project-llm-ref into main
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 1m52s
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
2026-04-19 21:39:54 +00:00
2155910f1c Merge pull request 'feat(mcplocal): RBAC-bounded vllm-managed failover' (#54) from feat/llm-failover into main
Some checks failed
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / lint (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:47 +00:00
d217eadd13 Merge pull request 'feat(mcpd): LLM inference proxy + OpenAI/Anthropic adapters' (#53) from feat/llm-infer into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / typecheck (push) Has started running
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:39 +00:00
9e3507752f Merge pull request 'feat(mcpd): Llm resource — CRUD + CLI + apply' (#52) from feat/llm into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:27 +00:00
97ac1e75ef Merge pull request 'feat(mcpd): pluggable SecretBackend + OpenBao driver + migrate' (#51) from feat/secretbackend into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / test (push) Has been cancelled
CI/CD / typecheck (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:17 +00:00
Michal
58788bc120 test(smoke): end-to-end coverage for SecretBackend, Llm, infer proxy, project-llm-ref
Covers the Phase 0-4 CLI contract against live mcpd. Matches the existing
mcptoken.smoke pattern: skip gracefully on unreachable /healthz, cleanup
fixtures in afterAll, use --direct to bypass mcplocal for admin operations.

- secretbackend.smoke.test.ts
  · seeded plaintext default exists + isDefault
  · create/describe/delete round-trip
  · refuses to delete the default backend (409 shape)
  · get -o yaml output starts with `kind: secretbackend` (apply-compatible)

- llm.smoke.test.ts
  · create secret + llm with --api-key-ref, verify describe hides the
    raw value but surfaces secret://name/key
  · yaml round-trip: get -o yaml > file → amend → apply -f → describe shows change
  · deleting the llm leaves the underlying Secret intact (onDelete: SetNull)

- llm-infer.smoke.test.ts
  · 404 for unknown name, 400 for missing messages
  · 5xx when upstream url is unreachable (proxy returns a structured error)
  · opt-in happy-path gated on LLM_INFER_SMOKE_REAL=1 + LLM_INFER_SMOKE_LLM=<name>
    so CI doesn't need a real provider key

- project-llm-ref.smoke.test.ts
  · describe project with --llm <registered> — no warning
  · describe project with --llm <nonexistent> — shows "warning: …registry default"
  · describe project with --llm none — explicit disable, no warning

These require PRs #51-55 to be merged and fulldeploy.sh run before they'll
find the new endpoints on live mcpd. Until then they skip or fail with
"Not Found". Unit tests for the same code paths (1853 total) continue to
pass against mocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:09:41 +01:00
Michal
de854b1944 feat(project): Project.llmProvider semantically names an Llm resource
Why: Phases 0-3 built the server-managed Llm registry; this phase pivots the
existing Project.llmProvider column from "local provider hint" to "named Llm
reference" so operators can pick a centralised Llm per project. No schema
change — the column stays a free-form string for backward compat.

- `mcpctl create project --llm <name>` (+ `--llm-model <override>`) sets
  llmProvider/llmModel to a centralised Llm reference, or 'none' to disable.
- `mcpctl describe project` fetches the Llm catalogue alongside prompts and
  flags values that don't resolve with a visible warning. 'none' is treated
  as an explicit disable, not an orphan.
- `apply -f` doc comments updated; --llm-provider still accepted but now
  documented as naming an Llm resource.
- New `resolveProjectLlmReference(mcpdClient, name)` helper in mcplocal's
  discovery: returns `registered`/`disabled`/`unregistered`/`unreachable`.
  The HTTP-mode proxy-model pipeline will consume this when it pivots to
  mcpd's /api/v1/llms/:name/infer proxy.
- project-mcp-endpoint.ts cache-namespace path gets a comment explaining
  the new resolution order — behavior unchanged, just clarified.

Tests: 6 resolver unit tests + 3 new describe-warning cases. Full suite
1853/1853 (+9 from Phase 3's 1844). TypeScript clean; completions
regenerated for the new create-project flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:28:46 +01:00
Michal
4d8ee23d0e feat(mcplocal): RBAC-bounded vllm-managed failover + name-based llm lookup
Why: when mcpd's inference proxy is unreachable, clients with a local
vllm-managed provider should be able to substitute — but only if they still
have view permission on the centralized Llm. Otherwise revoking an Llm
wouldn't actually stop a misbehaving client.

Infrastructure (the agent + mcplocal HTTP-mode wire-up will land separately
when those clients pivot to mcpd's proxy):

- LlmProviderFileEntry gains optional `failoverFor: <central llm name>`. The
  entry is otherwise the same local provider it always was; the new field
  just declares which central Llm it can substitute for.
- ProviderRegistry tracks a failover map (registerFailover / getFailoverFor /
  listFailovers). Unregister removes any failover entry pointing at the
  removed provider so we don't end up with dangling references.
- New FailoverRouter wraps a primary inference call. On primary failure: if
  a local provider is registered for the Llm, HEAD-probe `mcpd /api/v1/llms/
  :name` with the caller's bearer to verify view permission, then either
  invoke the local provider (allowed) or re-throw the primary error (403,
  401, network unreachable, anything else — all fail-closed).
- Server: GET /api/v1/llms/:idOrName accepts both CUID and human name. Lets
  FailoverRouter probe by name without a separate id-resolution call. HEAD
  derives automatically from GET in Fastify, which runs the same RBAC hook
  and drops the body — exactly what the probe needs.

Tests: 11 failover unit tests (registry map, decision flow, fail-closed for
forbidden + unreachable, checkAuth status mapping) + 4 new route tests
(name lookup, HEAD existing/missing). Full suite 1844/1844 (+14 from Phase
2's 1830). TypeScript clean across mcpd + mcplocal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:43 +01:00
Michal
23f53a0798 feat(mcpd): inference proxy — POST /api/v1/llms/:name/infer
Why: the point of the Llm resource (Phase 1) is that credentials never leave
the server. This lands the proxy: clients POST OpenAI chat/completions to
mcpd, mcpd attaches the provider API key server-side, and the response
streams back as OpenAI-format SSE.

Design:
- Wire format client-side is always OpenAI chat/completions — every existing
  SDK speaks it. Adapters translate on the provider side.
- `openai | vllm | deepseek | ollama` → pure passthrough (they already speak
  OpenAI). `anthropic` → translator to/from Anthropic Messages API
  (system-string extraction, content-block flattening, SSE event remap).
- Plain fetch; no @anthropic-ai/sdk dep. Consistent with the OpenBao driver
  shape and keeps the proxy layer thin.
- `gemini-cli` intentionally rejected — subprocess providers need extra
  lifecycle plumbing; deferred to a follow-up.
- Streaming: adapters yield `StreamingChunk`s; the route frames them as
  `data: <json>\n\n` + terminal `data: [DONE]\n\n` so any OpenAI client
  works unchanged.

RBAC:
- New URL special-case in mapUrlToPermission: `POST /api/v1/llms/:name/infer`
  → `run:llms:<name>` (not the default create:llms). Users need an explicit
  `{role: 'run', resource: 'llms', [name: X]}` binding to call infer.
- Possession of `edit:llms` does NOT imply `run` — keeps catalogue
  management separate from spend.

Audit: route emits an `llm_inference_call` event per request (llm name,
model, user/tokenSha, streaming, duration, status). main.ts wires it to the
structured logger for now; hook is in place for a richer audit sink later.

Tests:
- 11 adapter tests (passthrough POST shape + default URLs + no-auth ollama +
  SSE forwarding; anthropic translate request/response + non-2xx wrap + SSE
  event translation; registry dispatch + caching + unsupported-provider).
- 7 route tests (404, 400, non-streaming dispatch + audit, apiKey failure,
  null apiKeyRef path, streaming SSE output, 502 on adapter error).
- Full suite 1830/1830 (+18 from Phase 1's 1812). TypeScript clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:43:55 +01:00
Michal
6ff90a8228 feat(mcpd): Llm resource — CRUD + CLI + apply
Why: every client that wants an LLM (the agent, HTTP-mode mcplocal, Claude
Code's STDIO mcplocal) today has to know the provider URL + key, and each
user's ~/.mcpctl/config.json carries them. Centralising the catalogue on the
server is the prerequisite for Phase 2 (mcpd proxies inference so credentials
never leave the cluster).

This phase adds the `Llm` resource and its CRUD surface — no proxy yet, no
client pivot yet. Just enough to register what you have.

Schema:
- New `Llm` model: name/type/model/url/tier/description + {apiKeySecretId,
  apiKeySecretKey} FK pair. Reverse `llms` relation on Secret.
- Provider types: anthropic | openai | deepseek | vllm | ollama | gemini-cli.
- Tiers: fast | heavy.

mcpd:
- LlmRepository + LlmService + Zod validation schema + /api/v1/llms routes.
- API surface exposes `apiKeyRef: {name, key}` — the service translates to/
  from the FK pair so clients never deal in cuids.
- `resolveApiKey(llmName)` reads through SecretService (which itself dispatches
  to the right SecretBackend). That's the hook Phase 2's inference proxy uses.
- RBAC: added `'llms'` to RBAC_RESOURCES + resource alias. Standard
  view/create/edit/delete semantics.
- Wired into main.ts (repo, service, routes).

CLI:
- `mcpctl create llm <name> --type X --model Y --tier fast|heavy --api-key-ref SECRET/KEY [--url ...] [--extra k=v ...]`
- `mcpctl get|describe|delete llm` — standard resource verbs.
- `mcpctl apply -f` with `kind: llm` (single- or multi-doc yaml/json).
  Applied after secrets, before servers — apiKeyRef resolves an existing Secret.
- Shell completions regenerated.

Tests: 11 service unit tests + 9 route tests (happy path, 404s, 409, validation).
Full suite 1812/1812 (+20 from the 1792 Phase 0 baseline). TypeScript clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:28:43 +01:00
Michal
029c3d5f34 feat(mcpd): pluggable SecretBackend abstraction + OpenBao driver + migrate
All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / lint (pull_request) Successful in 1m47s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Successful in 4m34s
CI/CD / build (pull_request) Successful in 3m50s
CI/CD / publish (pull_request) Has been skipped
Why: API keys live in Postgres as plaintext JSON. A DB read exposes every
credential in the system. Before centralising more secrets (LLM keys, etc.)
we want to be able to point at an external KV store and drop DB access to
sensitive rows.

New model:
- `SecretBackend` resource (CRUD + isDefault invariant) owns how a secret is
  stored. `Secret` gains `backendId` FK and `externalRef`. Reads/writes
  dispatch through a driver.
- `plaintext` driver (near-noop, uses existing Secret.data column) is seeded
  as the `default` row at startup. Acts as trust root / bootstrap.
- `openbao` driver (also HashiCorp Vault KV v2 compatible) talks plain HTTP,
  no SDK dependency. Auth via static token pulled from a plaintext-backed
  `Secret` through the injected SecretRefResolver. Caches resolved token.
- `SecretMigrateService` moves secrets one-at-a-time: read → write dest →
  flip row → best-effort source delete. Interrupted runs are idempotent
  (skips secrets already on destination).

CLI surface:
- `mcpctl create|get|describe|delete secretbackend` + `--default` on create.
- `mcpctl migrate secrets --from X --to Y [--names a,b] [--keep-source] [--dry-run]`
- `apply -f` round-trips secretbackends (yaml/json multi-doc + grouped).
- RBAC: `secretbackends` resource + `run:migrate-secrets` operation.
- Fish + bash completions regenerated.

docs/secret-backends.md covers the OpenBao policy, chicken-and-egg auth flow,
and the migration semantics.

Broke the circular dep (OpenBao needs SecretService to resolve its own token,
SecretService needs SecretBackendService) with a deferred-resolver bridge in
mcpd startup. 11 new driver unit tests; existing env-resolver/secret-route/
backup tests updated for the new service signatures. Full suite: 1792/1792.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:29:55 +01:00
Michal
6946250090 Revert "feat(mcplocal): per-McpToken gate-ungate cache so service tokens survive proxies"
All checks were successful
CI/CD / lint (push) Successful in 51s
CI/CD / typecheck (push) Successful in 1m46s
CI/CD / test (push) Successful in 1m3s
CI/CD / build (push) Successful in 2m14s
CI/CD / smoke (push) Successful in 4m43s
CI/CD / publish (push) Successful in 1m23s
This reverts commit 39df459bb1.
2026-04-18 18:16:18 +01:00
1480d268c7 Merge pull request #50 feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing
Some checks failed
CI/CD / typecheck (push) Successful in 55s
CI/CD / lint (push) Successful in 1m42s
CI/CD / test (push) Successful in 1m5s
CI/CD / smoke (push) Failing after 3m40s
CI/CD / build (push) Successful in 3m52s
CI/CD / publish (push) Has been skipped
2026-04-18 16:37:50 +00:00
Michal
39df459bb1 feat(mcplocal): per-McpToken gate-ungate cache so service tokens survive proxies
All checks were successful
CI/CD / lint (pull_request) Successful in 1m0s
CI/CD / typecheck (pull_request) Successful in 1m51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / build (pull_request) Successful in 2m13s
CI/CD / smoke (pull_request) Successful in 4m49s
CI/CD / publish (pull_request) Has been skipped
Fixes the LiteLLM loop: LiteLLM's /mcp/ proxy doesn't propagate the
mcp-session-id header, so every tool call from qwen3 landed on a fresh
upstream session, which always started gated, so the only visible tool
was begin_session — forever.

The session-id gate works fine for Claude Code (stdio, long-lived), but
breaks through session-stripping proxies. Identity that DOES survive:
the McpToken (always in the Authorization header). So now the gate
keys its ungate state on both:

  - sessionId        → per-session (unchanged; Claude Code path)
  - tokenSha         → per-token (NEW; service-token path)

Flow for an McpToken caller:
  1. first begin_session succeeds → session ungated + tokenSha cached
  2. next request lands on a new mcp-session-id (proxy stripped it)
  3. SessionGate.createSession sees tokenSha, finds active token entry,
     starts the new session ungated with the prior tags + retrievedPrompts
  4. tools/list on the fresh session returns the full upstream set — no
     more begin_session loop

Plumbing:
  - AuditCollector.getSessionMcpTokenSha(sessionId) exposes the already-
    tracked principal.
  - PluginSessionContext gets getMcpTokenSha() so plugins can read the
    token identity without knowing about the collector.
  - SessionGate gains (tokenSha?: string) on createSession/ungate, plus
    isTokenUngated and revokeToken. TTL defaults to 1hr; tunable via
    MCPLOCAL_TOKEN_UNGATE_TTL_MS env var.
  - Gate plugin passes ctx.getMcpTokenSha() at every ungate call site
    (begin_session, gated-intercept, intercept-fallback).

Tests: 7 new cases in session-gate.test.ts covering cross-session
persistence, token isolation, STDIO-path unchanged, TTL expiry,
revokeToken, and the empty-string edge case. 21/21 pass; 690/690 in
mcplocal overall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:34:28 +01:00
Michal
75fe0533c1 fix(mcplocal): propagate caller's bearer to prompt-index and LLM-config calls
All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / lint (pull_request) Successful in 2m27s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Successful in 4m56s
CI/CD / publish (pull_request) Has been skipped
The proxy-path fix (5d10728) covered upstream tools/call routing via
McpdUpstream, but getOrCreateRouter in project-mcp-endpoint.ts had TWO
more mcpd-bound call sites that silently fell back to the pod's empty
default token:

  1. fetchProjectLlmConfig(mcpdClient, projectName)
  2. router.setPromptConfig(mcpdClient.withHeaders({...}))
     → which is what gate.ts begin_session uses via ctx.fetchPromptIndex()
       to hit /api/v1/projects/:name/prompts/visible

Symptom: in the k8s mcplocal pod, LiteLLM would initialize + tools/list
fine (showing begin_session), but tools/call begin_session returned
`{isError: true, content: "McpError: Authentication failed: invalid or
expired token"}`. Reproduced against the live cluster by driving
LiteLLM's /mcp/ endpoint with qwen3-thinking's exact payload.

Fix: build `requestClient = mcpdClient.withToken(authToken)` once at the
top of getOrCreateRouter and thread it through fetchProjectLlmConfig
and setPromptConfig. withHeaders still adds X-Service-Account for
mcpd-side audit tagging, but the bearer now carries the caller's
McpToken identity (resolves as McpToken:<sha> on mcpd).

Verified: unit tests pass (mock needed withToken/withTimeout stubs).
Next step: rebuild image + roll pod + retest LiteLLM→mcp flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:44:27 +01:00
Michal
5d1072889f fix(mcplocal): thread client bearer into per-upstream McpdClient
Symptom: HTTP-mode mcplocal accepted the incoming mcpctl_pat_ bearer,
but every /api/v1/mcp/proxy call to mcpd for upstream discovery came
back with "Authentication failed: invalid or expired token" — because
those proxy calls were using the pod's DEFAULT McpdClient token,
which in a container with no ~/.mcpctl/credentials is the empty
string. The discovery GET was correct (explicit authOverride in
forward()), but syncUpstreams() then created McpdUpstream instances
bound to the original mcpdClient — so every tools/list to each
upstream went out with `Authorization: Bearer ` (empty) and mcpd's
auth hook rejected it.

Fix: add McpdClient.withToken(token) and have refreshProjectUpstreams
swap to `mcpdClient.withToken(authToken)` before handing the client to
syncUpstreams. This keeps the "pod has no identity" design: the token
used for downstream /api/v1/mcp/proxy calls is the caller's McpToken,
same as the one used for the initial discovery GET and for introspect.

Tested: project-discovery.test.ts + mcpd-upstream.test.ts pass. Next:
rebuild + roll the mcplocal image and retry LiteLLM probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 03:06:55 +01:00
Michal
dfc53cd15e fix(mcpd): per-route /api/v1/mcp/proxy auth missed McpToken dispatch
Symptom: LiteLLM → mcplocal → mcpd proxy calls for project-scoped MCP
tool discovery all 401'd with "Authentication failed: invalid or
expired token", even though the same mcpctl_pat_ bearer works against
/api/v1/mcptokens/introspect and /api/v1/projects/:name/servers. Result:
the new k8s mcplocal pod could accept the bearer and respond to
/projects/:name/mcp (initialize was 200), but every downstream upstream
discovery call through /api/v1/mcp/proxy failed.

Root cause: registerMcpProxyRoutes installs its own route-scoped
createAuthMiddleware with the `authDeps` parameter it receives. In
main.ts that was being constructed with only `findSession` — missing
the `findMcpToken` that the GLOBAL auth hook already had. So a
mcpctl_pat_ bearer got all the way to the proxy route and then was
handed to an old-shape middleware that knew nothing about the prefix.

Fix: extract authDeps (findSession + findMcpToken) to a named const
and reuse it for both the global hook and the proxy route. Comment at
the declaration site warns future additions to keep the two paths in
sync — they have to agree or McpToken bearers silently break on
whichever one drifts.

Verified against the live cluster: LiteLLM's discoverTools path no
longer 401s; mcplocal logs now show successful upstream proxy calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:23:44 +01:00
Michal
1887d90821 docs: scrub MCPLOCAL_MCPD_TOKEN — pod has no persistent mcpd identity
Some checks failed
CI/CD / lint (pull_request) Successful in 50s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Failing after 7m3s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
The earlier plan recommended an MCPLOCAL_MCPD_TOKEN env var so the pod
would have a ServiceAccount session into mcpd. It's unnecessary: the
pod forwards every inbound client bearer (mcpctl_pat_...) verbatim to
mcpd for all downstream calls — both introspect and project discovery.
mcpd's auth middleware dispatches on the prefix and resolves the
McpToken principal directly. No pod secret, no rotation story.

Updates:
- serve.ts header: explicit "identity model" section calling this out
  so future readers don't restore the env var thinking it's missing.
- docs/mcptoken-implementation.md: drop the "mount MCPLOCAL_MCPD_TOKEN"
  Pulumi guidance and the "dedicated ServiceAccount" follow-up item;
  state the correct image URL (internal 10.0.0.194 registry) and the
  gated-vs-ungated rule for LLM config mounts.

No runtime code changes — serve.ts never actually required the token;
this just fixes the documentation and the header comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:54:46 +01:00
Michal
3061a5f6ae test+feat: token-auth unit coverage + env-tunable introspection TTLs
Some checks failed
CI/CD / lint (pull_request) Successful in 51s
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Failing after 3m24s
CI/CD / build (pull_request) Successful in 4m45s
CI/CD / publish (pull_request) Has been skipped
Verifies the HTTP-mode revocation lag ≤ 5s two ways:

1. Unit (tests/http/token-auth.test.ts, 8 cases): Fastify preHandler
   with injected fetch stub exercises the positive/negative cache
   directly — first call returns ok:true, we flip the stub to
   revoked:true, wait past the short positive TTL, next call gets 401
   with "revoked". Plus: non-Bearer 401, non-mcpctl_pat_ 401, wrong-
   project 403, mcpd-unreachable 401, happy-path caching (1 fetch for N
   requests within TTL), ok:false from mcpd 401.

2. End-to-end (smoke, run manually): added MCPLOCAL_TOKEN_POSITIVE_TTL_MS
   and MCPLOCAL_TOKEN_NEGATIVE_TTL_MS env vars to serve.ts so the smoke
   can shrink the 30s positive default for testing. Confirmed: with
   positive TTL = 2s, the mcptoken.smoke.test.ts revocation case passes
   against a local serve.js pointed at prod mcpd.

Operators get the same knobs in production — default behavior unchanged
(30s positive, 5s negative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:25:06 +01:00
Michal
913678e400 fix(smoke): mcptoken — runtime gatewayUp gate + scope revocation case to HTTP-mode
All checks were successful
CI/CD / lint (pull_request) Successful in 52s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 2m23s
CI/CD / build (pull_request) Successful in 2m52s
CI/CD / smoke (pull_request) Successful in 5m40s
CI/CD / publish (pull_request) Has been skipped
Two bugs found while trying to point MCPGW_URL=http://localhost:3200
(the systemd mcplocal) so we could get real smoke coverage before the
Pulumi stack for mcp.ad.itaz.eu lands:

1. describe.skipIf(!gatewayUp) was evaluated at parse time, before
   beforeAll ran, so gatewayUp was always false and the whole suite
   skipped. Switched to the vllm-managed.test.ts pattern: runtime
   `if (!gatewayUp) return` at the start of each it().

2. The revocation 401 assertion only makes sense against the
   containerized serve.ts entry, which has a 5s negative introspection
   cache. Against systemd mcplocal the whole project router is cached
   for minutes, so a deleted token with a warm session still succeeds.
   Added IS_HTTP_MODE detection (hostname not localhost/127/0.0.0.0,
   or MCPGW_IS_HTTP_MODE=true) and skip the assertion otherwise — still
   revoking the token so cleanup runs identically.

Run against systemd mcplocal locally:

    MCPGW_URL=http://localhost:3200 pnpm --filter @mcpctl/mcplocal \\
      exec vitest run --config vitest.smoke.config.ts mcptoken

  → 6/6 pass (revocation case explicitly deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:20:36 +01:00
Michal
f68e123821 fix(cli): https support in status + api-client; add demo-mcp-call.py
All checks were successful
CI/CD / lint (pull_request) Successful in 1m40s
CI/CD / typecheck (pull_request) Successful in 1m35s
CI/CD / test (pull_request) Successful in 2m16s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Successful in 4m37s
CI/CD / publish (pull_request) Has been skipped
- status.ts + api-client.ts now dispatch on URL scheme so an https
  mcpd URL no longer crashes with "Protocol https: not supported".
  Caught by fulldeploy smoke runs — status.ts had `import http` only
  and was synchronously throwing against https://mcpctl.ad.itaz.eu.
  Each http.get call is wrapped so future scheme-mismatch errors also
  degrade to "unreachable" instead of a stack trace.
- .dockerignore no longer excludes src/mcplocal/ (the new
  Dockerfile.mcplocal needs those files).
- scripts/demo-mcp-call.py: standalone, stdlib-only Python demo that
  makes an MCP request (initialize + tools/list, optional tools/call)
  using an mcpctl_pat_ bearer. Counterpart to `mcpctl test mcp` for
  showing external (e.g. vLLM) clients how the bearer flow works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:34:00 +01:00
Michal
2127b41d9f feat: HTTP-mode mcplocal container + mcpctl test mcp + token-auth preHandler
Delivers the final piece of the mcptoken stack: a containerized,
network-accessible mcplocal that serves Streamable-HTTP MCP to off-host
clients (the vLLM use case), authenticated by project-scoped McpTokens.

New binary (same package, new entry):
  - src/mcplocal/src/serve.ts — HTTP-only entry. Reads MCPLOCAL_MCPD_URL,
    MCPLOCAL_MCPD_TOKEN, MCPLOCAL_HTTP_HOST/PORT, MCPLOCAL_CACHE_DIR from
    env. No StdioProxyServer, no --upstream.
  - src/mcplocal/src/http/token-auth.ts — Fastify preHandler that
    validates mcpctl_pat_ bearers via mcpd's /api/v1/mcptokens/introspect.
    30s positive / 5s negative TTL. Rejects wrong-project with 403.

Shared HTTP MCP client:
  - src/shared/src/mcp-http/ — reusable McpHttpSession with initialize,
    listTools, callTool, close. Handles http+https, SSE, id correlation,
    distinct McpProtocolError / McpTransportError. Plus mcpHealthCheck
    and deriveBaseUrl helpers.

New CLI verb `mcpctl test mcp <url>`:
  - Flags: --token (also $MCPCTL_TOKEN), --tool, --args (JSON),
    --expect-tools, --timeout, -o text|json, --no-health.
  - Exit codes: 0 PASS, 1 TRANSPORT/AUTH FAIL, 2 CONTRACT FAIL.

Container + deploy:
  - deploy/Dockerfile.mcplocal (Node 20 alpine, multi-stage, pnpm
    workspace, CMD node src/mcplocal/dist/serve.js, VOLUME
    /var/lib/mcplocal/cache, HEALTHCHECK on :3200/healthz).
  - scripts/build-mcplocal.sh mirrors build-mcpd.sh.
  - fulldeploy.sh is now a 4-step pipeline that also builds + rolls out
    mcplocal (gated on `kubectl get deployment/mcplocal` so the script
    stays green before the Pulumi stack lands).

Audit + cache:
  - project-mcp-endpoint.ts passes MCPLOCAL_CACHE_DIR into FileCache at
    both construction sites and, when request.mcpToken is present, calls
    collector.setSessionMcpToken(id, ...) so audit events carry the
    tokenName/tokenSha.

Tests:
  - 9 unit cases on `mcpctl test mcp` (happy path, health miss,
    expect-tools hit/miss, transport throw, tool isError, json report,
    $MCPCTL_TOKEN env fallback, invalid --args).
  - Smoke test src/mcplocal/tests/smoke/mcptoken.smoke.test.ts —
    gated on healthz($MCPGW_URL), skipped cleanly when unreachable.
    Covers happy path, wrong-project 403, --expect-tools contract
    failure, and revocation 401 within the negative-cache window.

1773/1773 workspace tests pass. Pulumi resources (Deployment, Service,
Ingress, PVC, Secret, NetworkPolicy) still need to land in
../kubernetes-deployment before the smoke gate flips on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:21:42 +01:00
Michal
a151b2e756 feat: mcpctl mcptoken verbs + mcpd auth dispatch + audit plumbing
Adds the end-to-end CLI surface for McpTokens and the mcpd auth dispatch
that recognizes them.

mcpd auth middleware:
  - Dispatch on the `mcpctl_pat_` bearer prefix. McpToken bearers resolve
    through a new `findMcpToken(hash)` dep, populating `request.mcpToken`
    and `request.userId = ownerId`. Everything else follows the existing
    session path.
  - Returns 401 for revoked / expired / unknown tokens.
  - Global RBAC hook now threads `mcpTokenSha` into `canAccess` /
    `canRunOperation` / `getAllowedScope`, and enforces a hard
    project-scope check: a McpToken principal can only hit
    `/api/v1/projects/<its-project>/...`.

CLI verbs:
  - `mcpctl create mcptoken <name> -p <proj> [--rbac empty|clone]
    [--bind role:view,resource:servers] [--ttl 30d|never|ISO]
    [--description ...] [--force]` — returns the raw token once.
  - `mcpctl get mcptokens [-p <proj>]` — table with
    NAME/PROJECT/PREFIX/CREATED/LAST USED/EXPIRES/STATUS.
  - `mcpctl get mcptoken <name> -p <proj>` and
    `mcpctl describe mcptoken <name> -p <proj>` — describe surfaces the
    auto-created RBAC bindings.
  - `mcpctl delete mcptoken <name> -p <proj>`.
  - `apply -f` support with `kind: mcptoken`. Tokens are immutable, so
    apply creates if missing and skips if the name is already active.

Audit plumbing:
  - `AuditEvent` / collector now carry optional `tokenName` / `tokenSha`.
    `setSessionMcpToken` sits alongside `setSessionUserName`; both feed a
    per-session principal map used at emit time.
  - `AuditEventService` query accepts `tokenName` / `tokenSha` filters.
  - Console `AuditEvent` type carries the new fields so a follow-up can
    add a TOKEN column.

Completions regenerated. 1764/1764 tests pass workspace-wide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:12:43 +01:00
Michal
efcfeeab65 feat(cli)!: migrate create rbac bindings to --roleBindings kv syntax
BREAKING: `mcpctl create rbac` no longer accepts `--binding` or
`--operation`. Use `--roleBindings` instead with key:value pairs:

  # resource binding
  --roleBindings role:view,resource:servers
  --roleBindings role:view,resource:servers,name:my-ha

  # operation binding (role:run is implied by action:)
  --roleBindings action:logs

The on-disk YAML shape (`roleBindings: [{role, resource, name?}]` or
`{role:'run', action}`) is unchanged, so Git backups and existing
`apply -f` files continue to work. Only the command-line input format
changes.

The parser is extracted to src/cli/src/commands/rbac-bindings.ts so the
upcoming `mcpctl create mcptoken --bind <kv>` verb can reuse it.

Completions, tests, and the new parser unit test all pass (406/406).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:03:57 +01:00
Michal
2ddb493bb0 feat(mcpd): McpToken schema + CRUD routes + introspection
Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest,
optional expiry, revocable) plus backing repository, service, and REST
routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is
added to the subject enum and the service auto-creates an RbacDefinition
with subject McpToken:<sha> when bindings are provided.

Creator-permission ceiling: the service rejects any requested binding
the creator cannot already satisfy themselves (re-uses
rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the
creator's full permissions into the token.

Routes:
  POST   /api/v1/mcptokens              create (returns raw token once)
  GET    /api/v1/mcptokens              list (filter by project)
  GET    /api/v1/mcptokens/:id          describe (no secret in response)
  POST   /api/v1/mcptokens/:id/revoke   soft-delete + remove RbacDef
  DELETE /api/v1/mcptokens/:id          hard-delete
  GET    /api/v1/mcptokens/introspect   validate raw bearer (used by mcplocal)

Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so
token-driven activity can be filtered later. Adds token helpers in
@mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken,
isMcpToken, timingSafeEqualHex.

Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs,
and the HTTP-mode mcplocal that calls /introspect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:00:04 +01:00
Michal
3149ea3ae7 fix: MCP proxy resilience — discovery cache, default liveness probes
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / typecheck (push) Successful in 1m51s
CI/CD / test (push) Successful in 1m1s
CI/CD / smoke (push) Failing after 3m21s
CI/CD / build (push) Successful in 4m9s
CI/CD / publish (push) Has been skipped
Adds a per-server tools/list cache in McpRouter (positive + negative TTL)
so a slow or dead upstream only stalls the first discovery call, not every
subsequent client request. Invalidated on upstream add/remove.

Health probes now apply a default liveness spec (tools/list via the real
production path) to any RUNNING instance without an explicit healthCheck,
so synthetic and real failures converge on the same signal.

Includes supporting updates in mcpd-client, discovery, upstream/mcpd,
seeder, and fulldeploy/release scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:48:57 +01:00
c968d76e00 Merge pull request 'fix: wire STDIO attach for docker-image MCP servers' (#49) from feat/k8s-operator into main
Some checks failed
CI/CD / typecheck (push) Successful in 48s
CI/CD / lint (push) Successful in 1m40s
CI/CD / test (push) Successful in 1m0s
CI/CD / smoke (push) Failing after 3m20s
CI/CD / build (push) Successful in 1m58s
CI/CD / publish (push) Has been skipped
Reviewed-on: #49
2026-04-12 21:27:14 +00:00
Michal
9ff2dcc3d9 fix: actually wire STDIO attach for docker-image MCP servers
All checks were successful
CI/CD / typecheck (pull_request) Successful in 52s
CI/CD / lint (pull_request) Successful in 1m43s
CI/CD / test (pull_request) Successful in 1m2s
CI/CD / build (pull_request) Successful in 1m45s
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
CI/CD / smoke (pull_request) Successful in 9m51s
Commit 1bd5087 added attachInteractive to the orchestrator interface
but never hooked it up in mcp-proxy-service — sendViaPersistentAttach
was promised in the commit message but missing from the diff. Servers
with a distroless image whose entrypoint IS the MCP server (gitea-mcp)
ended up needing a bogus `command: [node, dist/index.js]` workaround
that silently failed on every exec, leaving clients with empty tool
lists.

Changes:
- PersistentStdioClient: take a StdioMode discriminated union. Exec
  mode runs a command via execInteractive; attach mode talks to PID 1
  via attachInteractive.
- mcp-proxy-service: dispatch by config — command → exec; packageName
  → exec via runtime runner; dockerImage-only → attach. Error
  serialization no longer drops non-Error objects as "[object Object]".
- templates/gitea.yaml: remove the command workaround; the image CMD
  runs as PID 1 and mcpd attaches.
- Add unit tests covering both modes and the unsupported-orchestrator
  paths.

Also required (separate repo): mcpd's k8s Role needed pods/attach
added alongside pods/exec; updated in kubernetes-deployment/…/mcpctl/server.ts
and kubectl-patched on the live cluster.

Verified end-to-end against mcpctl.ad.itaz.eu:
- gitea (attach): 49 tools listed, real tools/call round-trip.
- aws-docs (exec via packageName): 4 tools, no regression.
- docmost (exec via command): 11 tools, no regression.
- mcpd suite: 634/634 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:26:26 +01:00
c62a350da1 Merge pull request 'fix: MCP proxy resilience — timeouts, parallel discovery, error propagation' (#48) from feat/k8s-operator into main
Some checks failed
CI/CD / typecheck (push) Successful in 50s
CI/CD / lint (push) Successful in 1m49s
CI/CD / test (push) Successful in 1m3s
CI/CD / smoke (push) Failing after 3m22s
CI/CD / build (push) Successful in 1m53s
CI/CD / publish (push) Has been skipped
Reviewed-on: #48
2026-04-10 17:29:33 +00:00
Michal
857f8c72ae fix: MCP proxy resilience — timeouts, parallel discovery, error propagation
All checks were successful
CI/CD / typecheck (pull_request) Successful in 49s
CI/CD / lint (pull_request) Successful in 1m49s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / build (pull_request) Successful in 1m49s
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
CI/CD / smoke (pull_request) Successful in 10m3s
- McpdClient: add 30s AbortSignal timeout to all fetch calls (was infinite)
- CLI bridge: return JSON-RPC error on stdout when HTTP fails (was silent)
- Router: parallel tool/resource discovery via Promise.allSettled (was sequential — one slow server blocked all)
- vllm-managed: 60s error cooldown prevents retry-on-every-call when vLLM is broken
- Tests: McpdClient timeout suite (9), parallel discovery, vllm cooldown, bridge error response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:28:03 +01:00
Michal
383be66286 feat: add backup + server type smoke tests
New smoke test file: backup-and-servers.test.ts
- Backup completeness: prompts, templates, runtime, command, containerPort, replicas
- SSE server proxy (my-home-assistant): 84 tools
- Docker-image STDIO proxy (docmost): 11 tools
- Package STDIO proxy (aws-docs): 4 tools
- Instance status accuracy: RUNNING instances must respond to proxy

These tests would have caught every migration bug:
- Missing runtime (python servers on node runner)
- Missing command (HA SSE in STDIO mode)
- Missing containerPort (SSE on wrong port)
- Backup data loss (prompts, templates, server fields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:05:54 +01:00
757 changed files with 96885 additions and 1524 deletions

View File

@@ -12,4 +12,3 @@ dist
.env.*
deploy/docker-compose.yml
src/cli
src/mcplocal

6
.gitignore vendored
View File

@@ -44,3 +44,9 @@ logs.sh
mcpctl-backup.json
a.yaml
test-mcp.sh
# Claude Code local state
.claude/scheduled_tasks.lock
# Agent worktrees — scratch checkouts, never part of the tree
.claude/worktrees/

View File

@@ -0,0 +1,808 @@
# PRD: Agentic Teams — Foundation
## Overview
mcpctl today orchestrates MCP servers, projects, prompts, skills, and chat agents. It does not
orchestrate *work*. This PRD adds the foundation that lets human developers and autonomous agents
be peers in the same project: a versioned vision layer (Epic → epictask → task), git repositories
attached to projects, agents as many-to-many project members, ephemeral per-session pods where
agents do real work, a swappable agent runtime, a task lifecycle with QA/Security/SRE gates, and a
single human accept gate that fires a merge through the git provider's API.
The design record is `docs/agentic-teams.md`. This PRD converts §16 steps 1–3 of that document
into an implementation spec. Steps 4–8 are named as deferred work at the end, deliberately
without detail — they depend on open design questions (`docs/agentic-teams.md` §15) that are not
closed.
## Problem
Two failure classes motivate this work.
**Nothing tracks work.** There is no Epic, EpicTask, Task, Repository, or AgentProject model in
`src/db/prisma/schema.prisma`, and no repository, service, route, or CLI code for any of them.
Agents can chat and call tools, but there is no unit of work to assign, no lifecycle to advance,
no branch or PR to attach, and no gate for a human to accept.
**Completed tasks do not add up to the vision.** From the design doc: you have a grand vision, you
split it into tasks, you complete all the tasks, and you are left with a stick with wheels instead
of a car. The root cause is not execution — it is that decomposition is lossy, tasks are graded
mechanically ("compiles / lint / renders") rather than against intent, and integration is nobody's
job. The fix is an authoritative, versioned Epic that every task can walk up to, so "what am I
serving, and what does done-for-this look like" is always answerable.
A third, practical problem shapes the ordering: the design document's "don't rebuild, delegate"
principle is correct in spirit, but several primitives it assumes do not exist in mcpctl yet.
Those are enumerated under **Execution Substrate Changes** and must land before any agent can work
a task.
## Scope & Non-Goals
**In scope** (design doc §16 steps 1–3):
1. Epic / EpicTask / Task / CodeRepo entities + AgentProject membership, with project linkage,
mcpd CRUD, CLI verbs, RBAC, backup, docs, and tests.
2. An `AgentRuntime` adapter interface and a first implementation running a headless coding agent
inside an ephemeral per-session pod. Prove: pod spawn → clone repos → work a task branch →
push → report.
3. Task lifecycle with QA / Security / SRE stages, PR linkage, the human accept gate, and merge
through the git provider API, with post-merge failure reopening the task.
**Explicitly out of scope** — these are named in *Deferred* below and get placeholder tasks only:
the Tier-2 design/product critic and Epic-comparison loop; collaboration modes (standup, joint
`exec` sessions as a workflow); the confidence signal and escalation dashboard; per-persona memory
and the coverage ledger; script artifacts and the baseline scorecard.
**Not being built at all:** a permission engine. mcpctl enforces coarse gates (who may transition
a task, what RBAC a session needs); fine-grained per-tool approval stays inside the agent runtime.
## Decisions Already Made
| Decision | Choice | Rationale |
|---|---|---|
| Work-item RBAC name | Work items take `tasks`; the inference queue moves to `inference-tasks` | Matches the design doc's vocabulary. The `inference-tasks` alias already exists, so the URL prefix `/api/v1/inference-tasks` is unchanged |
| Roadmap-node spelling | Model `EpicTask`, resource `epictasks`, aliases `epictask`, `et` | Design doc §15 Q2; one word, matches the doc's own usage |
| Git-repo resource name | Model `CodeRepo`, resource `repos`, aliases `repo`, `repository`, `repositories` | Avoids `repository.repository.ts` and the collision with the data-access-layer term used throughout `src/mcpd/src/repositories/` |
| Epic revisions | Epic joins the existing `ResourceRevision` system as `resourceType: 'epic'` | The Epic is the auditable source of truth for "is this done"; scope changes must be visible. `ResourceType` is a string union by design, so this is a non-migration change |
| Agent membership | New `AgentProject` junction; `Agent.projectId` retained and kept in sync for one release | Design doc §4. `ProjectMember` was deliberately dropped in migration `20260223120000_remove_project_members`; this is a narrower reversal — agent membership only, not human membership, which stays RBAC-driven |
| Invariants | Enforced in the service layer, not CHECK constraints | See *Deployment constraint* below |
| Agent brain | Home brain (`Agent.llmId`) for bare chat; the project's `llmProvider`/`llmModel` when the agent acts inside a project; optional per-membership override on `AgentProject.llmId` | Design doc §15 Q1 leans this way. **This is the one open question that blocks the data model** — the `AgentProject.llmId` column exists so the decision can be reversed without a migration |
### Deployment constraint (affects every schema task)
`deploy/entrypoint.sh` provisions the database with `prisma db push --accept-data-loss`, not
`prisma migrate deploy`. Hand-written SQL in `src/db/prisma/migrations/` therefore **never runs in
production**. The single existing CHECK constraint, `ChatThread_agent_xor_project` (migration
`20260717234728_add_project_chat_threads`), is absent from any `db push`-provisioned database and
is enforced only by `ChatService`.
Consequence: every invariant introduced here must be either expressible in the Prisma schema
(unique constraints, non-null columns, enums, foreign keys) or enforced in the service layer with
a test. Do not write CHECK constraints and assume they hold.
## Core Concepts
### The vision layer
```
Epic (the vision — project-scoped, versioned, revision-tracked)
└── EpicTask — a roadmap node; chains to other epictasks (dependency graph)
└── Task — the unit of work, with its own lifecycle
```
An **Epic** holds the agreed done-criteria written at vision time, its scope-change history, and
accepted-tradeoff decisions. An Epic is done only when 100% of the vision is delivered, **or** the
user explicitly accepted the tradeoffs — recorded so "done" is honest and auditable.
An **EpicTask** is a roadmap node. EpicTasks chain to one another, so visualising them yields a
dependency graph rather than a flat sequence. Each carries the decomposed subset of the Epic's
acceptance criteria, so per-node completeness is computable.
A **Task** is the unit of work, owned by an EpicTask. Because Task → EpicTask → Epic, every task
can resolve what it is serving by walking up the chain.
### Two-level verification
| Level | Checks | Who | When |
|---|---|---|---|
| Tier 1 — correctness / security / ops | Builds, runs, passes tests, stays secure, and the deployment-as-code is right for this project's real infrastructure | Developer + QA + Security + SRE personas | Inside the task's own lifecycle, before Done |
| Tier 2 — vision fidelity | Does the assembled result match the vision | Design/product critic | At the Epic level — **deferred, not in this PRD** |
Tier 1 stages are gates *inside* the task loop. Their feedback bounces the task back to
`InProgress`. They are not post-submit reviewers.
The **SRE/Release stage** reviews the deployment-as-code — is it targeting the right
infrastructure, by the right method, with the right tooling. It reviews the deployment
*definition*; it does not deploy. It exists because a large share of failures come from implicit
operational rules a developer LLM does not know. Concrete example: this cluster runs default-deny
network policies, so any new workload needs explicit egress and ingress rules. A developer LLM
watching a connection fail will deep-dive the network-debugging path when the real cause is a
missing egress policy silently dropping traffic. The SRE persona, holding per-project infra
knowledge, flags that in review.
### Agents as people
An agent exists independently of any project — a persistent identity with a name, description,
system prompt, and a home brain. It joins zero, one, or many projects. Per-project role and
scope hang off the membership junction, not off the agent.
### Pod per session
Each agent session gets its own ephemeral pod. The pod *is* the workspace: an init container
clones the project's repositories, the session is attached to project prompts, MCP tools, and
scoped secrets, and the pod dies at session end. Agents do not share a live filesystem with each
other — handoffs happen via git (branch + diff + verdict pinned to a commit). A single human may
share one agent's environment via `exec`.
## Schema Changes
All new models follow existing house conventions: `id String @id @default(cuid())`,
`version Int @default(1)` as an optimistic-concurrency counter bumped unconditionally in the
repository layer (see `agent.repository.ts` `updateData.version = { increment: 1 }`), plus
`createdAt` / `updatedAt`.
### Epic
```prisma
model Epic {
id String @id @default(cuid())
name String
description String @default("")
// The agreed statement of the vision. Body of the Epic.
content String @db.Text
// Checkable outcomes + intent/quality attributes that define 100% delivery.
// Array of { id, text, kind: 'outcome' | 'quality', met: boolean }.
doneCriteria Json @default("[]")
// Accepted-tradeoff records: { what, why, who, deScopedBy, at }.
acceptedTradeoffs Json @default("[]")
projectId String
status EpicStatus @default(open)
semver String @default("0.1.0")
currentRevisionId String?
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
epicTasks EpicTask[]
@@unique([name, projectId])
@@index([projectId])
@@index([name])
}
enum EpicStatus {
open
delivered // 100% of doneCriteria met
delivered_with_tradeoffs
cancelled
}
```
### EpicTask
```prisma
model EpicTask {
id String @id @default(cuid())
name String
description String @default("")
epicId String
// Decomposed subset of the Epic's doneCriteria this node is responsible for.
doneCriteria Json @default("[]")
status EpicTaskStatus @default(todo)
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
epic Epic @relation(fields: [epicId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
tasks Task[]
dependsOn EpicTaskEdge[] @relation("EpicTaskEdgeFrom")
blocks EpicTaskEdge[] @relation("EpicTaskEdgeTo")
@@unique([name, epicId])
@@index([epicId])
}
// Chaining between roadmap nodes. Modelled as an explicit edge table rather
// than a self-referencing array so the graph can be walked in both directions
// and edges can later carry a type.
model EpicTaskEdge {
id String @id @default(cuid())
fromId String // the dependent node
toId String // the prerequisite node
createdAt DateTime @default(now())
from EpicTask @relation("EpicTaskEdgeFrom", fields: [fromId], references: [id], onDelete: Cascade)
to EpicTask @relation("EpicTaskEdgeTo", fields: [toId], references: [id], onDelete: Cascade)
@@unique([fromId, toId])
@@index([fromId])
@@index([toId])
}
enum EpicTaskStatus {
todo
in_progress
done
cancelled
}
```
Cycle prevention in the edge graph is a service-layer check (see *Deployment constraint*): before
creating an edge, walk `to`'s transitive dependencies and reject if `from` is reachable.
### Task
```prisma
model Task {
id String @id @default(cuid())
name String
title String
description String @default("") @db.Text
epicTaskId String
repoId String?
// The agent currently responsible. Null while unassigned.
assigneeId String?
state TaskState @default(todo)
// Drives how much Tier-1 verification is engaged: trivial work may pass on a
// light self-check; risky work engages QA / Security / SRE.
risk TaskRisk @default(normal)
// Git linkage, populated when the agent pushes.
branch String?
headCommit String?
prUrl String?
prNumber Int?
// Free-form reviewer comment attached at the accept gate.
reviewNote String? @db.Text
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
epicTask EpicTask @relation(fields: [epicTaskId], references: [id], onDelete: Cascade)
repo CodeRepo? @relation(fields: [repoId], references: [id], onDelete: SetNull)
assignee Agent? @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
verdicts TaskVerdict[]
sessions AgentSession[]
events TaskEvent[]
@@unique([name, epicTaskId])
@@index([epicTaskId])
@@index([state])
@@index([assigneeId])
}
enum TaskState {
todo
in_progress
building // compile, run, smoke
self_testing // developer's own tests
qa_review
security_review
sre_review
done // leaves the developer's loop
awaiting_review // human accept gate
merged
reopened // post-merge failure
cancelled
}
enum TaskRisk {
trivial // docs, formatting — light self-check may suffice
normal
high // auth, secrets, migrations, cross-contract
}
// A Tier-1 persona's verdict, pinned to the commit it judged. Immutable —
// this is the provenance record the design doc calls "QA verified 3f2ab".
model TaskVerdict {
id String @id @default(cuid())
taskId String
stage TaskState // qa_review | security_review | sre_review
agentId String?
commit String
passed Boolean
summary String @db.Text
// Structured findings: [{ severity, message, path?, line? }]
findings Json @default("[]")
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
@@index([taskId])
@@index([taskId, stage])
}
// Append-only transition log. Every state change writes one row.
model TaskEvent {
id String @id @default(cuid())
taskId String
fromState TaskState?
toState TaskState
actorType String // 'user' | 'agent' | 'system'
actorId String?
note String? @db.Text
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId, createdAt])
}
```
### CodeRepo
```prisma
model CodeRepo {
id String @id @default(cuid())
name String
projectId String
// Clone URL. https for token auth, ssh for key auth.
url String
defaultBranch String @default("main")
// Relative path under the session workspace root. Defaults to `name`.
path String @default("")
// Git provider, for PR/merge API calls. 'gitea' is the only driver in v1.
provider String @default("gitea")
// Secret holding the provider credential. Resolved through SecretService at
// session-provision time — never stored in plaintext on the row.
credSecretId String?
credSecretKey String?
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
cred Secret? @relation(fields: [credSecretId], references: [id])
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([name, projectId])
@@index([projectId])
}
```
### AgentProject
Mirrors `ProjectServer` (`schema.prisma`): explicit join model with its own cuid `id`, `createdAt`,
cascade on both sides, `@@unique([a, b])`. Indexes on both columns follow `PersonalityPrompt`,
because both reverse lookups are needed ("agents of project", "projects of agent").
```prisma
model AgentProject {
id String @id @default(cuid())
agentId String
projectId String
// Per-project persona role. TEXT, not an enum, so adding a role later is a
// non-migration change (same reasoning as ResourceProposal.resourceType).
// v1 values: 'developer' | 'qa' | 'security' | 'sre'.
role String @default("developer")
// Optional per-membership brain override. Present so the open question in
// docs/agentic-teams.md §15 Q1 can be answered either way without a migration.
llmId String?
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
llm Llm? @relation(fields: [llmId], references: [id], onDelete: SetNull)
@@unique([agentId, projectId])
@@index([agentId])
@@index([projectId])
}
```
`Agent.projectId` is **retained** and written in sync with the junction for one release, so
existing `prepareContext` / `findByProjectId` paths keep working. A follow-up removes it.
### AgentSession
```prisma
model AgentSession {
id String @id @default(cuid())
agentId String
projectId String
taskId String?
// Orchestrator handle — pod name on k8s, container id on docker.
containerId String?
state SessionState @default(pending)
image String
// Reaped when now() > expiresAt. Swept by the same style of GC loop as the
// inference-task sweep in main.ts.
expiresAt DateTime
lastError String? @db.Text
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
endedAt DateTime?
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@index([state])
@@index([taskId])
@@index([agentId])
}
enum SessionState {
pending
provisioning
running
ended
failed
}
```
### Back-relations on existing models
Prisma requires both sides of every relation. The blocks above show only the owning side; the
following fields must be added to existing models or `prisma generate` fails:
| Model | Fields to add |
|---|---|
| `Project` | `epics Epic[]`, `codeRepos CodeRepo[]`, `agentMemberships AgentProject[]`, `sessions AgentSession[]` |
| `User` | `ownedEpics Epic[]`, `ownedEpicTasks EpicTask[]`, `ownedTasks Task[]`, `ownedRepos CodeRepo[]`, `ownedSessions AgentSession[]` |
| `Agent` | `projectMemberships AgentProject[]`, `assignedTasks Task[]`, `verdicts TaskVerdict[]`, `sessions AgentSession[]` |
| `Llm` | `agentProjects AgentProject[]` |
| `Secret` | `codeRepos CodeRepo[]` |
`Agent.projectId` and its `project Project?` relation stay as-is during the transition release, so
`Project` carries both `agents Agent[]` (existing) and `agentMemberships AgentProject[]` (new).
### Test helper
`src/db/tests/helpers.ts` `clearAllTables` must gain `deleteMany` calls for every new model —
globals with nullable foreign keys do not cascade.
## API Changes
All routes sit under `/api/v1`, follow the existing Fastify + zod-in-the-service pattern, and
resolve `project` / `agent` name strings to ids at the route layer (as `routes/skills.ts` does).
### New endpoints
```
# Vision layer
GET|POST /api/v1/epics ?project=<name>
GET|PUT|DELETE /api/v1/epics/:id
GET /api/v1/epics/:id/roadmap → epictask graph + per-node completeness
POST /api/v1/epics/:id/tradeoffs → append an accepted-tradeoff record
GET|POST /api/v1/epictasks ?epic=<name>&project=<name>
GET|PUT|DELETE /api/v1/epictasks/:id
POST|DELETE /api/v1/epictasks/:id/edges → { toId } add/remove a dependency edge
# Work items
GET|POST /api/v1/tasks ?project=&epictask=&state=&assignee=
GET|PUT|DELETE /api/v1/tasks/:id
POST /api/v1/tasks/:id/transition → { toState, note? }
POST /api/v1/tasks/:id/verdict → record a QA/Security/SRE verdict
POST /api/v1/tasks/:id/accept → { note? } human accept gate → merge
POST /api/v1/tasks/:id/reject → { note } → back to in_progress
GET /api/v1/tasks/:id/events
# Repositories
GET|POST /api/v1/repos ?project=<name>
GET|PUT|DELETE /api/v1/repos/:id
# Agent membership
GET /api/v1/agents/:name/projects
POST|DELETE /api/v1/agents/:name/projects/:project → { role?, llm? }
GET /api/v1/projects/:name/agents
# Sessions & exec
GET|POST /api/v1/sessions ?project=&agent=&task=
GET|DELETE /api/v1/sessions/:id
POST /api/v1/sessions/:id/exec → one-shot exec, returns { exitCode, stdout, stderr }
GET /api/v1/sessions/:id/attach → WebSocket, bidirectional stdio
# Git provider webhook
POST /api/v1/webhooks/git/:provider → post-merge failure reopens the task
```
### Changed endpoints
`/api/v1/inference-tasks/*` is unchanged in shape. Only its RBAC resource name changes (see
below).
## RBAC Changes
### The `tasks` namespace split
`src/mcpd/src/validation/rbac-definition.schema.ts` currently declares `tasks` in
`RBAC_RESOURCES` and aliases `task`, `inference-task`, and `inference-tasks` all onto it — with
the comment "the only 'task' we have is inference for now". That is no longer true.
Required changes:
1. `RBAC_RESOURCES` gains `epics`, `epictasks`, `tasks` (now meaning work items), `repos`,
`sessions`, and `inference-tasks`.
2. `RESOURCE_ALIASES` maps `task → tasks`, `epic → epics`, `epictask → epictasks`,
`et → epictasks`, `repo → repos`, `repository → repos`, `repositories → repos`,
`session → sessions`, and `inference-task → inference-tasks`. The entry
`'inference-tasks': 'tasks'` is **removed**.
3. `mapUrlToPermission` in `src/mcpd/src/main.ts` gains `epics`, `epictasks`, `tasks`, `repos`,
`sessions`, and maps `/api/v1/inference-tasks` to `inference-tasks`. Non-CRUD verbs need
explicit cases above the map: `POST /tasks/:id/accept|reject|transition` → `run:tasks:<name>`,
`POST /sessions/:id/exec` and `GET /sessions/:id/attach` → `run:sessions:<name>`.
4. `nameResolvers` (`main.ts`) gains `epics`, `epictasks`, `tasks`, `repos`, `sessions` so
name-scoped grants match when the URL carries a cuid.
### One-shot binding backfill
`ResourceBindingSchema` transforms `resource` through `normalizeResource` **at parse time** and
stores the normalized string, so existing `RbacDefinition.roleBindings` rows already contain the
literal `"tasks"` meaning *inference*. After the split, those grants would silently become
work-item grants.
A one-shot startup migration must rewrite `resource: "tasks"` → `"inference-tasks"` across all
`RbacDefinition` rows. Model the walk on `migrateAdminRole` in `src/mcpd/src/main.ts` (it iterates
`rbacRepo.findAll()` and rewrites `roleBindings` in place).
It must be **guarded so it runs exactly once** — a blanket rewrite on every boot would clobber
legitimate new work-item grants. Use the existing system-settings pattern: a boolean key in the
`mcpctl-system-settings` Secret (`SYSTEM_SETTINGS_SECRET` in
`src/mcpd/src/bootstrap/system-settings.ts`), read before the walk and written after. Wildcard
bindings (`resource: '*'`) are untouched — they already cover both.
Document the rename in `docs/inference-tasks.md`.
## CLI Changes
Two project rules govern every resource added here:
- Everything settable via `apply -f` **must** also be a `create` flag.
- `mcpctl get RESOURCE NAME -o yaml` **must** round-trip through `mcpctl apply -f`.
### New commands
```
mcpctl get epics|epictasks|tasks|repos|sessions [--project NAME]
mcpctl describe epic|epictask|task|repo|session NAME
mcpctl create epic|epictask|task|repo ...
mcpctl edit epic|epictask|task|repo NAME
mcpctl delete epic|epictask|task|repo NAME
mcpctl describe epic NAME # roadmap: epictask graph + per-node completeness
mcpctl tasks accept NAME [--note ..] # human accept gate → merge via provider API
mcpctl tasks reject NAME --note .. # → back to in_progress
mcpctl exec --project NAME AGENT -- bash
```
`mcpctl exec` semantics (design doc §10): exec drops the human into the **working environment** —
the cloned repos, branches, tools, and deps. It does **not** attach to the agent's reasoning loop.
The agent remains a separate process the human can still talk to.
### Files to touch
| Concern | File |
|---|---|
| Resource aliases | `src/cli/src/commands/shared.ts` `RESOURCE_ALIASES` |
| `get` columns + yaml kind | `src/cli/src/commands/get.ts` — row interfaces, `getColumnsForResource`, `RESOURCE_KIND`, `toApplyDocs` strip blocks |
| `describe` formatters | `src/cli/src/commands/describe.ts` — clean sections and tables, not key-value dumps |
| `create` subcommands | `src/cli/src/commands/create.ts` |
| `apply -f` | `src/cli/src/commands/apply.ts` — spec schemas, `ApplyConfigSchema` keys, `KIND_TO_RESOURCE`, dry-run counts, apply loops |
| `edit` | `src/cli/src/commands/edit.ts` `validResources` |
| Scoped fetch | `src/cli/src/index.ts` `fetchResource` / `fetchSingleResource` for `--project` scoping |
| Completions | `scripts/generate-completions.ts` `CANONICAL_RESOURCES` + `ALIAS_ENTRIES`, then `pnpm completions:generate` |
Completions are generated, never hand-edited; `src/cli/tests/completions.test.ts` runs the
generator with `--check` and fails on drift.
## Execution Substrate Changes
These are the primitives the design document assumes but mcpctl does not have. They gate step 2.
### 1. Ephemeral pods
`ContainerSpec` in `src/mcpd/src/services/orchestrator.ts` has only image, name, command, env,
ports, limits, labels, and network. `generatePodSpec` in
`src/mcpd/src/services/k8s/manifest-generator.ts` hardcodes `restartPolicy: 'Always'`, and no code
anywhere uses the Kubernetes Job API.
`ContainerSpec` must gain:
- `restartPolicy?: 'Always' | 'Never' | 'OnFailure'`
- `workdir?: string`
- `volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }>` — the session
workspace
- `initContainers?: Array<{ name; image; command; env?; volumeMounts? }>` — the repo-clone step
Both backends implement them: `KubernetesOrchestrator` and `DockerContainerManager`.
### 2. Session provisioning
A `SessionService` that: creates the `AgentSession` row; resolves the project's `CodeRepo` list and
their credentials through `SecretService` (resolution failure is fatal for the attempt, matching
`InstanceService.attemptStart`); builds a spec with an init container that clones each repo to its
`path` under the workspace volume and checks out the task branch; starts the pod; and transitions
the row to `running`.
Reaping runs as a GC sweep in the same style as the existing inference-task sweep in
`src/mcpd/src/main.ts`: sessions past `expiresAt` are stopped, removed, and marked `ended`. Note
these loops assume a single mcpd replica — there is no leader election, and this inherits that.
### 3. Exec surface
The hard part already exists. `McpOrchestrator` declares `execInContainer`, `execInteractive`, and
`attachInteractive`; `KubernetesOrchestrator` implements the latter two over `k8s.Exec` and
`k8s.Attach`, and `src/mcpd/src/services/transport/persistent-stdio.ts` uses them in production
today. This work is an **API and CLI surface over a proven primitive**: a one-shot exec route, a
WebSocket attach route with terminal resize and proper exit-code propagation, and the
`mcpctl exec` client.
### 4. Git provider client
No Gitea or GitHub API client, PR creation, or webhook handling exists in product code. PRs are
made today by the root-level `pr.sh` (curl against the Gitea API) or by an agent calling the
`gitea-mcp-server` MCP tool.
A `GitProvider` interface in mcpd with a Gitea driver: `createPullRequest`, `getPullRequest`,
`getCombinedStatus`, `mergePullRequest`. Credentials resolve from the `CodeRepo`'s Secret.
`pr.sh` is the reference for the endpoint shapes (`POST /api/v1/repos/:owner/:repo/pulls`).
A webhook receiver validates the provider signature and reopens the task on post-merge failure.
## AgentRuntime Interface
mcpctl builds an *interface*, not a runtime. `ChatService` is not a candidate — its tool loop is
capped at `MAX_ITERATIONS = 12` (clamped 1..50), which is a soft-DoS guard appropriate for chat
and far too small for a coding task.
```ts
export interface AgentRuntime {
startSession(opts: RuntimeSessionOpts): Promise<RuntimeSession>;
send(sessionId: string, message: string): Promise<void>;
status(sessionId: string): Promise<RuntimeStatus>;
result(sessionId: string): Promise<RuntimeResult>; // branch, head commit, summary
endSession(sessionId: string): Promise<void>;
}
```
Implementation #1 runs a battle-tested headless coding agent inside the session pod. It already
solves per-tool permission approval, git and branch mechanics, subagents, streaming, and MCP
client behaviour — all things the design document explicitly declines to rebuild. The persona is
delivered as prompt files in the container, which suits a file-aware coding runtime.
A second adapter is explicitly deferred; the interface exists so it stays possible.
## Task Lifecycle
```
todo → in_progress → building → self_testing
→ qa_review (feedback → in_progress)
→ security_review (feedback → in_progress)
→ sre_review (feedback → in_progress)
→ done → awaiting_review
accept → merged
reject → in_progress
merged --post-merge failure (webhook)--> reopened
```
Verification effort scales with `Task.risk`: `trivial` work may pass on a light self-check;
`high` engages all three review stages. The stages decide their own depth.
Transitions are guarded by a table of legal `(from, to)` pairs and applied as **compare-and-swap**
— `updateMany` with the expected `state` in the `where` clause, then check the affected-row count.
This is the same idiom `InferenceTaskRepository` uses so two racing claimants cannot both win.
Every transition appends a `TaskEvent`.
The human approval gate is **not** a per-tool tri-state. It is a task-level yes/no plus a comment
on the resolved outcome, and accepting it fires the merge. The human should not have to go to git.
## Testing Strategy
### Unit — `src/db/tests/`
- Schema tests per model (pattern: `skill-schema.test.ts`): create, unique constraints, cascade
behaviour on project/epic/task delete, `AgentProject` uniqueness.
- `clearAllTables` in `helpers.ts` covers every new model.
### Unit — `src/mcpd/tests/`
- Service tests: Epic done-criteria roll-up; EpicTask edge cycle rejection; task transition table
including every illegal pair; compare-and-swap under concurrent transition.
- Route tests per resource (pattern: `prompt-routes.test.ts`, `agent-routes.test.ts`).
- RBAC: the `tasks` → `inference-tasks` backfill runs once and is idempotent on a second boot;
a pre-existing `view:tasks` grant still authorizes `/api/v1/inference-tasks` afterwards and does
**not** authorize `/api/v1/tasks`; name-scoped grants resolve for all new resources.
- Orchestrator: `generatePodSpec` emits `restartPolicy`, workdir, volumes, and init containers.
- Git provider: Gitea driver against a mocked HTTP server.
### Unit — `src/cli/tests/`
- `get -o yaml` → `apply -f` round-trip for every new resource.
- `create` flag coverage: every field settable in `apply` has a matching flag.
- `completions.test.ts` assertions for the new resource words.
### Smoke — `src/mcplocal/tests/smoke/`
Every feature ships smoke tests. Follow the CRUD pattern in `personality.smoke.test.ts`: a
`Date.now().toString(36)` name suffix, a `healthz()` preflight that **skips rather than fails**
when the daemon is unreachable, `mcpctl --direct` shelling, and explicit `afterAll` cleanup.
- `epic.smoke.test.ts` — Epic → EpicTask → Task create/list/describe/delete against live mcpd.
- `repo.smoke.test.ts` — CodeRepo CRUD, credential reference resolves.
- `session.smoke.test.ts` — provision a session pod against the real cluster, confirm the repo is
cloned, exec a command, confirm the pod is reaped.
- `task-lifecycle.smoke.test.ts` — drive a task through the full transition chain including a
rejected accept-gate bounce.
When a smoke test fails, fix the environment (deploy, backend, RBAC) — never weaken the assertion.
## Deferred
Named here so the boundary is explicit. Each gets a single placeholder task with no detail, to be
expanded once the corresponding open questions in `docs/agentic-teams.md` §15 are closed.
- **Tier-2 vision critic and Epic-comparison.** A review-only, non-executing design/product
persona that judges the assembled result against the Epic in its native medium, producing
reopen / new-task / `exec` outcomes. Blocked on §15 Q13 (does this need render/screenshot
infrastructure, or can a design-aware persona judge from a structured artifact?).
- **Collaboration modes.** On-demand async standup (`mcpctl standup --project NAME`) and `exec`
as a workflow rather than a command. Blocked on §15 Q4 and Q5.
- **Confidence signal and escalation dashboard.** Evidence-anchored confidence (retrieve K similar
past tasks with their *outcomes*, ask how confident we are this resolves cleanly), thresholds
driving autonomy, and a single cross-project human decision queue. Blocked on §15 Q12
(web / TUI / CLI?). Retrieval quality is the crux — weak retrieval quietly poisons the model.
- **Memory and the coverage ledger.** Global per-agent memory plus per-project knowledge, and a
commit-pinned coverage ledger enabling delta-verification. Non-negotiable prerequisite:
invalidation when a covered region changes, or the ledger becomes false confidence. Keep the
trust boundary — the ledger is platform-verified machine fact; agent prose notes are opinion and
never the gate.
- **Script artifacts and the baseline scorecard.** Reusable executable `script` artifacts at task,
project, and agent scope, plus a provisioned baseline-rules scorecard. Blocked on §15 Q15 and
Q16. Watch the incentive integrity risk: reward *reused* tools, not tool proliferation.
## Implementation Phases
**Phase A — foundation (blocks everything)**
1. RBAC `tasks` namespace split, alias repoint, and the one-shot guarded binding backfill.
2. Prisma models Epic, EpicTask, EpicTaskEdge, Task, TaskVerdict, TaskEvent, CodeRepo,
AgentSession + enums + migration + `clearAllTables`.
3. `AgentProject` junction, with `Agent.projectId` kept in sync for one release.
**Phase B — mcpd CRUD**
4. Epic resource: repository, service, zod validation, routes, `main.ts` wiring, revision opt-in.
5. EpicTask resource plus the edge graph and cycle rejection.
6. Task resource plus the transition table and compare-and-swap transitions.
7. CodeRepo resource with credential resolution.
8. RBAC wiring for all new resources: `mapUrlToPermission`, `nameResolvers`, non-CRUD verb cases.
9. Backup and YAML kinds for the new resources — `BACKUP_KINDS`, `APPLY_ORDER`, `kindMap`, the
export loop, `serializeResource`, and `main.ts` `kindFromSegment` / `importResource` /
`deleteResource`. Fix the pre-existing gap where `skill` is missing from the latter two.
**Phase C — CLI**
10. `get` columns and `describe` formatters for all new resources.
11. `create`, `apply -f`, `edit`, `delete`, with the yaml round-trip test.
12. Shell completions and their guard test.
13. Roadmap view: `describe epic` rendering the epictask graph with per-node completeness.
**Phase D — execution substrate**
14. Extend `ContainerSpec` and both orchestrator backends with restart policy, workdir, volumes,
and init containers.
15. `SessionService`: provisioning, repo-clone init container, credential resolution, TTL and GC.
16. `AgentRuntime` interface plus adapter #1 running a headless coding agent in-pod.
17. Exec surface: one-shot exec route, WebSocket attach, and `mcpctl exec`.
**Phase E — lifecycle and PR**
18. Task lifecycle state machine, transition API, and `TaskEvent` log.
19. `GitProvider` interface plus the Gitea driver.
20. PR linkage and the human accept gate firing merge through the provider API.
21. QA / Security / SRE stage verdicts, risk-scaled, pinned to a commit.
22. Post-merge failure webhook reopening the task.
**Phase F — cross-cutting**
23. Documentation: `docs/epics.md`, `docs/tasks.md`, `docs/repos.md`, `docs/sessions.md`, the
README resources table, and the CLAUDE.md resource cheatsheet.
24. Smoke tests for every new resource plus the session and lifecycle flows.
**Phase G — deferred placeholders**
25. Tier-2 vision critic and Epic-comparison loop.
26. Collaboration modes: async standup and `exec` joint sessions.
27. Confidence signal and escalation dashboard.
28. Per-agent memory and the coverage ledger.
29. Script artifacts and the baseline scorecard.

View File

@@ -0,0 +1,35 @@
# Task ID: 25
**Title:** Rename local-proxy to mcplocal
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Rename the src/local-proxy directory to src/mcplocal and update all package references, imports, and build configurations throughout the monorepo.
**Details:**
1. Rename directory: `mv src/local-proxy src/mcplocal`
2. Update package.json name from `@mcpctl/local-proxy` to `@mcpctl/mcplocal`
3. Update pnpm-workspace.yaml if needed
4. Update all imports in other packages that reference local-proxy:
- Search for `@mcpctl/local-proxy` and replace with `@mcpctl/mcplocal`
- Check tsconfig references and path mappings
5. Update any scripts in package.json root that reference local-proxy
6. Update docker-compose files in deploy/ if they reference local-proxy
7. Update documentation and README references
8. Run `pnpm install` to regenerate lockfile with new package name
9. Verify TypeScript compilation succeeds: `pnpm build`
10. Run existing tests to ensure nothing broke: `pnpm test`
**Test Strategy:**
1. Verify directory rename completed: `ls src/mcplocal`
2. Verify package.json has correct name
3. Run `pnpm install` - should complete without errors
4. Run `pnpm build` - all packages should compile
5. Run `pnpm test` - all existing tests should pass
6. Grep codebase for 'local-proxy' - should find no stale references except git history

View File

@@ -0,0 +1,46 @@
# Task ID: 26
**Title:** Add HTTP REST server to mcplocal
**Status:** done
**Dependencies:** 25 ✓
**Priority:** high
**Description:** Add a Fastify HTTP server to mcplocal that runs alongside the existing stdio server, providing REST endpoints for mcpctl management commands.
**Details:**
1. Add Fastify dependency to mcplocal package.json: `@fastify/cors`, `fastify`
2. Create `src/mcplocal/src/http/server.ts` with Fastify app setup:
```typescript
import Fastify from 'fastify';
import cors from '@fastify/cors';
export async function createHttpServer(config: HttpServerConfig) {
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
// Register routes
return app;
}
```
3. Create `src/mcplocal/src/http/routes/` directory structure
4. Create health check endpoint: `GET /health`
5. Create config types in `src/mcplocal/src/config.ts`:
- `httpPort`: number (default 3200)
- `httpHost`: string (default '127.0.0.1')
- `mcpdUrl`: string (default 'http://localhost:3100')
6. Update mcplocal entry point to start both servers:
- stdio server for Claude MCP protocol
- HTTP server for mcpctl REST API
7. Add graceful shutdown handling for both servers
**Test Strategy:**
1. Unit test: HTTP server starts on configured port
2. Unit test: Health endpoint returns 200 OK
3. Integration test: Both stdio and HTTP servers can run simultaneously
4. Test graceful shutdown stops both servers cleanly
5. Test CORS headers are present on responses
6. Manual test: curl http://localhost:3200/health

View File

@@ -0,0 +1,130 @@
# Task ID: 27
**Title:** Implement mcplocal management proxy routes
**Status:** done
**Dependencies:** 26 ✓
**Priority:** high
**Description:** Add REST endpoints to mcplocal that mirror mcpd's API and proxy management requests to mcpd without LLM processing. All requests must include proper authentication to mcpd using a Bearer token read from mcplocal config.
**Details:**
1. Create HTTP client for mcpd communication with auth: `src/local-proxy/src/http/mcpd-client.ts`
```typescript
export class McpdClient {
private token: string;
constructor(private baseUrl: string, token: string) {
this.token = token;
}
private getHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
};
}
async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'GET',
headers: this.getHeaders()
});
await this.handleAuthError(response);
return response.json();
}
async post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(body)
});
await this.handleAuthError(response);
return response.json();
}
async put<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: this.getHeaders(),
body: JSON.stringify(body)
});
await this.handleAuthError(response);
return response.json();
}
async delete<T>(path: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'DELETE',
headers: this.getHeaders()
});
await this.handleAuthError(response);
return response.json();
}
private async handleAuthError(response: Response): Promise<void> {
if (response.status === 401) {
throw new AuthenticationError('Invalid or expired token. Please check mcplocal config.');
}
}
}
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
```
2. Add token to mcplocal config type (extend ProxyConfig or similar):
```typescript
export interface McpdAuthConfig {
/** Bearer token for mcpd API authentication */
mcpdToken: string;
}
```
3. Create proxy routes in `src/local-proxy/src/http/routes/`:
- `servers.ts`: GET/POST /api/v1/servers, GET/PUT/DELETE /api/v1/servers/:id
- `profiles.ts`: GET/POST /api/v1/profiles, GET/PUT/DELETE /api/v1/profiles/:id
- `instances.ts`: GET/POST /api/v1/instances, GET/POST/DELETE /api/v1/instances/:id, etc.
- `projects.ts`: GET/POST /api/v1/projects, etc.
- `audit.ts`: GET /api/v1/audit-logs
- `backup.ts`: POST /api/v1/backup, POST /api/v1/restore
4. Each route handler forwards to mcpd with auth:
```typescript
app.get('/api/v1/servers', async (req, reply) => {
try {
const result = await mcpdClient.get('/api/v1/servers');
return result;
} catch (error) {
if (error instanceof AuthenticationError) {
return reply.status(401).send({ error: error.message });
}
throw error;
}
});
```
5. Add comprehensive error handling:
- If mcpd is unreachable, return 503 Service Unavailable
- If mcpd returns 401, return 401 with clear message about token configuration
- Forward other HTTP errors from mcpd with appropriate status codes
6. Add request/response logging for debugging
**Test Strategy:**
1. Unit test: McpdClient attaches Authorization header to all request methods (GET, POST, PUT, DELETE)
2. Unit test: McpdClient throws AuthenticationError on 401 response from mcpd
3. Unit test: Each proxy route forwards requests correctly with auth headers
4. Unit test: Error handling when mcpd is unreachable (503 response)
5. Unit test: Error handling when mcpd returns 401 (clear error message returned)
6. Integration test: Full request flow mcpctl -> mcplocal -> mcpd with valid token
7. Integration test: Full request flow with invalid token returns 401
8. Test query parameters are forwarded correctly
9. Test request body is forwarded correctly for POST/PUT
10. Test path parameters (:id) are passed through correctly
11. Mock mcpd responses and verify mcplocal returns them unchanged
12. Test token is read correctly from mcplocal config

View File

@@ -0,0 +1,149 @@
# Task ID: 28
**Title:** Add MCP proxy endpoint to mcpd
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Create a new endpoint in mcpd at /api/v1/mcp/proxy that accepts MCP tool call requests and executes them on managed MCP server instances. Also add authentication endpoints (login/logout) that mcpctl will use to authenticate users.
**Details:**
## MCP Proxy Endpoint
1. Create new route file: `src/mcpd/src/routes/mcp-proxy.ts`
2. Define request schema:
```typescript
interface McpProxyRequest {
serverId: string; // or instanceId
method: string; // e.g., 'tools/call', 'resources/read'
params: Record<string, unknown>;
}
```
3. Create McpProxyService in `src/mcpd/src/services/mcp-proxy-service.ts`:
- Look up instance by serverId (auto-start if profile allows)
- Connect to the container via stdio or HTTP (depending on transport type)
- Execute the MCP JSON-RPC call
- Return the result
4. Handle MCP JSON-RPC protocol:
```typescript
async executeCall(instanceId: string, method: string, params: unknown) {
const instance = await this.instanceService.getInstance(instanceId);
const connection = await this.getOrCreateConnection(instance);
const result = await connection.call(method, params);
return result;
}
```
5. Connection pooling: maintain persistent connections to running instances
6. Add route: `POST /api/v1/mcp/proxy` (must be behind auth middleware)
7. Add audit logging for all MCP proxy calls - include authenticated userId from request.userId
8. Handle errors: instance not found, instance not running, MCP call failed
## Authentication Endpoints
9. Create auth routes file: `src/mcpd/src/routes/auth.ts`
10. Implement `POST /api/v1/auth/login`:
- Request body: `{ username: string, password: string }`
- Validate credentials against User table (use bcrypt for password comparison)
- Create new Session record with token (use crypto.randomUUID or similar)
- Response: `{ token: string, expiresAt: string }`
11. Implement `POST /api/v1/auth/logout`:
- Requires Bearer token in Authorization header
- Delete/invalidate the Session record
- Response: `{ success: true }`
## Auth Integration Notes
- Existing auth middleware in `src/mcpd/src/middleware/auth.ts` validates Bearer tokens against Session table
- It sets `request.userId` on successful authentication
- MCP proxy endpoint MUST use this auth middleware
- Auth endpoints (login) should NOT require auth middleware
- Logout endpoint SHOULD require auth middleware to validate the session being invalidated
**Test Strategy:**
1. Unit test: Proxy service looks up correct instance
2. Unit test: JSON-RPC call is formatted correctly
3. Integration test: Full flow with a mock MCP server container
4. Test error handling: non-existent server returns 404
5. Test error handling: stopped instance returns appropriate error
6. Test audit log entries include authenticated userId
7. Test connection reuse for multiple calls to same instance
8. Test login endpoint: valid credentials return session token
9. Test login endpoint: invalid credentials return 401
10. Test logout endpoint: valid session is invalidated
11. Test logout endpoint: invalid/missing token returns 401
12. Test MCP proxy endpoint without auth token returns 401
13. Test MCP proxy endpoint with expired token returns 401
14. Test MCP proxy endpoint with valid token succeeds and logs userId in audit
## Subtasks
### 28.1. Create auth routes with login/logout endpoints
**Status:** pending
**Dependencies:** None
Create src/mcpd/src/routes/auth.ts with POST /api/v1/auth/login and POST /api/v1/auth/logout endpoints for mcpctl authentication.
**Details:**
Implement login endpoint: validate username/password against User table using bcrypt, create Session record with generated token and expiry. Implement logout endpoint: require auth middleware, delete/invalidate Session record. Login does NOT require auth, logout DOES require auth. Export registerAuthRoutes function and update routes/index.ts.
### 28.2. Create MCP proxy route file with auth middleware
**Status:** pending
**Dependencies:** 28.1
Create src/mcpd/src/routes/mcp-proxy.ts with POST /api/v1/mcp/proxy endpoint protected by auth middleware.
**Details:**
Define McpProxyRequest interface (serverId, method, params). Register route handler that extracts userId from request.userId (set by auth middleware). Apply auth middleware using preHandler hook. Validate request body schema.
### 28.3. Create McpProxyService for instance lookup and connection
**Status:** pending
**Dependencies:** None
Create src/mcpd/src/services/mcp-proxy-service.ts to handle instance lookup, connection management, and MCP call execution.
**Details:**
Implement getInstance to look up by serverId, auto-start if profile allows. Implement getOrCreateConnection for connection pooling. Handle both stdio and HTTP transports. Implement executeCall method that formats JSON-RPC call and returns result.
### 28.4. Implement MCP JSON-RPC call execution
**Status:** pending
**Dependencies:** 28.3
Implement the core JSON-RPC call logic in McpProxyService to execute tool calls on MCP server instances.
**Details:**
Format JSON-RPC 2.0 request with method and params. Send request over established connection (stdio/HTTP). Parse JSON-RPC response and handle errors. Return result or throw appropriate error for failed calls.
### 28.5. Add audit logging with userId for MCP proxy calls
**Status:** pending
**Dependencies:** 28.2, 28.4
Ensure all MCP proxy calls are logged to audit log including the authenticated userId from the session.
**Details:**
Use existing audit middleware/service. Include userId from request.userId in audit log entry. Log serverId, method, and outcome (success/failure). Log any errors that occur during MCP call execution.
### 28.6. Integrate auth and proxy routes into server.ts
**Status:** pending
**Dependencies:** 28.1, 28.2, 28.5
Register the new auth and mcp-proxy routes in the Fastify server with proper auth middleware wiring.
**Details:**
Update server.ts to register auth routes (no auth required for login). Register mcp-proxy routes with auth middleware. Ensure auth middleware is wired with findSession dependency from Prisma. Update routes/index.ts exports.

View File

@@ -0,0 +1,74 @@
# Task ID: 29
**Title:** Implement LLM pre-processing pipeline in mcplocal
**Status:** done
**Dependencies:** 25 ✓, 27 ✓, 28 ✓
**Priority:** high
**Description:** Create the core LLM pre-processing pipeline that intercepts MCP tool calls, uses a local LLM to optimize requests before sending to mcpd, and filters responses before returning to Claude.
**Details:**
1. Create `src/mcplocal/src/llm/processor.ts` - the core pipeline:
```typescript
export class LlmProcessor {
constructor(
private providerRegistry: ProviderRegistry,
private config: LlmProcessorConfig
) {}
async preprocessRequest(toolName: string, params: unknown): Promise<ProcessedRequest> {
// Use LLM to interpret and optimize the request
const prompt = this.buildRequestPrompt(toolName, params);
const result = await this.providerRegistry.getActiveProvider().complete({
systemPrompt: REQUEST_OPTIMIZATION_SYSTEM_PROMPT,
userPrompt: prompt
});
return this.parseOptimizedRequest(result);
}
async filterResponse(toolName: string, originalRequest: unknown, rawResponse: unknown): Promise<FilteredResponse> {
// Use LLM to filter/summarize the response
const prompt = this.buildFilterPrompt(toolName, originalRequest, rawResponse);
const result = await this.providerRegistry.getActiveProvider().complete({
systemPrompt: RESPONSE_FILTER_SYSTEM_PROMPT,
userPrompt: prompt
});
return this.parseFilteredResponse(result);
}
}
```
2. Create system prompts in `src/mcplocal/src/llm/prompts.ts`:
- REQUEST_OPTIMIZATION_SYSTEM_PROMPT: instruct LLM to generate optimal queries
- RESPONSE_FILTER_SYSTEM_PROMPT: instruct LLM to extract relevant information
3. Integrate into router.ts - wrap tools/call handler:
```typescript
async handleToolsCall(request: JsonRpcRequest) {
if (this.shouldPreprocess(request.params.name)) {
const processed = await this.llmProcessor.preprocessRequest(...);
// Call mcpd with processed request
const rawResponse = await this.callMcpd(processed);
const filtered = await this.llmProcessor.filterResponse(...);
return filtered;
}
return this.callMcpd(request.params);
}
```
4. Add configuration options:
- `enablePreprocessing`: boolean
- `preprocessingExclude`: string[] (tool names to skip)
- `preferredProvider`: string (ollama, gemini, deepseek, etc.)
5. Add bypass logic for simple operations (list, create, delete)
**Test Strategy:**
1. Unit test: Request preprocessing generates optimized queries
2. Unit test: Response filtering reduces data volume
3. Unit test: Bypass logic works for excluded tools
4. Integration test: Full pipeline with mock LLM provider
5. Test error handling: LLM failure falls back to unfiltered pass-through
6. Test configuration options are respected
7. Measure: response size reduction percentage

View File

@@ -0,0 +1,61 @@
# Task ID: 30
**Title:** Add Gemini CLI LLM provider
**Status:** done
**Dependencies:** 25 ✓
**Priority:** medium
**Description:** Implement a new LLM provider that uses the Gemini CLI binary for local, free LLM inference as the preferred provider for pre-processing.
**Details:**
1. Create `src/mcplocal/src/providers/gemini-cli.ts`:
```typescript
import { spawn } from 'child_process';
export class GeminiCliProvider implements LlmProvider {
readonly name = 'gemini-cli';
private binaryPath: string;
constructor(config: GeminiCliConfig) {
this.binaryPath = config.binaryPath || 'gemini';
}
async isAvailable(): Promise<boolean> {
// Check if gemini binary exists and is executable
try {
await this.runCommand(['--version']);
return true;
} catch {
return false;
}
}
async complete(options: CompletionOptions): Promise<CompletionResult> {
const input = this.formatPrompt(options);
const output = await this.runCommand(['--prompt', input]);
return { content: output, model: 'gemini-cli' };
}
private async runCommand(args: string[]): Promise<string> {
// Spawn gemini CLI process and capture output
}
}
```
2. Research actual Gemini CLI interface and adjust implementation
3. Add to provider registry with high priority (prefer over API providers)
4. Add configuration: `geminiCliBinaryPath`
5. Handle timeout for slow inference
6. Add fallback to next provider if Gemini CLI fails
**Test Strategy:**
1. Unit test: Provider correctly detects CLI availability
2. Unit test: Prompt formatting is correct
3. Unit test: Output parsing handles various formats
4. Integration test: Full completion with actual Gemini CLI (if available)
5. Test timeout handling for slow responses
6. Test fallback when CLI is not installed

View File

@@ -0,0 +1,59 @@
# Task ID: 31
**Title:** Add DeepSeek API LLM provider
**Status:** done
**Dependencies:** 25 ✓
**Priority:** medium
**Description:** Implement DeepSeek API provider as a cheap cloud-based fallback when local LLMs are unavailable.
**Details:**
1. Create `src/mcplocal/src/providers/deepseek.ts`:
```typescript
export class DeepSeekProvider implements LlmProvider {
readonly name = 'deepseek';
private apiKey: string;
private baseUrl = 'https://api.deepseek.com/v1';
constructor(config: DeepSeekConfig) {
this.apiKey = config.apiKey || process.env.DEEPSEEK_API_KEY;
}
async isAvailable(): Promise<boolean> {
return !!this.apiKey;
}
async complete(options: CompletionOptions): Promise<CompletionResult> {
// DeepSeek uses OpenAI-compatible API
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: options.userPrompt }]
})
});
// Parse and return
}
}
```
2. Add DEEPSEEK_API_KEY to configuration
3. Register in provider registry with medium priority
4. Support both deepseek-chat and deepseek-coder models
5. Add rate limiting handling
**Test Strategy:**
1. Unit test: Provider correctly checks API key availability
2. Unit test: Request formatting matches DeepSeek API spec
3. Unit test: Response parsing handles all fields
4. Integration test: Full completion with actual API (with valid key)
5. Test error handling for rate limits
6. Test error handling for invalid API key

View File

@@ -0,0 +1,70 @@
# Task ID: 32
**Title:** Implement smart context optimization
**Status:** done
**Dependencies:** 29 ✓
**Priority:** medium
**Description:** Add token counting and decision logic to intelligently skip LLM filtering when responses are small enough, and cache filtering decisions for repeated queries.
**Details:**
1. Create `src/mcplocal/src/llm/token-counter.ts`:
```typescript
export function estimateTokens(text: string): number {
// Simple estimation: ~4 chars per token for English
// More accurate: use tiktoken or similar library
return Math.ceil(text.length / 4);
}
```
2. Create `src/mcplocal/src/llm/filter-cache.ts`:
```typescript
export class FilterCache {
private cache: LRUCache<string, FilterDecision>;
shouldFilter(toolName: string, params: unknown, responseSize: number): boolean {
const key = this.computeKey(toolName, params);
const cached = this.cache.get(key);
if (cached) return cached.shouldFilter;
// No cache hit - use default threshold logic
return responseSize > this.tokenThreshold;
}
recordDecision(toolName: string, params: unknown, decision: FilterDecision): void {
const key = this.computeKey(toolName, params);
this.cache.set(key, decision);
}
}
```
3. Add configuration options:
- `tokenThreshold`: number (default 1000 tokens)
- `filterCacheSize`: number (default 1000 entries)
- `filterCacheTtl`: number (default 3600 seconds)
4. Integrate into LlmProcessor:
```typescript
async filterResponse(...) {
const tokens = estimateTokens(JSON.stringify(rawResponse));
if (tokens < this.config.tokenThreshold) {
// Not worth filtering - return as-is
return { filtered: false, response: rawResponse };
}
// Proceed with LLM filtering
}
```
5. Add metrics tracking:
- Total tokens processed
- Tokens saved by filtering
- Filter cache hit rate
- Average latency added by filtering
**Test Strategy:**
1. Unit test: Token estimation is reasonably accurate
2. Unit test: Cache correctly stores and retrieves decisions
3. Unit test: Threshold logic skips filtering for small responses
4. Unit test: Cache TTL expiration works correctly
5. Integration test: Metrics are recorded accurately
6. Performance test: Cache improves latency for repeated queries

View File

@@ -0,0 +1,171 @@
# Task ID: 33
**Title:** Update mcpctl to use mcplocal as daemon
**Status:** done
**Dependencies:** 27 ✓
**Priority:** high
**Description:** Modify mcpctl CLI to connect to mcplocal instead of mcpd directly, update configuration options, add dual connectivity status checking, and implement authentication commands (login/logout) with secure credential storage.
**Details:**
1. Update `src/cli/src/config/schema.ts`:
```typescript
export interface McpctlConfig {
mcplocalUrl: string; // NEW: default 'http://localhost:3200'
mcpdUrl: string; // Keep for reference/direct access if needed
// ... other fields
}
```
2. Update `src/cli/src/config/defaults.ts`:
- Change default daemonUrl to http://localhost:3200 (mcplocal)
3. Update `src/cli/src/api-client.ts`:
- Default baseUrl now points to mcplocal
4. Add new config commands in `src/cli/src/commands/config.ts`:
```typescript
.command('set-mcplocal-url <url>')
.command('set-mcpd-url <url>')
.command('get-mcplocal-url')
.command('get-mcpd-url')
```
5. Update `src/cli/src/commands/status.ts` to show both connections and auth status:
```
$ mcpctl status
mcplocal: connected (localhost:3200)
mcpd: connected (nas.local:3100) via mcplocal
Auth: logged in as user@example.com
LLM Provider: ollama (llama3.2)
Token savings: 45% (last 24h)
```
6. Update CLI --daemon-url flag to point to mcplocal
7. Add --direct flag to bypass mcplocal and talk to mcpd directly (for debugging)
8. Create `src/cli/src/commands/auth.ts` with login/logout commands:
- `mcpctl login`: Prompt for mcpd URL (if not configured) and credentials
- Call POST /api/v1/auth/login with { email, password }
- Store session token in ~/.mcpctl/credentials with 0600 permissions
- `mcpctl logout`: Invalidate session and delete stored token
9. Create `src/cli/src/auth/credentials.ts` for secure token storage:
- Use fs.chmod to set 0600 permissions on credentials file
- Token format: { token: string, mcpdUrl: string, user: string, expiresAt?: string }
10. Update api-client.ts to include stored token in requests to mcplocal
- mcplocal passes this token to mcpd for authentication
**Test Strategy:**
1. Unit test: Default config points to mcplocal URL
2. Unit test: Config commands update correct fields
3. Integration test: CLI commands work through mcplocal proxy
4. Test status command shows both mcplocal and mcpd status
5. Test --direct flag bypasses mcplocal
6. Test backward compatibility with existing config files
7. Unit test: login command stores token with correct permissions (0600)
8. Unit test: logout command removes credentials file
9. Integration test: login flow with POST /api/v1/auth/login
10. Test status command shows auth status (logged in as user)
11. Test token is passed to mcplocal in API requests
12. Test invalid credentials return appropriate error message
13. Test expired token handling
## Subtasks
### 33.1. Update config schema for mcplocal and mcpd URLs
**Status:** pending
**Dependencies:** None
Modify McpctlConfigSchema in src/cli/src/config/schema.ts to include separate mcplocalUrl and mcpdUrl fields with appropriate defaults.
**Details:**
Update the Zod schema to add mcplocalUrl (default: http://localhost:3200) and mcpdUrl (default: http://localhost:3100). Update DEFAULT_CONFIG and ensure backward compatibility with existing daemonUrl field by mapping it to mcplocalUrl.
### 33.2. Create auth credentials storage module
**Status:** pending
**Dependencies:** None
Create src/cli/src/auth/credentials.ts to handle secure storage and retrieval of session tokens in ~/.mcpctl/credentials.
**Details:**
Implement saveCredentials(token, mcpdUrl, user), loadCredentials(), and deleteCredentials() functions. Use fs.chmod to set 0600 permissions. Store JSON format: { token, mcpdUrl, user, expiresAt }. Handle file not found gracefully in loadCredentials.
### 33.3. Implement login command
**Status:** pending
**Dependencies:** 33.2
Create src/cli/src/commands/auth.ts with mcpctl login command that prompts for mcpd URL and credentials, calls POST /api/v1/auth/login, and stores the session token.
**Details:**
Use inquirer or prompts library for interactive credential input (email, password). If mcpdUrl not configured, prompt for it. Call POST /api/v1/auth/login with credentials. On success, save token using credentials module. Display 'Logged in as {user}' on success. Handle errors (invalid credentials, network errors) with clear messages.
### 33.4. Implement logout command
**Status:** pending
**Dependencies:** 33.2
Add mcpctl logout command to auth.ts that invalidates the session and removes stored credentials.
**Details:**
Load stored credentials, optionally call a logout endpoint on mcpd to invalidate server-side session, then delete the local credentials file. Display 'Logged out successfully' or 'Not logged in' as appropriate.
### 33.5. Update api-client to include auth token
**Status:** pending
**Dependencies:** 33.2
Modify src/cli/src/api-client.ts to load and include stored session token in Authorization header for requests to mcplocal.
**Details:**
Import loadCredentials from auth module. Add Authorization: Bearer {token} header to requests when credentials exist. Handle expired token by returning appropriate error suggesting re-login.
### 33.6. Update status command to show auth status
**Status:** pending
**Dependencies:** 33.2, 33.5
Modify src/cli/src/commands/status.ts to display authentication status (logged in as user X or not logged in) along with mcplocal and mcpd connectivity.
**Details:**
Load credentials and display auth status line: 'Auth: logged in as {user}' or 'Auth: not logged in'. Update status output format to show mcplocal and mcpd status separately with the auth info.
### 33.7. Add config commands for mcplocal and mcpd URLs
**Status:** pending
**Dependencies:** 33.1
Add set-mcplocal-url, set-mcpd-url, get-mcplocal-url, and get-mcpd-url commands to src/cli/src/commands/config.ts.
**Details:**
Add four new subcommands to the config command for setting and getting the mcplocal and mcpd URLs independently. Update the generic 'set' command to handle these new schema fields.
### 33.8. Add --direct flag for mcpd bypass
**Status:** pending
**Dependencies:** 33.1, 33.5
Add --direct flag to CLI commands that bypasses mcplocal and connects directly to mcpd for debugging purposes.
**Details:**
Add global --direct option to the main CLI. When set, api-client uses mcpdUrl instead of mcplocalUrl. Useful for debugging connectivity issues between mcplocal and mcpd.
### 33.9. Register auth commands in CLI entry point
**Status:** pending
**Dependencies:** 33.3, 33.4
Import and register the login and logout commands in src/cli/src/index.ts.
**Details:**
Import createAuthCommand from commands/auth.ts and add it to the main program with program.addCommand(createAuthCommand()).

View File

@@ -0,0 +1,63 @@
# Task ID: 34
**Title:** Connect mcplocal MCP router to mcpd proxy endpoint
**Status:** done
**Dependencies:** 28 ✓, 29 ✓
**Priority:** high
**Description:** Update mcplocal's MCP router to forward tool calls to mcpd's new /api/v1/mcp/proxy endpoint instead of connecting to MCP servers directly.
**Details:**
1. Update `src/mcplocal/src/router.ts` to use mcpd proxy:
```typescript
class Router {
private mcpdClient: McpdClient;
async handleToolsCall(request: JsonRpcRequest) {
const { name, arguments: args } = request.params;
const [serverName, toolName] = name.split('/');
// Pre-process with LLM if enabled
const processedArgs = this.config.enablePreprocessing
? await this.llmProcessor.preprocessRequest(toolName, args)
: args;
// Call mcpd proxy endpoint
const result = await this.mcpdClient.post('/api/v1/mcp/proxy', {
serverId: serverName,
method: 'tools/call',
params: { name: toolName, arguments: processedArgs }
});
// Post-process response with LLM if enabled
return this.config.enablePreprocessing
? await this.llmProcessor.filterResponse(toolName, args, result)
: result;
}
}
```
2. Update upstream configuration:
- Remove direct upstream connections for managed servers
- Keep option for local/unmanaged upstreams
3. Add server discovery from mcpd:
```typescript
async refreshServerList() {
const servers = await this.mcpdClient.get('/api/v1/servers');
this.updateAvailableTools(servers);
}
```
4. Handle tools/list by aggregating from mcpd servers
5. Handle resources/list and prompts/list similarly
**Test Strategy:**
1. Unit test: Tool calls are forwarded to mcpd proxy correctly
2. Unit test: Server name is extracted from namespaced tool name
3. Integration test: Full flow Claude -> mcplocal -> mcpd -> container
4. Test tools/list aggregates from all mcpd servers
5. Test error handling when mcpd is unreachable
6. Test LLM preprocessing is applied when enabled

View File

@@ -0,0 +1,64 @@
# Task ID: 35
**Title:** Implement health monitoring across all tiers
**Status:** done
**Dependencies:** 33 ✓, 34 ✓
**Priority:** medium
**Description:** Extend health monitoring to track connectivity and status across mcplocal, mcpd, and individual MCP server instances.
**Details:**
1. Update mcplocal health monitor in `src/mcplocal/src/health.ts`:
```typescript
export class TieredHealthMonitor {
async checkHealth(): Promise<TieredHealthStatus> {
return {
mcplocal: {
status: 'healthy',
llmProvider: await this.checkLlmProvider(),
uptime: process.uptime()
},
mcpd: await this.checkMcpdHealth(),
instances: await this.checkInstancesHealth()
};
}
private async checkMcpdHealth(): Promise<McpdHealth> {
try {
const health = await this.mcpdClient.get('/api/v1/health');
return { status: 'connected', ...health };
} catch {
return { status: 'disconnected' };
}
}
private async checkInstancesHealth(): Promise<InstanceHealth[]> {
const instances = await this.mcpdClient.get('/api/v1/instances');
return instances.map(i => ({
name: i.name,
status: i.status,
lastHealthCheck: i.lastHealthCheck
}));
}
}
```
2. Add health endpoint to mcplocal HTTP server: `GET /health`
3. Update mcpctl status command to display tiered health
4. Add degraded state detection:
- LLM provider unavailable but mcpd reachable
- Some instances down but others healthy
5. Add health event notifications for state transitions
6. Add configurable health check intervals
**Test Strategy:**
1. Unit test: Health check correctly identifies all states
2. Unit test: Degraded state is detected correctly
3. Integration test: Full health check across all tiers
4. Test health endpoint returns correct format
5. Test mcpctl status displays health correctly
6. Test state transition events are emitted

View File

@@ -0,0 +1,58 @@
# Task ID: 36
**Title:** End-to-end integration testing
**Status:** done
**Dependencies:** 29 ✓, 33 ✓, 34 ✓, 35 ✓
**Priority:** high
**Description:** Create comprehensive integration tests that validate the full data flow from mcpctl through mcplocal to mcpd to MCP server containers and back.
**Details:**
1. Create test fixtures in `src/mcplocal/test/fixtures/`:
- Mock MCP server that returns predictable responses
- Test configuration files
- Sample tool call payloads
2. Create integration test suite in `src/mcplocal/test/integration/`:
```typescript
describe('End-to-end flow', () => {
it('mcpctl -> mcplocal -> mcpd -> mcp_server', async () => {
// Start mock MCP server
// Start mcpd with test config
// Start mcplocal pointing to mcpd
// Execute mcpctl command
// Verify response flows back correctly
});
it('LLM pre-processing reduces response size', async () => {
// Send query that returns large dataset
// Verify LLM filtering reduces token count
// Verify relevant data is preserved
});
it('credentials never leave mcpd', async () => {
// Monitor all traffic from mcplocal
// Verify no credentials appear in requests/responses
});
});
```
3. Test scenarios:
- Management commands (get servers, instances, etc.)
- MCP tool calls with LLM preprocessing
- MCP tool calls without preprocessing
- Error handling (mcpd down, instance down, LLM failure)
- Health monitoring accuracy
4. Add CI integration test workflow
5. Create docker-compose.test.yml for test environment
**Test Strategy:**
1. All integration tests pass in CI environment
2. Test coverage includes happy path and error scenarios
3. Performance benchmarks: measure latency at each tier
4. Security test: verify credential isolation
5. Load test: multiple concurrent requests
6. Chaos test: random component failures

View File

@@ -0,0 +1,36 @@
# Task ID: 37
**Title:** Add priority, summary, chapters, and linkTarget fields to Prompt schema
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Extend the Prisma schema for the Prompt model to include priority (integer 1-10, default 5), summary (nullable string), chapters (nullable JSON array), and linkTarget (nullable string for prompt links).
**Details:**
1. Update `/src/db/prisma/schema.prisma` to add fields to the Prompt model:
- `priority Int @default(5)` with check constraint 1-10
- `summary String? @db.Text`
- `chapters Json?` (stored as JSON array of strings)
- `linkTarget String?` (format: `project/server:resource-uri`)
2. Create Prisma migration:
```bash
pnpm --filter db exec prisma migrate dev --name add-prompt-priority-summary-chapters-link
```
3. Update TypeScript types in shared package to reflect new fields
4. Add validation for priority range (1-10) at the database level if possible, otherwise enforce in application layer
**Test Strategy:**
- Unit test: Verify migration creates columns with correct types and defaults
- Unit test: Verify priority default is 5
- Unit test: Verify nullable fields accept null
- Unit test: Verify chapters stores/retrieves JSON arrays correctly
- Integration test: Create prompt with all new fields, retrieve and verify values

View File

@@ -0,0 +1,35 @@
# Task ID: 38
**Title:** Add priority field to PromptRequest schema
**Status:** done
**Dependencies:** 37 ✓
**Priority:** high
**Description:** Extend the Prisma schema for the PromptRequest model to include the priority field (integer 1-10, default 5) to match the Prompt model.
**Details:**
1. Update `/src/db/prisma/schema.prisma` to add to PromptRequest:
- `priority Int @default(5)`
2. Create Prisma migration:
```bash
pnpm --filter db exec prisma migrate dev --name add-promptrequest-priority
```
3. Update the `CreatePromptRequestSchema` in `/src/mcpd/src/validation/prompt.schema.ts` to include priority validation:
```typescript
priority: z.number().int().min(1).max(10).default(5).optional(),
```
4. Update TypeScript types in shared package
**Test Strategy:**
- Unit test: Migration creates priority column with default 5
- Unit test: PromptRequest creation with explicit priority
- Unit test: PromptRequest creation uses default priority when not specified
- Unit test: Validation rejects priority outside 1-10 range

View File

@@ -0,0 +1,38 @@
# Task ID: 39
**Title:** Add gated field to Project schema
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Extend the Prisma schema for the Project model to include the gated boolean field (default true) that controls whether sessions go through the keyword-driven prompt retrieval flow.
**Details:**
1. Update `/src/db/prisma/schema.prisma` to add to Project:
- `gated Boolean @default(true)`
2. Create Prisma migration:
```bash
pnpm --filter db exec prisma migrate dev --name add-project-gated
```
3. Update project-related TypeScript types
4. Update project validation schemas to include gated field:
```typescript
gated: z.boolean().default(true).optional(),
```
5. Update project API routes to accept and return the gated field
**Test Strategy:**
- Unit test: Migration creates gated column with default true
- Unit test: Project creation with gated=false
- Unit test: Project creation uses default gated=true when not specified
- Unit test: Project update can toggle gated field
- Integration test: GET /api/v1/projects/:name returns gated field

View File

@@ -0,0 +1,49 @@
# Task ID: 40
**Title:** Update Prompt CRUD API to handle priority and linkTarget
**Status:** done
**Dependencies:** 37 ✓
**Priority:** high
**Description:** Modify prompt API endpoints to accept, validate, and return the priority and linkTarget fields. LinkTarget should be immutable after creation.
**Details:**
1. Update `/src/mcpd/src/validation/prompt.schema.ts`:
```typescript
export const CreatePromptSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
priority: z.number().int().min(1).max(10).default(5).optional(),
linkTarget: z.string().regex(/^[a-z0-9-]+\/[a-z0-9-]+:[\S]+$/).optional(),
});
export const UpdatePromptSchema = z.object({
content: z.string().min(1).max(50000).optional(),
priority: z.number().int().min(1).max(10).optional(),
// Note: linkTarget is NOT included - links are immutable
});
```
2. Update `/src/mcpd/src/routes/prompts.ts`:
- POST /api/v1/prompts: Accept priority, linkTarget
- PUT /api/v1/prompts/:id: Accept priority only (not linkTarget)
- GET endpoints: Return priority, linkTarget in response
3. Update repository layer to handle new fields
4. Add linkTarget format validation: `project/server:resource-uri`
**Test Strategy:**
- Unit test: POST /api/v1/prompts with priority creates prompt with correct priority
- Unit test: POST /api/v1/prompts with linkTarget creates linked prompt
- Unit test: PUT /api/v1/prompts/:id with priority updates priority
- Unit test: PUT /api/v1/prompts/:id rejects linkTarget (immutable)
- Unit test: GET /api/v1/prompts returns priority and linkTarget fields
- Unit test: Invalid linkTarget format rejected (validation error)
- Unit test: Priority outside 1-10 range rejected

View File

@@ -0,0 +1,42 @@
# Task ID: 41
**Title:** Update PromptRequest API to handle priority
**Status:** done
**Dependencies:** 38 ✓
**Priority:** high
**Description:** Modify prompt request API endpoints to accept, validate, and return the priority field for proposed prompts.
**Details:**
1. Update validation in `/src/mcpd/src/validation/prompt.schema.ts`:
```typescript
export const CreatePromptRequestSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
createdBySession: z.string().optional(),
createdByUserId: z.string().optional(),
priority: z.number().int().min(1).max(10).default(5).optional(),
});
```
2. Update `/src/mcpd/src/routes/prompts.ts` for PromptRequest endpoints:
- POST /api/v1/promptrequests: Accept priority
- GET /api/v1/promptrequests: Return priority
- POST /api/v1/promptrequests/:id/approve: Preserve priority when creating Prompt
3. Update PromptService.approve() to copy priority from request to prompt
4. Update repository layer
**Test Strategy:**
- Unit test: POST /api/v1/promptrequests with priority creates request with correct priority
- Unit test: POST /api/v1/promptrequests uses default priority 5 when not specified
- Unit test: GET /api/v1/promptrequests returns priority field
- Unit test: Approve preserves priority from request to created prompt
- Unit test: Priority validation (1-10 range)

View File

@@ -0,0 +1,58 @@
# Task ID: 42
**Title:** Implement prompt summary generation service
**Status:** done
**Dependencies:** 37 ✓
**Priority:** high
**Description:** Create a service that auto-generates summary (20 words) and chapters (key sections) for prompts, using fast LLM when available or regex fallback.
**Details:**
1. Create `/src/mcpd/src/services/prompt-summary.service.ts`:
```typescript
export class PromptSummaryService {
constructor(
private llmClient: LlmClient | null,
private promptRepo: IPromptRepository
) {}
async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> {
if (this.llmClient) {
return this.generateWithLlm(content);
}
return this.generateWithRegex(content);
}
private async generateWithLlm(content: string): Promise<...> {
// Send content to fast LLM with prompt:
// "Generate a 20-word summary and extract key section topics..."
}
private generateWithRegex(content: string): { summary: string; chapters: string[] } {
// summary: first sentence of content (truncated to ~20 words)
// chapters: extract markdown headings via regex /^#+\s+(.+)$/gm
}
}
```
2. Integrate with PromptService:
- Call generateSummary on prompt create
- Call generateSummary on prompt update (when content changes)
- Cache results on the prompt record
3. Handle LLM availability check via existing LlmConfig patterns
**Test Strategy:**
- Unit test: generateWithRegex extracts first sentence as summary
- Unit test: generateWithRegex extracts markdown headings as chapters
- Unit test: generateWithLlm calls LLM with correct prompt (mock LLM)
- Unit test: generateSummary uses LLM when available
- Unit test: generateSummary falls back to regex when no LLM
- Unit test: Empty content handled gracefully
- Unit test: Content without headings returns empty chapters array
- Integration test: Creating prompt triggers summary generation

View File

@@ -0,0 +1,41 @@
# Task ID: 43
**Title:** Add regenerate-summary API endpoint
**Status:** done
**Dependencies:** 42 ✓
**Priority:** medium
**Description:** Create POST /api/v1/prompts/:id/regenerate-summary endpoint to force re-generation of summary and chapters for a prompt.
**Details:**
1. Add route in `/src/mcpd/src/routes/prompts.ts`:
```typescript
fastify.post('/api/v1/prompts/:id/regenerate-summary', async (request, reply) => {
const { id } = request.params as { id: string };
const prompt = await promptService.findById(id);
if (!prompt) {
return reply.status(404).send({ error: 'Prompt not found' });
}
const { summary, chapters } = await summaryService.generateSummary(prompt.content);
const updated = await promptService.updateSummary(id, summary, chapters);
return reply.send(updated);
});
```
2. Add `updateSummary(id, summary, chapters)` method to PromptRepository and PromptService
3. Return the updated prompt with new summary/chapters in response
**Test Strategy:**
- Unit test: POST to valid prompt ID regenerates summary
- Unit test: Returns updated prompt with new summary/chapters
- Unit test: 404 for non-existent prompt ID
- Unit test: Uses LLM when available, regex fallback otherwise
- Integration test: End-to-end regeneration updates database

View File

@@ -0,0 +1,48 @@
# Task ID: 44
**Title:** Create prompt-index API endpoint
**Status:** done
**Dependencies:** 42 ✓
**Priority:** medium
**Description:** Create GET /api/v1/projects/:name/prompt-index endpoint that returns a compact index of prompts (name, priority, summary, chapters) for a project.
**Details:**
1. Add route in `/src/mcpd/src/routes/prompts.ts`:
```typescript
fastify.get('/api/v1/projects/:name/prompt-index', async (request, reply) => {
const { name } = request.params as { name: string };
const project = await projectService.findByName(name);
if (!project) {
return reply.status(404).send({ error: 'Project not found' });
}
const prompts = await promptService.findByProject(project.id);
const index = prompts.map(p => ({
name: p.name,
priority: p.priority,
summary: p.summary,
chapters: p.chapters,
linkTarget: p.linkTarget,
}));
return reply.send({ prompts: index });
});
```
2. Consider adding global prompts to the index (inherited by all projects)
3. Sort by priority descending in response
**Test Strategy:**
- Unit test: Returns compact index for valid project
- Unit test: Index contains name, priority, summary, chapters, linkTarget
- Unit test: 404 for non-existent project
- Unit test: Empty array for project with no prompts
- Unit test: Results sorted by priority descending
- Integration test: End-to-end retrieval matches database state

View File

@@ -0,0 +1,73 @@
# Task ID: 45
**Title:** Implement tag-matching algorithm for prompt selection
**Status:** done
**Dependencies:** 44 ✓
**Priority:** high
**Description:** Create a deterministic keyword-based tag matching algorithm as the no-LLM fallback for prompt selection, with byte-budget allocation and priority weighting.
**Details:**
1. Create `/src/mcplocal/src/services/tag-matcher.service.ts`:
```typescript
interface MatchedPrompt {
prompt: PromptIndex;
score: number;
matchedTags: string[];
}
export class TagMatcherService {
constructor(private byteBudget: number = 8192) {}
matchPrompts(tags: string[], promptIndex: PromptIndex[]): {
fullContent: PromptIndex[]; // Prompts to include in full
indexOnly: PromptIndex[]; // Prompts to include as index entries
remaining: PromptIndex[]; // Non-matched prompts (names only)
} {
// 1. Priority 10 prompts: always included (score = Infinity)
// 2. For each prompt, compute score:
// - Check tags against summary + chapters (case-insensitive substring)
// - score = matching_tags_count * priority
// 3. Sort by score descending
// 4. Fill byte budget from top:
// - Include full content until budget exhausted
// - Remaining matched: include as index entries
// - Non-matched: names only
}
private computeScore(tags: string[], prompt: PromptIndex): number {
if (prompt.priority === 10) return Infinity;
const matchingTags = tags.filter(tag =>
this.matchesPrompt(tag.toLowerCase(), prompt)
);
return matchingTags.length * prompt.priority;
}
private matchesPrompt(tag: string, prompt: PromptIndex): boolean {
const searchText = [
prompt.summary || '',
...(prompt.chapters || [])
].join(' ').toLowerCase();
return searchText.includes(tag);
}
}
```
2. Handle edge cases: empty tags, no prompts, all priority 10, etc.
**Test Strategy:**
- Unit test: Priority 10 prompts always included regardless of tags
- Unit test: Score calculation: matching_tags * priority
- Unit test: Case-insensitive matching
- Unit test: Substring matching in summary and chapters
- Unit test: Byte budget exhaustion stops full content inclusion
- Unit test: Matched prompts beyond budget become index entries
- Unit test: Non-matched prompts listed as names only
- Unit test: Sorting by score descending
- Unit test: Empty tags returns priority 10 only
- Unit test: No prompts returns empty result

View File

@@ -0,0 +1,59 @@
# Task ID: 46
**Title:** Implement LLM-based prompt selection service
**Status:** done
**Dependencies:** 45 ✓
**Priority:** high
**Description:** Create a service that uses the heavy LLM to intelligently select relevant prompts based on tags and the full prompt index, understanding synonyms and context.
**Details:**
1. Create `/src/mcplocal/src/services/llm-prompt-selector.service.ts`:
```typescript
export class LlmPromptSelectorService {
constructor(
private llmClient: LlmClient,
private fastLlmClient: LlmClient | null,
private tagMatcher: TagMatcherService // fallback
) {}
async selectPrompts(tags: string[], promptIndex: PromptIndex[]): Promise<{
selected: Array<{ name: string; reason: string }>;
priority10: PromptIndex[]; // Always included
}> {
// 1. Extract priority 10 prompts (always included)
// 2. Generate missing summaries using fast LLM if needed
// 3. Send to heavy LLM:
const prompt = `
Given these keywords: ${tags.join(', ')}
And this prompt index:
${promptIndex.map(p => `- ${p.name}: ${p.summary}`).join('\n')}
Select the most relevant prompts for someone working on tasks
related to these keywords. Consider synonyms and related concepts.
Return a ranked JSON array: [{name: string, reason: string}]
`;
// 4. Parse LLM response
// 5. On LLM error, fall back to tag matcher
}
}
```
2. Handle LLM timeouts and errors gracefully with fallback
3. Validate LLM response format
**Test Strategy:**
- Unit test: Priority 10 prompts always returned regardless of LLM selection
- Unit test: LLM called with correct prompt format (mock)
- Unit test: LLM response parsed correctly
- Unit test: Invalid LLM response falls back to tag matcher
- Unit test: LLM timeout falls back to tag matcher
- Unit test: Missing summaries trigger fast LLM generation
- Unit test: No LLM available uses tag matcher directly
- Integration test: End-to-end selection with mock LLM

View File

@@ -0,0 +1,61 @@
# Task ID: 47
**Title:** Implement session state management for gating
**Status:** done
**Dependencies:** 39 ✓
**Priority:** high
**Description:** Extend the McpRouter to track per-session gating state including gated status, accumulated tags, and retrieved prompts set.
**Details:**
1. Update `/src/mcplocal/src/router.ts` to add session state:
```typescript
interface SessionState {
gated: boolean; // starts true if project is gated
tags: string[]; // accumulated from begin_session + read_prompts
retrievedPrompts: Set<string>; // prompts already sent (avoid duplicates)
}
export class McpRouter {
private sessionStates: Map<string, SessionState> = new Map();
getSessionState(sessionId: string): SessionState {
if (!this.sessionStates.has(sessionId)) {
this.sessionStates.set(sessionId, {
gated: this.projectConfig?.gated ?? true,
tags: [],
retrievedPrompts: new Set(),
});
}
return this.sessionStates.get(sessionId)!;
}
ungateSession(sessionId: string): void {
const state = this.getSessionState(sessionId);
state.gated = false;
}
addRetrievedPrompts(sessionId: string, names: string[]): void {
const state = this.getSessionState(sessionId);
names.forEach(n => state.retrievedPrompts.add(n));
}
}
```
2. Clean up session state when session closes
3. Handle session state for non-gated projects (gated=false from start)
**Test Strategy:**
- Unit test: New session starts with gated=true for gated project
- Unit test: New session starts with gated=false for non-gated project
- Unit test: ungateSession changes gated to false
- Unit test: addRetrievedPrompts adds to set
- Unit test: retrievedPrompts prevents duplicates
- Unit test: Session state isolated per sessionId
- Unit test: Session cleanup removes state

View File

@@ -0,0 +1,67 @@
# Task ID: 48
**Title:** Implement begin_session tool for gated sessions
**Status:** done
**Dependencies:** 46 ✓, 47 ✓
**Priority:** high
**Description:** Create the begin_session MCP tool that accepts 5 keywords, triggers prompt selection, returns matched content with encouragement, and ungates the session.
**Details:**
1. Add begin_session tool definition in `/src/mcplocal/src/router.ts`:
```typescript
private getBeginSessionTool(): Tool {
return {
name: 'begin_session',
description: 'Start your session by providing 5 keywords that describe your current task. You\'ll receive relevant project context, policies, and guidelines. Required before using other tools.',
inputSchema: {
type: 'object',
properties: {
tags: {
type: 'array',
items: { type: 'string' },
maxItems: 10,
description: '5 keywords describing your current task'
}
},
required: ['tags']
}
};
}
```
2. Implement begin_session handler:
- Validate tags array (1-10 items)
- Call LlmPromptSelector or TagMatcher based on LLM availability
- Fetch full content for selected prompts
- Build response with matched content + index + encouragement
- Ungate session
- Send `notifications/tools/list_changed`
3. Response format:
```
[Priority 10 prompts - full content]
[Tag-matched prompts - full content, priority-ordered]
Other prompts available that may become relevant...
- name: summary
...
If any seem related, request them with read_prompts({ tags: [...] }).
```
**Test Strategy:**
- Unit test: begin_session with valid tags returns matched prompts
- Unit test: begin_session includes priority 10 prompts always
- Unit test: begin_session response includes encouragement text
- Unit test: begin_session response includes prompt index
- Unit test: Session ungated after successful begin_session
- Unit test: notifications/tools/list_changed sent after ungating
- Unit test: Empty tags handled (returns priority 10 only)
- Unit test: Invalid tags rejected with error
- Unit test: begin_session while already ungated returns error

View File

@@ -0,0 +1,58 @@
# Task ID: 49
**Title:** Implement read_prompts tool for ongoing retrieval
**Status:** done
**Dependencies:** 48 ✓
**Priority:** high
**Description:** Create the read_prompts MCP tool that allows clients to request additional context by keywords after the session is ungated.
**Details:**
1. Add read_prompts tool definition:
```typescript
private getReadPromptsTool(): Tool {
return {
name: 'read_prompts',
description: 'Request additional project context by keywords. Use this whenever you need guidelines, policies, or conventions related to your current work.',
inputSchema: {
type: 'object',
properties: {
tags: {
type: 'array',
items: { type: 'string' },
description: 'Keywords describing what context you need'
}
},
required: ['tags']
}
};
}
```
2. Implement read_prompts handler:
- Always use keyword matching (not LLM) for precision
- Exclude already-retrieved prompts from response
- Add newly retrieved prompts to session state
- Include reminder about more prompts available
3. Response format:
```
[Matched prompt content - deduplicated]
Remember: you can request more prompts at any time with read_prompts({ tags: [...] }).
The project may have additional guidelines relevant to your current approach.
```
**Test Strategy:**
- Unit test: read_prompts returns matched prompts by keyword
- Unit test: Already retrieved prompts excluded from response
- Unit test: Newly retrieved prompts added to session state
- Unit test: Response includes reminder text
- Unit test: read_prompts while gated returns error
- Unit test: Empty tags returns empty response
- Unit test: Uses keyword matching not LLM

View File

@@ -0,0 +1,55 @@
# Task ID: 50
**Title:** Implement progressive tool exposure for gated sessions
**Status:** done
**Dependencies:** 48 ✓, 49 ✓
**Priority:** high
**Description:** Modify tools/list behavior to only expose begin_session while gated, and expose all tools plus read_prompts after ungating.
**Details:**
1. Update tools/list handling in `/src/mcplocal/src/router.ts`:
```typescript
async handleToolsList(sessionId: string): Promise<Tool[]> {
const state = this.getSessionState(sessionId);
if (state.gated) {
// Only show begin_session while gated
return [this.getBeginSessionTool()];
}
// After ungating: all upstream tools + read_prompts
const upstreamTools = await this.discoverTools();
return [...upstreamTools, this.getReadPromptsTool()];
}
```
2. Block direct tool calls while gated:
```typescript
async handleToolCall(sessionId: string, toolName: string, args: any): Promise<any> {
const state = this.getSessionState(sessionId);
if (state.gated && toolName !== 'begin_session') {
// Intercept: extract keywords, match prompts, inject briefing
return this.handleInterceptedCall(sessionId, toolName, args);
}
// Normal routing
return this.routeToolCall(toolName, args);
}
```
3. Ensure notifications/tools/list_changed is sent after ungating
**Test Strategy:**
- Unit test: tools/list while gated returns only begin_session
- Unit test: tools/list after ungating returns all tools + read_prompts
- Unit test: begin_session not visible after ungating
- Unit test: Tool call while gated (not begin_session) triggers intercept
- Unit test: Tool call after ungating routes normally
- Unit test: notifications/tools/list_changed sent on ungate

View File

@@ -0,0 +1,54 @@
# Task ID: 51
**Title:** Implement keyword extraction from tool calls
**Status:** done
**Dependencies:** None
**Priority:** medium
**Description:** Create a service that extracts keywords from tool names and arguments for the intercept fallback path when clients skip begin_session.
**Details:**
1. Create `/src/mcplocal/src/services/keyword-extractor.service.ts`:
```typescript
export class KeywordExtractorService {
extractKeywords(toolName: string, args: Record<string, any>): string[] {
const keywords: string[] = [];
// Extract from tool name (split on / and -)
// e.g., "home-assistant/get_entities" -> ["home", "assistant", "get", "entities"]
keywords.push(...this.extractFromName(toolName));
// Extract from argument values
// e.g., { domain: "light", entity_id: "light.kitchen" } -> ["light", "kitchen"]
keywords.push(...this.extractFromArgs(args));
// Deduplicate and sanitize
return [...new Set(keywords.map(k => this.sanitize(k)))];
}
private sanitize(keyword: string): string {
// Remove special characters, lowercase, limit length
return keyword.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50);
}
}
```
2. Handle various argument types: strings, arrays, nested objects
3. Prevent injection by sanitizing extracted keywords
**Test Strategy:**
- Unit test: Extracts keywords from tool name with /
- Unit test: Extracts keywords from tool name with -
- Unit test: Extracts keywords from string argument values
- Unit test: Extracts keywords from array argument values
- Unit test: Handles nested object arguments
- Unit test: Sanitizes special characters
- Unit test: Deduplicates keywords
- Unit test: Handles empty arguments
- Unit test: Limits keyword length to prevent abuse

View File

@@ -0,0 +1,63 @@
# Task ID: 52
**Title:** Implement tool call intercept with briefing injection
**Status:** done
**Dependencies:** 50 ✓, 51 ✓
**Priority:** high
**Description:** When a gated session calls a tool without first calling begin_session, intercept the call, extract keywords, match prompts, and inject the briefing alongside the real tool result.
**Details:**
1. Implement handleInterceptedCall in `/src/mcplocal/src/router.ts`:
```typescript
async handleInterceptedCall(
sessionId: string,
toolName: string,
args: any
): Promise<ToolResult> {
// 1. Extract keywords from tool call
const keywords = this.keywordExtractor.extractKeywords(toolName, args);
// 2. Match prompts using keywords
const { fullContent, indexOnly, remaining } =
await this.promptSelector.selectPrompts(keywords, this.promptIndex);
// 3. Execute the actual tool call
const actualResult = await this.routeToolCall(toolName, args);
// 4. Build briefing with intercept preamble
const briefing = this.buildBriefing(fullContent, indexOnly, remaining, 'intercept');
// 5. Ungate session
this.ungateSession(sessionId);
// 6. Send notifications/tools/list_changed
await this.sendToolsListChanged();
// 7. Return combined result
return {
content: [{
type: 'text',
text: `${briefing}\n\n---\n\n${actualResult.content[0].text}`
}]
};
}
```
2. Use gate-intercept-preamble system prompt for the briefing prefix
**Test Strategy:**
- Unit test: Tool call while gated triggers intercept
- Unit test: Keywords extracted from tool name and args
- Unit test: Prompts matched using extracted keywords
- Unit test: Actual tool still executes and returns result
- Unit test: Briefing prepended to tool result
- Unit test: Session ungated after intercept
- Unit test: notifications/tools/list_changed sent
- Unit test: Intercept preamble included in briefing
- Integration test: End-to-end intercept flow

View File

@@ -0,0 +1,54 @@
# Task ID: 53
**Title:** Add prompt index to initialize instructions
**Status:** done
**Dependencies:** 47 ✓, 44 ✓
**Priority:** high
**Description:** Modify the initialize handler to include the compact prompt index and gate message in instructions for gated projects.
**Details:**
1. Update initialize handling in `/src/mcplocal/src/router.ts`:
```typescript
async handleInitialize(sessionId: string): Promise<InitializeResult> {
const state = this.getSessionState(sessionId);
let instructions = this.projectConfig.prompt || '';
if (state.gated) {
// Add gate instructions
const gateInstructions = await this.getSystemPrompt('gate-instructions');
// Build prompt index (cap at 50, priority 7+ if over)
const index = this.buildPromptIndex();
instructions += `\n\n${gateInstructions.replace('{{prompt_index}}', index)}`;
}
return {
protocolVersion: '2024-11-05',
capabilities: { ... },
serverInfo: { ... },
instructions,
};
}
```
2. Build prompt index with cap:
- If <= 50 prompts: include all
- If > 50 prompts: include only priority 7+
- Format: `- <name>: <summary>` (~100 chars per entry)
**Test Strategy:**
- Unit test: Gated project includes gate instructions in initialize
- Unit test: Prompt index included in instructions
- Unit test: Index capped at 50 entries
- Unit test: Over 50 prompts shows priority 7+ only
- Unit test: Non-gated project skips gate instructions
- Unit test: {{prompt_index}} template replaced
- Integration test: End-to-end initialize with gated project

View File

@@ -0,0 +1,58 @@
# Task ID: 54
**Title:** Create mcpctl-system project with system prompts
**Status:** done
**Dependencies:** 40 ✓, 39 ✓
**Priority:** high
**Description:** Implement bootstrap logic to create the mcpctl-system project and its required system prompts on first startup, with protection against deletion.
**Details:**
1. Create seed migration or startup hook:
```typescript
async function bootstrapSystemProject() {
const systemProject = await projectRepo.findByName('mcpctl-system');
if (systemProject) return; // Already exists
// Create mcpctl-system project
const project = await projectRepo.create({
name: 'mcpctl-system',
description: 'System prompts for mcpctl gating and encouragement',
gated: false, // System project is not gated
ownerId: SYSTEM_USER_ID,
});
// Create required system prompts
const systemPrompts = [
{ name: 'gate-instructions', priority: 10, content: GATE_INSTRUCTIONS },
{ name: 'gate-encouragement', priority: 10, content: GATE_ENCOURAGEMENT },
{ name: 'read-prompts-reminder', priority: 10, content: READ_PROMPTS_REMINDER },
{ name: 'gate-intercept-preamble', priority: 10, content: GATE_INTERCEPT_PREAMBLE },
{ name: 'session-greeting', priority: 10, content: SESSION_GREETING },
];
for (const p of systemPrompts) {
await promptRepo.create({ ...p, projectId: project.id });
}
}
```
2. Add delete protection in prompt delete endpoint:
- Check if prompt belongs to mcpctl-system
- Return 403 error if attempting to delete system prompt
3. Define default content for each system prompt per PRD
**Test Strategy:**
- Unit test: System project created on first startup
- Unit test: All 5 system prompts created
- Unit test: Subsequent startups don't duplicate
- Unit test: Delete system prompt returns 403
- Unit test: System prompts have priority 10
- Unit test: mcpctl-system project has gated=false
- Integration test: End-to-end bootstrap flow

View File

@@ -0,0 +1,61 @@
# Task ID: 55
**Title:** Implement system prompt fetching and caching in mcplocal
**Status:** done
**Dependencies:** 54 ✓
**Priority:** high
**Description:** Add functionality to mcplocal router to fetch system prompts from mcpd and cache them with 60s TTL, supporting template variable replacement.
**Details:**
1. Add system prompt fetching in `/src/mcplocal/src/router.ts`:
```typescript
private systemPromptCache: Map<string, { content: string; expiresAt: number }> = new Map();
async getSystemPrompt(name: string): Promise<string> {
const cached = this.systemPromptCache.get(name);
if (cached && cached.expiresAt > Date.now()) {
return cached.content;
}
const prompts = await this.mcpdClient.fetch(
'/api/v1/projects/mcpctl-system/prompts/visible'
);
const prompt = prompts.find(p => p.name === name);
if (!prompt) {
throw new Error(`System prompt not found: ${name}`);
}
this.systemPromptCache.set(name, {
content: prompt.content,
expiresAt: Date.now() + 60000, // 60s TTL
});
return prompt.content;
}
```
2. Add template variable replacement:
```typescript
replaceTemplateVariables(content: string, vars: Record<string, string>): string {
return content
.replace(/\{\{prompt_index\}\}/g, vars.prompt_index || '')
.replace(/\{\{project_name\}\}/g, vars.project_name || '')
.replace(/\{\{matched_prompts\}\}/g, vars.matched_prompts || '')
.replace(/\{\{remaining_prompts\}\}/g, vars.remaining_prompts || '');
}
```
**Test Strategy:**
- Unit test: System prompt fetched from mcpd
- Unit test: Cached prompt returned within TTL
- Unit test: Cache miss triggers fresh fetch
- Unit test: Missing system prompt throws error
- Unit test: Template variables replaced correctly
- Unit test: Unknown template variables left as-is
- Integration test: End-to-end fetch and cache

View File

@@ -0,0 +1,67 @@
# Task ID: 56
**Title:** Implement prompt link resolution service
**Status:** done
**Dependencies:** 40 ✓
**Priority:** medium
**Description:** Create a service that fetches linked prompt content from source MCP servers using the project's service account, with dead link detection.
**Details:**
1. Create `/src/mcplocal/src/services/link-resolver.service.ts`:
```typescript
export class LinkResolverService {
constructor(private mcpdClient: McpdClient) {}
async resolveLink(linkTarget: string): Promise<{
content: string | null;
status: 'alive' | 'dead' | 'unknown';
error?: string;
}> {
// Parse linkTarget: project/server:resource-uri
const { project, server, uri } = this.parseLink(linkTarget);
try {
// Use service account for source project
const content = await this.fetchResource(project, server, uri);
return { content, status: 'alive' };
} catch (error) {
this.logDeadLink(linkTarget, error);
return {
content: null,
status: 'dead',
error: error.message
};
}
}
private parseLink(linkTarget: string): { project: string; server: string; uri: string } {
const match = linkTarget.match(/^([^/]+)\/([^:]+):(.+)$/);
if (!match) throw new Error('Invalid link format');
return { project: match[1], server: match[2], uri: match[3] };
}
private async fetchResource(project: string, server: string, uri: string): Promise<string> {
// Call mcpd to fetch resource via service account
// mcpd routes to the source project's MCP server
}
}
```
2. Log dead links as errors
3. Cache resolution results
**Test Strategy:**
- Unit test: Valid link parsed correctly
- Unit test: Invalid link format throws error
- Unit test: Successful resolution returns content and status='alive'
- Unit test: Failed resolution returns status='dead' with error
- Unit test: Dead link logged as error
- Unit test: Service account header included in request
- Integration test: End-to-end link resolution

View File

@@ -0,0 +1,50 @@
# Task ID: 57
**Title:** Add linkStatus to prompt GET responses
**Status:** done
**Dependencies:** 56 ✓
**Priority:** medium
**Description:** Modify the GET /api/v1/prompts endpoint to include linkStatus (alive/dead/unknown) for linked prompts by checking link health.
**Details:**
1. Update `/src/mcpd/src/routes/prompts.ts` GET endpoint:
```typescript
fastify.get('/api/v1/prompts', async (request, reply) => {
const prompts = await promptService.findAll(filter);
// Check link status for linked prompts
const promptsWithStatus = await Promise.all(
prompts.map(async (p) => {
if (!p.linkTarget) {
return { ...p, linkStatus: null };
}
const status = await linkResolver.checkLinkHealth(p.linkTarget);
return { ...p, linkStatus: status };
})
);
return reply.send(promptsWithStatus);
});
```
2. Consider caching link health to avoid repeated checks
3. Add `linkStatus` field to prompt response schema:
- `null` for non-linked prompts
- `'alive'` for working links
- `'dead'` for broken links
- `'unknown'` for unchecked links
**Test Strategy:**
- Unit test: Non-linked prompt has linkStatus=null
- Unit test: Linked prompt with working link has linkStatus='alive'
- Unit test: Linked prompt with broken link has linkStatus='dead'
- Unit test: Link health cached to avoid repeated checks
- Unit test: All prompts in response have linkStatus field
- Integration test: End-to-end GET with linked prompts

View File

@@ -0,0 +1,55 @@
# Task ID: 58
**Title:** Add RBAC for prompt link creation
**Status:** done
**Dependencies:** 56 ✓
**Priority:** medium
**Description:** Implement RBAC checks requiring edit permission on the target project to create prompt links, and auto-create service account permission on the source project.
**Details:**
1. Update prompt creation in `/src/mcpd/src/services/prompt.service.ts`:
```typescript
async createPrompt(data: CreatePromptInput, userId: string): Promise<Prompt> {
if (data.linkTarget) {
// Verify user has edit permission on target project RBAC
const hasPermission = await this.rbacService.checkPermission(
userId, data.projectId, 'edit'
);
if (!hasPermission) {
throw new ForbiddenError('Edit permission required to create prompt links');
}
// Parse link target
const { project: sourceProject, server, uri } = this.parseLink(data.linkTarget);
// Create service account permission on source project
await this.rbacService.createServiceAccountPermission(
data.projectId, // target project
sourceProject, // source project
server,
uri,
'read'
);
}
return this.promptRepo.create(data);
}
```
2. Clean up service account permission when link is deleted
3. Handle permission denied from source project
**Test Strategy:**
- Unit test: Link creation requires edit permission
- Unit test: Link creation without permission throws 403
- Unit test: Service account permission created on source project
- Unit test: Service account permission deleted when link deleted
- Unit test: Non-link prompts skip RBAC checks
- Integration test: End-to-end link creation with RBAC

View File

@@ -0,0 +1,64 @@
# Task ID: 59
**Title:** Update CLI create prompt command for priority and link
**Status:** done
**Dependencies:** 40 ✓
**Priority:** medium
**Description:** Extend the mcpctl create prompt command to accept --priority (1-10) and --link (project/server:uri) flags.
**Details:**
1. Update `/src/cli/src/commands/create.ts` for prompt:
```typescript
.command('prompt <name>')
.description('Create a new prompt')
.option('-p, --project <name>', 'Project to create prompt in')
.option('--priority <number>', 'Priority level (1-10, default: 5)', '5')
.option('--link <target>', 'Link to MCP resource (project/server:uri)')
.option('-f, --file <path>', 'Read content from file')
.action(async (name, options) => {
const priority = parseInt(options.priority, 10);
if (priority < 1 || priority > 10) {
console.error('Priority must be between 1 and 10');
process.exit(1);
}
let content = '';
if (options.link) {
// Linked prompts don't need content (fetched from source)
content = `[Link: ${options.link}]`;
} else if (options.file) {
content = await fs.readFile(options.file, 'utf-8');
} else {
content = await promptForContent();
}
const body = {
name,
content,
projectId: options.project,
priority,
linkTarget: options.link,
};
await api.post('/api/v1/prompts', body);
});
```
2. Validate link format: `project/server:resource-uri`
3. Add shell completions for new flags
**Test Strategy:**
- Unit test: --priority flag sets prompt priority
- Unit test: --priority validation (1-10 range)
- Unit test: --link flag sets linkTarget
- Unit test: --link validation (format check)
- Unit test: Linked prompt skips content prompt
- Unit test: Default priority is 5
- Integration test: End-to-end create with flags

View File

@@ -0,0 +1,54 @@
# Task ID: 60
**Title:** Update CLI get prompt command for -A flag and link columns
**Status:** done
**Dependencies:** 57 ✓, 59 ✓
**Priority:** medium
**Description:** Extend the mcpctl get prompt command with -A (all projects) flag and add link target and status columns to output.
**Details:**
1. Update `/src/cli/src/commands/get.ts` for prompt:
```typescript
.command('prompt [name]')
.option('-A, --all-projects', 'Show prompts from all projects')
.option('-p, --project <name>', 'Filter by project')
.action(async (name, options) => {
let url = '/api/v1/prompts';
if (options.allProjects) {
url += '?all=true';
} else if (options.project) {
url += `?project=${options.project}`;
}
const prompts = await api.get(url);
// Format table with new columns
formatPromptsTable(prompts, {
columns: ['PROJECT', 'NAME', 'PRIORITY', 'LINK', 'STATUS']
});
});
```
2. Update table formatter to handle link columns:
```
PROJECT NAME PRIORITY LINK STATUS
homeautomation security-policies 8 - -
homeautomation architecture-adr 6 system-public/docmost-mcp:docmost://pages/a1 alive
```
3. Add shell completions for -A flag
**Test Strategy:**
- Unit test: -A flag shows all projects
- Unit test: --project flag filters by project
- Unit test: PRIORITY column displayed
- Unit test: LINK column shows linkTarget or -
- Unit test: STATUS column shows linkStatus or -
- Unit test: Table formatted correctly
- Integration test: End-to-end get with flags

View File

@@ -0,0 +1,49 @@
# Task ID: 61
**Title:** Update CLI describe project command for gated status
**Status:** done
**Dependencies:** 44 ✓, 54 ✓
**Priority:** medium
**Description:** Extend mcpctl describe project to show gated status, session greeting, and prompt table with priority and link information.
**Details:**
1. Update `/src/cli/src/commands/get.ts` describe project:
```typescript
async function describeProject(name: string) {
const project = await api.get(`/api/v1/projects/${name}`);
const prompts = await api.get(`/api/v1/projects/${name}/prompt-index`);
const greeting = await getSessionGreeting(name);
console.log(`Name: ${project.name}`);
console.log(`Gated: ${project.gated}`);
console.log(`LLM Provider: ${project.llmProvider || '-'}`);
console.log(`...`);
console.log();
console.log(`Session greeting:`);
console.log(` ${greeting}`);
console.log();
console.log(`Prompts:`);
console.log(` NAME PRIORITY TYPE LINK`);
for (const p of prompts) {
const type = p.linkTarget ? 'link' : 'local';
const link = p.linkTarget || '-';
console.log(` ${p.name.padEnd(20)} ${p.priority.toString().padEnd(9)} ${type.padEnd(7)} ${link}`);
}
}
```
2. Fetch session greeting from system prompts or project config
**Test Strategy:**
- Unit test: Gated status displayed
- Unit test: Session greeting displayed
- Unit test: Prompt table with PRIORITY, TYPE, LINK columns
- Unit test: TYPE shows 'local' or 'link'
- Unit test: LINK shows target or -
- Integration test: End-to-end describe project

View File

@@ -0,0 +1,51 @@
# Task ID: 62
**Title:** Update CLI edit project command for gated field
**Status:** done
**Dependencies:** 39 ✓
**Priority:** medium
**Description:** Extend mcpctl edit project to allow editing the gated boolean field.
**Details:**
1. Update `/src/cli/src/commands/edit.ts` for project:
```typescript
async function editProject(name: string) {
const project = await api.get(`/api/v1/projects/${name}`);
// Add gated to editable fields
const yaml = `
name: ${project.name}
description: ${project.description}
gated: ${project.gated}
llmProvider: ${project.llmProvider || ''}
...`;
const edited = await openEditor(yaml);
const parsed = YAML.parse(edited);
// Validate gated is boolean
if (typeof parsed.gated !== 'boolean') {
console.error('gated must be true or false');
process.exit(1);
}
await api.put(`/api/v1/projects/${name}`, parsed);
}
```
2. Update project validation schema to accept gated
3. Handle conversion from string 'true'/'false' to boolean
**Test Strategy:**
- Unit test: Gated field appears in editor YAML
- Unit test: Gated field saved on edit
- Unit test: Boolean validation (true/false only)
- Unit test: String 'true'/'false' converted to boolean
- Integration test: End-to-end edit project gated

View File

@@ -0,0 +1,68 @@
# Task ID: 63
**Title:** Add unit tests for prompt priority and link CRUD
**Status:** done
**Dependencies:** 40 ✓, 41 ✓
**Priority:** high
**Description:** Create comprehensive unit tests for all prompt CRUD operations with the new priority and linkTarget fields.
**Details:**
1. Add tests in `/src/mcpd/tests/services/prompt-service.test.ts`:
```typescript
describe('Prompt Priority', () => {
it('creates prompt with explicit priority', async () => {
const prompt = await service.createPrompt({ ...data, priority: 8 });
expect(prompt.priority).toBe(8);
});
it('uses default priority 5 when not specified', async () => {
const prompt = await service.createPrompt(data);
expect(prompt.priority).toBe(5);
});
it('validates priority range 1-10', async () => {
await expect(service.createPrompt({ ...data, priority: 11 }))
.rejects.toThrow();
});
it('updates priority', async () => {
const updated = await service.updatePrompt(id, { priority: 3 });
expect(updated.priority).toBe(3);
});
});
describe('Prompt Links', () => {
it('creates linked prompt', async () => {
const prompt = await service.createPrompt({
...data,
linkTarget: 'project/server:uri'
});
expect(prompt.linkTarget).toBe('project/server:uri');
});
it('rejects invalid link format', async () => {
await expect(service.createPrompt({
...data,
linkTarget: 'invalid'
})).rejects.toThrow();
});
it('linkTarget is immutable on update', async () => {
// linkTarget not accepted in update schema
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- All priority CRUD tests pass
- All link CRUD tests pass
- Validation tests cover edge cases
- Tests use proper mocking patterns
- Coverage meets project standards

View File

@@ -0,0 +1,58 @@
# Task ID: 64
**Title:** Add unit tests for tag matching algorithm
**Status:** done
**Dependencies:** 45 ✓
**Priority:** high
**Description:** Create comprehensive unit tests for the deterministic tag matching algorithm covering score calculation, byte budget, and priority handling.
**Details:**
1. Add tests in `/src/mcplocal/tests/services/tag-matcher.test.ts`:
```typescript
describe('TagMatcherService', () => {
describe('score calculation', () => {
it('priority 10 prompts have infinite score', () => {
const score = matcher.computeScore(['any'], { priority: 10, ... });
expect(score).toBe(Infinity);
});
it('score = matching_tags * priority', () => {
const score = matcher.computeScore(
['tag1', 'tag2'],
{ priority: 5, summary: 'tag1 tag2', chapters: [] }
);
expect(score).toBe(10); // 2 tags * 5 priority
});
});
describe('matching', () => {
it('matches case-insensitively', () => {
const matches = matcher.matchesPrompt('ZIGBEE', { summary: 'zigbee setup' });
expect(matches).toBe(true);
});
it('matches substring in summary', () => { ... });
it('matches substring in chapters', () => { ... });
});
describe('byte budget', () => {
it('includes full content until budget exhausted', () => { ... });
it('matched prompts beyond budget become index entries', () => { ... });
it('non-matched prompts listed as names only', () => { ... });
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- Score calculation tests pass
- Matching tests cover all cases
- Byte budget tests verify allocation
- Edge cases handled (empty tags, no prompts, etc.)
- Tests are deterministic

View File

@@ -0,0 +1,63 @@
# Task ID: 65
**Title:** Add unit tests for gating state machine
**Status:** done
**Dependencies:** 50 ✓, 52 ✓
**Priority:** high
**Description:** Create comprehensive unit tests for the session gating state machine covering all transitions and edge cases.
**Details:**
1. Add tests in `/src/mcplocal/tests/router-gating.test.ts`:
```typescript
describe('Gating State Machine', () => {
describe('initial state', () => {
it('starts gated for gated project', () => {
const router = createRouter({ gated: true });
const state = router.getSessionState('session1');
expect(state.gated).toBe(true);
});
it('starts ungated for non-gated project', () => {
const router = createRouter({ gated: false });
const state = router.getSessionState('session1');
expect(state.gated).toBe(false);
});
});
describe('begin_session transition', () => {
it('ungates session on successful begin_session', async () => {
const router = createGatedRouter();
await router.handleBeginSession('session1', { tags: ['test'] });
expect(router.getSessionState('session1').gated).toBe(false);
});
it('returns matched prompts', async () => { ... });
it('sends notifications/tools/list_changed', async () => { ... });
});
describe('intercept transition', () => {
it('ungates session on tool call intercept', async () => { ... });
it('extracts keywords from tool call', async () => { ... });
it('injects briefing with tool result', async () => { ... });
});
describe('tools/list behavior', () => {
it('returns only begin_session while gated', async () => { ... });
it('returns all tools + read_prompts after ungating', async () => { ... });
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- Initial state tests pass
- Transition tests cover happy paths
- Edge case tests (already ungated, etc.)
- Notification tests verify signals sent
- Tests use proper mocking

View File

@@ -0,0 +1,61 @@
# Task ID: 66
**Title:** Add unit tests for LLM prompt selection
**Status:** done
**Dependencies:** 46 ✓
**Priority:** high
**Description:** Create unit tests for the LLM-based prompt selection service covering LLM interactions, fallback behavior, and priority 10 handling.
**Details:**
1. Add tests in `/src/mcplocal/tests/services/llm-prompt-selector.test.ts`:
```typescript
describe('LlmPromptSelectorService', () => {
describe('priority 10 handling', () => {
it('always includes priority 10 prompts', async () => {
const result = await selector.selectPrompts(['unrelated'], promptIndex);
expect(result.priority10).toContain(priority10Prompt);
});
});
describe('LLM selection', () => {
it('sends tags and index to heavy LLM', async () => {
await selector.selectPrompts(['zigbee', 'mqtt'], promptIndex);
expect(mockLlm.complete).toHaveBeenCalledWith(
expect.stringContaining('zigbee')
);
});
it('parses LLM response correctly', async () => {
mockLlm.complete.mockResolvedValue(
'[{"name": "prompt1", "reason": "relevant"}]'
);
const result = await selector.selectPrompts(['test'], promptIndex);
expect(result.selected[0].name).toBe('prompt1');
});
});
describe('fallback behavior', () => {
it('falls back to tag matcher on LLM error', async () => { ... });
it('falls back on LLM timeout', async () => { ... });
it('falls back when no LLM available', async () => { ... });
});
describe('summary generation', () => {
it('generates missing summaries with fast LLM', async () => { ... });
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- Priority 10 tests pass
- LLM interaction tests use proper mocks
- Fallback tests cover all error scenarios
- Summary generation tests pass
- Response parsing handles edge cases

View File

@@ -0,0 +1,64 @@
# Task ID: 67
**Title:** Add integration tests for gated session flow
**Status:** done
**Dependencies:** 65 ✓
**Priority:** high
**Description:** Create end-to-end integration tests for the complete gated session flow including connect, begin_session, tool calls, and read_prompts.
**Details:**
1. Add tests in `/src/mcplocal/tests/integration/gated-flow.test.ts`:
```typescript
describe('Gated Session Flow Integration', () => {
let app: FastifyInstance;
let mcpClient: McpClient;
beforeAll(async () => {
app = await createTestApp();
// Seed test project with gated=true and test prompts
});
describe('end-to-end gated flow', () => {
it('connect → begin_session with tags → tools available → correct prompts', async () => {
// 1. Connect to MCP endpoint
const session = await mcpClient.connect(app, 'test-project');
// 2. Verify only begin_session available
const toolsBefore = await session.listTools();
expect(toolsBefore.map(t => t.name)).toEqual(['begin_session']);
// 3. Call begin_session
const briefing = await session.callTool('begin_session', {
tags: ['test', 'integration']
});
expect(briefing).toContain('matched prompt content');
// 4. Verify all tools now available
const toolsAfter = await session.listTools();
expect(toolsAfter.map(t => t.name)).toContain('read_prompts');
});
});
describe('end-to-end intercept flow', () => {
it('connect → skip begin_session → call tool → keywords extracted → briefing injected', async () => { ... });
});
describe('end-to-end read_prompts', () => {
it('after ungating → request more context → additional prompts → no duplicates', async () => { ... });
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- Happy path tests pass
- Intercept path tests pass
- read_prompts deduplication works
- Tests use realistic data
- Tests clean up properly

View File

@@ -0,0 +1,58 @@
# Task ID: 68
**Title:** Add integration tests for prompt links
**Status:** done
**Dependencies:** 57 ✓, 58 ✓
**Priority:** medium
**Description:** Create end-to-end integration tests for prompt link creation, resolution, and dead link detection.
**Details:**
1. Add tests in `/src/mcplocal/tests/integration/prompt-links.test.ts`:
```typescript
describe('Prompt Links Integration', () => {
describe('link creation', () => {
it('creates link with RBAC permission', async () => {
// Setup: user with edit permission on target project
const prompt = await api.post('/api/v1/prompts', {
name: 'linked-prompt',
content: '[Link]',
projectId: targetProject.id,
linkTarget: 'source-project/server:uri'
});
expect(prompt.linkTarget).toBe('source-project/server:uri');
});
it('rejects link creation without RBAC permission', async () => { ... });
});
describe('link resolution', () => {
it('fetches content from source server', async () => { ... });
it('uses service account for RBAC', async () => { ... });
});
describe('dead link lifecycle', () => {
it('detects dead link when source unavailable', async () => {
// Kill source server
const prompts = await api.get('/api/v1/prompts');
const linked = prompts.find(p => p.linkTarget);
expect(linked.linkStatus).toBe('dead');
});
it('recovers when source restored', async () => { ... });
});
});
```
**Test Strategy:**
This task IS the test implementation. Verify:
- RBAC tests cover permission scenarios
- Resolution tests verify content fetched
- Dead link tests cover full lifecycle
- Tests properly mock/control source servers
- Tests clean up resources

View File

@@ -0,0 +1,59 @@
# Task ID: 69
**Title:** Add CLI unit tests for new prompt and project flags
**Status:** done
**Dependencies:** 59 ✓, 60 ✓, 61 ✓, 62 ✓
**Priority:** medium
**Description:** Create unit tests for the new CLI flags: --priority, --link for prompts, -A for get, and gated field for projects.
**Details:**
1. Add tests in `/src/cli/tests/commands/prompt.test.ts`:
```typescript
describe('create prompt command', () => {
it('--priority sets prompt priority', async () => {
await cli('create prompt test --priority 8');
expect(mockApi.post).toHaveBeenCalledWith(
'/api/v1/prompts',
expect.objectContaining({ priority: 8 })
);
});
it('--priority validates range 1-10', async () => {
await expect(cli('create prompt test --priority 15'))
.rejects.toThrow('Priority must be between 1 and 10');
});
it('--link sets linkTarget', async () => {
await cli('create prompt test --link proj/srv:uri');
expect(mockApi.post).toHaveBeenCalledWith(
'/api/v1/prompts',
expect.objectContaining({ linkTarget: 'proj/srv:uri' })
);
});
});
describe('get prompt command', () => {
it('-A shows all projects', async () => {
await cli('get prompt -A');
expect(mockApi.get).toHaveBeenCalledWith('/api/v1/prompts?all=true');
});
});
```
2. Add tests for project gated field editing
3. Add tests for describe project output
**Test Strategy:**
This task IS the test implementation. Verify:
- Flag parsing tests pass
- Validation tests cover edge cases
- API call tests verify correct parameters
- Output formatting tests verify columns
- Tests mock API properly

View File

@@ -0,0 +1,37 @@
# Task ID: 70
**Title:** Add shell completions for new CLI flags
**Status:** done
**Dependencies:** 59 ✓, 60 ✓
**Priority:** low
**Description:** Update shell completion scripts (bash, zsh, fish) to include completions for new flags: --priority, --link, -A, and gated values.
**Details:**
1. Update `/completions/mcpctl.fish`:
```fish
# create prompt completions
complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l priority -d 'Priority level (1-10)' -a '(seq 1 10)'
complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l link -d 'Link to MCP resource (project/server:uri)'
# get prompt completions
complete -c mcpctl -n '__fish_seen_subcommand_from get; and __fish_seen_subcommand_from prompt' -s A -l all-projects -d 'Show prompts from all projects'
```
2. Update bash completions similarly
3. Update zsh completions similarly
4. Add dynamic completion for priority values (1-10)
**Test Strategy:**
- Manual test: Fish completions suggest --priority with values 1-10
- Manual test: Fish completions suggest --link flag
- Manual test: Fish completions suggest -A/--all-projects
- Manual test: Bash completions work similarly
- Manual test: Zsh completions work similarly

View File

@@ -0,0 +1,70 @@
# Task ID: 71
**Title:** Define ProxyModel Public Type Contract
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Create the core TypeScript types for the ProxyModel framework that stages will import from `mcpctl/proxymodel`. This establishes the public API contract that stage authors write against.
**Details:**
Create `src/mcplocal/src/proxymodel/types.ts` with:
```typescript
export interface StageHandler {
(content: string, ctx: StageContext): Promise<StageResult>;
}
export interface StageContext {
contentType: 'prompt' | 'toolResult' | 'resource';
sourceName: string;
projectName: string;
sessionId: string;
originalContent: string;
llm: LLMProvider;
cache: CacheProvider;
log: Logger;
config: Record<string, unknown>;
}
export interface StageResult {
content: string;
sections?: Section[];
metadata?: Record<string, unknown>;
}
export interface Section {
id: string;
title: string;
content: string;
}
export interface LLMProvider {
complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise<string>;
available(): boolean;
}
export interface CacheProvider {
getOrCompute(key: string, compute: () => Promise<string>): Promise<string>;
hash(content: string): string;
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
}
export interface Logger {
debug(msg: string): void;
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
```
Also create `src/mcplocal/src/proxymodel/index.ts` as the public entrypoint that re-exports these types. Update `package.json` exports to expose `mcpctl/proxymodel`.
**Test Strategy:**
Unit tests verifying type exports are accessible from the public entrypoint. Create a sample stage file that imports from `mcpctl/proxymodel` and verify it compiles without errors.

View File

@@ -0,0 +1,49 @@
# Task ID: 72
**Title:** Implement LLMProvider Adapter
**Status:** done
**Dependencies:** 71 ✓
**Priority:** high
**Description:** Create an adapter that wraps the existing ProviderRegistry to implement the StageContext.llm interface, providing stages with a simplified LLM access API.
**Details:**
Create `src/mcplocal/src/proxymodel/llm-adapter.ts`:
```typescript
import type { LLMProvider } from './types';
import type { ProviderRegistry } from '../providers/registry';
export function createLLMAdapter(registry: ProviderRegistry, projectName: string): LLMProvider {
return {
async complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise<string> {
const provider = registry.getProvider('heavy');
if (!provider) throw new Error('No LLM provider configured');
const messages = options?.system
? [{ role: 'system', content: options.system }, { role: 'user', content: prompt }]
: [{ role: 'user', content: prompt }];
const result = await provider.complete({
messages,
maxTokens: options?.maxTokens ?? 1000,
});
return result.content;
},
available(): boolean {
return registry.getProvider('heavy') !== null;
}
};
}
```
This adapter uses the 'heavy' tier from the existing registry, preserving the project-level LLM configuration.
**Test Strategy:**
Unit test with mocked ProviderRegistry verifying complete() calls are delegated correctly. Test available() returns false when no provider is configured. Integration test with a real provider.

View File

@@ -0,0 +1,67 @@
# Task ID: 73
**Title:** Implement In-Memory CacheProvider
**Status:** done
**Dependencies:** 71 ✓
**Priority:** high
**Description:** Create the CacheProvider implementation that stages use for caching expensive computations. Start with in-memory cache for Phase 1, with content-addressed keys.
**Details:**
Create `src/mcplocal/src/proxymodel/cache-provider.ts`:
```typescript
import { createHash } from 'crypto';
import type { CacheProvider } from './types';
export class InMemoryCacheProvider implements CacheProvider {
private cache = new Map<string, { value: string; timestamp: number }>();
private maxSize: number;
private ttlMs: number;
constructor(options: { maxSize?: number; ttlMs?: number } = {}) {
this.maxSize = options.maxSize ?? 1000;
this.ttlMs = options.ttlMs ?? 3600000; // 1 hour default
}
hash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 16);
}
async get(key: string): Promise<string | null> {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return null;
}
return entry.value;
}
async set(key: string, value: string): Promise<void> {
if (this.cache.size >= this.maxSize) this.evictOldest();
this.cache.set(key, { value, timestamp: Date.now() });
}
async getOrCompute(key: string, compute: () => Promise<string>): Promise<string> {
const cached = await this.get(key);
if (cached !== null) return cached;
const value = await compute();
await this.set(key, value);
return value;
}
private evictOldest(): void {
const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
if (oldest) this.cache.delete(oldest[0]);
}
}
```
**Test Strategy:**
Unit tests for: hash() produces consistent output, get() returns null for missing keys, set()/get() round-trip works, TTL expiration works, LRU eviction triggers at maxSize, getOrCompute() caches and returns cached values.

View File

@@ -0,0 +1,57 @@
# Task ID: 74
**Title:** Implement Content Type Detection
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Create a utility that detects content type (JSON, YAML, XML, code, prose) for structural splitting in the section-split stage.
**Details:**
Create `src/mcplocal/src/proxymodel/content-detection.ts`:
```typescript
export type ContentType = 'json' | 'yaml' | 'xml' | 'code' | 'prose';
export function detectContentType(content: string): ContentType {
const trimmed = content.trimStart();
// JSON detection
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(content);
return 'json';
} catch { /* not valid JSON, continue */ }
}
// XML detection
if (trimmed.startsWith('<?xml') || /^<[a-zA-Z][^>]*>/.test(trimmed)) {
return 'xml';
}
// YAML detection (key: value at start of lines)
if (/^[a-zA-Z_][a-zA-Z0-9_]*:\s/m.test(trimmed) && !trimmed.includes('{')) {
return 'yaml';
}
// Code detection (common patterns)
const codePatterns = [
/^(function |class |def |const |let |var |import |export |package |pub fn |fn |impl )/m,
/^#include\s+[<"]/m,
/^(public |private |protected )?(static )?(void |int |string |bool )/m,
];
if (codePatterns.some(p => p.test(trimmed))) {
return 'code';
}
return 'prose';
}
```
**Test Strategy:**
Unit tests with sample content for each type: valid JSON objects/arrays, XML documents, YAML configs, code snippets in multiple languages (JS, Python, Rust, Go, Java), and prose markdown. Edge cases: JSON-like strings that aren't valid JSON, mixed content.

View File

@@ -0,0 +1,77 @@
# Task ID: 75
**Title:** Implement section-split Stage
**Status:** done
**Dependencies:** 71 ✓, 74 ✓
**Priority:** high
**Description:** Create the built-in section-split stage that splits content based on detected content type, using structural boundaries for JSON/YAML/XML and headers for prose.
**Details:**
Create `src/mcplocal/src/proxymodel/stages/section-split.ts`:
```typescript
import type { StageHandler, Section } from '../types';
import { detectContentType } from '../content-detection';
const handler: StageHandler = async (content, ctx) => {
const minSize = (ctx.config.minSectionSize as number) ?? 2000;
const maxSize = (ctx.config.maxSectionSize as number) ?? 15000;
const contentType = detectContentType(content);
let sections: Section[];
switch (contentType) {
case 'json':
sections = splitJson(content, minSize, maxSize);
break;
case 'yaml':
sections = splitYaml(content, minSize, maxSize);
break;
case 'xml':
sections = splitXml(content, minSize, maxSize);
break;
case 'code':
sections = splitCode(content, minSize);
break;
default:
sections = splitProse(content, minSize);
}
if (sections.length === 0) {
return { content, sections: [{ id: 'main', title: 'Content', content }] };
}
const toc = sections.map((s, i) => `[${s.id}] ${s.title}`).join('\n');
return {
content: `${sections.length} sections (${contentType}):\n${toc}`,
sections,
};
};
function splitJson(content: string, minSize: number, maxSize: number): Section[] {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
return parsed.map((item, i) => ({
id: item.id ?? item.name ?? `item-${i}`,
title: item.label ?? item.title ?? item.name ?? `Item ${i}`,
content: JSON.stringify(item, null, 2),
}));
}
return Object.entries(parsed).map(([key, value]) => ({
id: key,
title: key,
content: JSON.stringify(value, null, 2),
}));
}
// Similar implementations for splitYaml, splitXml, splitCode, splitProse
```
**Test Strategy:**
Unit tests for each content type: JSON arrays split by element, JSON objects split by key, YAML split by top-level keys, XML split by elements, prose split by markdown headers. Test minSize/maxSize thresholds. Test fallback when content can't be parsed.

View File

@@ -0,0 +1,84 @@
# Task ID: 76
**Title:** Implement summarize-tree Stage
**Status:** done
**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 74 ✓
**Priority:** high
**Description:** Create the built-in summarize-tree stage that recursively summarizes sections, using structural summaries for programmatic content and LLM summaries for prose.
**Details:**
Create `src/mcplocal/src/proxymodel/stages/summarize-tree.ts`:
```typescript
import type { StageHandler, Section, StageContext } from '../types';
import { detectContentType } from '../content-detection';
const handler: StageHandler = async (content, ctx) => {
const maxTokens = (ctx.config.maxSummaryTokens as number) ?? 200;
const maxGroup = (ctx.config.maxGroupSize as number) ?? 5;
const maxDepth = (ctx.config.maxDepth as number) ?? 3;
// Parse sections from previous stage or create single section
const inputSections = parseSectionsFromContent(content);
const tree = await buildTree(inputSections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 });
const toc = tree.map(s =>
`[${s.id}] ${s.title} — ${s.metadata?.summary ?? ''}` +
(s.sections?.length ? `\n → ${s.sections.length} sub-sections` : '')
).join('\n');
return {
content: `${tree.length} sections:\n${toc}\n\nUse section parameter to read details.`,
sections: tree,
};
};
async function buildTree(
sections: Section[],
ctx: StageContext,
opts: { maxTokens: number; maxGroup: number; maxDepth: number; depth: number }
): Promise<Section[]> {
for (const section of sections) {
const contentType = detectContentType(section.content);
// Structural summary for programmatic content (no LLM needed)
if (contentType !== 'prose') {
section.metadata = { summary: generateStructuralSummary(section.content, contentType) };
} else {
// LLM summary for prose (cached)
const cacheKey = `summary:${ctx.cache.hash(section.content)}:${opts.maxTokens}`;
const summary = await ctx.cache.getOrCompute(cacheKey, () =>
ctx.llm.complete(
`Summarize in ${opts.maxTokens} tokens, preserve MUST/REQUIRED items:\n\n${section.content}`
)
);
section.metadata = { summary };
}
// Recurse if large and not at max depth
if (section.content.length > 5000 && opts.depth < opts.maxDepth) {
section.sections = await buildTree(
splitContent(section.content),
ctx,
{ ...opts, depth: opts.depth + 1 }
);
}
}
return sections;
}
function generateStructuralSummary(content: string, type: string): string {
// Generate summary from structure: key names, array lengths, types
// No LLM needed for JSON/YAML/XML/code
}
```
**Test Strategy:**
Unit tests: prose content gets LLM summary (mock LLM), JSON content gets structural summary without LLM call, recursive splitting triggers at 5000 chars, maxDepth is respected, cache is used for repeated content. Integration test with real LLM provider.

View File

@@ -0,0 +1,63 @@
# Task ID: 77
**Title:** Implement passthrough and paginate Stages
**Status:** done
**Dependencies:** 71 ✓
**Priority:** high
**Description:** Create the built-in passthrough (no-op) and paginate (large response splitting) stages that form the default proxymodel.
**Details:**
Create `src/mcplocal/src/proxymodel/stages/passthrough.ts`:
```typescript
import type { StageHandler } from '../types';
const handler: StageHandler = async (content, ctx) => {
return { content };
};
export default handler;
```
Create `src/mcplocal/src/proxymodel/stages/paginate.ts`:
```typescript
import type { StageHandler, Section } from '../types';
const handler: StageHandler = async (content, ctx) => {
const pageSize = (ctx.config.pageSize as number) ?? 8000;
if (content.length <= pageSize) {
return { content };
}
const pages: Section[] = [];
let offset = 0;
let pageNum = 1;
while (offset < content.length) {
const pageContent = content.slice(offset, offset + pageSize);
pages.push({
id: `page-${pageNum}`,
title: `Page ${pageNum}`,
content: pageContent,
});
offset += pageSize;
pageNum++;
}
return {
content: `Content split into ${pages.length} pages (${content.length} chars total). Use section parameter to read specific pages.`,
sections: pages,
};
};
export default handler;
```
**Test Strategy:**
passthrough: verify content returned unchanged. paginate: verify content under threshold returns unchanged, content over threshold splits correctly, page boundaries are correct, section IDs are sequential.

View File

@@ -0,0 +1,78 @@
# Task ID: 78
**Title:** Create ProxyModel YAML Schema and Loader
**Status:** done
**Dependencies:** 71 ✓
**Priority:** high
**Description:** Define the YAML schema for proxymodel definitions and implement the loader that reads from ~/.mcpctl/proxymodels/ and merges with built-ins.
**Details:**
Create `src/mcplocal/src/proxymodel/schema.ts`:
```typescript
import { z } from 'zod';
export const ProxyModelSchema = z.object({
kind: z.literal('ProxyModel'),
metadata: z.object({
name: z.string(),
}),
spec: z.object({
controller: z.string().optional().default('gate'),
controllerConfig: z.record(z.unknown()).optional(),
stages: z.array(z.object({
type: z.string(),
config: z.record(z.unknown()).optional(),
})),
appliesTo: z.array(z.enum(['prompts', 'toolResults', 'resource'])).optional(),
cacheable: z.boolean().optional().default(true),
}),
});
export type ProxyModelDefinition = z.infer<typeof ProxyModelSchema>;
```
Create `src/mcplocal/src/proxymodel/loader.ts`:
```typescript
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { ProxyModelSchema, type ProxyModelDefinition } from './schema';
import { getBuiltInProxyModels } from './built-in-models';
const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');
export async function loadProxyModels(): Promise<Map<string, ProxyModelDefinition>> {
const models = new Map<string, ProxyModelDefinition>();
// Load built-ins first
for (const [name, model] of getBuiltInProxyModels()) {
models.set(name, model);
}
// Load local (overrides built-ins)
try {
const files = await readdir(PROXYMODELS_DIR);
for (const file of files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
const content = await readFile(join(PROXYMODELS_DIR, file), 'utf-8');
const parsed = parseYaml(content);
const validated = ProxyModelSchema.parse(parsed);
models.set(validated.metadata.name, validated);
}
} catch (e) {
// Directory doesn't exist or can't be read - use built-ins only
}
return models;
}
```
**Test Strategy:**
Unit tests: valid YAML parses correctly, invalid YAML throws validation error, local models override built-ins with same name, missing directory doesn't throw. Create test fixtures for various YAML configurations.

View File

@@ -0,0 +1,68 @@
# Task ID: 79
**Title:** Implement Stage Registry and Dynamic Loader
**Status:** done
**Dependencies:** 71 ✓, 75 ✓, 76 ✓, 77 ✓
**Priority:** high
**Description:** Create the stage registry that resolves stage names to handlers, loading from ~/.mcpctl/stages/ for custom stages and falling back to built-ins.
**Details:**
Create `src/mcplocal/src/proxymodel/stage-registry.ts`:
```typescript
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import type { StageHandler } from './types';
const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');
const builtInStages: Map<string, StageHandler> = new Map();
const customStages: Map<string, StageHandler> = new Map();
// Register built-ins at module load
import passthrough from './stages/passthrough';
import paginate from './stages/paginate';
import sectionSplit from './stages/section-split';
import summarizeTree from './stages/summarize-tree';
builtInStages.set('passthrough', passthrough);
builtInStages.set('paginate', paginate);
builtInStages.set('section-split', sectionSplit);
builtInStages.set('summarize-tree', summarizeTree);
export async function loadCustomStages(): Promise<void> {
customStages.clear();
try {
const files = await readdir(STAGES_DIR);
for (const file of files.filter(f => f.endsWith('.ts') || f.endsWith('.js'))) {
const name = file.replace(/\.(ts|js)$/, '');
const module = await import(join(STAGES_DIR, file));
customStages.set(name, module.default);
}
} catch { /* directory doesn't exist */ }
}
export function getStage(name: string): StageHandler | null {
return customStages.get(name) ?? builtInStages.get(name) ?? null;
}
export function listStages(): { name: string; source: 'built-in' | 'local' }[] {
const result: { name: string; source: 'built-in' | 'local' }[] = [];
for (const name of builtInStages.keys()) {
result.push({ name, source: customStages.has(name) ? 'local' : 'built-in' });
}
for (const name of customStages.keys()) {
if (!builtInStages.has(name)) result.push({ name, source: 'local' });
}
return result;
}
```
**Test Strategy:**
Unit tests: built-in stages are registered, getStage() returns correct handler, custom stages override built-ins, listStages() shows correct sources, missing stages return null. Integration test with actual stage files in temp directory.

View File

@@ -0,0 +1,90 @@
# Task ID: 80
**Title:** Implement Pipeline Executor
**Status:** done
**Dependencies:** 71 ✓, 72 ✓, 73 ✓, 79 ✓
**Priority:** high
**Description:** Create the pipeline executor that runs content through a sequence of stages, managing context, caching, and error handling.
**Details:**
Create `src/mcplocal/src/proxymodel/executor.ts`:
```typescript
import type { StageContext, StageResult, Section } from './types';
import type { ProxyModelDefinition } from './schema';
import { getStage } from './stage-registry';
import { createLLMAdapter } from './llm-adapter';
import { InMemoryCacheProvider } from './cache-provider';
import type { ProviderRegistry } from '../providers/registry';
export interface ExecuteOptions {
content: string;
contentType: 'prompt' | 'toolResult' | 'resource';
sourceName: string;
projectName: string;
sessionId: string;
proxyModel: ProxyModelDefinition;
providerRegistry: ProviderRegistry;
cache?: InMemoryCacheProvider;
}
export async function executePipeline(opts: ExecuteOptions): Promise<StageResult> {
const { content, proxyModel, providerRegistry } = opts;
const cache = opts.cache ?? new InMemoryCacheProvider();
const llm = createLLMAdapter(providerRegistry, opts.projectName);
let currentContent = content;
let sections: Section[] | undefined;
let metadata: Record<string, unknown> = {};
for (const stageConfig of proxyModel.spec.stages) {
const handler = getStage(stageConfig.type);
if (!handler) {
console.warn(`Stage '${stageConfig.type}' not found, skipping`);
continue;
}
const ctx: StageContext = {
contentType: opts.contentType,
sourceName: opts.sourceName,
projectName: opts.projectName,
sessionId: opts.sessionId,
originalContent: content,
llm,
cache,
log: createLogger(stageConfig.type),
config: stageConfig.config ?? {},
};
try {
const result = await handler(currentContent, ctx);
currentContent = result.content;
if (result.sections) sections = result.sections;
if (result.metadata) metadata = { ...metadata, ...result.metadata };
} catch (err) {
console.error(`Stage '${stageConfig.type}' failed:`, err);
// Continue with previous content on error
}
}
return { content: currentContent, sections, metadata };
}
function createLogger(stageName: string) {
return {
debug: (msg: string) => console.debug(`[${stageName}] ${msg}`),
info: (msg: string) => console.info(`[${stageName}] ${msg}`),
warn: (msg: string) => console.warn(`[${stageName}] ${msg}`),
error: (msg: string) => console.error(`[${stageName}] ${msg}`),
};
}
```
**Test Strategy:**
Unit tests: single stage executes correctly, multiple stages chain output to input, originalContent preserved across stages, missing stage logs warning and continues, stage error doesn't break pipeline, sections/metadata accumulate correctly.

View File

@@ -0,0 +1,59 @@
# Task ID: 81
**Title:** Define Built-in ProxyModels (default, subindex)
**Status:** done
**Dependencies:** 78 ✓
**Priority:** high
**Description:** Create the built-in proxymodel definitions for 'default' (current behavior) and 'subindex' (hierarchical navigation).
**Details:**
Create `src/mcplocal/src/proxymodel/built-in-models.ts`:
```typescript
import type { ProxyModelDefinition } from './schema';
export function getBuiltInProxyModels(): Map<string, ProxyModelDefinition> {
const models = new Map<string, ProxyModelDefinition>();
models.set('default', {
kind: 'ProxyModel',
metadata: { name: 'default' },
spec: {
controller: 'gate',
controllerConfig: { byteBudget: 8192 },
stages: [
{ type: 'passthrough' },
{ type: 'paginate', config: { pageSize: 8000 } },
],
appliesTo: ['prompts', 'toolResults'],
cacheable: false,
},
});
models.set('subindex', {
kind: 'ProxyModel',
metadata: { name: 'subindex' },
spec: {
controller: 'gate',
controllerConfig: { byteBudget: 8192 },
stages: [
{ type: 'section-split', config: { minSectionSize: 2000, maxSectionSize: 15000 } },
{ type: 'summarize-tree', config: { maxSummaryTokens: 200, maxGroupSize: 5, maxDepth: 3 } },
],
appliesTo: ['prompts', 'toolResults'],
cacheable: true,
},
});
return models;
}
```
**Test Strategy:**
Unit tests: both models are returned by getBuiltInProxyModels(), 'default' has passthrough+paginate stages, 'subindex' has section-split+summarize-tree stages, both schemas validate correctly.

View File

@@ -0,0 +1,66 @@
# Task ID: 82
**Title:** Integrate Pipeline Executor into Router
**Status:** done
**Dependencies:** 80 ✓, 81 ✓
**Priority:** high
**Description:** Modify the McpRouter to route content through the proxymodel pipeline, keeping the gating logic cleanly separated from content processing.
**Details:**
Modify `src/mcplocal/src/router.ts` to:
1. Add proxyModel resolution during router creation:
```typescript
import { loadProxyModels } from './proxymodel/loader';
import { executePipeline } from './proxymodel/executor';
interface RouterOptions {
proxyModelName?: string;
// ... existing options
}
async function createRouter(opts: RouterOptions): Promise<McpRouter> {
const proxyModels = await loadProxyModels();
const proxyModel = proxyModels.get(opts.proxyModelName ?? 'default');
// ...
}
```
2. Add content processing method:
```typescript
async processContent(
content: string,
type: 'prompt' | 'toolResult',
sourceName: string,
sessionId: string
): Promise<StageResult> {
if (!this.proxyModel) return { content };
const appliesTo = this.proxyModel.spec.appliesTo ?? ['prompts', 'toolResults'];
if (!appliesTo.includes(type === 'prompt' ? 'prompts' : 'toolResults')) {
return { content };
}
return executePipeline({
content,
contentType: type,
sourceName,
projectName: this.projectName,
sessionId,
proxyModel: this.proxyModel,
providerRegistry: this.providerRegistry,
cache: this.cache,
});
}
```
3. Call processContent at the appropriate points in the request flow (prompt serving, tool result handling) WITHOUT interweaving with gating logic.
**Test Strategy:**
Integration tests: default proxymodel passes content through unchanged, subindex proxymodel produces summaries, appliesTo filtering works correctly, gating still works as before with proxymodel processing happening at the right stage.

View File

@@ -0,0 +1,49 @@
# Task ID: 83
**Title:** Implement Section Drill-Down for Prompts
**Status:** cancelled
**Dependencies:** 82 ✓
**Priority:** high
**Description:** Extend read_prompts to support section parameter for drilling into specific sections produced by proxymodel stages.
**Details:**
Modify the read_prompts handler in `src/mcplocal/src/router.ts`:
```typescript
// In the read_prompts tool handler
if (args.section) {
// Look up section in the processed result
const sectionId = args.section;
const cachedResult = this.sectionCache.get(promptName);
if (cachedResult?.sections) {
const section = findSection(cachedResult.sections, sectionId);
if (section) {
return { content: [{ type: 'text', text: section.content }] };
}
return { content: [{ type: 'text', text: `Section '${sectionId}' not found` }], isError: true };
}
}
// Helper to find section by ID (supports nested sections)
function findSection(sections: Section[], id: string): Section | null {
for (const s of sections) {
if (s.id === id) return s;
if (s.sections) {
const nested = findSection(s.sections, id);
if (nested) return nested;
}
}
return null;
}
```
Also add a sectionCache Map to store processed results with their sections for drill-down.
**Test Strategy:**
Integration tests: read_prompts with section parameter returns correct section content, nested section lookup works, missing section returns error, section cache populated after initial processing.

View File

@@ -0,0 +1,47 @@
# Task ID: 84
**Title:** Implement Section Drill-Down for Tool Results
**Status:** done
**Dependencies:** 82 ✓
**Priority:** medium
**Description:** Extend tool result handling to support _section parameter for drilling into specific sections of large tool responses.
**Details:**
Modify tool call handling in `src/mcplocal/src/router.ts`:
```typescript
// When processing tool calls
if (args._section) {
const sectionId = args._section;
delete args._section; // Don't pass to upstream
// Check cache for previous full result
const cacheKey = `tool:${serverName}/${toolName}:${JSON.stringify(args)}`;
const cachedResult = this.toolResultCache.get(cacheKey);
if (cachedResult?.sections) {
const section = findSection(cachedResult.sections, sectionId);
if (section) {
return { content: [{ type: 'text', text: section.content }] };
}
}
// If no cache, make the full call and process, then serve section
}
// After receiving tool result, process through pipeline
const processed = await this.processContent(result, 'toolResult', `${serverName}/${toolName}`, sessionId);
if (processed.sections) {
this.toolResultCache.set(cacheKey, processed);
}
```
Add a toolResultCache Map with appropriate TTL.
**Test Strategy:**
Integration tests: large tool result gets processed into sections, _section parameter returns specific section, _section removed before upstream call, cache hit serves from cache, cache miss processes and caches.

View File

@@ -0,0 +1,65 @@
# Task ID: 85
**Title:** Implement Hot-Reload for Stages
**Status:** done
**Dependencies:** 79 ✓
**Priority:** medium
**Description:** Add file watching for ~/.mcpctl/stages/ to automatically reload custom stages when they change without restarting mcplocal.
**Details:**
Modify `src/mcplocal/src/proxymodel/stage-registry.ts`:
```typescript
import { watch, FSWatcher } from 'fs';
import { join, basename } from 'path';
let watcher: FSWatcher | null = null;
const stageFileHashes: Map<string, string> = new Map();
export function startStageWatcher(): void {
if (watcher) return;
try {
watcher = watch(STAGES_DIR, async (eventType, filename) => {
if (!filename || (!filename.endsWith('.ts') && !filename.endsWith('.js'))) return;
const name = filename.replace(/\.(ts|js)$/, '');
const fullPath = join(STAGES_DIR, filename);
if (eventType === 'rename') {
// File added or removed
await loadCustomStages();
console.info(`[proxymodel] Stages reloaded due to ${filename} change`);
} else if (eventType === 'change') {
// File modified - invalidate module cache and reload
delete require.cache[require.resolve(fullPath)];
try {
const module = await import(fullPath + '?t=' + Date.now());
customStages.set(name, module.default);
console.info(`[proxymodel] Stage '${name}' hot-reloaded`);
} catch (err) {
console.error(`[proxymodel] Failed to reload stage '${name}':`, err);
}
}
});
} catch {
// Directory doesn't exist - no watching needed
}
}
export function stopStageWatcher(): void {
watcher?.close();
watcher = null;
}
```
Call startStageWatcher() during mcplocal initialization.
**Test Strategy:**
Integration tests: modify a stage file and verify the new version is loaded without restart, add a new stage file and verify it becomes available, remove a stage file and verify it's no longer available, syntax errors in stage file don't crash the watcher.

View File

@@ -0,0 +1,57 @@
# Task ID: 86
**Title:** Implement Hot-Reload for ProxyModels
**Status:** done
**Dependencies:** 78 ✓
**Priority:** medium
**Description:** Add file watching for ~/.mcpctl/proxymodels/ to automatically reload proxymodel definitions when they change.
**Details:**
Create `src/mcplocal/src/proxymodel/model-watcher.ts`:
```typescript
import { watch, FSWatcher } from 'fs';
import { join } from 'path';
import { readFile } from 'fs/promises';
import { parse as parseYaml } from 'yaml';
import { ProxyModelSchema } from './schema';
const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');
let watcher: FSWatcher | null = null;
const modelUpdateCallbacks: Set<() => void> = new Set();
export function onModelUpdate(callback: () => void): () => void {
modelUpdateCallbacks.add(callback);
return () => modelUpdateCallbacks.delete(callback);
}
export function startModelWatcher(): void {
if (watcher) return;
try {
watcher = watch(PROXYMODELS_DIR, async (eventType, filename) => {
if (!filename || (!filename.endsWith('.yaml') && !filename.endsWith('.yml'))) return;
console.info(`[proxymodel] Model file ${filename} changed, reloading...`);
// Notify all subscribers to reload their models
for (const cb of modelUpdateCallbacks) {
try { cb(); } catch (err) { console.error('Model update callback failed:', err); }
}
});
} catch {
// Directory doesn't exist
}
}
```
Integrate with router to reload proxymodels when files change.
**Test Strategy:**
Integration tests: modify a proxymodel YAML and verify changes take effect, add a new proxymodel and verify it becomes available, invalid YAML logs error but doesn't crash.

View File

@@ -0,0 +1,48 @@
# Task ID: 87
**Title:** Add proxyModel Field to Project Schema
**Status:** cancelled
**Dependencies:** None
**Priority:** high
**Description:** Extend the Project database schema and API to support proxyModel field and proxyModelOverrides for per-content-type configuration.
**Details:**
Update `src/db/prisma/schema.prisma`:
```prisma
model Project {
// ... existing fields
proxyModel String? @default("default")
proxyModelOverrides Json? // { prompts: { "prompt-name": "model" }, toolResults: { "server/tool": "model" } }
}
```
Run `npx prisma migrate dev --name add_proxymodel_field`.
Update `src/mcpd/src/routes/projects.ts` to include the new fields in CRUD operations.
Update `src/cli/src/commands/get.ts` and `describe.ts` to display proxyModel.
Update `src/cli/src/commands/patch.ts` to support `--set proxyModel=<name>`.
**Test Strategy:**
Database migration test: verify migration applies cleanly. API tests: verify proxyModel field is returned in project GET, can be updated via PATCH. CLI tests: verify `mcpctl describe project <name>` shows proxyModel.
## Subtasks
### 87.1. Minimal placeholder subtask
**Status:** pending
**Dependencies:** None
This task requires complete rewrite before expansion.
**Details:**
Task 87 has been marked as DO NOT EXPAND and needs to be completely rewritten first. No subtasks should be generated until the task is properly redefined.

View File

@@ -0,0 +1,38 @@
# Task ID: 88
**Title:** Rename proxyMode: filtered to proxyMode: proxy
**Status:** cancelled
**Dependencies:** 87 ✗
**Priority:** low
**Description:** Rename the existing proxyMode value 'filtered' to 'proxy' for clarity, with backwards compatibility for existing configs.
**Details:**
Update `src/db/prisma/schema.prisma`:
```prisma
enum ProxyMode {
direct
proxy // renamed from 'filtered'
}
```
Create migration that updates existing 'filtered' values to 'proxy':
```sql
UPDATE Project SET proxyMode = 'proxy' WHERE proxyMode = 'filtered';
```
Update all code references from 'filtered' to 'proxy':
- `src/mcplocal/src/http/project-mcp-endpoint.ts`
- `src/cli/src/commands/create.ts`
- Documentation and help text
For backwards compatibility in config files, add a normalization step that treats 'filtered' as 'proxy'.
**Test Strategy:**
Migration test: existing projects with proxyMode='filtered' are updated to 'proxy'. Config parsing test: both 'filtered' and 'proxy' values work. CLI test: help text shows 'proxy' not 'filtered'.

View File

@@ -0,0 +1,53 @@
# Task ID: 89
**Title:** Implement mcpctl get proxymodels Command
**Status:** cancelled
**Dependencies:** 78 ✓, 79 ✓
**Priority:** medium
**Description:** Add CLI command to list all available proxymodels (built-in + local) with source, stages, and requirements.
**Details:**
Create `src/cli/src/commands/get-proxymodels.ts`:
```typescript
import { Command } from 'commander';
import { loadProxyModels } from 'mcplocal/proxymodel/loader';
import { listStages } from 'mcplocal/proxymodel/stage-registry';
import Table from 'cli-table3';
export function registerGetProxymodels(program: Command): void {
program
.command('get proxymodels')
.description('List all available proxymodels')
.action(async () => {
const models = await loadProxyModels();
const stageInfo = new Map(listStages().map(s => [s.name, s]));
const table = new Table({
head: ['NAME', 'SOURCE', 'STAGES', 'REQUIRES-LLM', 'CACHEABLE'],
});
for (const [name, model] of models) {
const source = isBuiltIn(name) ? 'built-in' : 'local';
const stages = model.spec.stages.map(s => s.type).join(',');
const requiresLlm = model.spec.stages.some(s => stageRequiresLlm(s.type));
const cacheable = model.spec.cacheable ? 'yes' : 'no';
table.push([name, source, stages, requiresLlm ? 'yes' : 'no', cacheable]);
}
console.log(table.toString());
});
}
```
Register in `src/cli/src/commands/get.ts` as a subcommand.
**Test Strategy:**
CLI test: `mcpctl get proxymodels` outputs table with expected columns. Test with only built-ins, test with local overrides, verify correct source detection.

View File

@@ -0,0 +1,47 @@
# Task ID: 90
**Title:** Implement mcpctl get stages Command
**Status:** cancelled
**Dependencies:** 79 ✓
**Priority:** medium
**Description:** Add CLI command to list all available stages (built-in + custom) with source and LLM requirements.
**Details:**
Create `src/cli/src/commands/get-stages.ts`:
```typescript
import { Command } from 'commander';
import { listStages } from 'mcplocal/proxymodel/stage-registry';
import Table from 'cli-table3';
const LLM_REQUIRING_STAGES = ['summarize', 'summarize-tree', 'enhance', 'compress'];
export function registerGetStages(program: Command): void {
program
.command('get stages')
.description('List all available stages')
.action(async () => {
const stages = listStages();
const table = new Table({
head: ['NAME', 'SOURCE', 'REQUIRES-LLM'],
});
for (const stage of stages) {
const requiresLlm = LLM_REQUIRING_STAGES.includes(stage.name);
table.push([stage.name, stage.source, requiresLlm ? 'yes' : 'no']);
}
console.log(table.toString());
});
}
```
**Test Strategy:**
CLI test: `mcpctl get stages` outputs table with expected columns. Test with only built-ins, test with custom stages in ~/.mcpctl/stages/, verify custom overrides show 'local' source.

View File

@@ -0,0 +1,57 @@
# Task ID: 91
**Title:** Implement mcpctl describe proxymodel Command
**Status:** cancelled
**Dependencies:** 78 ✓
**Priority:** medium
**Description:** Add CLI command to show detailed information about a specific proxymodel including full stage configuration.
**Details:**
Create `src/cli/src/commands/describe-proxymodel.ts`:
```typescript
import { Command } from 'commander';
import { loadProxyModels } from 'mcplocal/proxymodel/loader';
import { stringify as yamlStringify } from 'yaml';
export function registerDescribeProxymodel(program: Command): void {
program
.command('describe proxymodel <name>')
.description('Show detailed information about a proxymodel')
.action(async (name: string) => {
const models = await loadProxyModels();
const model = models.get(name);
if (!model) {
console.error(`Proxymodel '${name}' not found`);
process.exit(1);
}
console.log(`Name: ${model.metadata.name}`);
console.log(`Source: ${isBuiltIn(name) ? 'built-in' : 'local'}`);
console.log(`Controller: ${model.spec.controller ?? 'gate'}`);
console.log(`Cacheable: ${model.spec.cacheable ? 'yes' : 'no'}`);
console.log(`Applies to: ${(model.spec.appliesTo ?? ['prompts', 'toolResults']).join(', ')}`);
console.log('');
console.log('Stages:');
for (const stage of model.spec.stages) {
console.log(` - ${stage.type}`);
if (stage.config) {
console.log(` config:`);
for (const [k, v] of Object.entries(stage.config)) {
console.log(` ${k}: ${JSON.stringify(v)}`);
}
}
}
});
}
```
**Test Strategy:**
CLI test: `mcpctl describe proxymodel default` shows expected output. Test with proxymodel that has stage configs, verify all fields displayed correctly.

View File

@@ -0,0 +1,57 @@
# Task ID: 92
**Title:** Implement mcpctl describe stage Command
**Status:** cancelled
**Dependencies:** 79 ✓
**Priority:** medium
**Description:** Add CLI command to show detailed information about a specific stage including its source location.
**Details:**
Create `src/cli/src/commands/describe-stage.ts`:
```typescript
import { Command } from 'commander';
import { listStages, getStage } from 'mcplocal/proxymodel/stage-registry';
import { join } from 'path';
const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');
const STAGE_DESCRIPTIONS: Record<string, string> = {
'passthrough': 'Returns content unchanged. No processing.',
'paginate': 'Splits large content into pages with navigation.',
'section-split': 'Splits content on structural boundaries (headers, JSON keys, etc.).',
'summarize-tree': 'Recursively summarizes sections with hierarchical navigation.',
};
export function registerDescribeStage(program: Command): void {
program
.command('describe stage <name>')
.description('Show detailed information about a stage')
.action(async (name: string) => {
const stages = listStages();
const stageInfo = stages.find(s => s.name === name);
if (!stageInfo) {
console.error(`Stage '${name}' not found`);
process.exit(1);
}
console.log(`Name: ${name}`);
console.log(`Source: ${stageInfo.source}`);
if (stageInfo.source === 'local') {
console.log(`Path: ${join(STAGES_DIR, name + '.ts')}`);
}
console.log(`Description: ${STAGE_DESCRIPTIONS[name] ?? 'Custom stage'}`);
console.log(`Requires LLM: ${requiresLlm(name) ? 'yes' : 'no'}`);
});
}
```
**Test Strategy:**
CLI test: `mcpctl describe stage passthrough` shows expected output. Test with custom stage, verify path is shown correctly.

View File

@@ -0,0 +1,73 @@
# Task ID: 93
**Title:** Implement mcpctl create stage Command
**Status:** cancelled
**Dependencies:** 71 ✓
**Priority:** medium
**Description:** Add CLI command to scaffold a new custom stage with boilerplate TypeScript code.
**Details:**
Create `src/cli/src/commands/create-stage.ts`:
```typescript
import { Command } from 'commander';
import { mkdir, writeFile, access } from 'fs/promises';
import { join } from 'path';
const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');
const STAGE_TEMPLATE = `import type { StageHandler } from 'mcpctl/proxymodel';
/**
* Custom stage: {{name}}
*
* Modify this handler to transform content as needed.
* Available in ctx:
* - ctx.llm.complete(prompt) - call the configured LLM
* - ctx.cache.getOrCompute(key, fn) - cache expensive computations
* - ctx.config - stage configuration from proxymodel YAML
* - ctx.originalContent - raw content before any stage processing
* - ctx.log - structured logging
*/
const handler: StageHandler = async (content, ctx) => {
// TODO: Implement your transformation
return { content };
};
export default handler;
`;
export function registerCreateStage(program: Command): void {
program
.command('create stage <name>')
.description('Create a new custom stage')
.action(async (name: string) => {
await mkdir(STAGES_DIR, { recursive: true });
const filePath = join(STAGES_DIR, `${name}.ts`);
try {
await access(filePath);
console.error(`Stage '${name}' already exists at ${filePath}`);
process.exit(1);
} catch {
// File doesn't exist, good
}
const code = STAGE_TEMPLATE.replace(/\{\{name\}\}/g, name);
await writeFile(filePath, code);
console.log(`Created ${filePath}`);
console.log('Edit the file to implement your stage logic.');
});
}
```
**Test Strategy:**
CLI test: `mcpctl create stage my-filter` creates file at expected path with correct template. Test error when stage already exists. Verify generated code compiles.

View File

@@ -0,0 +1,66 @@
# Task ID: 94
**Title:** Implement mcpctl create proxymodel Command
**Status:** cancelled
**Dependencies:** 78 ✓
**Priority:** medium
**Description:** Add CLI command to scaffold a new proxymodel YAML file with specified stages.
**Details:**
Create `src/cli/src/commands/create-proxymodel.ts`:
```typescript
import { Command } from 'commander';
import { mkdir, writeFile, access } from 'fs/promises';
import { join } from 'path';
import { stringify as yamlStringify } from 'yaml';
const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');
export function registerCreateProxymodel(program: Command): void {
program
.command('create proxymodel <name>')
.description('Create a new proxymodel')
.option('--stages <stages>', 'Comma-separated list of stage names', 'passthrough')
.option('--controller <controller>', 'Session controller (gate or none)', 'gate')
.action(async (name: string, opts) => {
await mkdir(PROXYMODELS_DIR, { recursive: true });
const filePath = join(PROXYMODELS_DIR, `${name}.yaml`);
try {
await access(filePath);
console.error(`Proxymodel '${name}' already exists at ${filePath}`);
process.exit(1);
} catch {
// File doesn't exist, good
}
const stages = opts.stages.split(',').map((s: string) => ({ type: s.trim() }));
const model = {
kind: 'ProxyModel',
metadata: { name },
spec: {
controller: opts.controller,
stages,
appliesTo: ['prompts', 'toolResults'],
cacheable: true,
},
};
await writeFile(filePath, yamlStringify(model));
console.log(`Created ${filePath}`);
});
}
```
**Test Strategy:**
CLI test: `mcpctl create proxymodel my-pipeline --stages summarize,compress` creates valid YAML. Test default values. Verify generated YAML validates against schema.

View File

@@ -0,0 +1,70 @@
# Task ID: 95
**Title:** Implement mcpctl proxymodel validate Command
**Status:** cancelled
**Dependencies:** 78 ✓, 79 ✓
**Priority:** medium
**Description:** Add CLI command to validate a proxymodel definition, checking that all stages resolve and config is valid.
**Details:**
Create `src/cli/src/commands/proxymodel-validate.ts`:
```typescript
import { Command } from 'commander';
import { loadProxyModels } from 'mcplocal/proxymodel/loader';
import { getStage, loadCustomStages } from 'mcplocal/proxymodel/stage-registry';
export function registerProxymodelValidate(program: Command): void {
program
.command('proxymodel validate <name>')
.description('Validate a proxymodel definition')
.action(async (name: string) => {
await loadCustomStages();
const models = await loadProxyModels();
const model = models.get(name);
if (!model) {
console.error(`Proxymodel '${name}' not found`);
process.exit(1);
}
let valid = true;
const errors: string[] = [];
// Check all stages resolve
for (const stageConfig of model.spec.stages) {
const stage = getStage(stageConfig.type);
if (!stage) {
errors.push(`Stage '${stageConfig.type}' not found`);
valid = false;
}
}
// Check controller is valid
const validControllers = ['gate', 'none'];
if (model.spec.controller && !validControllers.includes(model.spec.controller)) {
errors.push(`Unknown controller '${model.spec.controller}'`);
valid = false;
}
if (valid) {
console.log(`✓ Proxymodel '${name}' is valid`);
} else {
console.error(`✗ Proxymodel '${name}' has errors:`);
for (const err of errors) {
console.error(` - ${err}`);
}
process.exit(1);
}
});
}
```
**Test Strategy:**
CLI test: valid proxymodel passes, proxymodel with unknown stage fails with clear error, proxymodel with unknown controller fails. Test with both built-in and custom stages.

View File

@@ -0,0 +1,53 @@
# Task ID: 96
**Title:** Implement mcpctl delete stage Command
**Status:** cancelled
**Dependencies:** 79 ✓
**Priority:** low
**Description:** Add CLI command to delete a custom stage file (cannot delete built-ins).
**Details:**
Create `src/cli/src/commands/delete-stage.ts`:
```typescript
import { Command } from 'commander';
import { unlink, access } from 'fs/promises';
import { join } from 'path';
import { listStages } from 'mcplocal/proxymodel/stage-registry';
const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');
export function registerDeleteStage(program: Command): void {
program
.command('delete stage <name>')
.description('Delete a custom stage')
.action(async (name: string) => {
const stages = listStages();
const stageInfo = stages.find(s => s.name === name);
if (!stageInfo) {
console.error(`Stage '${name}' not found`);
process.exit(1);
}
if (stageInfo.source === 'built-in') {
console.error(`Cannot delete built-in stage '${name}'`);
process.exit(1);
}
const filePath = join(STAGES_DIR, `${name}.ts`);
await unlink(filePath);
console.log(`Deleted ${filePath}`);
});
}
```
**Test Strategy:**
CLI test: can delete custom stage, cannot delete built-in stage (error message), deleting non-existent stage shows error.

View File

@@ -0,0 +1,64 @@
# Task ID: 97
**Title:** Implement mcpctl delete proxymodel Command
**Status:** cancelled
**Dependencies:** 78 ✓
**Priority:** low
**Description:** Add CLI command to delete a local proxymodel YAML file (cannot delete built-ins).
**Details:**
Create `src/cli/src/commands/delete-proxymodel.ts`:
```typescript
import { Command } from 'commander';
import { unlink, access } from 'fs/promises';
import { join } from 'path';
import { loadProxyModels } from 'mcplocal/proxymodel/loader';
import { getBuiltInProxyModels } from 'mcplocal/proxymodel/built-in-models';
const PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');
export function registerDeleteProxymodel(program: Command): void {
program
.command('delete proxymodel <name>')
.description('Delete a local proxymodel')
.action(async (name: string) => {
const models = await loadProxyModels();
const builtIns = getBuiltInProxyModels();
if (!models.has(name)) {
console.error(`Proxymodel '${name}' not found`);
process.exit(1);
}
const filePath = join(PROXYMODELS_DIR, `${name}.yaml`);
try {
await access(filePath);
} catch {
if (builtIns.has(name)) {
console.error(`Cannot delete built-in proxymodel '${name}'`);
} else {
console.error(`Proxymodel '${name}' file not found at ${filePath}`);
}
process.exit(1);
}
await unlink(filePath);
console.log(`Deleted ${filePath}`);
if (builtIns.has(name)) {
console.log(`Note: Built-in '${name}' will still be available`);
}
});
}
```
**Test Strategy:**
CLI test: can delete local proxymodel, cannot delete built-in (error message), deleting local override shows note about built-in fallback.

View File

@@ -0,0 +1,79 @@
# Task ID: 98
**Title:** Implement Persistent File Cache for Stages
**Status:** done
**Dependencies:** 73 ✓
**Priority:** high
**Description:** Extend CacheProvider with file-based persistence in ~/.mcpctl/cache/proxymodel/ for cross-session caching.
**Details:**
Create `src/mcplocal/src/proxymodel/file-cache.ts`:
```typescript
import { mkdir, readFile, writeFile, readdir, stat, unlink } from 'fs/promises';
import { join } from 'path';
import { createHash } from 'crypto';
import type { CacheProvider } from './types';
const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');
export class FileCacheProvider implements CacheProvider {
private memCache = new Map<string, string>();
private maxSizeBytes: number;
constructor(options: { maxSizeBytes?: number } = {}) {
this.maxSizeBytes = options.maxSizeBytes ?? 100 * 1024 * 1024; // 100MB default
}
hash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 16);
}
private keyToPath(key: string): string {
const safeKey = key.replace(/[^a-zA-Z0-9-_]/g, '_');
return join(CACHE_DIR, safeKey);
}
async get(key: string): Promise<string | null> {
// Check memory first
if (this.memCache.has(key)) return this.memCache.get(key)!;
// Check file
try {
const content = await readFile(this.keyToPath(key), 'utf-8');
this.memCache.set(key, content); // Warm memory cache
return content;
} catch {
return null;
}
}
async set(key: string, value: string): Promise<void> {
await mkdir(CACHE_DIR, { recursive: true });
this.memCache.set(key, value);
await writeFile(this.keyToPath(key), value);
await this.enforceMaxSize();
}
async getOrCompute(key: string, compute: () => Promise<string>): Promise<string> {
const cached = await this.get(key);
if (cached !== null) return cached;
const value = await compute();
await this.set(key, value);
return value;
}
private async enforceMaxSize(): Promise<void> {
// LRU eviction based on file mtime when cache exceeds maxSizeBytes
}
}
```
**Test Strategy:**
Unit tests: file-based persistence survives process restart, memory cache is warmed on file read, LRU eviction works when size exceeded, concurrent access is safe. Integration test with real filesystem.

View File

@@ -0,0 +1,61 @@
# Task ID: 99
**Title:** Add Cache Key with Stage File Hash
**Status:** done
**Dependencies:** 98 ✓
**Priority:** medium
**Description:** Include the stage file hash in cache keys so cached artifacts are automatically invalidated when stage code changes.
**Details:**
Modify `src/mcplocal/src/proxymodel/executor.ts`:
```typescript
import { readFile, stat } from 'fs/promises';
import { createHash } from 'crypto';
import { join } from 'path';
const STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');
const stageFileHashes: Map<string, string> = new Map();
async function getStageFileHash(stageName: string): Promise<string> {
// Check if custom stage file exists
const filePath = join(STAGES_DIR, `${stageName}.ts`);
try {
const content = await readFile(filePath, 'utf-8');
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
stageFileHashes.set(stageName, hash);
return hash;
} catch {
// Built-in stage, use version-based hash or fixed value
return 'builtin-v1';
}
}
// In executePipeline, compute cache key:
const stageHash = await getStageFileHash(stageConfig.type);
const cacheKey = [
'stage',
stageConfig.type,
stageHash,
cache.hash(currentContent),
cache.hash(JSON.stringify(stageConfig.config ?? {})),
].join(':');
// Use pipeline-level cache wrapping:
if (proxyModel.spec.cacheable) {
const cached = await cache.get(cacheKey);
if (cached) {
currentContent = cached;
continue; // Skip stage execution
}
}
```
**Test Strategy:**
Unit tests: changing stage file content changes the hash, built-in stages have stable hash, cache miss when stage file changes, cache hit when stage file unchanged.

View File

@@ -0,0 +1,77 @@
# Task ID: 100
**Title:** Implement mcpctl cache list Command
**Status:** done
**Dependencies:** 98 ✓
**Priority:** low
**Description:** Add CLI command to list cached proxymodel artifacts with size and age information.
**Details:**
Create `src/cli/src/commands/cache-list.ts`:
```typescript
import { Command } from 'commander';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import Table from 'cli-table3';
const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');
export function registerCacheList(program: Command): void {
program
.command('cache list')
.description('List cached proxymodel artifacts')
.option('--project <name>', 'Filter by project')
.action(async (opts) => {
try {
const files = await readdir(CACHE_DIR);
const table = new Table({
head: ['KEY', 'SIZE', 'AGE'],
});
let totalSize = 0;
for (const file of files) {
const filePath = join(CACHE_DIR, file);
const stats = await stat(filePath);
const age = formatAge(Date.now() - stats.mtimeMs);
const size = formatSize(stats.size);
totalSize += stats.size;
if (opts.project && !file.includes(opts.project)) continue;
table.push([file, size, age]);
}
console.log(table.toString());
console.log(`Total: ${formatSize(totalSize)}`);
} catch {
console.log('No cache entries found');
}
});
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
function formatAge(ms: number): string {
const mins = Math.floor(ms / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
```
**Test Strategy:**
CLI test: list shows cache entries with correct format, --project filter works, empty cache shows appropriate message, size/age formatting is correct.

View File

@@ -0,0 +1,61 @@
# Task ID: 101
**Title:** Implement mcpctl cache clear Command
**Status:** done
**Dependencies:** 98 ✓
**Priority:** low
**Description:** Add CLI command to clear the proxymodel cache, optionally filtered by project.
**Details:**
Create `src/cli/src/commands/cache-clear.ts`:
```typescript
import { Command } from 'commander';
import { readdir, unlink, rmdir } from 'fs/promises';
import { join } from 'path';
const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');
export function registerCacheClear(program: Command): void {
program
.command('cache clear')
.description('Clear the proxymodel cache')
.option('--project <name>', 'Clear only cache for a specific project')
.option('--force', 'Skip confirmation', false)
.action(async (opts) => {
try {
const files = await readdir(CACHE_DIR);
const toDelete = opts.project
? files.filter(f => f.includes(opts.project))
: files;
if (toDelete.length === 0) {
console.log('No cache entries to clear');
return;
}
if (!opts.force) {
console.log(`This will delete ${toDelete.length} cache entries.`);
// Add confirmation prompt
}
for (const file of toDelete) {
await unlink(join(CACHE_DIR, file));
}
console.log(`Cleared ${toDelete.length} cache entries`);
} catch {
console.log('Cache directory does not exist');
}
});
}
```
**Test Strategy:**
CLI test: clears all entries without --project, clears filtered entries with --project, confirmation required without --force, --force skips confirmation.

View File

@@ -0,0 +1,60 @@
# Task ID: 102
**Title:** Implement mcpctl cache stats Command
**Status:** done
**Dependencies:** 98 ✓
**Priority:** low
**Description:** Add CLI command to show cache statistics including hit rates, total size, and entry counts.
**Details:**
Create `src/cli/src/commands/cache-stats.ts`:
```typescript
import { Command } from 'commander';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
const CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');
export function registerCacheStats(program: Command): void {
program
.command('cache stats')
.description('Show cache statistics')
.action(async () => {
try {
const files = await readdir(CACHE_DIR);
let totalSize = 0;
let oldest = Date.now();
let newest = 0;
for (const file of files) {
const filePath = join(CACHE_DIR, file);
const stats = await stat(filePath);
totalSize += stats.size;
oldest = Math.min(oldest, stats.mtimeMs);
newest = Math.max(newest, stats.mtimeMs);
}
console.log(`Entries: ${files.length}`);
console.log(`Total size: ${formatSize(totalSize)}`);
console.log(`Oldest entry: ${formatAge(Date.now() - oldest)} ago`);
console.log(`Newest entry: ${formatAge(Date.now() - newest)} ago`);
// Note: hit rate tracking would require runtime instrumentation
console.log('\nNote: Hit rate statistics require runtime instrumentation.');
} catch {
console.log('No cache data available');
}
});
}
```
**Test Strategy:**
CLI test: shows correct stats for populated cache, handles empty cache gracefully, size formatting is correct.

View File

@@ -0,0 +1,47 @@
# Task ID: 103
**Title:** Add Shell Completions for ProxyModel Commands
**Status:** cancelled
**Dependencies:** 89 ✗, 90 ✗, 91 ✗, 92 ✗, 93 ✗, 94 ✗, 95 ✗, 96 ✗, 97 ✗, 100 ✓, 101 ✓, 102 ✓
**Priority:** low
**Description:** Extend shell completions to include all new proxymodel-related commands, resources, and flags.
**Details:**
Update `src/cli/src/completions.ts` to add completions for:
```typescript
// Resource types
const RESOURCE_TYPES = [...existing, 'proxymodels', 'stages'];
// Command completions
const COMMANDS = {
'get': ['proxymodels', 'stages', ...existing],
'describe': ['proxymodel', 'stage', ...existing],
'create': ['proxymodel', 'stage', ...existing],
'delete': ['proxymodel', 'stage', ...existing],
'proxymodel': ['validate'],
'cache': ['list', 'clear', 'stats'],
};
// Dynamic completions for proxymodel/stage names
async function completeProxymodelName(partial: string): Promise<string[]> {
const models = await loadProxyModels();
return [...models.keys()].filter(n => n.startsWith(partial));
}
async function completeStageName(partial: string): Promise<string[]> {
const stages = listStages();
return stages.map(s => s.name).filter(n => n.startsWith(partial));
}
```
Generate completion scripts for bash, zsh, and fish.
**Test Strategy:**
Manual test: completions work in bash/zsh/fish for all new commands. Test proxymodel name completion, stage name completion, subcommand completion.

View File

@@ -0,0 +1,67 @@
# Task ID: 104
**Title:** Extend Traffic Events for ProxyModel Processing
**Status:** cancelled
**Dependencies:** 80 ✓
**Priority:** medium
**Description:** Add new traffic event types for proxymodel processing: content_original, content_transformed, stage timing, cache hits/misses.
**Details:**
Modify `src/mcplocal/src/http/traffic.ts`:
```typescript
export type TrafficEventType =
| 'client_request'
| 'client_response'
| 'upstream_request'
| 'upstream_response'
| 'client_notification'
// New proxymodel events:
| 'content_original'
| 'content_transformed'
| 'stage_executed'
| 'stage_cache_hit'
| 'stage_cache_miss';
export interface ContentOriginalEvent {
eventType: 'content_original';
sessionId: string;
contentType: 'prompt' | 'toolResult';
sourceName: string;
content: string;
charCount: number;
}
export interface ContentTransformedEvent {
eventType: 'content_transformed';
sessionId: string;
contentType: 'prompt' | 'toolResult';
sourceName: string;
content: string;
charCount: number;
proxyModel: string;
stages: string[];
durationMs: number;
}
export interface StageExecutedEvent {
eventType: 'stage_executed';
sessionId: string;
stageName: string;
inputChars: number;
outputChars: number;
durationMs: number;
cacheHit: boolean;
}
```
Emit these events from the pipeline executor.
**Test Strategy:**
Unit tests: events emitted at correct points in pipeline execution, event payloads contain correct data, cache hit/miss events distinguish correctly. Integration test with inspector showing new events.

View File

@@ -0,0 +1,83 @@
# Task ID: 105
**Title:** Implement Model Studio TUI Base
**Status:** done
**Dependencies:** 104 ✗
**Priority:** medium
**Description:** Create the base TUI for mcpctl console --model-studio that extends --inspect with original vs transformed view.
**Details:**
Create `src/cli/src/commands/console/model-studio.tsx`:
```typescript
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { TrafficEvent } from './types';
interface ModelStudioProps {
projectName: string;
events: TrafficEvent[];
}
export function ModelStudio({ projectName, events }: ModelStudioProps) {
const [selectedIdx, setSelectedIdx] = useState(0);
const [viewMode, setViewMode] = useState<'original' | 'transformed' | 'diff'>('transformed');
const [pauseMode, setPauseMode] = useState(false);
useInput((input, key) => {
if (input === 'j') setSelectedIdx(i => Math.min(i + 1, events.length - 1));
if (input === 'k') setSelectedIdx(i => Math.max(i - 1, 0));
if (input === 'o') setViewMode(m => m === 'original' ? 'transformed' : m === 'transformed' ? 'diff' : 'original');
if (input === 'p') setPauseMode(p => !p);
if (input === 'G') setSelectedIdx(events.length - 1);
});
const selected = events[selectedIdx];
const isContentEvent = selected?.eventType === 'content_original' || selected?.eventType === 'content_transformed';
return (
<Box flexDirection="column" height="100%">
<Box borderStyle="single" padding={1}>
<Text>Model Studio: {projectName}</Text>
<Text> | </Text>
<Text>View: {viewMode}</Text>
<Text> | </Text>
<Text color={pauseMode ? 'red' : 'green'}>{pauseMode ? '⏸ PAUSED' : '▶ LIVE'}</Text>
</Box>
<Box flexGrow={1} flexDirection="row">
{/* Event list sidebar */}
<Box width="30%" borderStyle="single">
{events.map((e, i) => (
<Text key={i} inverse={i === selectedIdx}>
{formatEventLine(e)}
</Text>
))}
</Box>
{/* Content view */}
<Box width="70%" borderStyle="single">
{isContentEvent && (
<ContentView event={selected} mode={viewMode} />
)}
</Box>
</Box>
<Box borderStyle="single">
<Text>[o] toggle view [p] pause [j/k] navigate [G] latest [q] quit</Text>
</Box>
</Box>
);
}
```
Add --model-studio flag to console command.
**Test Strategy:**
Manual test: TUI renders correctly, keyboard navigation works, original/transformed/diff views switch correctly, pause indicator shows correctly.

View File

@@ -0,0 +1,97 @@
# Task ID: 106
**Title:** Implement Pause Queue for Model Studio
**Status:** done
**Dependencies:** 105 ✓
**Priority:** medium
**Description:** Add a pause queue in mcplocal that holds outgoing responses when model studio pause mode is active.
**Details:**
Create `src/mcplocal/src/proxymodel/pause-queue.ts`:
```typescript
interface PausedResponse {
id: string;
sessionId: string;
contentType: 'prompt' | 'toolResult';
sourceName: string;
original: string;
transformed: string;
resolve: (content: string) => void;
timestamp: number;
}
class PauseQueue {
private paused = false;
private queue: PausedResponse[] = [];
private listeners = new Set<(items: PausedResponse[]) => void>();
setPaused(paused: boolean): void {
this.paused = paused;
if (!paused) {
// Release all paused items with their transformed content
for (const item of this.queue) {
item.resolve(item.transformed);
}
this.queue = [];
}
this.notifyListeners();
}
isPaused(): boolean {
return this.paused;
}
async enqueue(item: Omit<PausedResponse, 'resolve' | 'id' | 'timestamp'>): Promise<string> {
if (!this.paused) return item.transformed;
return new Promise(resolve => {
this.queue.push({
...item,
id: crypto.randomUUID(),
timestamp: Date.now(),
resolve,
});
this.notifyListeners();
});
}
editAndRelease(id: string, editedContent: string): void {
const idx = this.queue.findIndex(q => q.id === id);
if (idx >= 0) {
const item = this.queue.splice(idx, 1)[0];
item.resolve(editedContent);
this.notifyListeners();
}
}
releaseOne(id: string): void {
const idx = this.queue.findIndex(q => q.id === id);
if (idx >= 0) {
const item = this.queue.splice(idx, 1)[0];
item.resolve(item.transformed);
this.notifyListeners();
}
}
dropOne(id: string): void {
const idx = this.queue.findIndex(q => q.id === id);
if (idx >= 0) {
const item = this.queue.splice(idx, 1)[0];
item.resolve(''); // Empty response
this.notifyListeners();
}
}
}
export const pauseQueue = new PauseQueue();
```
**Test Strategy:**
Unit tests: enqueue returns immediately when not paused, enqueue blocks when paused, releaseOne/editAndRelease/dropOne work correctly, setPaused(false) releases all.

View File

@@ -0,0 +1,70 @@
# Task ID: 107
**Title:** Implement Edit Mode for Model Studio
**Status:** done
**Dependencies:** 106 ✓
**Priority:** medium
**Description:** Add inline editing capability to model studio for modifying paused responses before release.
**Details:**
Extend `src/cli/src/commands/console/model-studio.tsx`:
```typescript
import { spawn } from 'child_process';
import { writeFileSync, readFileSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
async function editContent(original: string): Promise<string> {
const editor = process.env.EDITOR ?? 'vim';
const tmpFile = join(tmpdir(), `mcpctl-edit-${Date.now()}.txt`);
writeFileSync(tmpFile, original);
return new Promise((resolve, reject) => {
const proc = spawn(editor, [tmpFile], {
stdio: 'inherit',
});
proc.on('close', (code) => {
if (code === 0) {
const edited = readFileSync(tmpFile, 'utf-8');
unlinkSync(tmpFile);
resolve(edited);
} else {
unlinkSync(tmpFile);
reject(new Error(`Editor exited with code ${code}`));
}
});
});
}
// In the TUI component:
useInput(async (input, key) => {
if (input === 'e' && pauseMode && selectedPausedItem) {
const edited = await editContent(selectedPausedItem.transformed);
pauseQueue.editAndRelease(selectedPausedItem.id, edited);
// Emit correction event
trafficCapture.emit({
eventType: 'content_edited',
sessionId: selectedPausedItem.sessionId,
contentType: selectedPausedItem.contentType,
sourceName: selectedPausedItem.sourceName,
original: selectedPausedItem.original,
transformed: selectedPausedItem.transformed,
edited,
timestamp: Date.now(),
});
}
});
```
**Test Strategy:**
Integration test: pressing 'e' opens editor with content, saving and closing applies edit, edit event is emitted with correct before/after content.

View File

@@ -0,0 +1,75 @@
# Task ID: 108
**Title:** Implement Model Switch for Model Studio
**Status:** done
**Dependencies:** 105 ✓, 82 ✓
**Priority:** medium
**Description:** Add ability to switch the active proxymodel for a project mid-session from model studio.
**Details:**
Extend `src/cli/src/commands/console/model-studio.tsx`:
```typescript
function ModelPicker({ models, current, onSelect }: {
models: string[];
current: string;
onSelect: (name: string) => void;
}) {
const [selectedIdx, setSelectedIdx] = useState(models.indexOf(current));
useInput((input, key) => {
if (key.upArrow) setSelectedIdx(i => Math.max(0, i - 1));
if (key.downArrow) setSelectedIdx(i => Math.min(models.length - 1, i + 1));
if (key.return) onSelect(models[selectedIdx]);
});
return (
<Box flexDirection="column" borderStyle="single">
<Text bold>Select ProxyModel:</Text>
{models.map((m, i) => (
<Text key={m} inverse={i === selectedIdx}>
{m === current ? '✓ ' : ' '}{m}
</Text>
))}
</Box>
);
}
// Add to main component:
const [showModelPicker, setShowModelPicker] = useState(false);
const [activeModel, setActiveModel] = useState('default');
useInput((input) => {
if (input === 'm') setShowModelPicker(true);
});
async function switchModel(name: string) {
// Call mcplocal API to switch model
await fetch(`http://localhost:${port}/projects/${projectName}/proxymodel`, {
method: 'PUT',
body: JSON.stringify({ proxyModel: name }),
});
setActiveModel(name);
setShowModelPicker(false);
// Emit model_switched event
trafficCapture.emit({
eventType: 'model_switched',
projectName,
previousModel: activeModel,
newModel: name,
timestamp: Date.now(),
});
}
```
Add PUT endpoint to mcplocal for switching proxymodel.
**Test Strategy:**
Integration test: 'm' opens model picker, selecting a model updates the active model, subsequent content flows through new model, model_switched event is emitted.

View File

@@ -0,0 +1,94 @@
# Task ID: 109
**Title:** Implement Studio MCP Server Tools
**Status:** done
**Dependencies:** 104 ✗, 106 ✓, 107 ✓, 108 ✓
**Priority:** medium
**Description:** Create MCP tools for Claude Monitor to observe traffic, get corrections, switch models, and modify stages.
**Details:**
Extend `src/cli/src/commands/console/inspect-mcp.ts` with studio tools:
```typescript
const studioTools: Tool[] = [
{
name: 'get_content_diff',
description: 'Get original vs transformed vs edited content for a specific event',
inputSchema: {
type: 'object',
properties: {
eventId: { type: 'string', description: 'Event ID' },
},
required: ['eventId'],
},
},
{
name: 'get_corrections',
description: 'Get all user corrections (edits) in a session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Optional session filter' },
},
},
},
{
name: 'get_active_model',
description: 'Get current proxymodel name and stage list for a project',
inputSchema: {
type: 'object',
properties: {
project: { type: 'string' },
},
required: ['project'],
},
},
{
name: 'switch_model',
description: 'Hot-swap the active proxymodel on a project',
inputSchema: {
type: 'object',
properties: {
project: { type: 'string' },
model: { type: 'string' },
},
required: ['project', 'model'],
},
},
{
name: 'reload_stages',
description: 'Force reload all stages from ~/.mcpctl/stages/',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_stage_source',
description: 'Read the source code of a stage file',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
},
},
{
name: 'list_models',
description: 'List available proxymodels',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_stages',
description: 'List available stages',
inputSchema: { type: 'object', properties: {} },
},
];
```
**Test Strategy:**
Integration test with MCP client: each tool returns expected data format, switch_model actually changes the model, reload_stages picks up file changes, get_corrections returns user edits.

View File

@@ -0,0 +1,62 @@
# Task ID: 110
**Title:** Implement RBAC for ProxyModels
**Status:** deferred
**Dependencies:** 87 ✗
**Priority:** low
**Description:** Add 'run' permission on proxymodels resource controlling which proxymodels users can activate on projects.
**Details:**
Update RBAC schema and enforcement:
1. Add to `src/db/prisma/schema.prisma`:
```prisma
// Extend existing RbacBinding or Permission model
enum RbacResource {
// ... existing
proxymodels
}
enum RbacPermission {
// ... existing
run // permission to use a proxymodel
cache // permission to push to shared cache
}
```
2. Add enforcement in `src/mcplocal/src/router.ts`:
```typescript
async function resolveProxyModel(
requestedModel: string,
projectName: string,
userId: string
): Promise<ProxyModelDefinition> {
const models = await loadProxyModels();
const model = models.get(requestedModel);
if (!model) {
console.warn(`Proxymodel '${requestedModel}' not found, using default`);
return models.get('default')!;
}
// Check RBAC permission
const hasPermission = await checkPermission(userId, 'run', 'proxymodels', requestedModel);
if (!hasPermission) {
console.warn(`User lacks 'run' permission for proxymodel '${requestedModel}', using default`);
return models.get('default')!;
}
return model;
}
```
3. 'default' proxymodel requires no permission (always allowed).
**Test Strategy:**
Integration test: user with 'run' permission can use proxymodel, user without permission falls back to default, 'default' always works, permission check logs reason for fallback.

View File

@@ -0,0 +1,76 @@
# Task ID: 111
**Title:** Write Integration Tests for subindex Model
**Status:** done
**Dependencies:** 75 ✓, 76 ✓, 82 ✓, 83 ✗
**Priority:** high
**Description:** Create comprehensive integration tests for the subindex proxymodel processing real content through section-split and summarize-tree.
**Details:**
Create `src/mcplocal/tests/proxymodel/subindex.test.ts`:
```typescript
import { describe, it, expect, beforeAll } from 'vitest';
import { executePipeline } from '../../src/proxymodel/executor';
import { loadProxyModels } from '../../src/proxymodel/loader';
import { createMockProviderRegistry } from '../mocks/providers';
describe('subindex proxymodel', () => {
let proxyModel;
let mockRegistry;
beforeAll(async () => {
const models = await loadProxyModels();
proxyModel = models.get('subindex');
mockRegistry = createMockProviderRegistry({
complete: async (prompt) => 'Mock summary of the content',
});
});
it('splits JSON array into sections', async () => {
const content = JSON.stringify([
{ id: 'flow1', label: 'Thermostat', nodes: [] },
{ id: 'flow2', label: 'Lighting', nodes: [] },
]);
const result = await executePipeline({
content,
contentType: 'toolResult',
sourceName: 'test/get_flows',
projectName: 'test',
sessionId: 'test-session',
proxyModel,
providerRegistry: mockRegistry,
});
expect(result.sections).toHaveLength(2);
expect(result.sections[0].id).toBe('flow1');
expect(result.content).toContain('2 sections');
});
it('provides drill-down to exact JSON content', async () => {
// Test that drilling into a section returns exact original JSON
});
it('uses structural summaries for JSON (no LLM call)', async () => {
// Verify LLM not called for JSON content
});
it('uses LLM summaries for prose content', async () => {
// Verify LLM called for markdown content
});
it('caches summaries across requests', async () => {
// Verify cache hit on second request with same content
});
});
```
**Test Strategy:**
Run with vitest, verify all test cases pass, check LLM call counts are as expected (structural vs prose), verify cache behavior.

View File

@@ -0,0 +1,47 @@
# Task ID: 112
**Title:** Write Documentation for ProxyModel Authoring
**Status:** done
**Dependencies:** 71 ✓, 78 ✓, 93 ✗, 94 ✗
**Priority:** low
**Description:** Create comprehensive documentation for users wanting to create custom stages and proxymodels.
**Details:**
Create documentation covering:
1. `docs/proxymodels/authoring-guide.md` - Complete guide from PRD's "Authoring Guide" section:
- Concepts: stages, proxymodels, framework
- File locations
- Step-by-step stage creation
- Step-by-step proxymodel creation
- Testing with mcpctl proxymodel validate
- Section drill-down
- Cache usage
- Error handling
- Full example
2. `docs/proxymodels/built-in-stages.md` - Reference for all built-in stages:
- passthrough
- paginate
- section-split
- summarize-tree
- Config options for each
3. `docs/proxymodels/api-reference.md` - Type reference:
- StageHandler
- StageContext
- StageResult
- Section
- LLMProvider
- CacheProvider
4. Update main README with proxymodels overview.
**Test Strategy:**
Review documentation for completeness, verify all code examples compile, test example stage from documentation works end-to-end.

View File

@@ -0,0 +1,31 @@
# Task ID: 113
**Title:** Write Documentation for Model Studio
**Status:** done
**Dependencies:** 105 ✓, 106 ✓, 107 ✓, 108 ✓, 109 ✓
**Priority:** low
**Description:** Create documentation for using model studio for live proxymodel development and debugging.
**Details:**
Create `docs/proxymodels/model-studio.md` covering:
1. Overview: Three-window setup (Claude Client, Model Studio, Claude Monitor)
2. Starting Model Studio: `mcpctl console --model-studio <project>`
3. Keyboard shortcuts reference
4. Viewing original vs transformed content
5. Pause mode: when and why to use it
6. Editing paused responses
7. Switching proxymodels mid-session
8. Using Claude Monitor to observe and modify stages
9. The correction workflow: edit → observe → adjust stage → verify
10. MCP tools available to Claude Monitor
11. Troubleshooting common issues
**Test Strategy:**
Review documentation for completeness, verify all described features work as documented.

View File

@@ -0,0 +1,74 @@
# Task ID: 114
**Title:** ProxyModel v2: Code-based MCP middleware plugin system
**Status:** done
**Dependencies:** None
**Priority:** high
**Description:** Redesign the ProxyModel framework from a YAML-configured content transformation pipeline into a full code-based MCP middleware plugin system. Proxy models become TypeScript files that can intercept any MCP request/response, create synthetic tools, maintain per-session state, and compose via multiple inheritance with compile-time conflict detection. The existing gate functionality (begin_session, tools/list filtering, prompt scoring, ungating) becomes the first proxy model implementation, proving the framework works by implementing gate entirely as a plugin with zero gate-specific code in router.ts.
**Details:**
## Vision
A proxy model is a TypeScript code file (not YAML) that acts as full MCP middleware. It can:
- Intercept any MCP request (initialize, tools/list, tools/call, resources/*, prompts/*)
- Modify any response before it reaches the client
- Create synthetic tools (e.g. begin_session doesn't exist upstream)
- Maintain per-session state (gated/ungated, accumulated tags, etc.)
- Access project resources (prompts, servers, config)
- Transform content (what stages do today: paginate, section-split, etc.)
## Key design decisions
1. Code not YAML: Proxy models live as .ts files in a known directory (e.g. ~/.mcpctl/proxymodels/). File exists = model exists. No create/delete via CLI.
2. Stages deprecated: No separate stage resource. Content transformation is just code inside the proxy model.
3. Multiple inheritance: A model can extend [gate, subindex] to compose behaviors from multiple parents. Conflicts (two parents intercepting the same method incompatibly) detected at load/compile time, not runtime.
4. Gate is just a proxy model: The ~300 lines of gate logic in router.ts move into a gate.ts proxy model file. Router becomes thin plumbing (~100 lines).
5. gated:true replaced by proxyModel field: Projects get a proxyModel: gate field. If the assigned model implements gating, the project is gated. No separate boolean.
6. Discoverable as resources: mcpctl get proxymodels lists available models (discovered from files). mcpctl describe proxymodel gate shows details. But no create/delete commands.
7. Attached to projects: mcpctl edit project foo --proxyModel gate or via apply YAML.
## Framework interface (sketch)
export interface ProxyModelContext {
session: SessionState;
project: ProjectConfig;
upstream: UpstreamClient;
llm?: LLMProvider;
cache?: CacheProvider;
}
export interface ProxyModel {
name: string;
extends?: string[];
onInitialize?(ctx, request): Promise<InitializeResult>;
onToolsList?(ctx): Promise<Tool[]>;
onToolCall?(ctx, name, args): Promise<ToolResult | null>;
onResourceRead?(ctx, uri): Promise<ResourceContent | null>;
transformContent?(ctx, content, contentType): Promise<string>;
createSessionState?(): Record<string, unknown>;
}
## Migration path
1. Define the ProxyModel TypeScript interface
2. Implement the plugin loader (discover .ts files, compile, validate inheritance, detect conflicts)
3. Implement the router integration (router delegates to loaded proxy model)
4. Extract gate logic from router.ts into gate.ts proxy model
5. Extract content pipeline (passthrough, paginate, section-split) into proxy model code
6. Add proxyModel field to Project schema (replaces gated boolean)
7. Add CLI: get proxymodels, describe proxymodel, edit project --proxyModel
8. Add smoke tests: gate proxy model produces identical behavior to current hardcoded gate
9. Deprecate gated field (backward compat: gated:true maps to proxyModel:gate)
## Supersedes
This task supersedes deferred tasks 83, 85-97, 98-99, 103, 104, 110, 111-112 which assumed the old YAML/stage architecture.
**Test Strategy:**
1. Gate proxy model smoke test: identical behavior to current hardcoded gate (begin_session, tools/list filtering, ungating). 2. Composition test: model extending [gate, paginate] inherits both behaviors. 3. Conflict detection test: two parents intercepting same hook differently = compile-time error. 4. Discovery test: drop a .ts file in proxymodels dir, mcpctl get proxymodels shows it. 5. Existing smoke tests (proxy-pipeline.test.ts) pass unchanged after migration.

View File

@@ -0,0 +1,29 @@
# Task ID: 115
**Title:** RBAC tasks namespace split and alias repoint
**Status:** pending
**Dependencies:** None
**Priority:** high
**Description:** Split the RBAC 'tasks' resource into 'tasks' (work items) and 'inference-tasks', update RBAC_RESOURCES and RESOURCE_ALIASES in rbac-definition.schema.ts
**Details:**
In src/mcpd/src/validation/rbac-definition.schema.ts:
1. Update RBAC_RESOURCES array to include: 'epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks'
2. Update RESOURCE_ALIASES:
- task → tasks (work items)
- epic → epics
- epictask → epictasks, et → epictasks
- repo → repos, repository → repos, repositories → repos
- session → sessions
- inference-task → inference-tasks
3. REMOVE the entry 'inference-tasks': 'tasks'
4. Export normalizeResource unchanged (it uses the alias map)
**Test Strategy:**
Unit tests in src/mcpd/tests/validation/: verify normalizeResource('task') returns 'tasks', normalizeResource('inference-task') returns 'inference-tasks', normalizeResource('et') returns 'epictasks'. Test that RBAC_RESOURCES includes all new entries.

View File

@@ -0,0 +1,26 @@
# Task ID: 116
**Title:** One-shot RBAC binding backfill migration
**Status:** pending
**Dependencies:** 115
**Priority:** high
**Description:** Implement guarded startup migration to rewrite existing 'tasks' bindings to 'inference-tasks' so pre-existing grants continue authorizing inference endpoints
**Details:**
In src/mcpd/src/main.ts or a new bootstrap module:
1. Read system settings from SYSTEM_SETTINGS_SECRET (see src/mcpd/src/bootstrap/system-settings.ts pattern)
2. Check for boolean key 'rbacTasksBackfillComplete'
3. If false/missing: iterate rbacRepo.findAll(), for each RbacDefinition row, rewrite roleBindings where resource === 'tasks' to resource: 'inference-tasks'
4. Use rbacRepo.update() to persist changes
5. Set 'rbacTasksBackfillComplete': true in system settings after completion
6. Wildcard bindings (resource: '*') are untouched
7. Model on migrateAdminRole pattern in main.ts
**Test Strategy:**
Unit test: create RbacDefinition with resource:'tasks', run backfill, verify resource changed to 'inference-tasks'. Test idempotency: run twice, verify no duplicate rewrites. Test wildcard unchanged.

View File

@@ -0,0 +1,25 @@
# Task ID: 117
**Title:** Add Epic, EpicTask, EpicTaskEdge Prisma models
**Status:** pending
**Dependencies:** None
**Priority:** high
**Description:** Add Epic, EpicTask, EpicTaskEdge models with enums EpicStatus and EpicTaskStatus to schema.prisma following PRD specifications
**Details:**
In src/db/prisma/schema.prisma add:
1. enum EpicStatus { open, delivered, delivered_with_tradeoffs, cancelled }
2. enum EpicTaskStatus { todo, in_progress, done, cancelled }
3. model Epic with: id, name, description, content (@db.Text), doneCriteria (Json), acceptedTradeoffs (Json), projectId, status, semver, currentRevisionId, ownerId, version, createdAt, updatedAt. Relations: project, owner (User), epicTasks[]. @@unique([name, projectId]), @@index([projectId]), @@index([name])
4. model EpicTask with: id, name, description, epicId, doneCriteria (Json), status, ownerId, version, createdAt, updatedAt. Relations: epic, owner, tasks[], dependsOn (EpicTaskEdge[]), blocks (EpicTaskEdge[]). @@unique([name, epicId]), @@index([epicId])
5. model EpicTaskEdge with: id, fromId, toId, createdAt. Relations using @relation names. @@unique([fromId, toId]), @@index([fromId]), @@index([toId])
6. Add reverse relations to User and Project models
**Test Strategy:**
Schema tests in src/db/tests/: create Epic, verify unique constraint on (name, projectId), test cascade delete from Project, test EpicTask creation with Epic reference, test EpicTaskEdge uniqueness.

View File

@@ -0,0 +1,25 @@
# Task ID: 118
**Title:** Add Task, TaskVerdict, TaskEvent Prisma models
**Status:** pending
**Dependencies:** 117
**Priority:** high
**Description:** Add Task, TaskVerdict, TaskEvent models with enums TaskState and TaskRisk to schema.prisma for work item tracking
**Details:**
In src/db/prisma/schema.prisma add:
1. enum TaskState { todo, in_progress, building, self_testing, qa_review, security_review, sre_review, done, awaiting_review, merged, reopened, cancelled }
2. enum TaskRisk { trivial, normal, high }
3. model Task with: id, name, title, description (@db.Text), epicTaskId, repoId?, assigneeId?, state, risk, branch?, headCommit?, prUrl?, prNumber?, reviewNote? (@db.Text), ownerId, version, createdAt, updatedAt. Relations: epicTask, repo (CodeRepo?), assignee (Agent?), owner, verdicts[], sessions[], events[]. @@unique([name, epicTaskId]), indexes on epicTaskId, state, assigneeId
4. model TaskVerdict with: id, taskId, stage (TaskState), agentId?, commit, passed, summary (@db.Text), findings (Json), createdAt. Relations: task, agent. @@index([taskId]), @@index([taskId, stage])
5. model TaskEvent with: id, taskId, fromState?, toState, actorType, actorId?, note? (@db.Text), createdAt. @@index([taskId, createdAt])
6. Add reverse relations to Agent model (assignedTasks[], verdicts[])
**Test Strategy:**
Schema tests: create Task linked to EpicTask, verify cascade on EpicTask delete, test TaskVerdict immutability pattern, test TaskEvent append-only by creating multiple events for same task.

View File

@@ -0,0 +1,25 @@
# Task ID: 119
**Title:** Add CodeRepo Prisma model
**Status:** pending
**Dependencies:** None
**Priority:** high
**Description:** Add CodeRepo model to schema.prisma for git repository management within projects
**Details:**
In src/db/prisma/schema.prisma add:
1. model CodeRepo with: id, name, projectId, url, defaultBranch (@default("main")), path (@default("")), provider (@default("gitea")), credSecretId?, credSecretKey?, ownerId, version, createdAt, updatedAt
2. Relations: project (Project), cred (Secret?), owner (User), tasks (Task[])
3. @@unique([name, projectId]), @@index([projectId])
4. Add repos CodeRepo[] relation to Project model
5. Add codeRepos CodeRepo[] relation to Secret model
6. Add ownedRepos CodeRepo[] relation to User model
**Test Strategy:**
Schema tests: create CodeRepo linked to Project, verify unique constraint on (name, projectId), test cascade delete from Project, test SetNull behavior when Secret is deleted.

View File

@@ -0,0 +1,26 @@
# Task ID: 120
**Title:** Add AgentProject junction model
**Status:** pending
**Dependencies:** None
**Priority:** high
**Description:** Add AgentProject many-to-many junction table linking Agents to Projects with role and optional LLM override
**Details:**
In src/db/prisma/schema.prisma add:
1. model AgentProject with: id, agentId, projectId, role (@default("developer")), llmId?, createdAt
2. Relations: agent (Agent), project (Project), llm (Llm?)
3. @@unique([agentId, projectId]), @@index([agentId]), @@index([projectId])
4. Add projectMemberships AgentProject[] to Agent model
5. Add agentMemberships AgentProject[] to Project model
6. Add agentProjects AgentProject[] to Llm model
7. Keep Agent.projectId for backward compatibility (one release)
**Test Strategy:**
Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete.

View File

@@ -0,0 +1,24 @@
# Task ID: 121
**Title:** Add AgentSession Prisma model
**Status:** pending
**Dependencies:** 118, 120
**Priority:** high
**Description:** Add AgentSession model with SessionState enum for ephemeral per-session pod tracking
**Details:**
In src/db/prisma/schema.prisma add:
1. enum SessionState { pending, provisioning, running, ended, failed }
2. model AgentSession with: id, agentId, projectId, taskId?, containerId?, state, image, expiresAt, lastError? (@db.Text), ownerId, createdAt, updatedAt, endedAt?
3. Relations: agent (Agent), project (Project), task (Task?), owner (User)
4. @@index([state]), @@index([taskId]), @@index([agentId])
5. Add sessions AgentSession[] to Agent, Project, Task, User models
**Test Strategy:**
Schema tests: create AgentSession with all relations, test cascade on Agent/Project delete, test SetNull on Task delete, verify state enum transitions.

Some files were not shown because too many files have changed in this diff Show More