Commit Graph

539 Commits

Author SHA1 Message Date
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
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