ecd86fa35758c25a7b7bdd981646f9f65753f458
120 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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 |
|||
| 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
|
|||
| 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
|
|||
| 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
|
|||
| 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
|
|||
|
|
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
|
||
|
|
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 |
||
| 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
|
|||
|
|
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 |
||
|
|
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 |
||
| eb3e558a44 |
Merge pull request 'feat(secrets): survive OpenBao outages and report real backend health' (#115) from feat/openbao-resilience into main
Some checks failed
|
|||
|
|
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
|
||
|
|
c66502e590 |
fix(mcplocal): fetch project instructions with the caller's token (#113)
Some checks failed
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|