From d1fd0bcf68e8ba9eec6e864b5796ad872c522abc Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 14 Sep 2026 21:58:59 +0300 Subject: [PATCH 01/13] docs: plan MCP source reconciliation Co-Authored-By: mecatl --- docs/acceptance/README.md | 1 + docs/acceptance/mcp-source-reconciliation.md | 128 +++++++++++++++++++ docs/adr/0342-mcp-source-reconciliation.md | 80 ++++++++++++ docs/adr/README.md | 3 +- 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 docs/acceptance/mcp-source-reconciliation.md create mode 100644 docs/adr/0342-mcp-source-reconciliation.md diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 6e1645c9a7..8fe6e5ff71 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -155,6 +155,7 @@ PR after verification. There is no cleanup or status-only PR. system escape and deterministic crash-residue reaping. Status: landed. - [Session title generation and token usage](session-title-generation.md) — mecatui `/title`, an opt-in routed model title after up to three genuine prompts, and durable title-model token attribution. Status: draft. - [Mecatui-owned configurable terminal titles](mecatui-terminal-title-controller.md) — replaces Bubble Tea title emission with a renderer-serialized OSC 0 controller, user-global plain-text title templates over display-safe status facts, explicit disablement precedence, and live-run `/session` identity access. Status: proposed. +- [MCP source reconciliation](mcp-source-reconciliation.md) — one source-neutral Build-owned reconciler publishes immutable leased direct/global MCP generations, durably attenuates identity-bound grants, and exposes explicit owner-authorized `/mcp-refresh` for additions. Status: proposed. - [Per-upstream MCP broker OAuth grants](mcp-broker-multi-upstream-oauth.md) — accept multiple broker OAuth upstreams while keeping grants, callback state, authenticated discovery, and workspace-enrollment progression backend-scoped. Status: draft. - [Broker MCP status](broker-mcp-status.md) — approved owner-scoped broker connector inventory and enrollment/catalogue status behind `/mcp`, without probes or new diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md new file mode 100644 index 0000000000..f7f3063dad --- /dev/null +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -0,0 +1,128 @@ +# MCP source reconciliation — acceptance plan + +**Contract:** human-reviewed/v2 +**Work classification:** Architectural — changes process-wide MCP publication, durable capability identity and persistence, public controls, and importable engine APIs. +**Decision record:** [ADR 0342](../adr/0342-mcp-source-reconciliation.md) +**Phase:** Source-neutral dynamic direct/global MCP reconciliation +**Status:** proposed, 2026-09-14. Directing-human decisions are settled; ready for Plan / Interface review. +**Delivery:** Split. Generation ownership, durable authority migration, and additive public controls require contract review before implementation. +**Expected tasks:** deferred to orchestration. +**Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) +**Plan PR:** absent until opened. +**Approved baseline:** absent until the Plan / Interface PR merges. + +Mecatl currently resolves configured and ToolHive-discovered direct MCP servers into one process-start manager and catalogue. Later source observations and MCP list-changed notifications cannot safely replace existing session tools. This plan introduces one Build-owned, source-neutral reconciler over the ordered source seam in [`internal/adapter/mcp/source/source.go`](../../internal/adapter/mcp/source/source.go), preserving configured/static-over-ToolHive precedence. It publishes immutable generation-owned bundles instead of mutating `tool.Catalog` or `mcp.Manager` membership in place; [`internal/app/catalog.go`](../../internal/app/catalog.go) remains the single full catalogue registration path and accepts one explicit generation contribution. + +Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable secret-free binding digest covering its source/server/routing and complete dispatch-relevant contract. A same-name source, endpoint, schema, or read-only change is removal plus addition. Run entry durably attenuates removed or mismatched grants before model work; explicit owner-authorized `/mcp-refresh` is the only way an established root session adopts current additions. Broker/protected MCP remains under `/tools-connect` and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. + +## Human decisions + +- [x] Source and publication model — Decision: one Build-owned source-neutral reconciler consumes complete immutable desired snapshots from ordered sources. Configured/static sources retain precedence over ToolHive collisions; ToolHive is the first dynamic source and future dynamic sources use the same seam. Publication produces immutable complete **active** generations, never in-place catalogue or manager mutation. +- [x] Durable direct-MCP identity — Decision: add bounded `session.DirectMCPBinding` records to `session.Authority`. Each record binds one tool or synthetic resource capability name to a fixed-format secret-free digest over source identity, server identity, canonical endpoint/routing identity, complete advertised contract/schema, and dispatch-relevant annotations including read-only. Credentials, tokens, headers, and rotating OAuth material are excluded. Name plus matching digest is required for execution. +- [x] Removal, mismatch, and legacy behavior — Decision: same-name binding change is removal plus addition. At the next run entry, removed/mismatched grants are durably revoked and their names removed before model/tool work; persistence uncertainty fails closed. Revoked records remain durable unavailable evidence, so reappearance never automatically regrants, including after restart. A legacy authority with no binding field performs one persisted adoption only for same-name current direct MCP capabilities already present in its exact name ceiling; it never adds an absent name. +- [x] Explicit refresh set algebra — Decision: `/mcp-refresh` replaces only the direct/global MCP subset. It preserves every capability not identified by durable direct-MCP records, removes all prior direct-MCP capability names, retains omitted/mismatched old records as revoked evidence, and unions successfully active generation bindings and names. It never replaces core, latent Team, profile, client-MCP, or broker authority. +- [x] Generation ownership and leases — Decision: a generation owns its direct/global servers/manager view, tools, resource/prompt provider, source/status snapshot, binding identities, and generation-bound shared engine/factories. Shared, cached per-session, specialist/team, run, and out-of-run MCP operations hold explicit generation leases for their useful lifetime. Publication atomically swaps the shared-engine generation; stale cached engines are tagged, evicted/rebuilt under existing guards, and bounded so idle caches cannot retain generations forever. No generation closes before every engine/operation/run lease drains. +- [x] Reconciliation failure semantics — Decision: source consultation failure retains that source's last-known-good snapshot; a successful snapshot is desired truth and successful removal withdraws immediately for new generations. Reuse unchanged healthy connections. Connect additions independently. A changed binding withdraws the old binding before attempting its replacement; replacement failure leaves that capability unavailable rather than retaining the superseded binding. Unrelated valid changes still publish, status becomes stale/degraded, and `/mcp-refresh` grants only successfully active bindings. Partially constructed candidates close. +- [x] Trigger and resource bounds — Decision: automatic ToolHive polling is bounded, jittered, and enabled only with ToolHive discovery; MCP list-changed notifications and manual refresh use the same reconciler. There is one active cycle, at most one coalesced queued invalidation, and a global cooldown; caller cancellation never cancels shared work, and manual refresh during cooldown adopts the current active generation. Hard finite source/server/tool/schema/generation-retention bounds are internal constants with fail-stale diagnostics. Automatic work never launches browser/OAuth consent. +- [x] Persistence protocol — Decision: refresh and automatic attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability` discipline; shared-store mutation without proven exclusion fails closed. Mutate a detached loaded aggregate, persist, and confirm ambiguous saves by authoritative reload. No widened live engine is published before confirmation. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against current generation. +- [x] Removed-call behavior — Decision: remove the process-global tombstone cache. A run receives bounded unavailable names derived only from that session's durable revoked/mismatched bindings. Dispatch returns `removed from current MCP configuration; do not retry unless the catalog changes` as a bounded permanent result without advertising, authorizing, or dispatching the name. No synthetic model/history refresh message is added. +- [x] Status and compatibility — Decision: `/mcp` reports observed versus active generation and stale/degraded/reconciling truth. Preserve existing `McpServerInfo.url` and pre-shadow source rows for compatibility; additive status, diagnostics, and removal results introduce no new secret exposure. Settings/CLI/environment inputs remain restart-only and project-tier MCP remains ignored. + +## Interface contract + +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. `RefreshMcpSourcesRequest` contains only `string session_id = 1`. `RefreshMcpSourcesResponse` contains `uint64 generation = 1` and `bool changed = 2`, where `generation` is the active generation whose direct-MCP subset was durably installed and `changed` reports an exact authority change. Add `ServerCapabilities.mcp_refresh = 30`. Extend `ListMcpSourcesResponse` after `sources = 1` with `uint64 observed_generation = 2`, `uint64 active_generation = 3`, `bool stale = 4`, `bool reconciling = 5`, and `bool degraded = 6`. Preserve `McpServerInfo.url = 2` and the existing per-source pre-shadow row semantics; generation fields are separate response metadata. HTTP adds bodyless `POST /v1/sessions/{id}/mcp-refresh`. Both transports use existing ownership concealment and map ineligible/conflicting state to `FAILED_PRECONDITION`/409. Caller cancellation stops only waiting; it does not cancel shared reconciliation. +- **Exported Go APIs / interfaces:** In `engine/session`, add `type DirectMCPBinding struct` with exact fields `Capability string`, `Digest string`, and `Revoked bool`; their JSON keys are respectively `capability`, `digest`, and `revoked` (`revoked` uses `omitempty`). Add `Authority.DirectMCPBindings []DirectMCPBinding` with JSON key `direct_mcp_bindings` and no `omitempty`: a nil slice means a legacy authority, while a present empty JSON array means migration completed with no direct grants. Validation allows at most 512 records; each capability is non-empty, valid UTF-8, control-free, at most 256 bytes; each digest is exactly `sha256:` plus 64 lowercase hexadecimal characters; capabilities are unique; active inputs may not set `Revoked`; rejected mutations are non-mutating. Add exactly `func (s *Session) AttenuateDirectMCPAuthority(active []DirectMCPBinding) error` and `func (s *Session) ReplaceDirectMCPAuthority(active []DirectMCPBinding) error`. Both are idle-only, require bound ordinary-root authority and no pending permission/external authorization/workspace enrollment, preserve all non-direct fields, clone inputs, and apply the algebra below; `Attenuate…` never adds authority and performs one-time legacy adoption, while `Replace…` is the explicit-widen path. Delegated authority derivation copies only active parent binding records whose capabilities survive the derived child ceiling; resume validation requires the persisted child's bindings to remain a digest-identical subset of the current parent/run generation and never invokes legacy adoption for a child. In `engine/agent.RunRequest`, add `UnavailableTools []string`: a bounded duplicate-free run-scoped set (maximum 512 names, each using the same capability framing bound) that shadows matching names before request-schema projection, catalogue lookup, authority evaluation, permission, or dispatch; it produces the fixed permanent result and is never advertised or authority-exempt. These exported additions require `task api:update` and Added/minor `engine/CHANGELOG.md` entries. Generation/source/lease interfaces remain internal; no ToolHive type enters `engine/`. +- **Tool schemas:** No model-visible tool is added. A direct/global MCP tool is advertised and executable only when its name is in `CapabilitySet.Tools`, its active `DirectMCPBinding` digest equals the run generation's digest, and existing permission evaluation allows it. The synthetic `governance.MCPResourceCapability(server)` follows the same binding rule for direct MCP resources. A changed source, canonical endpoint/routing identity, complete schema/contract, or dispatch annotation including read-only changes the digest and is remove-plus-add. `RunRequest.UnavailableTools` shadows even a current same-name catalogue entry before schema projection or lookup; unavailable names cannot pass authority/permission or dispatch, and a generated call receives the fixed permanent result. +- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing `mcp.servers`, MCP CLI flags, ToolHive enable/group options, and environment-referenced credentials are process-start snapshots reused by every reconciliation. ToolHive discovery alone enables automatic polling. Project-tier MCP remains ignored. Mecatui adds exact bare builtin `/mcp-refresh`, capability-gated by `mcp_refresh`, with no arguments. +- **Events / persistence:** Add no event and no generation ID to the session snapshot. `Authority.DirectMCPBindings` round-trips through the existing authority snapshot; absent means legacy, present empty means adopted/no grants. Composition computes each digest from canonical source identity, server identity, canonical endpoint/routing identity (raw URL never enters the digest record), complete advertised tool contract/schema, and dispatch annotations; secret/token/header/rotating OAuth material is excluded. Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. Automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. Explicit refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`; bindings become every record from `A` active plus prior records whose `Capability` is absent from `A` retained as revoked evidence, subject to the 512-record bound. For a same-name changed binding, explicit refresh replaces the old revoked record with the new active record because that explicit action is the regrant; at most one record exists per capability. Legacy attenuation first sets a present binding list from only `T ∩ C(A)`, persists it, and never adds `C(A) \ T`; later reappearance cannot adopt because the list is no longer nil. Refresh/attenuation operate on a detached authoritative load under `runEntryMu`, mutation lease, and mutation capability; they save before publishing success or starting model work. After ambiguous Save, reload: exact candidate durable means success; exact old state means failure; any mismatch/unknown fails closed. `/mcp-refresh` builds no engine. No process-global tombstones persist or cache. +- **Security / authority:** Refresh is owner-authorized, ordinary-root-only, idle-only, and rejected for running, awaiting, child/delegation, debug, pending permission/authorization/enrollment, broker-refresh conflict, or unproved shared-store exclusion. Automatic attenuation uses the same mutation capability/lease discipline and failure prevents all model/tool work. The request supplies no names, source, config, endpoint, or credential. Digest input is secret-free and persisted output is hash-only. Broker/protected and client-provided MCP remain outside reconciliation. Existing URL projection is not broadened; new generation metadata, diagnostics, errors, and model result fields are canary-tested against raw config, headers, tokens, and credentials. +- **Compatibility / migration:** Existing `ListMcpSources` clients retain URLs and pre-shadow rows and ignore additive response fields. Existing sessions receive one-time, non-widening legacy binding adoption before their first post-upgrade run; it is durably confirmed before model work. A current same-name entry may be adopted only if already in the legacy exact name ceiling. Existing settings/CLI behavior, static-over-ToolHive precedence, and streaming-HTTP transport remain. ADR 0342 supersedes ADR 0057 only for its deferred no-live-catalogue decision; ADR 0057's notification transport/lazy-list/reconnect details remain. Broker refresh remains ADR 0335 `/tools-connect`; client MCP remains session-local. Implementation updates living architecture/design/user docs and ADR 0027 inventories; this Plan PR does not describe unshipped behavior there. + +## In scope — 4 scenarios, in implementation order + +### Scenario 1 — Desired source snapshots publish a bounded active generation + +The reconciler consults ordered sources once per cycle, preserves source LKG on consultation failure, and treats every successful snapshot—including empty—as desired truth. It reuses unchanged healthy bindings, connects additions independently, and treats a changed binding as withdrawal followed by a new attempt. Valid unrelated changes publish even when an addition/replacement fails; the immutable generation is the complete successfully **active** set, while status remains stale/degraded against desired truth. This is [ADR 0342](../adr/0342-mcp-source-reconciliation.md)'s source-neutral publication boundary. + +**Acceptance:** +- AC1.1: Static/configured and ToolHive snapshots merge deterministically with earlier/static precedence, one consultation per source per cycle, preserved pre-shadow inventory rows, and secret-free binding digests covering source/server/routing/contract/read-only identity for every tool and direct resource capability. + - verify: `TestMCPSourceReconciliation_Scenario1_OrderedBindingsAndPrecedence` +- AC1.2: Consultation failure retains that source's LKG; successful empty withdraws it. Unchanged connections are reused, additions connect independently, and changed bindings withdraw old before replacement; failed additions/replacements are unavailable while unrelated healthy changes publish, partial candidates close, and status is stale/degraded. + - verify: `TestMCPSourceReconciliation_Scenario1_FailedEmptyAndPartialActiveSnapshots` +- AC1.3: ToolHive polling is disabled without ToolHive discovery and otherwise bounded/jittered. Notifications and manual requests enter one reconciler with one active cycle, at most one queued invalidation, and global cooldown; sequential refreshes cannot force reconnect storms, caller cancellation does not cancel shared work, and automatic work never launches consent. + - verify: `TestADR_0342_TriggersAreBoundedCoalescedAndCancellationDetached` +- AC1.4: Hard finite source/server/tool/schema/generation-retention constants fail stale with bounded diagnostics rather than allocating or retaining unbounded state. + - verify: `TestADR_0342_ReconciliationAndRetentionBounds` + +### Scenario 2 — Generation-owned engines and operations drain safely + +A generation owns the direct/global manager view, tools, resources/prompts, status, bindings, and generation-bound factories/engines. `assembleCatalog` remains the only full registration path and takes one generation contribution. Service atomically swaps the shared-engine generation, tags cached engines, and evicts/rebuilds stale idle entries under existing run-entry/liveness guards. Engines hold leases for useful lifetime; runs and out-of-run MCP operations hold short leases where their engine lease is insufficient. [ADR 0342](../adr/0342-mcp-source-reconciliation.md) makes lifetime—not only a run pointer—the close-safety boundary. + +**Acceptance:** +- AC2.1: Shared fast path and cached selector, no-FS, client-MCP, mode-specific, and direct-MCP debug engines are generation-tagged; publication swaps the shared engine atomically, stale idle caches are boundedly evicted/rebuilt, and active cached engines close only after their run. A debug session remains non-refreshable and its existing selected-name ceiling additionally requires digest-identical bindings; removal or identity drift fails closed without admitting additions. + - verify: `TestMCPSourceReconciliation_Scenario2_SharedAndCachedEngineMatrix` +- AC2.2: Direct RunTeam, Subagent/Parallel/Team members, named specialists, and referenced-agent MCP use generation-bound engines/factories from the root generation where applicable; derived child authorities copy only digest-identical active direct bindings that survive their tool ceiling, persisted child resume rejects drift, and reconciliation racing activity cannot mix schemas, lookup, permission, dispatch, resources/prompts, or manager identity. + - verify: `TestMCPSourceReconciliation_Scenario2_DelegationRunTeamAndReferenceMatrix` +- AC2.3: `ListMcpResources`, `ReadMcpResource`, `ListMcpPrompts`, and `GetMcpPrompt` each acquire a short-lived current-generation lease. Each call is internally consistent; list-then-later-get is not snapshot-atomic and a stale identifier fails visibly. Prompt expansion inside a run uses that run's generation. + - verify: `TestMCPSourceReconciliation_Scenario2_OutOfRunResourcePromptLeases` +- AC2.4: No displaced generation closes until all shared/per-session/specialist/team engine leases and operation/run leases drain; each closes once. Stale idle eviction is bounded, active leases drain after use, and shutdown joins polling/reconciliation and closes every generation without leaks. + - verify: `TestADR_0342_GenerationLeaseDrainEvictionAndShutdown` + +### Scenario 3 — Durable bindings attenuate before runs and refresh exactly + +At run entry, the Service compares durable active bindings with the selected generation before building/using an engine. Missing or mismatched identities are revoked and removed on a detached aggregate, then durably confirmed under existing exclusion before any model/tool work. Legacy name-only authorities adopt only current same-name entries already in their ceiling, once. `/mcp-refresh` uses the exact direct-subset algebra in the interface contract, persists first, and leaves engine rebuilding to the next run. This preserves the aggregate discipline in [`engine/session/principal.go`](../../engine/session/principal.go) and the durable authority decision in [ADR 0342](../adr/0342-mcp-source-reconciliation.md). + +**Acceptance:** +- AC3.1: Same-name source/server/canonical-endpoint/schema/read-only change is remove-plus-add. Automatic run-entry attenuation removes the name, retains the old digest revoked, confirms persistence before model work, and never automatically regrants on reappearance or restart; save failure/ambiguity without exact confirmation means zero model/tool work. + - verify: `TestMCPSourceReconciliation_Scenario3_AttenuationPersistsBeforeRun` +- AC3.2: A legacy ordinary-root authority with absent bindings adopts and persists only currently active direct capabilities whose names were already authorized, marks migration complete even when empty, never adds an absent name, and cannot adopt a later reappearance automatically. Delegation never treats a child as a legacy root: new children receive the digest-identical active subset derived from the parent, and resumed children fail closed on binding drift. + - verify: `TestMCPSourceReconciliation_Scenario3_LegacyAdoptionIsOneTimeAndNonWidening` +- AC3.3: Owner-authorized argument-free `/mcp-refresh` preserves `T \ C(B)`, replaces only current direct/global grants with successfully active `A`, retains prior identities whose capability is omitted as revoked, replaces a same-name revoked identity only through this explicit regrant, preserves conversation/placement/non-direct authority, builds no engine, and the next run rebuilds against current generation. + - verify: `TestMCPSourceReconciliation_Scenario3_ExplicitRefreshSetAlgebra` +- AC3.4: Refresh and attenuation use owner checks, `runEntryMu`, real mutation lease/capability, detached load, and authoritative reload after ambiguous Save. Exact candidate durable reports success, exact old durable reports failure, and mismatched/unknown state fails closed; no widened live engine/session appears before confirmation. + - verify: `TestInvariant_direct_mcp_authority_mutation_is_confirmed_before_use` + +### Scenario 4 — Removed calls, status, and controls are safe and transport-equivalent + +The server derives unavailable names from the session's durable revoked/mismatched records and supplies them via `RunRequest.UnavailableTools`; there is no global tombstone cache. Existing `/mcp` inventory keeps URL and pre-shadow compatibility while adding generation truth. gRPC, HTTP, and mecatui expose the same owner-scoped refresh, without crossing broker/client boundaries. See [ADR 0342](../adr/0342-mcp-source-reconciliation.md). + +**Acceptance:** +- AC4.1: An unavailable stale call is not advertised or authorized and does not dispatch; it returns the exact bounded permanent removal result from run-scoped durable evidence. Unknown non-recorded names retain ordinary unknown-tool behavior, and no synthetic history refresh message is recorded. + - verify: `TestMCPSourceReconciliation_Scenario4_DurableUnavailableRunInput` +- AC4.2: `ListMcpSources` and `/mcp` preserve existing URLs and pre-shadow rows and add observed/active generation plus stale/degraded/reconciling truth from cached reconciler state without an independent probe. Canary headers, tokens, credentials, and non-URL raw configuration never appear in newly added generation/status fields, diagnostics, errors, or removal results; the pre-existing URL field is unchanged. + - verify: `TestMCPSourceReconciliation_Scenario4_StatusCompatibilityAndSecretCanaries` +- AC4.3: gRPC `RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and mecatui `/mcp-refresh` have parity for cooldown adoption, success, caller cancellation, ownership concealment, active/awaiting/child/debug/broker-conflict rejection, unsupported capability, and persistence outcomes. + - verify: `TestMCPSourceReconciliation_Scenario4_TransportAndUIParity` +- AC4.4: No automatic/manual direct reconciliation or status call attaches/authorizes/refreshes protected MCP, launches consent, or changes client-provided MCP. No-FS/profile sessions retain their non-direct authority and receive direct grants only under the same binding rules. + - verify: `TestADR_0342_BrokerClientAndProfileIsolation` + +## Out of scope + +| Item | Defer-to | Decision | +|---|---|---| +| Live reload of operator settings, CLI inputs, or environment-referenced credentials | Future narrow operator/admin operation | Process-start snapshots participate in every generation; ordinary sessions may never reload operator config. | +| Project-tier MCP | Existing trust/config policy | It remains ignored. | +| Broker/protected MCP grants, attachment, or refresh | [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md) | `/tools-connect` remains the sole protected-MCP refresh path. | +| Global management of client-provided per-session MCP | Existing session-local lifecycle | Client MCP remains outside the reconciler. | +| Snapshot-atomic list-then-get resource/prompt workflow | Future handle/version design if needed | Each out-of-run call is generation-consistent; stale later identifiers fail visibly. | +| Mid-run mutation of catalogues/managers | Explicitly prohibited | Generation-owned immutable bundles and leases are the consistency boundary. | +| Synthetic model/history refresh notices | None | Current schemas and durable unavailable-call result are sufficient. | +| Updating living architecture/user docs in this Plan PR | Implementation PR | Those references describe shipped behavior. | + +## Definition of done + +1. The Plan / Interface PR is merged and its commit recorded before implementation begins. +2. Offline tests prove source merge/LKG/empty/partial-active behavior; binding digest and secret exclusion; shared/cached/no-FS/client/mode engine matrix; active-run race; direct RunTeam/delegation/reference MCP; out-of-run leases; authority non-widening, legacy adoption, explicit refresh algebra, ambiguous-save faults; permanent removal result; notifications/cooldown/bounds; transport/UI parity; broker/client isolation; shutdown and leaks. +3. `DirectMCPBinding`, `Authority.DirectMCPBindings`, both aggregate mutation methods, and `RunRequest.UnavailableTools` are recorded by `task api:update` in `engine/api/*.txt` and as Added/minor in `engine/CHANGELOG.md`; snapshot compatibility and standalone engine tests pass. +4. Every reconciler goroutine/timer, source LKG/status cache, generation bundle, shared/cached engine lease, operation/run lease, stale-engine eviction structure, cooldown/singleflight state, and retained durable/runtime identity state is inventoried in ADR 0027 List 1 and, where restart-relevant, List 2 with an explicit rehydration decision. +5. Implementation updates `docs/architecture.md`, `docs/architecture/extensibility.md`, `docs/design/IMPLEMENTATION-NOTES.md`, owning `user-docs/` MCP/mecatui pages, generated API/config references, and ADR 0057's index annotation; no living shipped-behavior doc is changed by this Plan PR. +6. `task generate`, `task lint`, `task test`, `task api:check`, `task docs`, `task site:build`, `task ac-trace-strict`, and `go run ./cmd/mecademo` pass at implementation completion. +7. The implementation PR links the approved plan commit, reports exact interface conformance, and has no unwaived `/panel-review` blocker. + +## Deferred decisions and known risks + +- Exact numeric reconciler source/server/tool/schema/generation-retention, cooldown, poll, and stale-idle-eviction constants are implementation details, but all are mandatory finite internal constants with boundary tests and fail-stale diagnostics; changing public configuration is out of scope. +- Digest canonicalization is correctness- and compatibility-critical. Implementation must define one deterministic composition-only encoder with domain separation and explicit field ordering; any included dispatch-relevant contract change intentionally revokes the old grant, while credentials and rotating authorization material must remain excluded. +- The 512 durable-record bound intentionally rejects a refresh that cannot retain required revoked evidence rather than silently dropping evidence or widening authority. Automatic attenuation changes record state without increasing count; legacy adoption also fails closed if its authorized intersection exceeds the bound. +- Generation leases increase lifecycle complexity. The acceptance matrix and ADR 0027 inventory are load-bearing: an engine cache must neither use a closed manager nor retain stale generations indefinitely. +- Out-of-run MCP list/get calls are individually consistent, not a multi-call snapshot. Clients must handle a visible stale-identifier error after publication between calls. diff --git a/docs/adr/0342-mcp-source-reconciliation.md b/docs/adr/0342-mcp-source-reconciliation.md new file mode 100644 index 0000000000..8d9f96936a --- /dev/null +++ b/docs/adr/0342-mcp-source-reconciliation.md @@ -0,0 +1,80 @@ +# ADR 0342 — Reconcile direct MCP through durable bindings and leased generations + +- Status: Proposed +- Date: 2026-09-14 +- Scope: direct/global MCP source discovery, durable authority identity, generation-owned catalogue publication, and root-session refresh +- Supersedes: ADR 0057 only for its deferred “no live catalog mutation” decision; its notification transport, dirty invalidation, bounded lazy-list, reconnect, and teardown decisions remain +- Superseded by: None + +## Context + +Mecatl has an ordered, source-neutral MCP discovery seam: configured/static servers precede ToolHive workloads, so configured names win collisions. It currently connects one manager and registers its tools into process-wide and per-session catalogues. Status can later re-probe sources, and MCP servers emit list-changed notifications, but existing catalogues cannot safely change. ADR 0057 deferred this because `tool.Catalog` is append-only. + +Name-only durable authority is insufficient for live reconciliation. A same-named capability can move to another source or endpoint, change schema, or change read-only classification while retaining its catalogue name. Treating that as the same grant silently changes dispatch authority. Conversely, deleting only a process-local catalogue entry is not durable: restart can make the same name executable again from persisted name authority. + +In-place manager/catalogue mutation also creates mixed-generation behavior and unsafe teardown. A model could receive one schema, permission-check another definition, dispatch through a replaced connection, and construct a child from a third view. Cached engines, direct RunTeam, referenced-agent MCP, and out-of-run resource/prompt calls outlive one lookup and therefore need ownership stronger than a run-local pointer. + +Protected broker MCP is separate. ADR 0335 gives it `/tools-connect`, ToolHive-owned consent/grants, and session attachment state. Client-provided MCP is also session-local. Neither belongs in process-wide direct/global reconciliation. + +## Decision + +1. **Reconcile ordered source snapshots through one Build-owned component.** The source-neutral reconciler consumes complete immutable desired snapshots. Earlier sources win collisions, preserving configured/static-over-ToolHive precedence. ToolHive is the first dynamic source; future sources use the same seam. Configured settings, CLI inputs, and environment-referenced credentials are captured at process start and reused in every cycle. Project-tier MCP stays ignored; ordinary sessions never reload operator configuration. + +2. **Bind durable authority to capability identity, not only name.** Add `session.DirectMCPBinding` records to `session.Authority`. Each record carries a capability name, an opaque `sha256:<64 lowercase hex>` digest, and revoked state. Composition computes the secret-free digest with domain-separated deterministic encoding over source identity, server identity, canonical endpoint/routing identity, the complete advertised tool contract/schema, and every dispatch-relevant annotation including read-only. It excludes headers, credentials, token values, and rotating OAuth material. Direct MCP synthetic per-server resource capabilities receive bindings under the same rule. Execution requires both the existing `CapabilitySet.Tools` name and an active digest equal to the selected generation's digest. + +3. **Treat identity change as removal plus addition.** A same-name source, server, canonical endpoint/routing, schema/contract, or dispatch-annotation change revokes the old grant. At the next run entry, the Service compares durable bindings with the current generation, removes missing/mismatched names, retains old binding records as revoked/unavailable evidence, and durably confirms attenuation before model or tool work. Reappearance never automatically grants authority, including after restart. If storage exclusion or persistence confirmation fails, the run does not begin. + +4. **Adopt legacy name-only authority once without widening.** An absent binding field denotes legacy authority; a present empty list denotes completed adoption with no direct grants. Before the first post-upgrade run, materialize bindings only for current same-name direct MCP capabilities already present in the legacy exact tool ceiling. Never add a name absent from that ceiling. Persist adoption before model work. Once the field is present, later reappearance cannot trigger adoption. + +5. **Refresh only the direct/global subset.** `/mcp-refresh` is an owner-authorized, argument-free, idle ordinary-root operation. Let `B` be prior direct binding records, `C(B)` all their names, `T` all prior tool names, and `A` successfully active current-generation direct bindings. Refresh sets tool names to `stableUnique((T ∖ C(B)) ∪ C(A))`: all non-direct/core/latent-Team/profile/client/broker names survive, every prior identified direct name is removed, and only current active direct names are added. Current `A` records become active; prior records whose capability is absent from `A` remain revoked evidence. A same-name changed record is replaced by the new active record because this explicit operation is the regrant, preserving one record per capability. The bounded aggregate rejects overflow instead of silently dropping evidence. Refresh preserves conversation, placement, ownership, and every non-direct authority axis. + +6. **Expose narrow aggregate and run-scoped engine APIs.** `Session.AttenuateDirectMCPAuthority([]DirectMCPBinding) error` performs non-widening run-entry attenuation and one-time legacy adoption for ordinary roots. `Session.ReplaceDirectMCPAuthority([]DirectMCPBinding) error` performs the explicit refresh algebra. Both are idle-only, bounded, clone inputs, preserve unrelated aggregate state, and reject pending permission/external-authorization/workspace-enrollment state. Delegated authority derivation copies only the digest-identical active parent bindings whose names survive the child's tightened tool ceiling; persisted child resume rejects binding drift and never treats a child as a legacy root. `agent.RunRequest.UnavailableTools []string` carries bounded unavailable names derived only from that session's revoked/mismatched bindings and shadows even a current same-name catalogue entry before schema projection, lookup, authority, permission, or dispatch. Such calls return the fixed permanent result `removed from current MCP configuration; do not retry unless the catalog changes`. There is no process-global tombstone cache and no synthetic model/history refresh message. These are intentional Added/minor engine API changes. + +7. **Publish immutable generation-owned bundles with explicit leases.** A generation owns direct/global servers and manager view, tools, resource/prompt provider, source/status snapshot, binding identities, and generation-bound shared engine/factories. `assembleCatalog` remains the single complete registration path and receives one explicit generation contribution. Service atomically swaps the current shared-engine generation. Cached per-session engines are generation-tagged; publication marks them stale, and the next eligible boundary evicts/rebuilds them under existing run-entry/liveness guards. Stale idle eviction is bounded so unused sessions cannot retain a generation indefinitely; active engines close after their runs. + + Every generation-bound shared, per-session, debug, specialist, and team engine holds a lease for its useful lifetime. Direct-MCP debug sessions remain non-refreshable: their selected-name ceiling additionally requires digest-identical bindings, and removal or identity drift fails closed without admitting additions. Active root runs, direct RunTeam, delegation/reference-MCP consumers, and any operation not already covered by an engine lifetime hold the needed run/operation lease. Each out-of-run list/read/get resource or prompt operation takes one short-lived current-generation lease; a list followed by a later get is not snapshot-atomic, and a stale later identifier fails visibly. Prompt expansion inside a run uses that run's generation. No displaced generation closes until all engine, run, and operation leases drain. + +8. **Publish a complete active set, not an all-or-nothing desired set.** A source consultation failure retains that source's last-known-good desired snapshot. A successful snapshot, including empty, is authoritative desired truth; removal withdraws immediately in new generations. Reuse unchanged healthy server connections/bindings. Connect additions independently. For a changed binding, withdraw the old identity and attempt the new one; failed connect/initialize/list/validation leaves it unavailable and never restores the superseded binding. Failure of one addition/replacement does not block unrelated valid changes. Partially built candidates close. The published immutable generation contains the complete successfully active set; observed-versus-active status remains stale/degraded where desired entries failed. + +9. **Bound every trigger and retained structure.** Automatic ToolHive polling exists only when ToolHive discovery is enabled and is bounded and jittered. Tool/resource/prompt list-changed notifications and manual refresh enter the same reconciler. Exactly one cycle runs, at most one invalidation is queued/coalesced, and a global cooldown prevents sequential session refreshes from forcing unbounded reconnect work. Caller cancellation stops waiting but never cancels shared reconciliation; a refresh during cooldown adopts the current active generation. Hard finite source, server, tool, schema, and generation-retention constants fail stale with bounded diagnostics. Automatic work never launches browser/OAuth consent. + +10. **Persist authority before use under existing exclusion.** Refresh and attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability`; a shared store without proven mutation exclusion fails closed. They mutate a detached authoritative load, not a live/cached session. A successful save is confirmed before widening or model work. After an ambiguous save, reload authoritative state: exact candidate means success, exact old state means failure, and mismatch/unknown fails closed. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against the current generation. No widened live engine is published before durability is known. + +11. **Add status/control metadata without breaking inventory.** Preserve `McpServerInfo.url` and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields, plus matching gRPC, HTTP, and mecatui refresh controls. New status, diagnostics, errors, binding records, and model results expose no new raw configuration, header, token, or credential material; canary tests enforce that boundary. + +## Consequences + +A durable grant now identifies the direct capability contract it authorized. Endpoint, schema, or read-only changes require explicit owner refresh even when names remain stable. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. + +Generation consistency extends across engine lifetimes, cached variants, direct RunTeam/delegation/reference MCP, and out-of-run resource/prompt operations. Old connections remain alive until every lease drains, while bounded stale-idle eviction prevents unused cached sessions from retaining them forever. Complete active-set publication allows unrelated healthy changes through but makes observed, desired, and active truth distinct and requires clear stale/degraded status. + +The reconciler may reuse unchanged healthy connections, but changed identities are withdrawn before replacement succeeds. This favors authority correctness over availability for that capability. Source consultation failure is different: its LKG remains desired because no newer source truth was obtained. Successful empty is newer truth and withdraws the source. + +`Authority` snapshot JSON gains an additive binding list. Missing is the one-time legacy state; present empty is meaningful and must not collapse back to missing. The maximum of 512 durable binding records, 256-byte capability framing, and fixed digest format are public validation contracts. Other source/server/tool/schema/generation/cooldown/eviction bounds remain tested internal constants. + +The process gains long-lived generation bundles, leases, source LKG/status caches, stale-engine eviction, and trigger/cooldown state. Implementation must inventory every such resource in ADR 0027 List 1 and every restart-relevant state decision in List 2. There is no process-global tombstone cache; unavailable-call evidence is session-durable. + +Configured settings/CLI/environment inputs remain restart-only. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. + +## Rejected alternatives + +- **Authorize by name only.** Same-name endpoint/schema/read-only changes silently alter the granted capability and can resurrect after restart. +- **Use a generation ID as durable authority.** Process generations are runtime ownership, not stable capability identity; a secret-free contract digest survives restart without persisting endpoints. +- **Keep a process-global removed-name tombstone cache.** It is lost on restart, detached from session authority, and grows another long-lived cache. Durable revoked bindings already provide exact evidence. +- **Mutate shared catalogues/managers in place or pin only active runs.** Cached engines, teams, referenced agents, and out-of-run resource/prompt operations can outlive the pin and observe closed or mixed managers. +- **Publish desired state all-or-nothing.** One failed addition would block unrelated removals and healthy changes. Publishing the successfully active subset is safer and more available while status remains honest. +- **Keep a superseded binding when replacement fails.** That executes an identity the successful source snapshot explicitly replaced. +- **Treat source consultation failure as empty.** A transient runtime failure would masquerade as authoritative removal; LKG is retained until a successful source snapshot says otherwise. +- **Automatically add every new binding to old sessions.** Dynamic discovery would silently widen durable exact authority. +- **Mutate a live session/engine before save or accept ambiguous save as success.** Failure can leave runtime authority wider than durable truth. Detached mutation plus reload confirmation avoids that split. +- **Reload operator settings during `/mcp-refresh`.** It gives an ordinary session an operator-config ingestion capability and obscures the restart boundary. +- **Fold broker or client MCP into global generations.** Their authorization, attachment, and teardown contracts are session-local and materially different. +- **Make list-then-get snapshot-atomic.** That requires public generation handles and retained cross-call leases; individual call consistency with visible stale identifiers is sufficient. + +## See also + +- [MCP source reconciliation acceptance plan](../acceptance/mcp-source-reconciliation.md) +- [ADR 0057 — MCP client server notifications](./0057-mcp-server-notifications.md) +- [ADR 0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) +- [ADR 0027 — Cloud-native arc and resource inventory](./0027-cloud-native.md) +- [Extensibility architecture](../architecture/extensibility.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index c603579cbf..85c6afc95e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -197,7 +197,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0342; notification transport, lazy-list, reconnect, and teardown decisions retained)* +- [0342 — Reconcile direct MCP through durable bindings and leased generations](./0342-mcp-source-reconciliation.md) *(proposed; supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 83854c8654eecb5125077fabcb63eb5eaa2c0387 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 14 Sep 2026 22:21:24 +0300 Subject: [PATCH 02/13] docs: link MCP reconciliation plan PR Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index f7f3063dad..e362e97b25 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -8,7 +8,7 @@ **Delivery:** Split. Generation ownership, durable authority migration, and additive public controls require contract review before implementation. **Expected tasks:** deferred to orchestration. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) -**Plan PR:** absent until opened. +**Plan PR:** [#1527](https://github.com/stacklok/mecatl/pull/1527) **Approved baseline:** absent until the Plan / Interface PR merges. Mecatl currently resolves configured and ToolHive-discovered direct MCP servers into one process-start manager and catalogue. Later source observations and MCP list-changed notifications cannot safely replace existing session tools. This plan introduces one Build-owned, source-neutral reconciler over the ordered source seam in [`internal/adapter/mcp/source/source.go`](../../internal/adapter/mcp/source/source.go), preserving configured/static-over-ToolHive precedence. It publishes immutable generation-owned bundles instead of mutating `tool.Catalog` or `mcp.Manager` membership in place; [`internal/app/catalog.go`](../../internal/app/catalog.go) remains the single full catalogue registration path and accepts one explicit generation contribution. From 06eb79b5c2a3d85792660cc8c299893a5b85cd98 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Tue, 15 Sep 2026 09:06:42 +0300 Subject: [PATCH 03/13] docs: address MCP reconciliation plan review Co-Authored-By: OpenAI --- docs/acceptance/mcp-source-reconciliation.md | 36 +++++++++++--------- docs/adr/0342-mcp-source-reconciliation.md | 18 +++++----- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index e362e97b25..6c6b454985 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -5,22 +5,24 @@ **Decision record:** [ADR 0342](../adr/0342-mcp-source-reconciliation.md) **Phase:** Source-neutral dynamic direct/global MCP reconciliation **Status:** proposed, 2026-09-14. Directing-human decisions are settled; ready for Plan / Interface review. -**Delivery:** Split. Generation ownership, durable authority migration, and additive public controls require contract review before implementation. +**Delivery:** Split. Generation ownership, durable authority migration, and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on an exact recorded plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge the plan, and human review/merge plus contract-drift gates remain. **Expected tasks:** deferred to orchestration. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) **Plan PR:** [#1527](https://github.com/stacklok/mecatl/pull/1527) -**Approved baseline:** absent until the Plan / Interface PR merges. +**Approved baseline:** absent until the Plan / Interface PR merges; any pre-merge stacked implementation records the exact proposed plan commit it follows. Mecatl currently resolves configured and ToolHive-discovered direct MCP servers into one process-start manager and catalogue. Later source observations and MCP list-changed notifications cannot safely replace existing session tools. This plan introduces one Build-owned, source-neutral reconciler over the ordered source seam in [`internal/adapter/mcp/source/source.go`](../../internal/adapter/mcp/source/source.go), preserving configured/static-over-ToolHive precedence. It publishes immutable generation-owned bundles instead of mutating `tool.Catalog` or `mcp.Manager` membership in place; [`internal/app/catalog.go`](../../internal/app/catalog.go) remains the single full catalogue registration path and accepts one explicit generation contribution. -Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable secret-free binding digest covering its source/server/routing and complete dispatch-relevant contract. A same-name source, endpoint, schema, or read-only change is removal plus addition. Run entry durably attenuates removed or mismatched grants before model work; explicit owner-authorized `/mcp-refresh` is the only way an established root session adopts current additions. Broker/protected MCP remains under `/tools-connect` and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. +Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable secret-free binding digest covering its source/server/routing and complete dispatch-relevant contract. A same-name source, endpoint, schema, or read-only change is removal plus addition. Run entry automatically and durably attenuates removed or mismatched grants before model work; it never adopts newly observed capabilities into an established session. Additions and changed identities enter such a session only through explicit owner-authorized `/mcp-refresh`. This deliberately expands #1511's stale-catalogue repair: digest identity, remove-plus-add semantics, and one-time legacy migration are authority hardening beyond staleness. Safe manager lifetimes are required by reconciliation; immutable leased generations are the selected architecture rather than merely unspecified lifetime plumbing. Broker/protected MCP uses the same client-visible `/mcp-refresh` command but routes to the existing `ConnectWorkspaceServices` operation and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. ## Human decisions +- [x] Scope and lifetime architecture — Decision: #1511's stale-catalogue problem requires safe connection lifetimes, but this plan deliberately adds digest identity, remove-plus-add/regrant semantics, and one-time legacy migration as authority hardening beyond staleness; immutable complete generations with explicit leases are the chosen architecture for those lifetimes. - [x] Source and publication model — Decision: one Build-owned source-neutral reconciler consumes complete immutable desired snapshots from ordered sources. Configured/static sources retain precedence over ToolHive collisions; ToolHive is the first dynamic source and future dynamic sources use the same seam. Publication produces immutable complete **active** generations, never in-place catalogue or manager mutation. - [x] Durable direct-MCP identity — Decision: add bounded `session.DirectMCPBinding` records to `session.Authority`. Each record binds one tool or synthetic resource capability name to a fixed-format secret-free digest over source identity, server identity, canonical endpoint/routing identity, complete advertised contract/schema, and dispatch-relevant annotations including read-only. Credentials, tokens, headers, and rotating OAuth material are excluded. Name plus matching digest is required for execution. -- [x] Removal, mismatch, and legacy behavior — Decision: same-name binding change is removal plus addition. At the next run entry, removed/mismatched grants are durably revoked and their names removed before model/tool work; persistence uncertainty fails closed. Revoked records remain durable unavailable evidence, so reappearance never automatically regrants, including after restart. A legacy authority with no binding field performs one persisted adoption only for same-name current direct MCP capabilities already present in its exact name ceiling; it never adds an absent name. +- [x] Removal, mismatch, and legacy behavior — Decision: same-name binding change is removal plus addition. At the next run entry, removed/mismatched grants are automatically and durably revoked and their names removed before model/tool work; persistence uncertainty fails closed. Automatic run entry only attenuates: it never adds a newly observed capability to an established session. Revoked records remain durable unavailable evidence, so reappearance never automatically regrants, including after restart. A legacy authority with no binding field performs one persisted adoption only for same-name current direct MCP capabilities already present in its exact name ceiling; it never adds an absent name. Any later addition or changed identity requires explicit owner refresh. - [x] Explicit refresh set algebra — Decision: `/mcp-refresh` replaces only the direct/global MCP subset. It preserves every capability not identified by durable direct-MCP records, removes all prior direct-MCP capability names, retains omitted/mismatched old records as revoked evidence, and unions successfully active generation bindings and names. It never replaces core, latent Team, profile, client-MCP, or broker authority. +- [x] Unified client command and capability routing — Decision: mecatui exposes one argument-free `/mcp-refresh`. When exactly the direct `mcp_refresh` capability is supported it invokes only the new `RefreshMcpSources`; when exactly the existing broker `workspace_enrollment` capability is supported it invokes only the existing `ConnectWorkspaceServices` whole-bundle operation. Direct refresh is consent-free. Broker refresh preserves consent/presentation, cancellation, and the destructive-reconnection disclosure from ADR 0335. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains the broker cancellation command; neither is made a direct-MCP capability. Missing collaborators, both mode bits, or otherwise unknown/contradictory capability combinations fail closed with no fallback or double operation. - [x] Generation ownership and leases — Decision: a generation owns its direct/global servers/manager view, tools, resource/prompt provider, source/status snapshot, binding identities, and generation-bound shared engine/factories. Shared, cached per-session, specialist/team, run, and out-of-run MCP operations hold explicit generation leases for their useful lifetime. Publication atomically swaps the shared-engine generation; stale cached engines are tagged, evicted/rebuilt under existing guards, and bounded so idle caches cannot retain generations forever. No generation closes before every engine/operation/run lease drains. - [x] Reconciliation failure semantics — Decision: source consultation failure retains that source's last-known-good snapshot; a successful snapshot is desired truth and successful removal withdraws immediately for new generations. Reuse unchanged healthy connections. Connect additions independently. A changed binding withdraws the old binding before attempting its replacement; replacement failure leaves that capability unavailable rather than retaining the superseded binding. Unrelated valid changes still publish, status becomes stale/degraded, and `/mcp-refresh` grants only successfully active bindings. Partially constructed candidates close. - [x] Trigger and resource bounds — Decision: automatic ToolHive polling is bounded, jittered, and enabled only with ToolHive discovery; MCP list-changed notifications and manual refresh use the same reconciler. There is one active cycle, at most one coalesced queued invalidation, and a global cooldown; caller cancellation never cancels shared work, and manual refresh during cooldown adopts the current active generation. Hard finite source/server/tool/schema/generation-retention bounds are internal constants with fail-stale diagnostics. Automatic work never launches browser/OAuth consent. @@ -30,13 +32,13 @@ Authority becomes identity-bearing rather than name-only. Each authorized direct ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. `RefreshMcpSourcesRequest` contains only `string session_id = 1`. `RefreshMcpSourcesResponse` contains `uint64 generation = 1` and `bool changed = 2`, where `generation` is the active generation whose direct-MCP subset was durably installed and `changed` reports an exact authority change. Add `ServerCapabilities.mcp_refresh = 30`. Extend `ListMcpSourcesResponse` after `sources = 1` with `uint64 observed_generation = 2`, `uint64 active_generation = 3`, `bool stale = 4`, `bool reconciling = 5`, and `bool degraded = 6`. Preserve `McpServerInfo.url = 2` and the existing per-source pre-shadow row semantics; generation fields are separate response metadata. HTTP adds bodyless `POST /v1/sessions/{id}/mcp-refresh`. Both transports use existing ownership concealment and map ineligible/conflicting state to `FAILED_PRECONDITION`/409. Caller cancellation stops only waiting; it does not cancel shared reconciliation. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. `RefreshMcpSourcesRequest` contains only `string session_id = 1`. `RefreshMcpSourcesResponse` contains `uint64 generation = 1` and `bool changed = 2`, where `generation` is the active generation whose direct-MCP subset was durably installed and `changed` reports an exact authority change. Add `ServerCapabilities.mcp_refresh = 30`; this bit advertises only the new direct-MCP RPC and says nothing about broker refresh. The existing `ServerCapabilities.workspace_enrollment = 28` continues to advertise the broker `ConnectWorkspaceServices` operation. Extend `ListMcpSourcesResponse` after `sources = 1` with `uint64 observed_generation = 2`, `uint64 active_generation = 3`, `bool stale = 4`, `bool reconciling = 5`, and `bool degraded = 6`. Preserve `McpServerInfo.url = 2` and the existing per-source pre-shadow row semantics; generation fields are separate response metadata. HTTP adds bodyless `POST /v1/sessions/{id}/mcp-refresh` for direct MCP; the existing broker route remains `POST /v1/sessions/{id}/workspace-enrollment/connect` (with its existing retry/cancel companions). Both direct transports use existing ownership concealment and map ineligible/conflicting state to `FAILED_PRECONDITION`/409. Caller cancellation stops only waiting; it does not cancel shared direct reconciliation. No broker RPC, HTTP route, response, or capability is renamed. - **Exported Go APIs / interfaces:** In `engine/session`, add `type DirectMCPBinding struct` with exact fields `Capability string`, `Digest string`, and `Revoked bool`; their JSON keys are respectively `capability`, `digest`, and `revoked` (`revoked` uses `omitempty`). Add `Authority.DirectMCPBindings []DirectMCPBinding` with JSON key `direct_mcp_bindings` and no `omitempty`: a nil slice means a legacy authority, while a present empty JSON array means migration completed with no direct grants. Validation allows at most 512 records; each capability is non-empty, valid UTF-8, control-free, at most 256 bytes; each digest is exactly `sha256:` plus 64 lowercase hexadecimal characters; capabilities are unique; active inputs may not set `Revoked`; rejected mutations are non-mutating. Add exactly `func (s *Session) AttenuateDirectMCPAuthority(active []DirectMCPBinding) error` and `func (s *Session) ReplaceDirectMCPAuthority(active []DirectMCPBinding) error`. Both are idle-only, require bound ordinary-root authority and no pending permission/external authorization/workspace enrollment, preserve all non-direct fields, clone inputs, and apply the algebra below; `Attenuate…` never adds authority and performs one-time legacy adoption, while `Replace…` is the explicit-widen path. Delegated authority derivation copies only active parent binding records whose capabilities survive the derived child ceiling; resume validation requires the persisted child's bindings to remain a digest-identical subset of the current parent/run generation and never invokes legacy adoption for a child. In `engine/agent.RunRequest`, add `UnavailableTools []string`: a bounded duplicate-free run-scoped set (maximum 512 names, each using the same capability framing bound) that shadows matching names before request-schema projection, catalogue lookup, authority evaluation, permission, or dispatch; it produces the fixed permanent result and is never advertised or authority-exempt. These exported additions require `task api:update` and Added/minor `engine/CHANGELOG.md` entries. Generation/source/lease interfaces remain internal; no ToolHive type enters `engine/`. - **Tool schemas:** No model-visible tool is added. A direct/global MCP tool is advertised and executable only when its name is in `CapabilitySet.Tools`, its active `DirectMCPBinding` digest equals the run generation's digest, and existing permission evaluation allows it. The synthetic `governance.MCPResourceCapability(server)` follows the same binding rule for direct MCP resources. A changed source, canonical endpoint/routing identity, complete schema/contract, or dispatch annotation including read-only changes the digest and is remove-plus-add. `RunRequest.UnavailableTools` shadows even a current same-name catalogue entry before schema projection or lookup; unavailable names cannot pass authority/permission or dispatch, and a generated call receives the fixed permanent result. -- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing `mcp.servers`, MCP CLI flags, ToolHive enable/group options, and environment-referenced credentials are process-start snapshots reused by every reconciliation. ToolHive discovery alone enables automatic polling. Project-tier MCP remains ignored. Mecatui adds exact bare builtin `/mcp-refresh`, capability-gated by `mcp_refresh`, with no arguments. +- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing `mcp.servers`, MCP CLI flags, ToolHive enable/group options, and environment-referenced credentials are process-start snapshots reused by every reconciliation. ToolHive discovery alone enables automatic polling. Project-tier MCP remains ignored. Mecatui adds one exact bare builtin `/mcp-refresh`, with no arguments. It is offered for a supported direct `ServerCapabilities.mcp_refresh` path or supported broker `ServerCapabilities.workspace_enrollment` path and invokes exactly one corresponding collaborator: direct calls only `RefreshMcpSources`; broker calls only the existing `ConnectWorkspaceServices`. Direct refresh is consent-free. Broker refresh retains ADR 0335's consent/presentation, cancellation through `/tools-cancel`, and explicit destructive replacement disclosure. `/tools-connect` is deprecated but remains a broker-only compatibility alias with its prior `ConnectWorkspaceServices` semantics; it is not gated by `mcp_refresh` and never invokes direct refresh. Both capability bits, an advertised bit without its collaborator, or another unknown/contradictory combination fails closed without fallback or invoking both operations. - **Events / persistence:** Add no event and no generation ID to the session snapshot. `Authority.DirectMCPBindings` round-trips through the existing authority snapshot; absent means legacy, present empty means adopted/no grants. Composition computes each digest from canonical source identity, server identity, canonical endpoint/routing identity (raw URL never enters the digest record), complete advertised tool contract/schema, and dispatch annotations; secret/token/header/rotating OAuth material is excluded. Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. Automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. Explicit refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`; bindings become every record from `A` active plus prior records whose `Capability` is absent from `A` retained as revoked evidence, subject to the 512-record bound. For a same-name changed binding, explicit refresh replaces the old revoked record with the new active record because that explicit action is the regrant; at most one record exists per capability. Legacy attenuation first sets a present binding list from only `T ∩ C(A)`, persists it, and never adds `C(A) \ T`; later reappearance cannot adopt because the list is no longer nil. Refresh/attenuation operate on a detached authoritative load under `runEntryMu`, mutation lease, and mutation capability; they save before publishing success or starting model work. After ambiguous Save, reload: exact candidate durable means success; exact old state means failure; any mismatch/unknown fails closed. `/mcp-refresh` builds no engine. No process-global tombstones persist or cache. -- **Security / authority:** Refresh is owner-authorized, ordinary-root-only, idle-only, and rejected for running, awaiting, child/delegation, debug, pending permission/authorization/enrollment, broker-refresh conflict, or unproved shared-store exclusion. Automatic attenuation uses the same mutation capability/lease discipline and failure prevents all model/tool work. The request supplies no names, source, config, endpoint, or credential. Digest input is secret-free and persisted output is hash-only. Broker/protected and client-provided MCP remain outside reconciliation. Existing URL projection is not broadened; new generation metadata, diagnostics, errors, and model result fields are canary-tested against raw config, headers, tokens, and credentials. -- **Compatibility / migration:** Existing `ListMcpSources` clients retain URLs and pre-shadow rows and ignore additive response fields. Existing sessions receive one-time, non-widening legacy binding adoption before their first post-upgrade run; it is durably confirmed before model work. A current same-name entry may be adopted only if already in the legacy exact name ceiling. Existing settings/CLI behavior, static-over-ToolHive precedence, and streaming-HTTP transport remain. ADR 0342 supersedes ADR 0057 only for its deferred no-live-catalogue decision; ADR 0057's notification transport/lazy-list/reconnect details remain. Broker refresh remains ADR 0335 `/tools-connect`; client MCP remains session-local. Implementation updates living architecture/design/user docs and ADR 0027 inventories; this Plan PR does not describe unshipped behavior there. +- **Security / authority:** Direct `RefreshMcpSources` is owner-authorized, ordinary-root-only, idle-only, consent-free, and rejected for running, awaiting, child/delegation, debug, pending permission/authorization/enrollment, broker-refresh conflict, or unproved shared-store exclusion. Automatic attenuation uses the same mutation capability/lease discipline and failure prevents all model/tool work. The request supplies no names, source, config, endpoint, or credential. Digest input is secret-free and persisted output is hash-only. The common client command does not merge authority domains: broker mode routes only to existing `ConnectWorkspaceServices` and preserves ToolHive consent, cancellation, and destructive replacement semantics; direct mode never invokes broker attachment, authorization, credential refresh, or presentation. Client-provided MCP remains outside reconciliation. Unknown or contradictory capability/collaborator states fail closed with no fallback or double operation. Existing URL projection is not broadened; new generation metadata, diagnostics, errors, and model result fields are canary-tested against raw config, headers, tokens, and credentials. +- **Compatibility / migration:** Existing `ListMcpSources` clients retain URLs and pre-shadow rows and ignore additive response fields. Existing sessions receive one-time, non-widening legacy binding adoption before their first post-upgrade run; it is durably confirmed before model work. A current same-name entry may be adopted only if already in the legacy exact name ceiling. Existing settings/CLI behavior, static-over-ToolHive precedence, and streaming-HTTP transport remain. ADR 0342 supersedes ADR 0057 only for its deferred no-live-catalogue decision; ADR 0057's notification transport/lazy-list/reconnect details remain. Broker authority and server operation remain ADR 0335 `ConnectWorkspaceServices`; only the preferred mecatui spelling is unified as `/mcp-refresh`. `/tools-connect` remains a deprecated compatibility alias retaining prior broker semantics and never gaining direct `mcp_refresh` behavior; `/tools-cancel` is unchanged. Client MCP remains session-local. Implementation updates living architecture/design/user docs and ADR 0027 inventories; this Plan PR does not describe unshipped behavior there. ## In scope — 4 scenarios, in implementation order @@ -61,7 +63,7 @@ A generation owns the direct/global manager view, tools, resources/prompts, stat **Acceptance:** - AC2.1: Shared fast path and cached selector, no-FS, client-MCP, mode-specific, and direct-MCP debug engines are generation-tagged; publication swaps the shared engine atomically, stale idle caches are boundedly evicted/rebuilt, and active cached engines close only after their run. A debug session remains non-refreshable and its existing selected-name ceiling additionally requires digest-identical bindings; removal or identity drift fails closed without admitting additions. - verify: `TestMCPSourceReconciliation_Scenario2_SharedAndCachedEngineMatrix` -- AC2.2: Direct RunTeam, Subagent/Parallel/Team members, named specialists, and referenced-agent MCP use generation-bound engines/factories from the root generation where applicable; derived child authorities copy only digest-identical active direct bindings that survive their tool ceiling, persisted child resume rejects drift, and reconciliation racing activity cannot mix schemas, lookup, permission, dispatch, resources/prompts, or manager identity. +- AC2.2: Direct RunTeam, Subagent/Parallel/Team members, named specialists, and referenced-agent MCP use generation-bound engines/factories from the root generation where applicable; derived child authorities copy only digest-identical active direct bindings that survive their tool ceiling, persisted child resume rejects drift, and reconciliation racing activity cannot mix schemas, lookup, permission, dispatch, resources/prompts, or manager identity. The named proof uses a table-driven matrix with independently identifiable subtests covering: direct RunTeam; each Subagent, Parallel, and Team-member path; named specialists; referenced-agent MCP; derived-authority filtering; resumed-child match and drift rejection; and publication races at schema projection, lookup/permission/dispatch, resource/prompt access, and manager selection. - verify: `TestMCPSourceReconciliation_Scenario2_DelegationRunTeamAndReferenceMatrix` - AC2.3: `ListMcpResources`, `ReadMcpResource`, `ListMcpPrompts`, and `GetMcpPrompt` each acquire a short-lived current-generation lease. Each call is internally consistent; list-then-later-get is not snapshot-atomic and a stale identifier fails visibly. Prompt expansion inside a run uses that run's generation. - verify: `TestMCPSourceReconciliation_Scenario2_OutOfRunResourcePromptLeases` @@ -79,21 +81,21 @@ At run entry, the Service compares durable active bindings with the selected gen - verify: `TestMCPSourceReconciliation_Scenario3_LegacyAdoptionIsOneTimeAndNonWidening` - AC3.3: Owner-authorized argument-free `/mcp-refresh` preserves `T \ C(B)`, replaces only current direct/global grants with successfully active `A`, retains prior identities whose capability is omitted as revoked, replaces a same-name revoked identity only through this explicit regrant, preserves conversation/placement/non-direct authority, builds no engine, and the next run rebuilds against current generation. - verify: `TestMCPSourceReconciliation_Scenario3_ExplicitRefreshSetAlgebra` -- AC3.4: Refresh and attenuation use owner checks, `runEntryMu`, real mutation lease/capability, detached load, and authoritative reload after ambiguous Save. Exact candidate durable reports success, exact old durable reports failure, and mismatched/unknown state fails closed; no widened live engine/session appears before confirmation. +- AC3.4: Refresh and attenuation use owner checks, `runEntryMu`, real mutation lease/capability, detached load, and authoritative reload after ambiguous Save. Exact candidate durable reports success, exact old durable reports failure, and mismatched/unknown state fails closed; no widened live engine/session appears before confirmation. The named proof uses table-driven matrices with independently identifiable subtests for both refresh and attenuation, each covering: unambiguous save success; ambiguous save followed by exact-candidate reload; ambiguous save followed by exact-old reload; ambiguous save followed by mismatched durable state; and ambiguous save followed by reload error/unknown state. - verify: `TestInvariant_direct_mcp_authority_mutation_is_confirmed_before_use` ### Scenario 4 — Removed calls, status, and controls are safe and transport-equivalent -The server derives unavailable names from the session's durable revoked/mismatched records and supplies them via `RunRequest.UnavailableTools`; there is no global tombstone cache. Existing `/mcp` inventory keeps URL and pre-shadow compatibility while adding generation truth. gRPC, HTTP, and mecatui expose the same owner-scoped refresh, without crossing broker/client boundaries. See [ADR 0342](../adr/0342-mcp-source-reconciliation.md). +The server derives unavailable names from the session's durable revoked/mismatched records and supplies them via `RunRequest.UnavailableTools`; there is no global tombstone cache. Existing `/mcp` inventory keeps URL and pre-shadow compatibility while adding generation truth. Direct gRPC and HTTP controls remain transport-equivalent. Mecatui exposes one `/mcp-refresh` command but capability-directs it to exactly one authority domain: new direct `RefreshMcpSources` under `mcp_refresh`, or existing broker `ConnectWorkspaceServices` under `workspace_enrollment`. See [ADR 0342](../adr/0342-mcp-source-reconciliation.md). **Acceptance:** - AC4.1: An unavailable stale call is not advertised or authorized and does not dispatch; it returns the exact bounded permanent removal result from run-scoped durable evidence. Unknown non-recorded names retain ordinary unknown-tool behavior, and no synthetic history refresh message is recorded. - verify: `TestMCPSourceReconciliation_Scenario4_DurableUnavailableRunInput` - AC4.2: `ListMcpSources` and `/mcp` preserve existing URLs and pre-shadow rows and add observed/active generation plus stale/degraded/reconciling truth from cached reconciler state without an independent probe. Canary headers, tokens, credentials, and non-URL raw configuration never appear in newly added generation/status fields, diagnostics, errors, or removal results; the pre-existing URL field is unchanged. - verify: `TestMCPSourceReconciliation_Scenario4_StatusCompatibilityAndSecretCanaries` -- AC4.3: gRPC `RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and mecatui `/mcp-refresh` have parity for cooldown adoption, success, caller cancellation, ownership concealment, active/awaiting/child/debug/broker-conflict rejection, unsupported capability, and persistence outcomes. +- AC4.3: One table-driven transport/outcome matrix with independently identifiable subtests proves every branch. For each direct surface—gRPC `RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and mecatui `/mcp-refresh` routed through `mcp_refresh`—the cases are cooldown adoption, unchanged success, changed success, caller cancellation, ownership concealment, active/awaiting/child/debug/broker-conflict rejection, unsupported capability, and every AC3.4 persistence outcome. Client routing cases are direct-only `mcp_refresh` to only `RefreshMcpSources`; broker-only `workspace_enrollment` to only existing `ConnectWorkspaceServices`; neither capability to no offered command; and both bits, missing matching collaborator, and unknown contradiction to fail closed. Broker-routed `/mcp-refresh` separately covers destructive-reconnection disclosure, presentation/consent, pending observation, connected, denied, expired, failed, cancelled, and `/tools-cancel`, with no direct call. `/tools-connect` is independently covered as a deprecated broker-only alias retaining prior semantics, including when direct `mcp_refresh` is also present. No branch falls back or invokes both operations; direct branches are consent-free and never call broker controls. - verify: `TestMCPSourceReconciliation_Scenario4_TransportAndUIParity` -- AC4.4: No automatic/manual direct reconciliation or status call attaches/authorizes/refreshes protected MCP, launches consent, or changes client-provided MCP. No-FS/profile sessions retain their non-direct authority and receive direct grants only under the same binding rules. +- AC4.4: Direct automatic/manual reconciliation and direct status calls never attach, authorize, refresh, or launch consent for protected MCP, and neither direct nor broker routing changes client-provided MCP. The common client command crosses no boundary because broker mode invokes only ADR 0335's existing explicit owner operation. No-FS/profile sessions retain their non-direct authority and receive direct grants only under the same binding rules. - verify: `TestADR_0342_BrokerClientAndProfileIsolation` ## Out of scope @@ -102,7 +104,7 @@ The server derives unavailable names from the session's durable revoked/mismatch |---|---|---| | Live reload of operator settings, CLI inputs, or environment-referenced credentials | Future narrow operator/admin operation | Process-start snapshots participate in every generation; ordinary sessions may never reload operator config. | | Project-tier MCP | Existing trust/config policy | It remains ignored. | -| Broker/protected MCP grants, attachment, or refresh | [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md) | `/tools-connect` remains the sole protected-MCP refresh path. | +| Broker/protected MCP grants, attachment, refresh semantics, and server controls | [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md) | They remain owned by existing `ConnectWorkspaceServices`, `workspace_enrollment`, consent/cancellation, and destructive replacement contracts. This plan changes only mecatui's preferred spelling to common `/mcp-refresh`; deprecated `/tools-connect` retains the same broker operation. | | Global management of client-provided per-session MCP | Existing session-local lifecycle | Client MCP remains outside the reconciler. | | Snapshot-atomic list-then-get resource/prompt workflow | Future handle/version design if needed | Each out-of-run call is generation-consistent; stale later identifiers fail visibly. | | Mid-run mutation of catalogues/managers | Explicitly prohibited | Generation-owned immutable bundles and leases are the consistency boundary. | @@ -111,13 +113,13 @@ The server derives unavailable names from the session's durable revoked/mismatch ## Definition of done -1. The Plan / Interface PR is merged and its commit recorded before implementation begins. -2. Offline tests prove source merge/LKG/empty/partial-active behavior; binding digest and secret exclusion; shared/cached/no-FS/client/mode engine matrix; active-run race; direct RunTeam/delegation/reference MCP; out-of-run leases; authority non-widening, legacy adoption, explicit refresh algebra, ambiguous-save faults; permanent removal result; notifications/cooldown/bounds; transport/UI parity; broker/client isolation; shutdown and leaks. +1. Before stacked implementation begins, it records the exact proposed plan commit it follows and its implementation PR targets `plan/mcp-source-reconciliation`. This directing-human authorization permits implementation before Plan PR merge only; Plan PR merge remains the approval event, implementation may not be merged ahead of it, and material contract drift still stops for human review. +2. Offline tests prove source merge/LKG/empty/partial-active behavior; binding digest and secret exclusion; shared/cached/no-FS/client/mode engine matrix; active-run race; direct RunTeam/delegation/reference MCP; out-of-run leases; authority non-widening, legacy adoption, explicit refresh algebra, the complete AC3.4 persistence matrix; permanent removal result; notifications/cooldown/bounds; the complete direct-transport and client routing/outcome matrix including broker compatibility aliases; broker/client isolation; shutdown and leaks. 3. `DirectMCPBinding`, `Authority.DirectMCPBindings`, both aggregate mutation methods, and `RunRequest.UnavailableTools` are recorded by `task api:update` in `engine/api/*.txt` and as Added/minor in `engine/CHANGELOG.md`; snapshot compatibility and standalone engine tests pass. 4. Every reconciler goroutine/timer, source LKG/status cache, generation bundle, shared/cached engine lease, operation/run lease, stale-engine eviction structure, cooldown/singleflight state, and retained durable/runtime identity state is inventoried in ADR 0027 List 1 and, where restart-relevant, List 2 with an explicit rehydration decision. 5. Implementation updates `docs/architecture.md`, `docs/architecture/extensibility.md`, `docs/design/IMPLEMENTATION-NOTES.md`, owning `user-docs/` MCP/mecatui pages, generated API/config references, and ADR 0057's index annotation; no living shipped-behavior doc is changed by this Plan PR. 6. `task generate`, `task lint`, `task test`, `task api:check`, `task docs`, `task site:build`, `task ac-trace-strict`, and `go run ./cmd/mecademo` pass at implementation completion. -7. The implementation PR links the approved plan commit, reports exact interface conformance, and has no unwaived `/panel-review` blocker. +7. The implementation PR targets the Plan PR branch while stacked, links the exact proposed plan commit, retargets only after the plan merges, reports exact interface conformance, and has no unwaived `/panel-review` blocker. Human plan and implementation merges remain separate gates. ## Deferred decisions and known risks diff --git a/docs/adr/0342-mcp-source-reconciliation.md b/docs/adr/0342-mcp-source-reconciliation.md index 8d9f96936a..ce2345c96f 100644 --- a/docs/adr/0342-mcp-source-reconciliation.md +++ b/docs/adr/0342-mcp-source-reconciliation.md @@ -10,11 +10,11 @@ Mecatl has an ordered, source-neutral MCP discovery seam: configured/static servers precede ToolHive workloads, so configured names win collisions. It currently connects one manager and registers its tools into process-wide and per-session catalogues. Status can later re-probe sources, and MCP servers emit list-changed notifications, but existing catalogues cannot safely change. ADR 0057 deferred this because `tool.Catalog` is append-only. -Name-only durable authority is insufficient for live reconciliation. A same-named capability can move to another source or endpoint, change schema, or change read-only classification while retaining its catalogue name. Treating that as the same grant silently changes dispatch authority. Conversely, deleting only a process-local catalogue entry is not durable: restart can make the same name executable again from persisted name authority. +Name-only durable authority is insufficient for live reconciliation. A same-named capability can move to another source or endpoint, change schema, or change read-only classification while retaining its catalogue name. Treating that as the same grant silently changes dispatch authority. Conversely, deleting only a process-local catalogue entry is not durable: restart can make the same name executable again from persisted name authority. Issue #1511 establishes the stale-catalogue problem; digest identity, remove-plus-add/regrant semantics, and one-time legacy migration are a deliberate authority-hardening expansion beyond merely correcting staleness. -In-place manager/catalogue mutation also creates mixed-generation behavior and unsafe teardown. A model could receive one schema, permission-check another definition, dispatch through a replaced connection, and construct a child from a third view. Cached engines, direct RunTeam, referenced-agent MCP, and out-of-run resource/prompt calls outlive one lookup and therefore need ownership stronger than a run-local pointer. +In-place manager/catalogue mutation also creates mixed-generation behavior and unsafe teardown. A model could receive one schema, permission-check another definition, dispatch through a replaced connection, and construct a child from a third view. Cached engines, direct RunTeam, referenced-agent MCP, and out-of-run resource/prompt calls outlive one lookup and therefore need ownership stronger than a run-local pointer. Safe lifetimes are required; immutable leased generations are the selected architecture, not unspecified implementation plumbing. -Protected broker MCP is separate. ADR 0335 gives it `/tools-connect`, ToolHive-owned consent/grants, and session attachment state. Client-provided MCP is also session-local. Neither belongs in process-wide direct/global reconciliation. +Protected broker MCP is separate. ADR 0335 gives it the existing `ConnectWorkspaceServices` operation, `workspace_enrollment` capability, ToolHive-owned consent/grants, cancellation, destructive replacement semantics, and session attachment state. Client-provided MCP is also session-local. Neither belongs in process-wide direct/global reconciliation. Mecatui may present one common `/mcp-refresh` spelling while capability-routing to these separate operations; command unification does not unify their authority or lifecycle. ## Decision @@ -22,11 +22,11 @@ Protected broker MCP is separate. ADR 0335 gives it `/tools-connect`, ToolHive-o 2. **Bind durable authority to capability identity, not only name.** Add `session.DirectMCPBinding` records to `session.Authority`. Each record carries a capability name, an opaque `sha256:<64 lowercase hex>` digest, and revoked state. Composition computes the secret-free digest with domain-separated deterministic encoding over source identity, server identity, canonical endpoint/routing identity, the complete advertised tool contract/schema, and every dispatch-relevant annotation including read-only. It excludes headers, credentials, token values, and rotating OAuth material. Direct MCP synthetic per-server resource capabilities receive bindings under the same rule. Execution requires both the existing `CapabilitySet.Tools` name and an active digest equal to the selected generation's digest. -3. **Treat identity change as removal plus addition.** A same-name source, server, canonical endpoint/routing, schema/contract, or dispatch-annotation change revokes the old grant. At the next run entry, the Service compares durable bindings with the current generation, removes missing/mismatched names, retains old binding records as revoked/unavailable evidence, and durably confirms attenuation before model or tool work. Reappearance never automatically grants authority, including after restart. If storage exclusion or persistence confirmation fails, the run does not begin. +3. **Treat identity change as removal plus addition, and automatically attenuate only.** Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. At the next run entry, automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. A same-name source, server, canonical endpoint/routing, schema/contract, or dispatch-annotation change is therefore removal plus addition, not an automatic replacement. The Service durably confirms attenuation before model or tool work. Reappearance and newly observed capabilities never automatically grant authority to an established session, including after restart; they require explicit owner refresh. If storage exclusion or persistence confirmation fails, the run does not begin. 4. **Adopt legacy name-only authority once without widening.** An absent binding field denotes legacy authority; a present empty list denotes completed adoption with no direct grants. Before the first post-upgrade run, materialize bindings only for current same-name direct MCP capabilities already present in the legacy exact tool ceiling. Never add a name absent from that ceiling. Persist adoption before model work. Once the field is present, later reappearance cannot trigger adoption. -5. **Refresh only the direct/global subset.** `/mcp-refresh` is an owner-authorized, argument-free, idle ordinary-root operation. Let `B` be prior direct binding records, `C(B)` all their names, `T` all prior tool names, and `A` successfully active current-generation direct bindings. Refresh sets tool names to `stableUnique((T ∖ C(B)) ∪ C(A))`: all non-direct/core/latent-Team/profile/client/broker names survive, every prior identified direct name is removed, and only current active direct names are added. Current `A` records become active; prior records whose capability is absent from `A` remain revoked evidence. A same-name changed record is replaced by the new active record because this explicit operation is the regrant, preserving one record per capability. The bounded aggregate rejects overflow instead of silently dropping evidence. Refresh preserves conversation, placement, ownership, and every non-direct authority axis. +5. **Refresh only the direct/global subset.** `/mcp-refresh` is an owner-authorized, argument-free, idle ordinary-root operation. Using the definitions above, explicit refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`; bindings become every record from `A` active plus prior records whose `Capability` is absent from `A` retained as revoked evidence, subject to the 512-record bound. Thus all non-direct/core/latent-Team/profile/client/broker names survive, every prior identified direct name is removed, and only current successfully active direct names are added. For a same-name changed binding, explicit refresh replaces the old revoked record with the new active record because this explicit action is the regrant; at most one record exists per capability. The bounded aggregate rejects overflow instead of silently dropping evidence. Refresh preserves conversation, placement, ownership, and every non-direct authority axis. 6. **Expose narrow aggregate and run-scoped engine APIs.** `Session.AttenuateDirectMCPAuthority([]DirectMCPBinding) error` performs non-widening run-entry attenuation and one-time legacy adoption for ordinary roots. `Session.ReplaceDirectMCPAuthority([]DirectMCPBinding) error` performs the explicit refresh algebra. Both are idle-only, bounded, clone inputs, preserve unrelated aggregate state, and reject pending permission/external-authorization/workspace-enrollment state. Delegated authority derivation copies only the digest-identical active parent bindings whose names survive the child's tightened tool ceiling; persisted child resume rejects binding drift and never treats a child as a legacy root. `agent.RunRequest.UnavailableTools []string` carries bounded unavailable names derived only from that session's revoked/mismatched bindings and shadows even a current same-name catalogue entry before schema projection, lookup, authority, permission, or dispatch. Such calls return the fixed permanent result `removed from current MCP configuration; do not retry unless the catalog changes`. There is no process-global tombstone cache and no synthetic model/history refresh message. These are intentional Added/minor engine API changes. @@ -40,11 +40,11 @@ Protected broker MCP is separate. ADR 0335 gives it `/tools-connect`, ToolHive-o 10. **Persist authority before use under existing exclusion.** Refresh and attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability`; a shared store without proven mutation exclusion fails closed. They mutate a detached authoritative load, not a live/cached session. A successful save is confirmed before widening or model work. After an ambiguous save, reload authoritative state: exact candidate means success, exact old state means failure, and mismatch/unknown fails closed. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against the current generation. No widened live engine is published before durability is known. -11. **Add status/control metadata without breaking inventory.** Preserve `McpServerInfo.url` and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields, plus matching gRPC, HTTP, and mecatui refresh controls. New status, diagnostics, errors, binding records, and model results expose no new raw configuration, header, token, or credential material; canary tests enforce that boundary. +11. **Add status metadata and one capability-directed client command without merging operations.** Preserve `McpServerInfo.url` and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields. Add direct-only `ServerCapabilities.mcp_refresh` for the new `RefreshMcpSources` gRPC operation and direct HTTP refresh route; it does not advertise broker refresh. Existing `ServerCapabilities.workspace_enrollment` continues to advertise broker `ConnectWorkspaceServices`. Mecatui offers one argument-free `/mcp-refresh` when a supported direct or broker path is present: direct-only routing invokes `RefreshMcpSources` and is consent-free; broker-only routing invokes `ConnectWorkspaceServices` and preserves ADR 0335's consent/presentation, pending observation, cancellation, and destructive reconnection disclosure. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains broker cancellation; neither gains direct behavior. Both mode bits, a missing matching collaborator, or another unknown/contradictory combination fails closed with no fallback and no double operation. New status, diagnostics, errors, binding records, and model results expose no new raw configuration, header, token, or credential material; canary tests enforce that boundary. ## Consequences -A durable grant now identifies the direct capability contract it authorized. Endpoint, schema, or read-only changes require explicit owner refresh even when names remain stable. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. +A durable grant now identifies the direct capability contract it authorized. Endpoint, schema, or read-only changes require explicit owner refresh even when names remain stable. Automatic run entry only removes/revokes missing or mismatched direct grants; it never adds newly observed capabilities to an established session. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. Generation consistency extends across engine lifetimes, cached variants, direct RunTeam/delegation/reference MCP, and out-of-run resource/prompt operations. Old connections remain alive until every lease drains, while bounded stale-idle eviction prevents unused cached sessions from retaining them forever. Complete active-set publication allows unrelated healthy changes through but makes observed, desired, and active truth distinct and requires clear stale/degraded status. @@ -54,7 +54,7 @@ The reconciler may reuse unchanged healthy connections, but changed identities a The process gains long-lived generation bundles, leases, source LKG/status caches, stale-engine eviction, and trigger/cooldown state. Implementation must inventory every such resource in ADR 0027 List 1 and every restart-relevant state decision in List 2. There is no process-global tombstone cache; unavailable-call evidence is session-durable. -Configured settings/CLI/environment inputs remain restart-only. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. +Configured settings/CLI/environment inputs remain restart-only. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. The shared client spelling does not change broker wire semantics: `ConnectWorkspaceServices`, `workspace_enrollment`, consent, cancellation, and destructive replacement remain authoritative, while direct `mcp_refresh` advertises only `RefreshMcpSources`. ## Rejected alternatives @@ -68,7 +68,7 @@ Configured settings/CLI/environment inputs remain restart-only. Live operator se - **Automatically add every new binding to old sessions.** Dynamic discovery would silently widen durable exact authority. - **Mutate a live session/engine before save or accept ambiguous save as success.** Failure can leave runtime authority wider than durable truth. Detached mutation plus reload confirmation avoids that split. - **Reload operator settings during `/mcp-refresh`.** It gives an ordinary session an operator-config ingestion capability and obscures the restart boundary. -- **Fold broker or client MCP into global generations.** Their authorization, attachment, and teardown contracts are session-local and materially different. +- **Fold broker or client MCP into global generations or one server refresh operation.** Their authorization, attachment, consent, cancellation, and teardown contracts are session-local and materially different. One client command may capability-route to the two existing authority domains, but fallback or double execution would cross that boundary. - **Make list-then-get snapshot-atomic.** That requires public generation handles and retained cross-call leases; individual call consistency with visible stale identifiers is sufficient. ## See also From 687f7e14ec191268cdfc4490f373d291b677b139 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 16 Sep 2026 09:28:12 +0300 Subject: [PATCH 04/13] docs: amend MCP endpoint identity contract Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 45 ++++++++++++++------ docs/adr/0342-mcp-source-reconciliation.md | 15 ++++--- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 6c6b454985..a64497685e 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -11,15 +11,18 @@ **Plan PR:** [#1527](https://github.com/stacklok/mecatl/pull/1527) **Approved baseline:** absent until the Plan / Interface PR merges; any pre-merge stacked implementation records the exact proposed plan commit it follows. +**Amendment (2026-09-16):** after implementation stopped on contract drift, the directing human clarified that the credential-free endpoint URL is itself dispatch identity, including its exact escaped path and raw query. Evidence is the shipped Streamable HTTP configuration/transport split—`internal/adapter/permconfig/schema.go` validates endpoint shape, while `internal/adapter/mcp/mcp.go` sends `ServerConfig.URL` and carries credentials separately—and the MCP Authorization specification's [Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage), which requires the Authorization header and says access tokens **MUST NOT** appear in URI query strings. MCP's rule does not prohibit non-secret routing queries; mecatl's broader policy makes the complete endpoint URL public, credential-free configuration. Compatibility consequence: compliant existing query-routed endpoints remain valid and query/path routing changes now intentionally change durable binding identity; endpoint URLs containing the protocol-standard `access_token` query parameter are rejected rather than treated as secret-bearing identity. This amendment changes no proposed SDK, RPC, tool, or exported Go API and does not approve or merge the plan. + Mecatl currently resolves configured and ToolHive-discovered direct MCP servers into one process-start manager and catalogue. Later source observations and MCP list-changed notifications cannot safely replace existing session tools. This plan introduces one Build-owned, source-neutral reconciler over the ordered source seam in [`internal/adapter/mcp/source/source.go`](../../internal/adapter/mcp/source/source.go), preserving configured/static-over-ToolHive precedence. It publishes immutable generation-owned bundles instead of mutating `tool.Catalog` or `mcp.Manager` membership in place; [`internal/app/catalog.go`](../../internal/app/catalog.go) remains the single full catalogue registration path and accepts one explicit generation contribution. -Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable secret-free binding digest covering its source/server/routing and complete dispatch-relevant contract. A same-name source, endpoint, schema, or read-only change is removal plus addition. Run entry automatically and durably attenuates removed or mismatched grants before model work; it never adopts newly observed capabilities into an established session. Additions and changed identities enter such a session only through explicit owner-authorized `/mcp-refresh`. This deliberately expands #1511's stale-catalogue repair: digest identity, remove-plus-add semantics, and one-time legacy migration are authority hardening beyond staleness. Safe manager lifetimes are required by reconciliation; immutable leased generations are the selected architecture rather than merely unspecified lifetime plumbing. Broker/protected MCP uses the same client-visible `/mcp-refresh` command but routes to the existing `ConnectWorkspaceServices` operation and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. +Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable binding digest covering its source/server, complete credential-free routing URL, and complete dispatch-relevant contract while excluding separately structured credentials. A same-name source, endpoint routing URL, schema, or read-only change is removal plus addition. Run entry automatically and durably attenuates removed or mismatched grants before model work; it never adopts newly observed capabilities into an established session. Additions and changed identities enter such a session only through explicit owner-authorized `/mcp-refresh`. This deliberately expands #1511's stale-catalogue repair: digest identity, remove-plus-add semantics, and one-time legacy migration are authority hardening beyond staleness. Safe manager lifetimes are required by reconciliation; immutable leased generations are the selected architecture rather than merely unspecified lifetime plumbing. Broker/protected MCP uses the same client-visible `/mcp-refresh` command but routes to the existing `ConnectWorkspaceServices` operation and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. ## Human decisions - [x] Scope and lifetime architecture — Decision: #1511's stale-catalogue problem requires safe connection lifetimes, but this plan deliberately adds digest identity, remove-plus-add/regrant semantics, and one-time legacy migration as authority hardening beyond staleness; immutable complete generations with explicit leases are the chosen architecture for those lifetimes. - [x] Source and publication model — Decision: one Build-owned source-neutral reconciler consumes complete immutable desired snapshots from ordered sources. Configured/static sources retain precedence over ToolHive collisions; ToolHive is the first dynamic source and future dynamic sources use the same seam. Publication produces immutable complete **active** generations, never in-place catalogue or manager mutation. -- [x] Durable direct-MCP identity — Decision: add bounded `session.DirectMCPBinding` records to `session.Authority`. Each record binds one tool or synthetic resource capability name to a fixed-format secret-free digest over source identity, server identity, canonical endpoint/routing identity, complete advertised contract/schema, and dispatch-relevant annotations including read-only. Credentials, tokens, headers, and rotating OAuth material are excluded. Name plus matching digest is required for execution. +- [x] Durable direct-MCP identity — Decision: add bounded `session.DirectMCPBinding` records to `session.Authority`. Each record binds one tool or synthetic resource capability name to a fixed-format digest over source identity, server identity, the complete credential-free endpoint routing URL, complete advertised contract/schema, and dispatch-relevant annotations including read-only. Endpoint identity preserves the exact escaped dispatch path and raw query semantics, with only scheme and DNS-host case folding and default-port elision. Credentials in separate headers, token sources, and OAuth state are excluded. Name plus matching digest is required for execution. +- [x] Endpoint routing and credential boundary — Decision: operator-configured and source-observed direct/global MCP endpoint URLs are public, non-secret routing input by mecatl contract, not a claim that arbitrary query values can be proven secret-free at runtime. URL userinfo and fragments are invalid. The complete dispatch URL participates in the digest: do not clean or decode the path, collapse empty/trailing/repeated slashes or dot segments, or decode/sort/re-encode the query; preserve repeated-key order, original query encoding, and the distinction between no query and an explicit empty query when dispatch observes it. Reject the decoded query parameter name `access_token` as the narrow protocol-standard token field; infer no generic credential-name denylist and drop no other query key heuristically. Structured `Headers`, `TokenSource`, and OAuth credentials remain the credential channels and do not affect the digest. Hashing a misconfigured secret-bearing URL would not sanitize it, and exhaustive secret detection is impossible, so compliant credential-free input is the enforcement precondition. - [x] Removal, mismatch, and legacy behavior — Decision: same-name binding change is removal plus addition. At the next run entry, removed/mismatched grants are automatically and durably revoked and their names removed before model/tool work; persistence uncertainty fails closed. Automatic run entry only attenuates: it never adds a newly observed capability to an established session. Revoked records remain durable unavailable evidence, so reappearance never automatically regrants, including after restart. A legacy authority with no binding field performs one persisted adoption only for same-name current direct MCP capabilities already present in its exact name ceiling; it never adds an absent name. Any later addition or changed identity requires explicit owner refresh. - [x] Explicit refresh set algebra — Decision: `/mcp-refresh` replaces only the direct/global MCP subset. It preserves every capability not identified by durable direct-MCP records, removes all prior direct-MCP capability names, retains omitted/mismatched old records as revoked evidence, and unions successfully active generation bindings and names. It never replaces core, latent Team, profile, client-MCP, or broker authority. - [x] Unified client command and capability routing — Decision: mecatui exposes one argument-free `/mcp-refresh`. When exactly the direct `mcp_refresh` capability is supported it invokes only the new `RefreshMcpSources`; when exactly the existing broker `workspace_enrollment` capability is supported it invokes only the existing `ConnectWorkspaceServices` whole-bundle operation. Direct refresh is consent-free. Broker refresh preserves consent/presentation, cancellation, and the destructive-reconnection disclosure from ADR 0335. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains the broker cancellation command; neither is made a direct-MCP capability. Missing collaborators, both mode bits, or otherwise unknown/contradictory capability combinations fail closed with no fallback or double operation. @@ -32,13 +35,29 @@ Authority becomes identity-bearing rather than name-only. Each authorized direct ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. `RefreshMcpSourcesRequest` contains only `string session_id = 1`. `RefreshMcpSourcesResponse` contains `uint64 generation = 1` and `bool changed = 2`, where `generation` is the active generation whose direct-MCP subset was durably installed and `changed` reports an exact authority change. Add `ServerCapabilities.mcp_refresh = 30`; this bit advertises only the new direct-MCP RPC and says nothing about broker refresh. The existing `ServerCapabilities.workspace_enrollment = 28` continues to advertise the broker `ConnectWorkspaceServices` operation. Extend `ListMcpSourcesResponse` after `sources = 1` with `uint64 observed_generation = 2`, `uint64 active_generation = 3`, `bool stale = 4`, `bool reconciling = 5`, and `bool degraded = 6`. Preserve `McpServerInfo.url = 2` and the existing per-source pre-shadow row semantics; generation fields are separate response metadata. HTTP adds bodyless `POST /v1/sessions/{id}/mcp-refresh` for direct MCP; the existing broker route remains `POST /v1/sessions/{id}/workspace-enrollment/connect` (with its existing retry/cancel companions). Both direct transports use existing ownership concealment and map ineligible/conflicting state to `FAILED_PRECONDITION`/409. Caller cancellation stops only waiting; it does not cancel shared direct reconciliation. No broker RPC, HTTP route, response, or capability is renamed. -- **Exported Go APIs / interfaces:** In `engine/session`, add `type DirectMCPBinding struct` with exact fields `Capability string`, `Digest string`, and `Revoked bool`; their JSON keys are respectively `capability`, `digest`, and `revoked` (`revoked` uses `omitempty`). Add `Authority.DirectMCPBindings []DirectMCPBinding` with JSON key `direct_mcp_bindings` and no `omitempty`: a nil slice means a legacy authority, while a present empty JSON array means migration completed with no direct grants. Validation allows at most 512 records; each capability is non-empty, valid UTF-8, control-free, at most 256 bytes; each digest is exactly `sha256:` plus 64 lowercase hexadecimal characters; capabilities are unique; active inputs may not set `Revoked`; rejected mutations are non-mutating. Add exactly `func (s *Session) AttenuateDirectMCPAuthority(active []DirectMCPBinding) error` and `func (s *Session) ReplaceDirectMCPAuthority(active []DirectMCPBinding) error`. Both are idle-only, require bound ordinary-root authority and no pending permission/external authorization/workspace enrollment, preserve all non-direct fields, clone inputs, and apply the algebra below; `Attenuate…` never adds authority and performs one-time legacy adoption, while `Replace…` is the explicit-widen path. Delegated authority derivation copies only active parent binding records whose capabilities survive the derived child ceiling; resume validation requires the persisted child's bindings to remain a digest-identical subset of the current parent/run generation and never invokes legacy adoption for a child. In `engine/agent.RunRequest`, add `UnavailableTools []string`: a bounded duplicate-free run-scoped set (maximum 512 names, each using the same capability framing bound) that shadows matching names before request-schema projection, catalogue lookup, authority evaluation, permission, or dispatch; it produces the fixed permanent result and is never advertised or authority-exempt. These exported additions require `task api:update` and Added/minor `engine/CHANGELOG.md` entries. Generation/source/lease interfaces remain internal; no ToolHive type enters `engine/`. -- **Tool schemas:** No model-visible tool is added. A direct/global MCP tool is advertised and executable only when its name is in `CapabilitySet.Tools`, its active `DirectMCPBinding` digest equals the run generation's digest, and existing permission evaluation allows it. The synthetic `governance.MCPResourceCapability(server)` follows the same binding rule for direct MCP resources. A changed source, canonical endpoint/routing identity, complete schema/contract, or dispatch annotation including read-only changes the digest and is remove-plus-add. `RunRequest.UnavailableTools` shadows even a current same-name catalogue entry before schema projection or lookup; unavailable names cannot pass authority/permission or dispatch, and a generated call receives the fixed permanent result. -- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing `mcp.servers`, MCP CLI flags, ToolHive enable/group options, and environment-referenced credentials are process-start snapshots reused by every reconciliation. ToolHive discovery alone enables automatic polling. Project-tier MCP remains ignored. Mecatui adds one exact bare builtin `/mcp-refresh`, with no arguments. It is offered for a supported direct `ServerCapabilities.mcp_refresh` path or supported broker `ServerCapabilities.workspace_enrollment` path and invokes exactly one corresponding collaborator: direct calls only `RefreshMcpSources`; broker calls only the existing `ConnectWorkspaceServices`. Direct refresh is consent-free. Broker refresh retains ADR 0335's consent/presentation, cancellation through `/tools-cancel`, and explicit destructive replacement disclosure. `/tools-connect` is deprecated but remains a broker-only compatibility alias with its prior `ConnectWorkspaceServices` semantics; it is not gated by `mcp_refresh` and never invokes direct refresh. Both capability bits, an advertised bit without its collaborator, or another unknown/contradictory combination fails closed without fallback or invoking both operations. -- **Events / persistence:** Add no event and no generation ID to the session snapshot. `Authority.DirectMCPBindings` round-trips through the existing authority snapshot; absent means legacy, present empty means adopted/no grants. Composition computes each digest from canonical source identity, server identity, canonical endpoint/routing identity (raw URL never enters the digest record), complete advertised tool contract/schema, and dispatch annotations; secret/token/header/rotating OAuth material is excluded. Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. Automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. Explicit refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`; bindings become every record from `A` active plus prior records whose `Capability` is absent from `A` retained as revoked evidence, subject to the 512-record bound. For a same-name changed binding, explicit refresh replaces the old revoked record with the new active record because that explicit action is the regrant; at most one record exists per capability. Legacy attenuation first sets a present binding list from only `T ∩ C(A)`, persists it, and never adds `C(A) \ T`; later reappearance cannot adopt because the list is no longer nil. Refresh/attenuation operate on a detached authoritative load under `runEntryMu`, mutation lease, and mutation capability; they save before publishing success or starting model work. After ambiguous Save, reload: exact candidate durable means success; exact old state means failure; any mismatch/unknown fails closed. `/mcp-refresh` builds no engine. No process-global tombstones persist or cache. -- **Security / authority:** Direct `RefreshMcpSources` is owner-authorized, ordinary-root-only, idle-only, consent-free, and rejected for running, awaiting, child/delegation, debug, pending permission/authorization/enrollment, broker-refresh conflict, or unproved shared-store exclusion. Automatic attenuation uses the same mutation capability/lease discipline and failure prevents all model/tool work. The request supplies no names, source, config, endpoint, or credential. Digest input is secret-free and persisted output is hash-only. The common client command does not merge authority domains: broker mode routes only to existing `ConnectWorkspaceServices` and preserves ToolHive consent, cancellation, and destructive replacement semantics; direct mode never invokes broker attachment, authorization, credential refresh, or presentation. Client-provided MCP remains outside reconciliation. Unknown or contradictory capability/collaborator states fail closed with no fallback or double operation. Existing URL projection is not broadened; new generation metadata, diagnostics, errors, and model result fields are canary-tested against raw config, headers, tokens, and credentials. -- **Compatibility / migration:** Existing `ListMcpSources` clients retain URLs and pre-shadow rows and ignore additive response fields. Existing sessions receive one-time, non-widening legacy binding adoption before their first post-upgrade run; it is durably confirmed before model work. A current same-name entry may be adopted only if already in the legacy exact name ceiling. Existing settings/CLI behavior, static-over-ToolHive precedence, and streaming-HTTP transport remain. ADR 0342 supersedes ADR 0057 only for its deferred no-live-catalogue decision; ADR 0057's notification transport/lazy-list/reconnect details remain. Broker authority and server operation remain ADR 0335 `ConnectWorkspaceServices`; only the preferred mecatui spelling is unified as `/mcp-refresh`. `/tools-connect` remains a deprecated compatibility alias retaining prior broker semantics and never gaining direct `mcp_refresh` behavior; `/tools-cancel` is unchanged. Client MCP remains session-local. Implementation updates living architecture/design/user docs and ADR 0027 inventories; this Plan PR does not describe unshipped behavior there. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources`, request field `string session_id = 1`, response fields `uint64 generation = 1` and `bool changed = 2`, and direct-only `ServerCapabilities.mcp_refresh = 30`. Extend `ListMcpSourcesResponse` with `observed_generation = 2`, `active_generation = 3`, `stale = 4`, `reconciling = 5`, and `degraded = 6`; preserve `McpServerInfo.url = 2` and pre-shadow rows. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. Both direct transports retain ownership concealment, use `FAILED_PRECONDITION`/409 for ineligible state, and detach shared reconciliation from caller cancellation. +- **Exported Go APIs / interfaces:** Add `engine/session.DirectMCPBinding{Capability string, Digest string, Revoked bool}`, `Authority.DirectMCPBindings []DirectMCPBinding`, idle-only `Session.AttenuateDirectMCPAuthority` and `Session.ReplaceDirectMCPAuthority`, and bounded run-scoped `engine/agent.RunRequest.UnavailableTools []string`. Exact JSON, validation, mutation, derivation, and resume contracts are detailed below. These are Added/minor engine API changes requiring `task api:update` and `engine/CHANGELOG.md`; generation/source/lease interfaces remain internal and ToolHive-free. +- **Tool schemas:** No model-visible tool is added. A direct/global MCP tool is advertised and executable only when its name is in `CapabilitySet.Tools`, its active `DirectMCPBinding` digest equals the run generation's digest, and existing permission evaluation allows it. The synthetic `governance.MCPResourceCapability(server)` follows the same binding rule for direct MCP resources. A changed source, server, compliant endpoint routing identity, complete schema/contract, or dispatch annotation including read-only changes the digest and is remove-plus-add under the endpoint-encoding contract below. `RunRequest.UnavailableTools` shadows even a current same-name catalogue entry before schema projection or lookup; unavailable names cannot pass authority/permission or dispatch, and a generated call receives the fixed permanent result. +- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing process-start MCP inputs remain restart-only; ToolHive discovery alone enables polling and project-tier MCP remains ignored. Existing non-secret routing queries remain supported under the validation contract below. Add exact argument-free mecatui `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Contradictory or missing capability/collaborator states fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains unchanged. +- **Events / persistence:** Add no event or snapshot generation ID. `Authority.DirectMCPBindings` round-trips through the existing authority snapshot; absent means legacy and present empty means adopted/no grants. Composition persists only the digest of source, server, compliant endpoint identity, complete tool contract/schema, and dispatch annotations; structured credential channels are excluded. Exact attenuation/refresh algebra and save confirmation are detailed below. No process-global tombstone persists or caches. +- **Security / authority:** Direct refresh is owner-authorized, ordinary-root-only, idle-only, consent-free, and fails closed for conflicting/lower-authority state or unproved store exclusion; automatic attenuation uses the same mutation exclusion and blocks model/tool work on failure. Requests carry no source, endpoint, or credential. The exact endpoint/credential boundary is below. Broker, direct, and client-MCP authority remain separate. Existing URL projection/redaction is unchanged; new outputs are canary-tested with secrets exclusively in structured credential channels. +- **Compatibility / migration:** Existing clients retain complete `McpServerInfo.url` values/pre-shadow rows and ignore additive fields. Existing sessions get one durably confirmed, non-widening legacy adoption. Existing compliant query-routed endpoints, config shape, source precedence, and Streamable HTTP remain; only protocol-standard `access_token` query input is newly invalid. URL redaction is unchanged. ADR 0342 supersedes only ADR 0057's deferred live-catalogue decision. Broker/client MCP and compatibility aliases remain unchanged. This amendment changes no proposed SDK, RPC, tool schema, or exported Go API; living/user docs update in implementation. + +## Endpoint binding encoding and credential boundary + +The deterministic, domain-separated composition encoder includes the complete compliant endpoint routing URL. It folds only scheme and DNS-host case and elides the scheme's default port. It preserves `EscapedPath()` exactly—including empty versus `/`, trailing and repeated slashes, escaped octets, and dot segments—and separately encodes `RawQuery` plus `ForceQuery`. It never applies `path.Clean`, unescapes the path, or parses, sorts, and re-encodes query pairs; repeated-key order, original escaping, and absent versus explicit empty query therefore remain distinct whenever HTTP dispatch observes them. + +Direct/global operator-configured and source-observed endpoint URLs are public, credential-free routing input by mecatl contract: absolute HTTP(S), host-bearing, without userinfo or fragment. Existing operator-profile URL validation and every-source reconciler admission enforce this before connection, publication, or digesting. They also reject a query parameter whose decoded name is exactly `access_token`, the narrow field prohibited by MCP 2025-11-25 Access Token Usage. They preserve every other query key and perform no generic credential-name inference. This mecatl policy is broader than MCP's token-placement rule; neither policy proves arbitrary custom query values secret-free. Headers, token sources, and OAuth credential/rotation material are the separate structured credential channels and are excluded from the digest. A secret misplaced in the endpoint violates the input contract and cannot be made safe by hashing. + +### Exported API details + +`DirectMCPBinding` uses JSON keys `capability`, `digest`, and `revoked`, with `omitempty` only on `revoked`; `Authority.DirectMCPBindings` uses `direct_mcp_bindings` without `omitempty`, preserving nil as legacy and `[]` as migration-complete. At most 512 unique records are valid. Capability is non-empty, valid UTF-8, control-free, and at most 256 bytes; digest is exactly `sha256:` plus 64 lowercase hexadecimal characters; active mutation input cannot set `Revoked`, and rejection is non-mutating. The two session methods clone input, require idle bound ordinary roots with no pending permission, external authorization, or workspace enrollment, and preserve unrelated state. Attenuation never widens and alone performs legacy adoption; replacement is the explicit-widen path. Child derivation copies only active digest-identical records surviving its ceiling; resume requires a digest-identical subset and never adopts legacy child state. `UnavailableTools` is duplicate-free, uses the same 512-name and capability framing bounds, shadows before schema/lookup/authority/permission/dispatch, is never advertised or exempted, and returns the fixed permanent removal result. + +### Durable set algebra and persistence + +Let `B` be prior bindings, `C(B)` all their capability names, `T` prior `CapabilitySet.Tools`, `A` successfully active generation bindings, and `Match(B,A)` only active prior records with the same digest in `A`. Attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`: matches stay active; removed/mismatched records keep their old digest with `Revoked=true`; new `A` names are not added. Refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`: `A` becomes active, omitted prior capabilities remain revoked evidence, and an explicitly refreshed same-name change replaces the old record. Legacy adoption persists only `T ∩ C(A)`, including a present empty list, and never adds `C(A) \ T`. + +Both operations mutate a detached authoritative load under `runEntryMu`, the mutation lease, and mutation capability, then save before success or model work; `/mcp-refresh` builds no engine. After ambiguous save, authoritative reload treats exact candidate as success, exact old state as failure, and every mismatch or unknown as fail-closed. ## In scope — 4 scenarios, in implementation order @@ -47,7 +66,7 @@ Authority becomes identity-bearing rather than name-only. Each authorized direct The reconciler consults ordered sources once per cycle, preserves source LKG on consultation failure, and treats every successful snapshot—including empty—as desired truth. It reuses unchanged healthy bindings, connects additions independently, and treats a changed binding as withdrawal followed by a new attempt. Valid unrelated changes publish even when an addition/replacement fails; the immutable generation is the complete successfully **active** set, while status remains stale/degraded against desired truth. This is [ADR 0342](../adr/0342-mcp-source-reconciliation.md)'s source-neutral publication boundary. **Acceptance:** -- AC1.1: Static/configured and ToolHive snapshots merge deterministically with earlier/static precedence, one consultation per source per cycle, preserved pre-shadow inventory rows, and secret-free binding digests covering source/server/routing/contract/read-only identity for every tool and direct resource capability. +- AC1.1: Static/configured and ToolHive snapshots merge deterministically with earlier/static precedence, one consultation per source per cycle, and preserved pre-shadow inventory rows. A named binding-encoder matrix proves source/server/contract/read-only identity and complete compliant endpoint routing identity for every tool and direct resource capability: query `route=A` versus `route=B` changes both digest and connection identity; repeated query-key order, original query escaping, and absent versus explicit empty query remain distinct; escaped slash versus literal slash, empty versus `/`, trailing/repeated slash, and dot-segment paths remain distinct; only scheme/DNS-host case and default-port spelling normalize. Rotation of structured headers, token sources, and OAuth credentials changes neither digest nor durable identity and none of their canary values enters digest/status/error output. Userinfo, fragments, and the decoded query name `access_token` are rejected before connection/digesting; benign routing queries are retained verbatim, with no generic credential-key inference. - verify: `TestMCPSourceReconciliation_Scenario1_OrderedBindingsAndPrecedence` - AC1.2: Consultation failure retains that source's LKG; successful empty withdraws it. Unchanged connections are reused, additions connect independently, and changed bindings withdraw old before replacement; failed additions/replacements are unavailable while unrelated healthy changes publish, partial candidates close, and status is stale/degraded. - verify: `TestMCPSourceReconciliation_Scenario1_FailedEmptyAndPartialActiveSnapshots` @@ -75,7 +94,7 @@ A generation owns the direct/global manager view, tools, resources/prompts, stat At run entry, the Service compares durable active bindings with the selected generation before building/using an engine. Missing or mismatched identities are revoked and removed on a detached aggregate, then durably confirmed under existing exclusion before any model/tool work. Legacy name-only authorities adopt only current same-name entries already in their ceiling, once. `/mcp-refresh` uses the exact direct-subset algebra in the interface contract, persists first, and leaves engine rebuilding to the next run. This preserves the aggregate discipline in [`engine/session/principal.go`](../../engine/session/principal.go) and the durable authority decision in [ADR 0342](../adr/0342-mcp-source-reconciliation.md). **Acceptance:** -- AC3.1: Same-name source/server/canonical-endpoint/schema/read-only change is remove-plus-add. Automatic run-entry attenuation removes the name, retains the old digest revoked, confirms persistence before model work, and never automatically regrants on reappearance or restart; save failure/ambiguity without exact confirmation means zero model/tool work. +- AC3.1: Same-name source/server/complete-endpoint/schema/read-only change is remove-plus-add. The named matrix includes query `route=A` versus `route=B` and every AC1.1 path/query distinction, proving that each routing-significant difference changes digest and selected connection while the limited equivalent scheme/DNS-host/default-port spellings do not. Structured credential rotation leaves the durable binding unchanged. Automatic run-entry attenuation removes the name, retains the old digest revoked, confirms persistence before model work, and never automatically regrants on reappearance or restart; save failure/ambiguity without exact confirmation means zero model/tool work. - verify: `TestMCPSourceReconciliation_Scenario3_AttenuationPersistsBeforeRun` - AC3.2: A legacy ordinary-root authority with absent bindings adopts and persists only currently active direct capabilities whose names were already authorized, marks migration complete even when empty, never adds an absent name, and cannot adopt a later reappearance automatically. Delegation never treats a child as a legacy root: new children receive the digest-identical active subset derived from the parent, and resumed children fail closed on binding drift. - verify: `TestMCPSourceReconciliation_Scenario3_LegacyAdoptionIsOneTimeAndNonWidening` @@ -91,7 +110,7 @@ The server derives unavailable names from the session's durable revoked/mismatch **Acceptance:** - AC4.1: An unavailable stale call is not advertised or authorized and does not dispatch; it returns the exact bounded permanent removal result from run-scoped durable evidence. Unknown non-recorded names retain ordinary unknown-tool behavior, and no synthetic history refresh message is recorded. - verify: `TestMCPSourceReconciliation_Scenario4_DurableUnavailableRunInput` -- AC4.2: `ListMcpSources` and `/mcp` preserve existing URLs and pre-shadow rows and add observed/active generation plus stale/degraded/reconciling truth from cached reconciler state without an independent probe. Canary headers, tokens, credentials, and non-URL raw configuration never appear in newly added generation/status fields, diagnostics, errors, or removal results; the pre-existing URL field is unchanged. +- AC4.2: `ListMcpSources` and `/mcp` preserve existing complete URLs and pre-shadow rows and add observed/active generation plus stale/degraded/reconciling truth from cached reconciler state without an independent probe. Canary secrets placed exclusively in structured headers, token sources, and OAuth credential channels never appear in binding/status fields, diagnostics, errors, or removal results. The pre-existing `McpServerInfo.url` compatibility and URL redaction behavior are unchanged; this amendment does not add a projection or attempt heuristic URL redaction. - verify: `TestMCPSourceReconciliation_Scenario4_StatusCompatibilityAndSecretCanaries` - AC4.3: One table-driven transport/outcome matrix with independently identifiable subtests proves every branch. For each direct surface—gRPC `RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and mecatui `/mcp-refresh` routed through `mcp_refresh`—the cases are cooldown adoption, unchanged success, changed success, caller cancellation, ownership concealment, active/awaiting/child/debug/broker-conflict rejection, unsupported capability, and every AC3.4 persistence outcome. Client routing cases are direct-only `mcp_refresh` to only `RefreshMcpSources`; broker-only `workspace_enrollment` to only existing `ConnectWorkspaceServices`; neither capability to no offered command; and both bits, missing matching collaborator, and unknown contradiction to fail closed. Broker-routed `/mcp-refresh` separately covers destructive-reconnection disclosure, presentation/consent, pending observation, connected, denied, expired, failed, cancelled, and `/tools-cancel`, with no direct call. `/tools-connect` is independently covered as a deprecated broker-only alias retaining prior semantics, including when direct `mcp_refresh` is also present. No branch falls back or invokes both operations; direct branches are consent-free and never call broker controls. - verify: `TestMCPSourceReconciliation_Scenario4_TransportAndUIParity` @@ -124,7 +143,7 @@ The server derives unavailable names from the session's durable revoked/mismatch ## Deferred decisions and known risks - Exact numeric reconciler source/server/tool/schema/generation-retention, cooldown, poll, and stale-idle-eviction constants are implementation details, but all are mandatory finite internal constants with boundary tests and fail-stale diagnostics; changing public configuration is out of scope. -- Digest canonicalization is correctness- and compatibility-critical. Implementation must define one deterministic composition-only encoder with domain separation and explicit field ordering; any included dispatch-relevant contract change intentionally revokes the old grant, while credentials and rotating authorization material must remain excluded. +- Digest encoding is correctness- and compatibility-critical. Implementation must define one deterministic composition-only encoder with domain separation and explicit field ordering. The compliant endpoint field preserves exact escaped-path and raw-query dispatch semantics (including `ForceQuery`) while normalizing only scheme/DNS-host case and default port; it performs no path cleaning or query decode/sort/re-encode. Any included dispatch-relevant contract change intentionally revokes the old grant. Separate headers, token sources, and OAuth credentials must remain excluded; hashing a secret misplaced in an endpoint URL does not sanitize it, so credential-free URL validation is a required precondition rather than a heuristic detector. - The 512 durable-record bound intentionally rejects a refresh that cannot retain required revoked evidence rather than silently dropping evidence or widening authority. Automatic attenuation changes record state without increasing count; legacy adoption also fails closed if its authorized intersection exceeds the bound. - Generation leases increase lifecycle complexity. The acceptance matrix and ADR 0027 inventory are load-bearing: an engine cache must neither use a closed manager nor retain stale generations indefinitely. - Out-of-run MCP list/get calls are individually consistent, not a multi-call snapshot. Clients must handle a visible stale-identifier error after publication between calls. diff --git a/docs/adr/0342-mcp-source-reconciliation.md b/docs/adr/0342-mcp-source-reconciliation.md index ce2345c96f..05831413be 100644 --- a/docs/adr/0342-mcp-source-reconciliation.md +++ b/docs/adr/0342-mcp-source-reconciliation.md @@ -16,13 +16,15 @@ In-place manager/catalogue mutation also creates mixed-generation behavior and u Protected broker MCP is separate. ADR 0335 gives it the existing `ConnectWorkspaceServices` operation, `workspace_enrollment` capability, ToolHive-owned consent/grants, cancellation, destructive replacement semantics, and session attachment state. Client-provided MCP is also session-local. Neither belongs in process-wide direct/global reconciliation. Mecatui may present one common `/mcp-refresh` spelling while capability-routing to these separate operations; command unification does not unify their authority or lifecycle. +This Proposed ADR was amended on 2026-09-16 after implementation stopped on contract drift. The shipped configuration accepts query-routed Streamable HTTP endpoints, carries `ServerConfig.URL` separately from headers/token sources/OAuth, rejects URL userinfo and fragments in operator profiles, and preserves `McpServerInfo.url`. The MCP 2025-11-25 Authorization specification's [Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage) requires the Authorization header and states that access tokens **MUST NOT** be included in URI query strings. That protocol rule does not ban non-secret queries or every custom credential convention. Mecatl therefore defines the broader endpoint URL as credential-free public routing configuration and binds its complete dispatch semantics; this is a configuration contract, not a runtime proof that arbitrary query values contain no secret. + ## Decision 1. **Reconcile ordered source snapshots through one Build-owned component.** The source-neutral reconciler consumes complete immutable desired snapshots. Earlier sources win collisions, preserving configured/static-over-ToolHive precedence. ToolHive is the first dynamic source; future sources use the same seam. Configured settings, CLI inputs, and environment-referenced credentials are captured at process start and reused in every cycle. Project-tier MCP stays ignored; ordinary sessions never reload operator configuration. -2. **Bind durable authority to capability identity, not only name.** Add `session.DirectMCPBinding` records to `session.Authority`. Each record carries a capability name, an opaque `sha256:<64 lowercase hex>` digest, and revoked state. Composition computes the secret-free digest with domain-separated deterministic encoding over source identity, server identity, canonical endpoint/routing identity, the complete advertised tool contract/schema, and every dispatch-relevant annotation including read-only. It excludes headers, credentials, token values, and rotating OAuth material. Direct MCP synthetic per-server resource capabilities receive bindings under the same rule. Execution requires both the existing `CapabilitySet.Tools` name and an active digest equal to the selected generation's digest. +2. **Bind durable authority to capability identity, not only name.** Add `session.DirectMCPBinding` records to `session.Authority`. Each record carries a capability name, an opaque `sha256:<64 lowercase hex>` digest, and revoked state. Composition uses domain-separated deterministic encoding over source identity, server identity, the complete compliant endpoint routing URL, the complete advertised tool contract/schema, and every dispatch-relevant annotation including read-only. Endpoint encoding folds only scheme and DNS-host case and elides the scheme's default port. It preserves the exact escaped dispatch path—without cleaning, unescaping, or collapsing empty/trailing/repeated slashes or dot segments—and separately preserves raw query plus the explicit-empty-query bit, without decoding, sorting, or re-encoding; repeated-key order and original escaping therefore remain identity. URL userinfo and fragments are invalid. The exact decoded query parameter name `access_token` is rejected before connection/digesting as the narrow field prohibited by the MCP authorization contract; benign routing queries remain, and no generic credential-name denylist infers or drops other keys. Headers, token sources, and OAuth credentials/rotation state are separate structured channels excluded from the digest. Direct MCP synthetic per-server resource capabilities receive bindings under the same rule. Execution requires both the existing `CapabilitySet.Tools` name and an active digest equal to the selected generation's digest. -3. **Treat identity change as removal plus addition, and automatically attenuate only.** Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. At the next run entry, automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. A same-name source, server, canonical endpoint/routing, schema/contract, or dispatch-annotation change is therefore removal plus addition, not an automatic replacement. The Service durably confirms attenuation before model or tool work. Reappearance and newly observed capabilities never automatically grant authority to an established session, including after restart; they require explicit owner refresh. If storage exclusion or persistence confirmation fails, the run does not begin. +3. **Treat identity change as removal plus addition, and automatically attenuate only.** Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. At the next run entry, automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. A same-name source, server, complete endpoint routing URL, schema/contract, or dispatch-annotation change is therefore removal plus addition, not an automatic replacement. In particular, routing query A versus B and dispatch-distinct escaped paths select different identities and connections, while rotating a separately structured credential does not. The Service durably confirms attenuation before model or tool work. Reappearance and newly observed capabilities never automatically grant authority to an established session, including after restart; they require explicit owner refresh. If storage exclusion or persistence confirmation fails, the run does not begin. 4. **Adopt legacy name-only authority once without widening.** An absent binding field denotes legacy authority; a present empty list denotes completed adoption with no direct grants. Before the first post-upgrade run, materialize bindings only for current same-name direct MCP capabilities already present in the legacy exact tool ceiling. Never add a name absent from that ceiling. Persist adoption before model work. Once the field is present, later reappearance cannot trigger adoption. @@ -40,11 +42,11 @@ Protected broker MCP is separate. ADR 0335 gives it the existing `ConnectWorkspa 10. **Persist authority before use under existing exclusion.** Refresh and attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability`; a shared store without proven mutation exclusion fails closed. They mutate a detached authoritative load, not a live/cached session. A successful save is confirmed before widening or model work. After an ambiguous save, reload authoritative state: exact candidate means success, exact old state means failure, and mismatch/unknown fails closed. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against the current generation. No widened live engine is published before durability is known. -11. **Add status metadata and one capability-directed client command without merging operations.** Preserve `McpServerInfo.url` and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields. Add direct-only `ServerCapabilities.mcp_refresh` for the new `RefreshMcpSources` gRPC operation and direct HTTP refresh route; it does not advertise broker refresh. Existing `ServerCapabilities.workspace_enrollment` continues to advertise broker `ConnectWorkspaceServices`. Mecatui offers one argument-free `/mcp-refresh` when a supported direct or broker path is present: direct-only routing invokes `RefreshMcpSources` and is consent-free; broker-only routing invokes `ConnectWorkspaceServices` and preserves ADR 0335's consent/presentation, pending observation, cancellation, and destructive reconnection disclosure. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains broker cancellation; neither gains direct behavior. Both mode bits, a missing matching collaborator, or another unknown/contradictory combination fails closed with no fallback and no double operation. New status, diagnostics, errors, binding records, and model results expose no new raw configuration, header, token, or credential material; canary tests enforce that boundary. +11. **Add status metadata and one capability-directed client command without merging operations.** Preserve the complete `McpServerInfo.url`, existing URL redaction behavior, and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields. Add direct-only `ServerCapabilities.mcp_refresh` for the new `RefreshMcpSources` gRPC operation and direct HTTP refresh route; it does not advertise broker refresh. Existing `ServerCapabilities.workspace_enrollment` continues to advertise broker `ConnectWorkspaceServices`. Mecatui offers one argument-free `/mcp-refresh` when a supported direct or broker path is present: direct-only routing invokes `RefreshMcpSources` and is consent-free; broker-only routing invokes `ConnectWorkspaceServices` and preserves ADR 0335's consent/presentation, pending observation, cancellation, and destructive reconnection disclosure. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains broker cancellation; neither gains direct behavior. Both mode bits, a missing matching collaborator, or another unknown/contradictory combination fails closed with no fallback and no double operation. New status, diagnostics, errors, binding records, and model results expose no separately structured header, token, or credential material; canary tests place secrets exclusively in those compliant credential channels. ## Consequences -A durable grant now identifies the direct capability contract it authorized. Endpoint, schema, or read-only changes require explicit owner refresh even when names remain stable. Automatic run entry only removes/revokes missing or mismatched direct grants; it never adds newly observed capabilities to an established session. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. +A durable grant now identifies the direct capability contract it authorized. Source, server, complete compliant endpoint routing URL, schema, or read-only changes require explicit owner refresh even when names remain stable. Exact escaped paths and raw query routing are identity; only scheme/DNS-host case and default-port spelling normalize. Structured credential rotation is not identity. Automatic run entry only removes/revokes missing or mismatched direct grants; it never adds newly observed capabilities to an established session. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. Generation consistency extends across engine lifetimes, cached variants, direct RunTeam/delegation/reference MCP, and out-of-run resource/prompt operations. Old connections remain alive until every lease drains, while bounded stale-idle eviction prevents unused cached sessions from retaining them forever. Complete active-set publication allows unrelated healthy changes through but makes observed, desired, and active truth distinct and requires clear stale/degraded status. @@ -54,12 +56,13 @@ The reconciler may reuse unchanged healthy connections, but changed identities a The process gains long-lived generation bundles, leases, source LKG/status caches, stale-engine eviction, and trigger/cooldown state. Implementation must inventory every such resource in ADR 0027 List 1 and every restart-relevant state decision in List 2. There is no process-global tombstone cache; unavailable-call evidence is session-durable. -Configured settings/CLI/environment inputs remain restart-only. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. The shared client spelling does not change broker wire semantics: `ConnectWorkspaceServices`, `workspace_enrollment`, consent, cancellation, and destructive replacement remain authoritative, while direct `mcp_refresh` advertises only `RefreshMcpSources`. +Configured settings/CLI/environment inputs remain restart-only. Endpoint URLs are public, credential-free routing configuration: userinfo and fragments are invalid, and the protocol-standard `access_token` query parameter is rejected at operator-profile validation and source admission. Other queries remain compatible and participate byte-faithfully in identity. This narrow enforcement cannot reliably detect arbitrary custom secrets; hashing does not sanitize a misconfigured URL, so operators and source adapters must keep credentials in the separate header/token/OAuth channels. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. The shared client spelling does not change broker wire semantics: `ConnectWorkspaceServices`, `workspace_enrollment`, consent, cancellation, and destructive replacement remain authoritative, while direct `mcp_refresh` advertises only `RefreshMcpSources`. The amendment changes no SDK, RPC, tool, or exported Go API selected by this ADR; living implementation and user documentation updates remain implementation work. ## Rejected alternatives - **Authorize by name only.** Same-name endpoint/schema/read-only changes silently alter the granted capability and can resurrect after restart. -- **Use a generation ID as durable authority.** Process generations are runtime ownership, not stable capability identity; a secret-free contract digest survives restart without persisting endpoints. +- **Use a generation ID as durable authority.** Process generations are runtime ownership, not stable capability identity; a deterministic contract digest survives restart without persisting the endpoint in each binding record. +- **Drop all query strings as potential secrets, or infer credential-like names.** Non-secret queries can select the dispatched MCP route and are supported by existing configuration. Dropping them aliases distinct connections; sorting/re-encoding them can also change dispatch. Mecatl instead rejects the exact protocol-standard `access_token` field, treats the complete compliant query as public routing identity, and keeps arbitrary credentials in structured channels without claiming exhaustive secret detection. - **Keep a process-global removed-name tombstone cache.** It is lost on restart, detached from session authority, and grows another long-lived cache. Durable revoked bindings already provide exact evidence. - **Mutate shared catalogues/managers in place or pin only active runs.** Cached engines, teams, referenced agents, and out-of-run resource/prompt operations can outlive the pin and observe closed or mixed managers. - **Publish desired state all-or-nothing.** One failed addition would block unrelated removals and healthy changes. Publishing the successfully active subset is safer and more available while status remains honest. From 380d8201a63a750ea3f24fba25d1c8d81a4eb296 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 16 Sep 2026 10:02:48 +0300 Subject: [PATCH 05/13] docs: simplify MCP source reconciliation plan Co-Authored-By: mecatl --- docs/acceptance/README.md | 2 +- docs/acceptance/mcp-source-reconciliation.md | 181 ++++++++----------- docs/adr/0342-mcp-source-reconciliation.md | 83 --------- docs/adr/0345-mcp-source-reconciliation.md | 68 +++++++ docs/adr/README.md | 4 +- 5 files changed, 145 insertions(+), 193 deletions(-) delete mode 100644 docs/adr/0342-mcp-source-reconciliation.md create mode 100644 docs/adr/0345-mcp-source-reconciliation.md diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 8fe6e5ff71..843a85ebb9 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -154,8 +154,8 @@ PR after verification. There is no cleanup or status-only PR. attributable Linux and macOS command/job temporary storage with a permission-visible system escape and deterministic crash-residue reaping. Status: landed. - [Session title generation and token usage](session-title-generation.md) — mecatui `/title`, an opt-in routed model title after up to three genuine prompts, and durable title-model token attribution. Status: draft. +- [MCP source reconciliation](mcp-source-reconciliation.md) — bounded ToolHive polling, MCP notifications, and manual refresh publish immutable direct MCP runtimes while preserving exact-name authority; explicit owner refresh unions additions. Status: proposed. - [Mecatui-owned configurable terminal titles](mecatui-terminal-title-controller.md) — replaces Bubble Tea title emission with a renderer-serialized OSC 0 controller, user-global plain-text title templates over display-safe status facts, explicit disablement precedence, and live-run `/session` identity access. Status: proposed. -- [MCP source reconciliation](mcp-source-reconciliation.md) — one source-neutral Build-owned reconciler publishes immutable leased direct/global MCP generations, durably attenuates identity-bound grants, and exposes explicit owner-authorized `/mcp-refresh` for additions. Status: proposed. - [Per-upstream MCP broker OAuth grants](mcp-broker-multi-upstream-oauth.md) — accept multiple broker OAuth upstreams while keeping grants, callback state, authenticated discovery, and workspace-enrollment progression backend-scoped. Status: draft. - [Broker MCP status](broker-mcp-status.md) — approved owner-scoped broker connector inventory and enrollment/catalogue status behind `/mcp`, without probes or new diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index a64497685e..38835c4286 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -1,149 +1,116 @@ # MCP source reconciliation — acceptance plan **Contract:** human-reviewed/v2 -**Work classification:** Architectural — changes process-wide MCP publication, durable capability identity and persistence, public controls, and importable engine APIs. -**Decision record:** [ADR 0342](../adr/0342-mcp-source-reconciliation.md) -**Phase:** Source-neutral dynamic direct/global MCP reconciliation -**Status:** proposed, 2026-09-14. Directing-human decisions are settled; ready for Plan / Interface review. -**Delivery:** Split. Generation ownership, durable authority migration, and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on an exact recorded plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge the plan, and human review/merge plus contract-drift gates remain. -**Expected tasks:** deferred to orchestration. -**Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) +**Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. +**Decision record:** [ADR 0345](../adr/0345-mcp-source-reconciliation.md) +**Phase:** Minimal stale direct/global MCP source reconciliation +**Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. +**Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. +**Expected tasks:** deferred to orchestration after parent advisory review. +**Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. **Plan PR:** [#1527](https://github.com/stacklok/mecatl/pull/1527) -**Approved baseline:** absent until the Plan / Interface PR merges; any pre-merge stacked implementation records the exact proposed plan commit it follows. +**Approved baseline:** absent until the Plan / Interface PR merges; the authorized stacked implementation records the exact proposed plan commit it follows. -**Amendment (2026-09-16):** after implementation stopped on contract drift, the directing human clarified that the credential-free endpoint URL is itself dispatch identity, including its exact escaped path and raw query. Evidence is the shipped Streamable HTTP configuration/transport split—`internal/adapter/permconfig/schema.go` validates endpoint shape, while `internal/adapter/mcp/mcp.go` sends `ServerConfig.URL` and carries credentials separately—and the MCP Authorization specification's [Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage), which requires the Authorization header and says access tokens **MUST NOT** appear in URI query strings. MCP's rule does not prohibit non-secret routing queries; mecatl's broader policy makes the complete endpoint URL public, credential-free configuration. Compatibility consequence: compliant existing query-routed endpoints remain valid and query/path routing changes now intentionally change durable binding identity; endpoint URLs containing the protocol-standard `access_token` query parameter are rejected rather than treated as secret-bearing identity. This amendment changes no proposed SDK, RPC, tool, or exported Go API and does not approve or merge the plan. - -Mecatl currently resolves configured and ToolHive-discovered direct MCP servers into one process-start manager and catalogue. Later source observations and MCP list-changed notifications cannot safely replace existing session tools. This plan introduces one Build-owned, source-neutral reconciler over the ordered source seam in [`internal/adapter/mcp/source/source.go`](../../internal/adapter/mcp/source/source.go), preserving configured/static-over-ToolHive precedence. It publishes immutable generation-owned bundles instead of mutating `tool.Catalog` or `mcp.Manager` membership in place; [`internal/app/catalog.go`](../../internal/app/catalog.go) remains the single full catalogue registration path and accepts one explicit generation contribution. - -Authority becomes identity-bearing rather than name-only. Each authorized direct/global MCP tool, and each synthetic per-server resource capability, has a durable binding digest covering its source/server, complete credential-free routing URL, and complete dispatch-relevant contract while excluding separately structured credentials. A same-name source, endpoint routing URL, schema, or read-only change is removal plus addition. Run entry automatically and durably attenuates removed or mismatched grants before model work; it never adopts newly observed capabilities into an established session. Additions and changed identities enter such a session only through explicit owner-authorized `/mcp-refresh`. This deliberately expands #1511's stale-catalogue repair: digest identity, remove-plus-add semantics, and one-time legacy migration are authority hardening beyond staleness. Safe manager lifetimes are required by reconciliation; immutable leased generations are the selected architecture rather than merely unspecified lifetime plumbing. Broker/protected MCP uses the same client-visible `/mcp-refresh` command but routes to the existing `ConnectWorkspaceServices` operation and [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md); client-provided per-session MCP retains its existing lifecycle. +This amendment replaces the prior digest/binding/revocation design with the original stale-source scope. It preserves current exact-name authority semantics: disappearance filters availability without deleting the durable grant, exact-name reappearance remains authorized, and same-name endpoint/schema/read-only drift remains the same name grant. Automatic ToolHive polling, notification-driven reconciliation, and explicit `/mcp-refresh` additions remain required. ## Human decisions -- [x] Scope and lifetime architecture — Decision: #1511's stale-catalogue problem requires safe connection lifetimes, but this plan deliberately adds digest identity, remove-plus-add/regrant semantics, and one-time legacy migration as authority hardening beyond staleness; immutable complete generations with explicit leases are the chosen architecture for those lifetimes. -- [x] Source and publication model — Decision: one Build-owned source-neutral reconciler consumes complete immutable desired snapshots from ordered sources. Configured/static sources retain precedence over ToolHive collisions; ToolHive is the first dynamic source and future dynamic sources use the same seam. Publication produces immutable complete **active** generations, never in-place catalogue or manager mutation. -- [x] Durable direct-MCP identity — Decision: add bounded `session.DirectMCPBinding` records to `session.Authority`. Each record binds one tool or synthetic resource capability name to a fixed-format digest over source identity, server identity, the complete credential-free endpoint routing URL, complete advertised contract/schema, and dispatch-relevant annotations including read-only. Endpoint identity preserves the exact escaped dispatch path and raw query semantics, with only scheme and DNS-host case folding and default-port elision. Credentials in separate headers, token sources, and OAuth state are excluded. Name plus matching digest is required for execution. -- [x] Endpoint routing and credential boundary — Decision: operator-configured and source-observed direct/global MCP endpoint URLs are public, non-secret routing input by mecatl contract, not a claim that arbitrary query values can be proven secret-free at runtime. URL userinfo and fragments are invalid. The complete dispatch URL participates in the digest: do not clean or decode the path, collapse empty/trailing/repeated slashes or dot segments, or decode/sort/re-encode the query; preserve repeated-key order, original query encoding, and the distinction between no query and an explicit empty query when dispatch observes it. Reject the decoded query parameter name `access_token` as the narrow protocol-standard token field; infer no generic credential-name denylist and drop no other query key heuristically. Structured `Headers`, `TokenSource`, and OAuth credentials remain the credential channels and do not affect the digest. Hashing a misconfigured secret-bearing URL would not sanitize it, and exhaustive secret detection is impossible, so compliant credential-free input is the enforcement precondition. -- [x] Removal, mismatch, and legacy behavior — Decision: same-name binding change is removal plus addition. At the next run entry, removed/mismatched grants are automatically and durably revoked and their names removed before model/tool work; persistence uncertainty fails closed. Automatic run entry only attenuates: it never adds a newly observed capability to an established session. Revoked records remain durable unavailable evidence, so reappearance never automatically regrants, including after restart. A legacy authority with no binding field performs one persisted adoption only for same-name current direct MCP capabilities already present in its exact name ceiling; it never adds an absent name. Any later addition or changed identity requires explicit owner refresh. -- [x] Explicit refresh set algebra — Decision: `/mcp-refresh` replaces only the direct/global MCP subset. It preserves every capability not identified by durable direct-MCP records, removes all prior direct-MCP capability names, retains omitted/mismatched old records as revoked evidence, and unions successfully active generation bindings and names. It never replaces core, latent Team, profile, client-MCP, or broker authority. -- [x] Unified client command and capability routing — Decision: mecatui exposes one argument-free `/mcp-refresh`. When exactly the direct `mcp_refresh` capability is supported it invokes only the new `RefreshMcpSources`; when exactly the existing broker `workspace_enrollment` capability is supported it invokes only the existing `ConnectWorkspaceServices` whole-bundle operation. Direct refresh is consent-free. Broker refresh preserves consent/presentation, cancellation, and the destructive-reconnection disclosure from ADR 0335. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains the broker cancellation command; neither is made a direct-MCP capability. Missing collaborators, both mode bits, or otherwise unknown/contradictory capability combinations fail closed with no fallback or double operation. -- [x] Generation ownership and leases — Decision: a generation owns its direct/global servers/manager view, tools, resource/prompt provider, source/status snapshot, binding identities, and generation-bound shared engine/factories. Shared, cached per-session, specialist/team, run, and out-of-run MCP operations hold explicit generation leases for their useful lifetime. Publication atomically swaps the shared-engine generation; stale cached engines are tagged, evicted/rebuilt under existing guards, and bounded so idle caches cannot retain generations forever. No generation closes before every engine/operation/run lease drains. -- [x] Reconciliation failure semantics — Decision: source consultation failure retains that source's last-known-good snapshot; a successful snapshot is desired truth and successful removal withdraws immediately for new generations. Reuse unchanged healthy connections. Connect additions independently. A changed binding withdraws the old binding before attempting its replacement; replacement failure leaves that capability unavailable rather than retaining the superseded binding. Unrelated valid changes still publish, status becomes stale/degraded, and `/mcp-refresh` grants only successfully active bindings. Partially constructed candidates close. -- [x] Trigger and resource bounds — Decision: automatic ToolHive polling is bounded, jittered, and enabled only with ToolHive discovery; MCP list-changed notifications and manual refresh use the same reconciler. There is one active cycle, at most one coalesced queued invalidation, and a global cooldown; caller cancellation never cancels shared work, and manual refresh during cooldown adopts the current active generation. Hard finite source/server/tool/schema/generation-retention bounds are internal constants with fail-stale diagnostics. Automatic work never launches browser/OAuth consent. -- [x] Persistence protocol — Decision: refresh and automatic attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability` discipline; shared-store mutation without proven exclusion fails closed. Mutate a detached loaded aggregate, persist, and confirm ambiguous saves by authoritative reload. No widened live engine is published before confirmation. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against current generation. -- [x] Removed-call behavior — Decision: remove the process-global tombstone cache. A run receives bounded unavailable names derived only from that session's durable revoked/mismatched bindings. Dispatch returns `removed from current MCP configuration; do not retry unless the catalog changes` as a bounded permanent result without advertising, authorizing, or dispatching the name. No synthetic model/history refresh message is added. -- [x] Status and compatibility — Decision: `/mcp` reports observed versus active generation and stale/degraded/reconciling truth. Preserve existing `McpServerInfo.url` and pre-shadow source rows for compatibility; additive status, diagnostics, and removal results introduce no new secret exposure. Settings/CLI/environment inputs remain restart-only and project-tier MCP remains ignored. +- [x] Authority semantics — Decision: retain existing exact-name `session.Authority.CapabilitySet.Tools`; add no digest, `DirectMCPBinding`, revoked-record list, migration, or credential-free URL policy. Current availability filters durable grants. +- [x] Removal and reappearance — Decision: successful source removal is automatically absent at the next operation boundary; stale calls get a permanent unavailable result. The durable name may remain, and exact-name reappearance is authorized. This is not permanent revocation. +- [x] Additions — Decision: only explicit owner `/mcp-refresh` stable-unions current active direct MCP names into the existing ceiling, preserving unrelated names. Refresh is not durable direct-subset replacement. +- [x] Triggers — Decision: bounded jittered polling is enabled only for ToolHive; MCP list-change notifications and manual requests share one serialized/coalesced bounded reconciler. +- [x] Source failure — Decision: consultation failure retains source LKG; successful empty is authoritative withdrawal. +- [x] Candidate failure — Decision: candidates are complete/all-or-nothing. Any desired-server construction failure closes the candidate and retains the previous runtime; this may delay an unrelated removal, reported as stale/degraded, until a later retry. +- [x] Runtime ownership — Decision: one immutable current direct runtime plus bounded retiring runtimes; operation/run pins protect active use and are inherited by delegation. Revision tags rebuild stale shared/cached engines; idle engine existence does not lease a runtime. +- [x] Refresh lifecycle — Decision: idle and quiescent completed ordinary roots are eligible without reopening completed state. Failed/cancelled and every active/pending/non-root form are rejected. Mutation exclusion and confirmed save are required only when the union adds authority. +- [x] Unified UX — Decision: `/mcp-refresh` capability-routes to direct `RefreshMcpSources` or existing broker `ConnectWorkspaceServices`; broker consent/cancellation/disclosure remains distinct, and `/tools-connect` remains a deprecated broker-only alias. ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources`, request field `string session_id = 1`, response fields `uint64 generation = 1` and `bool changed = 2`, and direct-only `ServerCapabilities.mcp_refresh = 30`. Extend `ListMcpSourcesResponse` with `observed_generation = 2`, `active_generation = 3`, `stale = 4`, `reconciling = 5`, and `degraded = 6`; preserve `McpServerInfo.url = 2` and pre-shadow rows. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. Both direct transports retain ownership concealment, use `FAILED_PRECONDITION`/409 for ineligible state, and detach shared reconciliation from caller cancellation. -- **Exported Go APIs / interfaces:** Add `engine/session.DirectMCPBinding{Capability string, Digest string, Revoked bool}`, `Authority.DirectMCPBindings []DirectMCPBinding`, idle-only `Session.AttenuateDirectMCPAuthority` and `Session.ReplaceDirectMCPAuthority`, and bounded run-scoped `engine/agent.RunRequest.UnavailableTools []string`. Exact JSON, validation, mutation, derivation, and resume contracts are detailed below. These are Added/minor engine API changes requiring `task api:update` and `engine/CHANGELOG.md`; generation/source/lease interfaces remain internal and ToolHive-free. -- **Tool schemas:** No model-visible tool is added. A direct/global MCP tool is advertised and executable only when its name is in `CapabilitySet.Tools`, its active `DirectMCPBinding` digest equals the run generation's digest, and existing permission evaluation allows it. The synthetic `governance.MCPResourceCapability(server)` follows the same binding rule for direct MCP resources. A changed source, server, compliant endpoint routing identity, complete schema/contract, or dispatch annotation including read-only changes the digest and is remove-plus-add under the endpoint-encoding contract below. `RunRequest.UnavailableTools` shadows even a current same-name catalogue entry before schema projection or lookup; unavailable names cannot pass authority/permission or dispatch, and a generated call receives the fixed permanent result. -- **CLI / config:** Add no flag, key, selector, endpoint, credential input, or poll tuning. Existing process-start MCP inputs remain restart-only; ToolHive discovery alone enables polling and project-tier MCP remains ignored. Existing non-secret routing queries remain supported under the validation contract below. Add exact argument-free mecatui `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Contradictory or missing capability/collaborator states fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains unchanged. -- **Events / persistence:** Add no event or snapshot generation ID. `Authority.DirectMCPBindings` round-trips through the existing authority snapshot; absent means legacy and present empty means adopted/no grants. Composition persists only the digest of source, server, compliant endpoint identity, complete tool contract/schema, and dispatch annotations; structured credential channels are excluded. Exact attenuation/refresh algebra and save confirmation are detailed below. No process-global tombstone persists or caches. -- **Security / authority:** Direct refresh is owner-authorized, ordinary-root-only, idle-only, consent-free, and fails closed for conflicting/lower-authority state or unproved store exclusion; automatic attenuation uses the same mutation exclusion and blocks model/tool work on failure. Requests carry no source, endpoint, or credential. The exact endpoint/credential boundary is below. Broker, direct, and client-MCP authority remain separate. Existing URL projection/redaction is unchanged; new outputs are canary-tested with secrets exclusively in structured credential channels. -- **Compatibility / migration:** Existing clients retain complete `McpServerInfo.url` values/pre-shadow rows and ignore additive fields. Existing sessions get one durably confirmed, non-widening legacy adoption. Existing compliant query-routed endpoints, config shape, source precedence, and Streamable HTTP remain; only protocol-standard `access_token` query input is newly invalid. URL redaction is unchanged. ADR 0342 supersedes only ADR 0057's deferred live-catalogue decision. Broker/client MCP and compatibility aliases remain unchanged. This amendment changes no proposed SDK, RPC, tool schema, or exported Go API; living/user docs update in implementation. - -## Endpoint binding encoding and credential boundary - -The deterministic, domain-separated composition encoder includes the complete compliant endpoint routing URL. It folds only scheme and DNS-host case and elides the scheme's default port. It preserves `EscapedPath()` exactly—including empty versus `/`, trailing and repeated slashes, escaped octets, and dot segments—and separately encodes `RawQuery` plus `ForceQuery`. It never applies `path.Clean`, unescapes the path, or parses, sorts, and re-encodes query pairs; repeated-key order, original escaping, and absent versus explicit empty query therefore remain distinct whenever HTTP dispatch observes them. - -Direct/global operator-configured and source-observed endpoint URLs are public, credential-free routing input by mecatl contract: absolute HTTP(S), host-bearing, without userinfo or fragment. Existing operator-profile URL validation and every-source reconciler admission enforce this before connection, publication, or digesting. They also reject a query parameter whose decoded name is exactly `access_token`, the narrow field prohibited by MCP 2025-11-25 Access Token Usage. They preserve every other query key and perform no generic credential-name inference. This mecatl policy is broader than MCP's token-placement rule; neither policy proves arbitrary custom query values secret-free. Headers, token sources, and OAuth credential/rotation material are the separate structured credential channels and are excluded from the digest. A secret misplaced in the endpoint violates the input contract and cannot be made safe by hashing. - -### Exported API details - -`DirectMCPBinding` uses JSON keys `capability`, `digest`, and `revoked`, with `omitempty` only on `revoked`; `Authority.DirectMCPBindings` uses `direct_mcp_bindings` without `omitempty`, preserving nil as legacy and `[]` as migration-complete. At most 512 unique records are valid. Capability is non-empty, valid UTF-8, control-free, and at most 256 bytes; digest is exactly `sha256:` plus 64 lowercase hexadecimal characters; active mutation input cannot set `Revoked`, and rejection is non-mutating. The two session methods clone input, require idle bound ordinary roots with no pending permission, external authorization, or workspace enrollment, and preserve unrelated state. Attenuation never widens and alone performs legacy adoption; replacement is the explicit-widen path. Child derivation copies only active digest-identical records surviving its ceiling; resume requires a digest-identical subset and never adopts legacy child state. `UnavailableTools` is duplicate-free, uses the same 512-name and capability framing bounds, shadows before schema/lookup/authority/permission/dispatch, is never advertised or exempted, and returns the fixed permanent removal result. - -### Durable set algebra and persistence - -Let `B` be prior bindings, `C(B)` all their capability names, `T` prior `CapabilitySet.Tools`, `A` successfully active generation bindings, and `Match(B,A)` only active prior records with the same digest in `A`. Attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`: matches stay active; removed/mismatched records keep their old digest with `Revoked=true`; new `A` names are not added. Refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`: `A` becomes active, omitted prior capabilities remain revoked evidence, and an explicitly refreshed same-name change replaces the old record. Legacy adoption persists only `T ∩ C(A)`, including a present empty list, and never adds `C(A) \ T`. - -Both operations mutate a detached authoritative load under `runEntryMu`, the mutation lease, and mutation capability, then save before success or model work; `/mcp-refresh` builds no engine. After ambiguous save, authoritative reload treats exact candidate as success, exact old state as failure, and every mismatch or unknown as fail-closed. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`, where changed means the completed request either published a runtime revision or added session authority. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`2a0c9cb11bedc6ef88503f504bbd4f94a6d31690`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. +- **Exported Go APIs / interfaces:** Add only `engine/session.(*Session).GrantToolAuthority([]string) error`. It validates and stable-unions tool names into already-bound authority; preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. +- **Tool schemas:** No model-visible tool is added or changed. Current catalog membership intersects existing exact-name authority. An authority-granted but catalog-absent call returns the exact permanent error `tool is currently unavailable; do not retry unless the catalog changes`; an ungranted absent name retains existing unknown-tool behavior. +- **CLI / config:** Add no flag, key, URL rule, selector, credential input, or poll tuning. ToolHive discovery alone enables bounded automatic polling. Mecatui adds argument-free `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Both/neither incompatible modes or missing collaborators fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` is unchanged. +- **Events / persistence:** Add no event, snapshot revision, binding, revoked-name record, or migration. `Authority.CapabilitySet.Tools` remains the durable name ceiling. Explicit refresh performs at most one confirmed snapshot save only when its stable union adds names; runtime removal/status/revisions remain process state. +- **Security / authority:** Refresh authenticates ownership before caller-selected locking. Authority addition requires `runEntryMu`, mutation lease/capability, fresh authoritative load, ordinary-main taxonomy, eligible lifecycle state, same-process liveness exclusion, aggregate union, and successful save. A no-op refresh does not acquire mutation authority or save. Automatic reconciliation never widens authority or launches consent. Existing endpoint/credential validation and URL projection remain unchanged. +- **Compatibility / migration:** Existing sessions and snapshots require no migration. Existing granted names remain granted even while unavailable. Same-name source/endpoint/schema/read-only drift follows current name semantics. Additive protobuf fields are ignored by old clients. Broker/client MCP behavior and configured-over-ToolHive name precedence remain unchanged. ## In scope — 4 scenarios, in implementation order -### Scenario 1 — Desired source snapshots publish a bounded active generation +### Scenario 1 — Ordered sources reconcile automatically and boundedly -The reconciler consults ordered sources once per cycle, preserves source LKG on consultation failure, and treats every successful snapshot—including empty—as desired truth. It reuses unchanged healthy bindings, connects additions independently, and treats a changed binding as withdrawal followed by a new attempt. Valid unrelated changes publish even when an addition/replacement fails; the immutable generation is the complete successfully **active** set, while status remains stale/degraded against desired truth. This is [ADR 0342](../adr/0342-mcp-source-reconciliation.md)'s source-neutral publication boundary. +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0345](../adr/0345-mcp-source-reconciliation.md). **Acceptance:** -- AC1.1: Static/configured and ToolHive snapshots merge deterministically with earlier/static precedence, one consultation per source per cycle, and preserved pre-shadow inventory rows. A named binding-encoder matrix proves source/server/contract/read-only identity and complete compliant endpoint routing identity for every tool and direct resource capability: query `route=A` versus `route=B` changes both digest and connection identity; repeated query-key order, original query escaping, and absent versus explicit empty query remain distinct; escaped slash versus literal slash, empty versus `/`, trailing/repeated slash, and dot-segment paths remain distinct; only scheme/DNS-host case and default-port spelling normalize. Rotation of structured headers, token sources, and OAuth credentials changes neither digest nor durable identity and none of their canary values enters digest/status/error output. Userinfo, fragments, and the decoded query name `access_token` are rejected before connection/digesting; benign routing queries are retained verbatim, with no generic credential-key inference. - - verify: `TestMCPSourceReconciliation_Scenario1_OrderedBindingsAndPrecedence` -- AC1.2: Consultation failure retains that source's LKG; successful empty withdraws it. Unchanged connections are reused, additions connect independently, and changed bindings withdraw old before replacement; failed additions/replacements are unavailable while unrelated healthy changes publish, partial candidates close, and status is stale/degraded. - - verify: `TestMCPSourceReconciliation_Scenario1_FailedEmptyAndPartialActiveSnapshots` -- AC1.3: ToolHive polling is disabled without ToolHive discovery and otherwise bounded/jittered. Notifications and manual requests enter one reconciler with one active cycle, at most one queued invalidation, and global cooldown; sequential refreshes cannot force reconnect storms, caller cancellation does not cancel shared work, and automatic work never launches consent. - - verify: `TestADR_0342_TriggersAreBoundedCoalescedAndCancellationDetached` -- AC1.4: Hard finite source/server/tool/schema/generation-retention constants fail stale with bounded diagnostics rather than allocating or retaining unbounded state. - - verify: `TestADR_0342_ReconciliationAndRetentionBounds` +- AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. + - verify: `TestMCPSourceReconciliation_Scenario1_OrderedSourcesLKGAndEmpty` +- AC1.2: Polling is absent without ToolHive and bounded/jittered with it. Tool/resource/prompt notifications from the current runtime and manual requests serialize behind one active cycle with at most one coalesced successor; stale-runtime notifications are ignored and caller cancellation does not cancel shared work. + - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` +- AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics; automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. + - verify: `TestADR_0345_ReconciliationBoundsConsentAndShutdown` -### Scenario 2 — Generation-owned engines and operations drain safely +### Scenario 2 — Complete runtimes publish atomically and drain safely -A generation owns the direct/global manager view, tools, resources/prompts, status, bindings, and generation-bound factories/engines. `assembleCatalog` remains the only full registration path and takes one generation contribution. Service atomically swaps the shared-engine generation, tags cached engines, and evicts/rebuilds stale idle entries under existing run-entry/liveness guards. Engines hold leases for useful lifetime; runs and out-of-run MCP operations hold short leases where their engine lease is insufficient. [ADR 0342](../adr/0342-mcp-source-reconciliation.md) makes lifetime—not only a run pointer—the close-safety boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0345](../adr/0345-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** -- AC2.1: Shared fast path and cached selector, no-FS, client-MCP, mode-specific, and direct-MCP debug engines are generation-tagged; publication swaps the shared engine atomically, stale idle caches are boundedly evicted/rebuilt, and active cached engines close only after their run. A debug session remains non-refreshable and its existing selected-name ceiling additionally requires digest-identical bindings; removal or identity drift fails closed without admitting additions. - - verify: `TestMCPSourceReconciliation_Scenario2_SharedAndCachedEngineMatrix` -- AC2.2: Direct RunTeam, Subagent/Parallel/Team members, named specialists, and referenced-agent MCP use generation-bound engines/factories from the root generation where applicable; derived child authorities copy only digest-identical active direct bindings that survive their tool ceiling, persisted child resume rejects drift, and reconciliation racing activity cannot mix schemas, lookup, permission, dispatch, resources/prompts, or manager identity. The named proof uses a table-driven matrix with independently identifiable subtests covering: direct RunTeam; each Subagent, Parallel, and Team-member path; named specialists; referenced-agent MCP; derived-authority filtering; resumed-child match and drift rejection; and publication races at schema projection, lookup/permission/dispatch, resource/prompt access, and manager selection. - - verify: `TestMCPSourceReconciliation_Scenario2_DelegationRunTeamAndReferenceMatrix` -- AC2.3: `ListMcpResources`, `ReadMcpResource`, `ListMcpPrompts`, and `GetMcpPrompt` each acquire a short-lived current-generation lease. Each call is internally consistent; list-then-later-get is not snapshot-atomic and a stale identifier fails visibly. Prompt expansion inside a run uses that run's generation. - - verify: `TestMCPSourceReconciliation_Scenario2_OutOfRunResourcePromptLeases` -- AC2.4: No displaced generation closes until all shared/per-session/specialist/team engine leases and operation/run leases drain; each closes once. Stale idle eviction is bounded, active leases drain after use, and shutdown joins polling/reconciliation and closes every generation without leaks. - - verify: `TestADR_0342_GenerationLeaseDrainEvictionAndShutdown` +- AC2.1: A complete candidate publishes additions and removals, including successful empty. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks status stale, and delays otherwise valid changes until a later successful retry. + - verify: `TestMCPSourceReconciliation_Scenario2_AllOrNothingPublication` +- AC2.2: The real shared and session factory paths cover shared/default, selector, no-FS, client-MCP, mode-specific, specialist, and debug engines. Revision mismatch rebuilds before use; a debug name ceiling admits no additions and fails when selected names are absent. `assembleCatalog` remains the single complete registration path. + - verify: `TestMCPSourceReconciliation_Scenario2_ProductionEngineRevisionMatrix` +- AC2.3: A production-path race matrix covers prompt start, failed-step retry, restored approval, direct `RunTeam`, Subagent, Parallel, Team member, named specialist, referenced-agent MCP, in-run prompt expansion, and out-of-run list/read/get resource/prompt calls. Each operation observes one runtime across schema, lookup, authority, permission, dispatch, provider, and manager; delegation inherits the root pin. Retiring runtimes close once after pins drain, and a full retirement set defers publication without force-close. + - verify: `TestMCPSourceReconciliation_Scenario2_RuntimeConsistencyMatrix` -### Scenario 3 — Durable bindings attenuate before runs and refresh exactly +### Scenario 3 — Name authority filters availability and widens only explicitly -At run entry, the Service compares durable active bindings with the selected generation before building/using an engine. Missing or mismatched identities are revoked and removed on a detached aggregate, then durably confirmed under existing exclusion before any model/tool work. Legacy name-only authorities adopt only current same-name entries already in their ceiling, once. `/mcp-refresh` uses the exact direct-subset algebra in the interface contract, persists first, and leaves engine rebuilding to the next run. This preserves the aggregate discipline in [`engine/session/principal.go`](../../engine/session/principal.go) and the durable authority decision in [ADR 0342](../adr/0342-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0345](../adr/0345-mcp-source-reconciliation.md). **Acceptance:** -- AC3.1: Same-name source/server/complete-endpoint/schema/read-only change is remove-plus-add. The named matrix includes query `route=A` versus `route=B` and every AC1.1 path/query distinction, proving that each routing-significant difference changes digest and selected connection while the limited equivalent scheme/DNS-host/default-port spellings do not. Structured credential rotation leaves the durable binding unchanged. Automatic run-entry attenuation removes the name, retains the old digest revoked, confirms persistence before model work, and never automatically regrants on reappearance or restart; save failure/ambiguity without exact confirmation means zero model/tool work. - - verify: `TestMCPSourceReconciliation_Scenario3_AttenuationPersistsBeforeRun` -- AC3.2: A legacy ordinary-root authority with absent bindings adopts and persists only currently active direct capabilities whose names were already authorized, marks migration complete even when empty, never adds an absent name, and cannot adopt a later reappearance automatically. Delegation never treats a child as a legacy root: new children receive the digest-identical active subset derived from the parent, and resumed children fail closed on binding drift. - - verify: `TestMCPSourceReconciliation_Scenario3_LegacyAdoptionIsOneTimeAndNonWidening` -- AC3.3: Owner-authorized argument-free `/mcp-refresh` preserves `T \ C(B)`, replaces only current direct/global grants with successfully active `A`, retains prior identities whose capability is omitted as revoked, replaces a same-name revoked identity only through this explicit regrant, preserves conversation/placement/non-direct authority, builds no engine, and the next run rebuilds against current generation. - - verify: `TestMCPSourceReconciliation_Scenario3_ExplicitRefreshSetAlgebra` -- AC3.4: Refresh and attenuation use owner checks, `runEntryMu`, real mutation lease/capability, detached load, and authoritative reload after ambiguous Save. Exact candidate durable reports success, exact old durable reports failure, and mismatched/unknown state fails closed; no widened live engine/session appears before confirmation. The named proof uses table-driven matrices with independently identifiable subtests for both refresh and attenuation, each covering: unambiguous save success; ambiguous save followed by exact-candidate reload; ambiguous save followed by exact-old reload; ambiguous save followed by mismatched durable state; and ambiguous save followed by reload error/unknown state. - - verify: `TestInvariant_direct_mcp_authority_mutation_is_confirmed_before_use` +- AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. + - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` +- AC3.2: `Session.GrantToolAuthority` stable-unions validated names into bound authority in idle and completed states, preserves unrelated names/axes, conversation/history, placement, owner, counters, and the exact lifecycle state, and rejects pending controls or other states without mutation. + - verify: `TestADR_0345_GrantToolAuthorityPreservesAggregateState` +- AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. + - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` -### Scenario 4 — Removed calls, status, and controls are safe and transport-equivalent +### Scenario 4 — Explicit direct refresh and unified UX preserve boundaries -The server derives unavailable names from the session's durable revoked/mismatched records and supplies them via `RunRequest.UnavailableTools`; there is no global tombstone cache. Existing `/mcp` inventory keeps URL and pre-shadow compatibility while adding generation truth. Direct gRPC and HTTP controls remain transport-equivalent. Mecatui exposes one `/mcp-refresh` command but capability-directs it to exactly one authority domain: new direct `RefreshMcpSources` under `mcp_refresh`, or existing broker `ConnectWorkspaceServices` under `workspace_enrollment`. See [ADR 0342](../adr/0342-mcp-source-reconciliation.md). +Direct refresh reconciles shared state first, then adds only missing active direct names to one eligible owned root. Broker refresh remains the existing enrollment operation in [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md). **Acceptance:** -- AC4.1: An unavailable stale call is not advertised or authorized and does not dispatch; it returns the exact bounded permanent removal result from run-scoped durable evidence. Unknown non-recorded names retain ordinary unknown-tool behavior, and no synthetic history refresh message is recorded. - - verify: `TestMCPSourceReconciliation_Scenario4_DurableUnavailableRunInput` -- AC4.2: `ListMcpSources` and `/mcp` preserve existing complete URLs and pre-shadow rows and add observed/active generation plus stale/degraded/reconciling truth from cached reconciler state without an independent probe. Canary secrets placed exclusively in structured headers, token sources, and OAuth credential channels never appear in binding/status fields, diagnostics, errors, or removal results. The pre-existing `McpServerInfo.url` compatibility and URL redaction behavior are unchanged; this amendment does not add a projection or attempt heuristic URL redaction. - - verify: `TestMCPSourceReconciliation_Scenario4_StatusCompatibilityAndSecretCanaries` -- AC4.3: One table-driven transport/outcome matrix with independently identifiable subtests proves every branch. For each direct surface—gRPC `RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and mecatui `/mcp-refresh` routed through `mcp_refresh`—the cases are cooldown adoption, unchanged success, changed success, caller cancellation, ownership concealment, active/awaiting/child/debug/broker-conflict rejection, unsupported capability, and every AC3.4 persistence outcome. Client routing cases are direct-only `mcp_refresh` to only `RefreshMcpSources`; broker-only `workspace_enrollment` to only existing `ConnectWorkspaceServices`; neither capability to no offered command; and both bits, missing matching collaborator, and unknown contradiction to fail closed. Broker-routed `/mcp-refresh` separately covers destructive-reconnection disclosure, presentation/consent, pending observation, connected, denied, expired, failed, cancelled, and `/tools-cancel`, with no direct call. `/tools-connect` is independently covered as a deprecated broker-only alias retaining prior semantics, including when direct `mcp_refresh` is also present. No branch falls back or invokes both operations; direct branches are consent-free and never call broker controls. - - verify: `TestMCPSourceReconciliation_Scenario4_TransportAndUIParity` -- AC4.4: Direct automatic/manual reconciliation and direct status calls never attach, authorize, refresh, or launch consent for protected MCP, and neither direct nor broker routing changes client-provided MCP. The common client command crosses no boundary because broker mode invokes only ADR 0335's existing explicit owner operation. No-FS/profile sessions retain their non-direct authority and receive direct grants only under the same binding rules. - - verify: `TestADR_0342_BrokerClientAndProfileIsolation` +- AC4.1: A Service-level matrix covers idle and quiescent completed success without reopening; no-op success with no lease/save; addition with owner preflight, `runEntryMu`, mutation lease/capability, fresh load, state plus liveness proof, one aggregate union, and one confirmed save; and rejection of active/running/awaiting/authorizing/failed/cancelled/child/schedule/debug/broker-conflicting sessions. Save failure leaves durable authority and model/history unchanged. The proof uses the real Service control, not direct aggregate calls or a fake pin-only harness. + - verify: `TestMCPSourceReconciliation_Scenario4_ServiceRefreshMutationMatrix` +- AC4.2: gRPC, HTTP, and mecatui direct paths return the current revision and exact changed semantics for unchanged, runtime-only change, authority-only change, both, stale candidate, cancellation, ownership concealment, and unsupported states. `ListMcpSources` returns cached revision/stale/reconciling and performs no probe. + - verify: `TestMCPSourceReconciliation_Scenario4_DirectTransportStatusMatrix` +- AC4.3: Mecatui routes direct-only `mcp_refresh` solely to `RefreshMcpSources` without consent and broker-only `workspace_enrollment` solely to `ConnectWorkspaceServices` with destructive-reconnection disclosure, consent/presentation, observation, cancellation, and existing outcomes. Both bits, neither bit, or a missing collaborator fail closed; `/tools-connect` remains broker-only even when direct refresh exists, and client-provided MCP is untouched. + - verify: `TestMCPSourceReconciliation_Scenario4_UnifiedCommandRoutingMatrix` ## Out of scope | Item | Defer-to | Decision | |---|---|---| -| Live reload of operator settings, CLI inputs, or environment-referenced credentials | Future narrow operator/admin operation | Process-start snapshots participate in every generation; ordinary sessions may never reload operator config. | +| Digest/canonical-JSON capability identity, endpoint identity normalization, `DirectMCPBinding`, revoked records, or legacy migration | Separate security proposal if ever desired | Existing exact-name authority is preserved. | +| Live operator settings/CLI/environment reload | Future operator/admin design | Configured input remains process-start state; only ToolHive is polled. | | Project-tier MCP | Existing trust/config policy | It remains ignored. | -| Broker/protected MCP grants, attachment, refresh semantics, and server controls | [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md) | They remain owned by existing `ConnectWorkspaceServices`, `workspace_enrollment`, consent/cancellation, and destructive replacement contracts. This plan changes only mecatui's preferred spelling to common `/mcp-refresh`; deprecated `/tools-connect` retains the same broker operation. | -| Global management of client-provided per-session MCP | Existing session-local lifecycle | Client MCP remains outside the reconciler. | -| Snapshot-atomic list-then-get resource/prompt workflow | Future handle/version design if needed | Each out-of-run call is generation-consistent; stale later identifiers fail visibly. | -| Mid-run mutation of catalogues/managers | Explicitly prohibited | Generation-owned immutable bundles and leases are the consistency boundary. | -| Synthetic model/history refresh notices | None | Current schemas and durable unavailable-call result are sufficient. | -| Updating living architecture/user docs in this Plan PR | Implementation PR | Those references describe shipped behavior. | +| Broker/protected MCP and client-provided MCP lifecycle | Existing ADRs/session lifecycle | Only mecatui's preferred command spelling is shared. | +| Partial-active candidate publication or connection reuse | Future optimization with its own ownership proof | Complete candidates retain the previous runtime on any construction failure. | +| Mid-operation catalog/manager mutation | Prohibited | Immutable runtime revisions are the consistency boundary. | +| Poll/bound tuning surface | Not planned | Finite values are internal constants with boundary tests. | +| Shipped architecture/user documentation in this Plan PR | Implementation PR | Living/user docs describe shipped behavior only. | ## Definition of done -1. Before stacked implementation begins, it records the exact proposed plan commit it follows and its implementation PR targets `plan/mcp-source-reconciliation`. This directing-human authorization permits implementation before Plan PR merge only; Plan PR merge remains the approval event, implementation may not be merged ahead of it, and material contract drift still stops for human review. -2. Offline tests prove source merge/LKG/empty/partial-active behavior; binding digest and secret exclusion; shared/cached/no-FS/client/mode engine matrix; active-run race; direct RunTeam/delegation/reference MCP; out-of-run leases; authority non-widening, legacy adoption, explicit refresh algebra, the complete AC3.4 persistence matrix; permanent removal result; notifications/cooldown/bounds; the complete direct-transport and client routing/outcome matrix including broker compatibility aliases; broker/client isolation; shutdown and leaks. -3. `DirectMCPBinding`, `Authority.DirectMCPBindings`, both aggregate mutation methods, and `RunRequest.UnavailableTools` are recorded by `task api:update` in `engine/api/*.txt` and as Added/minor in `engine/CHANGELOG.md`; snapshot compatibility and standalone engine tests pass. -4. Every reconciler goroutine/timer, source LKG/status cache, generation bundle, shared/cached engine lease, operation/run lease, stale-engine eviction structure, cooldown/singleflight state, and retained durable/runtime identity state is inventoried in ADR 0027 List 1 and, where restart-relevant, List 2 with an explicit rehydration decision. -5. Implementation updates `docs/architecture.md`, `docs/architecture/extensibility.md`, `docs/design/IMPLEMENTATION-NOTES.md`, owning `user-docs/` MCP/mecatui pages, generated API/config references, and ADR 0057's index annotation; no living shipped-behavior doc is changed by this Plan PR. +1. Stacked implementation records the exact amended plan commit and targets `plan/mcp-source-reconciliation`; parent advisory review precedes any plan push. Plan merge remains approval and humans alone merge. +2. The named production-path tests prove all 12 ACs, including automatic ToolHive polling, notifications, LKG/empty, complete-candidate failure, runtime consistency, name reappearance, no-op versus widening refresh, transport parity, and broker/client isolation. +3. The sole exported engine addition, `Session.GrantToolAuthority`, is recorded by `task api:update` and as Added/minor in `engine/CHANGELOG.md`. No other engine API widening is needed. +4. ADR 0027 inventories the poller/timer, reconciler/coalescing state, source LKG/status, immutable current and retiring runtimes, revision-tagged caches, and run/operation pins with restart decisions. +5. Implementation updates `docs/architecture.md`, `docs/architecture/extensibility.md`, `docs/design/IMPLEMENTATION-NOTES.md`, owning `user-docs/` MCP/mecatui pages, generated API/config references, and ADR 0057's index annotation. 6. `task generate`, `task lint`, `task test`, `task api:check`, `task docs`, `task site:build`, `task ac-trace-strict`, and `go run ./cmd/mecademo` pass at implementation completion. -7. The implementation PR targets the Plan PR branch while stacked, links the exact proposed plan commit, retargets only after the plan merges, reports exact interface conformance, and has no unwaived `/panel-review` blocker. Human plan and implementation merges remain separate gates. +7. The implementation PR remains stacked until plan merge, reports exact interface conformance, and has no unwaived review blocker. This plan tracks #1511 but does not close it. ## Deferred decisions and known risks -- Exact numeric reconciler source/server/tool/schema/generation-retention, cooldown, poll, and stale-idle-eviction constants are implementation details, but all are mandatory finite internal constants with boundary tests and fail-stale diagnostics; changing public configuration is out of scope. -- Digest encoding is correctness- and compatibility-critical. Implementation must define one deterministic composition-only encoder with domain separation and explicit field ordering. The compliant endpoint field preserves exact escaped-path and raw-query dispatch semantics (including `ForceQuery`) while normalizing only scheme/DNS-host case and default port; it performs no path cleaning or query decode/sort/re-encode. Any included dispatch-relevant contract change intentionally revokes the old grant. Separate headers, token sources, and OAuth credentials must remain excluded; hashing a secret misplaced in an endpoint URL does not sanitize it, so credential-free URL validation is a required precondition rather than a heuristic detector. -- The 512 durable-record bound intentionally rejects a refresh that cannot retain required revoked evidence rather than silently dropping evidence or widening authority. Automatic attenuation changes record state without increasing count; legacy adoption also fails closed if its authorized intersection exceeds the bound. -- Generation leases increase lifecycle complexity. The acceptance matrix and ADR 0027 inventory are load-bearing: an engine cache must neither use a closed manager nor retain stale generations indefinitely. -- Out-of-run MCP list/get calls are individually consistent, not a multi-call snapshot. Clients must handle a visible stale-identifier error after publication between calls. +- Exact poll, cooldown, source/server/tool/list, connect, and retirement bounds are finite tested implementation constants. +- Complete candidates can delay a successful removal while an unrelated desired server fails. Cached stale/degraded status and all three triggers make this visible and retryable; partial-active publication is intentionally not required. +- Cached engines may reference a retired runtime object but cannot use it: every root operation pins current runtime and verifies the engine revision first. Engine close paths must not own the shared direct manager; only runtime retirement closes it. +- Refresh can grant names from a runtime superseded immediately after the pin is released. This is safe under name authority: the grant remains durable but unavailable until that exact name is current, and no live catalog is widened by the save. diff --git a/docs/adr/0342-mcp-source-reconciliation.md b/docs/adr/0342-mcp-source-reconciliation.md deleted file mode 100644 index 05831413be..0000000000 --- a/docs/adr/0342-mcp-source-reconciliation.md +++ /dev/null @@ -1,83 +0,0 @@ -# ADR 0342 — Reconcile direct MCP through durable bindings and leased generations - -- Status: Proposed -- Date: 2026-09-14 -- Scope: direct/global MCP source discovery, durable authority identity, generation-owned catalogue publication, and root-session refresh -- Supersedes: ADR 0057 only for its deferred “no live catalog mutation” decision; its notification transport, dirty invalidation, bounded lazy-list, reconnect, and teardown decisions remain -- Superseded by: None - -## Context - -Mecatl has an ordered, source-neutral MCP discovery seam: configured/static servers precede ToolHive workloads, so configured names win collisions. It currently connects one manager and registers its tools into process-wide and per-session catalogues. Status can later re-probe sources, and MCP servers emit list-changed notifications, but existing catalogues cannot safely change. ADR 0057 deferred this because `tool.Catalog` is append-only. - -Name-only durable authority is insufficient for live reconciliation. A same-named capability can move to another source or endpoint, change schema, or change read-only classification while retaining its catalogue name. Treating that as the same grant silently changes dispatch authority. Conversely, deleting only a process-local catalogue entry is not durable: restart can make the same name executable again from persisted name authority. Issue #1511 establishes the stale-catalogue problem; digest identity, remove-plus-add/regrant semantics, and one-time legacy migration are a deliberate authority-hardening expansion beyond merely correcting staleness. - -In-place manager/catalogue mutation also creates mixed-generation behavior and unsafe teardown. A model could receive one schema, permission-check another definition, dispatch through a replaced connection, and construct a child from a third view. Cached engines, direct RunTeam, referenced-agent MCP, and out-of-run resource/prompt calls outlive one lookup and therefore need ownership stronger than a run-local pointer. Safe lifetimes are required; immutable leased generations are the selected architecture, not unspecified implementation plumbing. - -Protected broker MCP is separate. ADR 0335 gives it the existing `ConnectWorkspaceServices` operation, `workspace_enrollment` capability, ToolHive-owned consent/grants, cancellation, destructive replacement semantics, and session attachment state. Client-provided MCP is also session-local. Neither belongs in process-wide direct/global reconciliation. Mecatui may present one common `/mcp-refresh` spelling while capability-routing to these separate operations; command unification does not unify their authority or lifecycle. - -This Proposed ADR was amended on 2026-09-16 after implementation stopped on contract drift. The shipped configuration accepts query-routed Streamable HTTP endpoints, carries `ServerConfig.URL` separately from headers/token sources/OAuth, rejects URL userinfo and fragments in operator profiles, and preserves `McpServerInfo.url`. The MCP 2025-11-25 Authorization specification's [Access Token Usage](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage) requires the Authorization header and states that access tokens **MUST NOT** be included in URI query strings. That protocol rule does not ban non-secret queries or every custom credential convention. Mecatl therefore defines the broader endpoint URL as credential-free public routing configuration and binds its complete dispatch semantics; this is a configuration contract, not a runtime proof that arbitrary query values contain no secret. - -## Decision - -1. **Reconcile ordered source snapshots through one Build-owned component.** The source-neutral reconciler consumes complete immutable desired snapshots. Earlier sources win collisions, preserving configured/static-over-ToolHive precedence. ToolHive is the first dynamic source; future sources use the same seam. Configured settings, CLI inputs, and environment-referenced credentials are captured at process start and reused in every cycle. Project-tier MCP stays ignored; ordinary sessions never reload operator configuration. - -2. **Bind durable authority to capability identity, not only name.** Add `session.DirectMCPBinding` records to `session.Authority`. Each record carries a capability name, an opaque `sha256:<64 lowercase hex>` digest, and revoked state. Composition uses domain-separated deterministic encoding over source identity, server identity, the complete compliant endpoint routing URL, the complete advertised tool contract/schema, and every dispatch-relevant annotation including read-only. Endpoint encoding folds only scheme and DNS-host case and elides the scheme's default port. It preserves the exact escaped dispatch path—without cleaning, unescaping, or collapsing empty/trailing/repeated slashes or dot segments—and separately preserves raw query plus the explicit-empty-query bit, without decoding, sorting, or re-encoding; repeated-key order and original escaping therefore remain identity. URL userinfo and fragments are invalid. The exact decoded query parameter name `access_token` is rejected before connection/digesting as the narrow field prohibited by the MCP authorization contract; benign routing queries remain, and no generic credential-name denylist infers or drops other keys. Headers, token sources, and OAuth credentials/rotation state are separate structured channels excluded from the digest. Direct MCP synthetic per-server resource capabilities receive bindings under the same rule. Execution requires both the existing `CapabilitySet.Tools` name and an active digest equal to the selected generation's digest. - -3. **Treat identity change as removal plus addition, and automatically attenuate only.** Let `B` be prior binding records, `C(B)` their capability names (active and revoked), `T` prior `CapabilitySet.Tools`, and `A` the successfully active generation bindings. Let `Match(B,A)` contain only capabilities whose prior record is active (not revoked) and has the same digest in `A`. At the next run entry, automatic attenuation sets `Tools' = T ∩ (complement(C(B)) ∪ Match(B,A))`; matching prior records stay active, removed/mismatched prior records stay with the old digest and `Revoked=true`, and capabilities in `A` absent from `B` are not added. A same-name source, server, complete endpoint routing URL, schema/contract, or dispatch-annotation change is therefore removal plus addition, not an automatic replacement. In particular, routing query A versus B and dispatch-distinct escaped paths select different identities and connections, while rotating a separately structured credential does not. The Service durably confirms attenuation before model or tool work. Reappearance and newly observed capabilities never automatically grant authority to an established session, including after restart; they require explicit owner refresh. If storage exclusion or persistence confirmation fails, the run does not begin. - -4. **Adopt legacy name-only authority once without widening.** An absent binding field denotes legacy authority; a present empty list denotes completed adoption with no direct grants. Before the first post-upgrade run, materialize bindings only for current same-name direct MCP capabilities already present in the legacy exact tool ceiling. Never add a name absent from that ceiling. Persist adoption before model work. Once the field is present, later reappearance cannot trigger adoption. - -5. **Refresh only the direct/global subset.** `/mcp-refresh` is an owner-authorized, argument-free, idle ordinary-root operation. Using the definitions above, explicit refresh sets `Tools' = stableUnique((T \ C(B)) ∪ C(A))`; bindings become every record from `A` active plus prior records whose `Capability` is absent from `A` retained as revoked evidence, subject to the 512-record bound. Thus all non-direct/core/latent-Team/profile/client/broker names survive, every prior identified direct name is removed, and only current successfully active direct names are added. For a same-name changed binding, explicit refresh replaces the old revoked record with the new active record because this explicit action is the regrant; at most one record exists per capability. The bounded aggregate rejects overflow instead of silently dropping evidence. Refresh preserves conversation, placement, ownership, and every non-direct authority axis. - -6. **Expose narrow aggregate and run-scoped engine APIs.** `Session.AttenuateDirectMCPAuthority([]DirectMCPBinding) error` performs non-widening run-entry attenuation and one-time legacy adoption for ordinary roots. `Session.ReplaceDirectMCPAuthority([]DirectMCPBinding) error` performs the explicit refresh algebra. Both are idle-only, bounded, clone inputs, preserve unrelated aggregate state, and reject pending permission/external-authorization/workspace-enrollment state. Delegated authority derivation copies only the digest-identical active parent bindings whose names survive the child's tightened tool ceiling; persisted child resume rejects binding drift and never treats a child as a legacy root. `agent.RunRequest.UnavailableTools []string` carries bounded unavailable names derived only from that session's revoked/mismatched bindings and shadows even a current same-name catalogue entry before schema projection, lookup, authority, permission, or dispatch. Such calls return the fixed permanent result `removed from current MCP configuration; do not retry unless the catalog changes`. There is no process-global tombstone cache and no synthetic model/history refresh message. These are intentional Added/minor engine API changes. - -7. **Publish immutable generation-owned bundles with explicit leases.** A generation owns direct/global servers and manager view, tools, resource/prompt provider, source/status snapshot, binding identities, and generation-bound shared engine/factories. `assembleCatalog` remains the single complete registration path and receives one explicit generation contribution. Service atomically swaps the current shared-engine generation. Cached per-session engines are generation-tagged; publication marks them stale, and the next eligible boundary evicts/rebuilds them under existing run-entry/liveness guards. Stale idle eviction is bounded so unused sessions cannot retain a generation indefinitely; active engines close after their runs. - - Every generation-bound shared, per-session, debug, specialist, and team engine holds a lease for its useful lifetime. Direct-MCP debug sessions remain non-refreshable: their selected-name ceiling additionally requires digest-identical bindings, and removal or identity drift fails closed without admitting additions. Active root runs, direct RunTeam, delegation/reference-MCP consumers, and any operation not already covered by an engine lifetime hold the needed run/operation lease. Each out-of-run list/read/get resource or prompt operation takes one short-lived current-generation lease; a list followed by a later get is not snapshot-atomic, and a stale later identifier fails visibly. Prompt expansion inside a run uses that run's generation. No displaced generation closes until all engine, run, and operation leases drain. - -8. **Publish a complete active set, not an all-or-nothing desired set.** A source consultation failure retains that source's last-known-good desired snapshot. A successful snapshot, including empty, is authoritative desired truth; removal withdraws immediately in new generations. Reuse unchanged healthy server connections/bindings. Connect additions independently. For a changed binding, withdraw the old identity and attempt the new one; failed connect/initialize/list/validation leaves it unavailable and never restores the superseded binding. Failure of one addition/replacement does not block unrelated valid changes. Partially built candidates close. The published immutable generation contains the complete successfully active set; observed-versus-active status remains stale/degraded where desired entries failed. - -9. **Bound every trigger and retained structure.** Automatic ToolHive polling exists only when ToolHive discovery is enabled and is bounded and jittered. Tool/resource/prompt list-changed notifications and manual refresh enter the same reconciler. Exactly one cycle runs, at most one invalidation is queued/coalesced, and a global cooldown prevents sequential session refreshes from forcing unbounded reconnect work. Caller cancellation stops waiting but never cancels shared reconciliation; a refresh during cooldown adopts the current active generation. Hard finite source, server, tool, schema, and generation-retention constants fail stale with bounded diagnostics. Automatic work never launches browser/OAuth consent. - -10. **Persist authority before use under existing exclusion.** Refresh and attenuation use existing owner authorization, `runEntryMu`, mutation lease, and `SessionMutationCapability`; a shared store without proven mutation exclusion fails closed. They mutate a detached authoritative load, not a live/cached session. A successful save is confirmed before widening or model work. After an ambiguous save, reload authoritative state: exact candidate means success, exact old state means failure, and mismatch/unknown fails closed. `/mcp-refresh` builds no per-session engine; the next run selects/rebuilds against the current generation. No widened live engine is published before durability is known. - -11. **Add status metadata and one capability-directed client command without merging operations.** Preserve the complete `McpServerInfo.url`, existing URL redaction behavior, and existing per-source pre-shadow rows. Add separate observed/active generation and stale/degraded/reconciling fields. Add direct-only `ServerCapabilities.mcp_refresh` for the new `RefreshMcpSources` gRPC operation and direct HTTP refresh route; it does not advertise broker refresh. Existing `ServerCapabilities.workspace_enrollment` continues to advertise broker `ConnectWorkspaceServices`. Mecatui offers one argument-free `/mcp-refresh` when a supported direct or broker path is present: direct-only routing invokes `RefreshMcpSources` and is consent-free; broker-only routing invokes `ConnectWorkspaceServices` and preserves ADR 0335's consent/presentation, pending observation, cancellation, and destructive reconnection disclosure. `/tools-connect` remains a deprecated broker-only compatibility alias with its prior semantics, and `/tools-cancel` remains broker cancellation; neither gains direct behavior. Both mode bits, a missing matching collaborator, or another unknown/contradictory combination fails closed with no fallback and no double operation. New status, diagnostics, errors, binding records, and model results expose no separately structured header, token, or credential material; canary tests place secrets exclusively in those compliant credential channels. - -## Consequences - -A durable grant now identifies the direct capability contract it authorized. Source, server, complete compliant endpoint routing URL, schema, or read-only changes require explicit owner refresh even when names remain stable. Exact escaped paths and raw query routing are identity; only scheme/DNS-host case and default-port spelling normalize. Structured credential rotation is not identity. Automatic run entry only removes/revokes missing or mismatched direct grants; it never adds newly observed capabilities to an established session. Automatic attenuation and one-time legacy adoption add a persistence operation to run entry; unavailable or ambiguous storage can prevent a run, which is the deliberate fail-closed cost of avoiding authority resurrection. - -Generation consistency extends across engine lifetimes, cached variants, direct RunTeam/delegation/reference MCP, and out-of-run resource/prompt operations. Old connections remain alive until every lease drains, while bounded stale-idle eviction prevents unused cached sessions from retaining them forever. Complete active-set publication allows unrelated healthy changes through but makes observed, desired, and active truth distinct and requires clear stale/degraded status. - -The reconciler may reuse unchanged healthy connections, but changed identities are withdrawn before replacement succeeds. This favors authority correctness over availability for that capability. Source consultation failure is different: its LKG remains desired because no newer source truth was obtained. Successful empty is newer truth and withdraws the source. - -`Authority` snapshot JSON gains an additive binding list. Missing is the one-time legacy state; present empty is meaningful and must not collapse back to missing. The maximum of 512 durable binding records, 256-byte capability framing, and fixed digest format are public validation contracts. Other source/server/tool/schema/generation/cooldown/eviction bounds remain tested internal constants. - -The process gains long-lived generation bundles, leases, source LKG/status caches, stale-engine eviction, and trigger/cooldown state. Implementation must inventory every such resource in ADR 0027 List 1 and every restart-relevant state decision in List 2. There is no process-global tombstone cache; unavailable-call evidence is session-durable. - -Configured settings/CLI/environment inputs remain restart-only. Endpoint URLs are public, credential-free routing configuration: userinfo and fragments are invalid, and the protocol-standard `access_token` query parameter is rejected at operator-profile validation and source admission. Other queries remain compatible and participate byte-faithfully in identity. This narrow enforcement cannot reliably detect arbitrary custom secrets; hashing does not sanitize a misconfigured URL, so operators and source adapters must keep credentials in the separate header/token/OAuth channels. Live operator settings reload is deferred to a future narrow admin operation and may not become ordinary session behavior. Broker/protected and client-provided MCP remain separate authority/lifecycle domains. The shared client spelling does not change broker wire semantics: `ConnectWorkspaceServices`, `workspace_enrollment`, consent, cancellation, and destructive replacement remain authoritative, while direct `mcp_refresh` advertises only `RefreshMcpSources`. The amendment changes no SDK, RPC, tool, or exported Go API selected by this ADR; living implementation and user documentation updates remain implementation work. - -## Rejected alternatives - -- **Authorize by name only.** Same-name endpoint/schema/read-only changes silently alter the granted capability and can resurrect after restart. -- **Use a generation ID as durable authority.** Process generations are runtime ownership, not stable capability identity; a deterministic contract digest survives restart without persisting the endpoint in each binding record. -- **Drop all query strings as potential secrets, or infer credential-like names.** Non-secret queries can select the dispatched MCP route and are supported by existing configuration. Dropping them aliases distinct connections; sorting/re-encoding them can also change dispatch. Mecatl instead rejects the exact protocol-standard `access_token` field, treats the complete compliant query as public routing identity, and keeps arbitrary credentials in structured channels without claiming exhaustive secret detection. -- **Keep a process-global removed-name tombstone cache.** It is lost on restart, detached from session authority, and grows another long-lived cache. Durable revoked bindings already provide exact evidence. -- **Mutate shared catalogues/managers in place or pin only active runs.** Cached engines, teams, referenced agents, and out-of-run resource/prompt operations can outlive the pin and observe closed or mixed managers. -- **Publish desired state all-or-nothing.** One failed addition would block unrelated removals and healthy changes. Publishing the successfully active subset is safer and more available while status remains honest. -- **Keep a superseded binding when replacement fails.** That executes an identity the successful source snapshot explicitly replaced. -- **Treat source consultation failure as empty.** A transient runtime failure would masquerade as authoritative removal; LKG is retained until a successful source snapshot says otherwise. -- **Automatically add every new binding to old sessions.** Dynamic discovery would silently widen durable exact authority. -- **Mutate a live session/engine before save or accept ambiguous save as success.** Failure can leave runtime authority wider than durable truth. Detached mutation plus reload confirmation avoids that split. -- **Reload operator settings during `/mcp-refresh`.** It gives an ordinary session an operator-config ingestion capability and obscures the restart boundary. -- **Fold broker or client MCP into global generations or one server refresh operation.** Their authorization, attachment, consent, cancellation, and teardown contracts are session-local and materially different. One client command may capability-route to the two existing authority domains, but fallback or double execution would cross that boundary. -- **Make list-then-get snapshot-atomic.** That requires public generation handles and retained cross-call leases; individual call consistency with visible stale identifiers is sufficient. - -## See also - -- [MCP source reconciliation acceptance plan](../acceptance/mcp-source-reconciliation.md) -- [ADR 0057 — MCP client server notifications](./0057-mcp-server-notifications.md) -- [ADR 0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) -- [ADR 0027 — Cloud-native arc and resource inventory](./0027-cloud-native.md) -- [Extensibility architecture](../architecture/extensibility.md) diff --git a/docs/adr/0345-mcp-source-reconciliation.md b/docs/adr/0345-mcp-source-reconciliation.md new file mode 100644 index 0000000000..2d7d9a52a5 --- /dev/null +++ b/docs/adr/0345-mcp-source-reconciliation.md @@ -0,0 +1,68 @@ +# ADR 0345 — Reconcile stale direct MCP source snapshots + +- Status: Proposed +- Date: 2026-09-16 +- Scope: direct/global MCP source refresh, immutable runtime publication, and explicit name-authority additions +- Supersedes: ADR 0057 only for its deferred “no live catalog mutation” decision; notification transport, dirty invalidation, bounded lazy-list, reconnect, and teardown decisions remain +- Superseded by: None + +## Context + +Mecatl resolves configured direct MCP servers before ToolHive workloads, so configured names win collisions. It currently builds one process-lifetime manager and copies its tools into shared and per-session catalogs. ToolHive can change after startup, and MCP servers can send tool/resource/prompt list-change notifications, but ADR 0057 deliberately stopped at refreshing adapter-local snapshots because `tool.Catalog` is append-only. A removed tool can therefore remain advertised until restart, while an added ToolHive workload remains undiscoverable. + +The stale-source defect does not require a new capability identity. Mecatl's durable authority is intentionally name-based: `session.Authority.CapabilitySet.Tools` is the exact name ceiling, delegation narrows that set, and execution independently checks the current catalog. Under those existing semantics a same-name endpoint, schema, or read-only change remains the same grant; disappearance does not permanently revoke the name; and reappearance of an already granted name is authorized. This ADR preserves that contract rather than adding digests, binding records, provenance subtraction, or a migration. + +Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `workspace_enrollment`, consent, cancellation, and whole-bundle replacement. Client-provided MCP remains session-local. Mecatui may present a unified `/mcp-refresh` spelling while capability-routing to these distinct operations. + +## Decision + +1. **Reconcile the existing ordered source seam.** One Build-owned reconciler consults configured/static and ToolHive sources in their existing order and applies the existing first-name-wins resolution. Configured process-start input remains static. Automatic polling is enabled only when ToolHive discovery is configured. Tool/resource/prompt list-change notifications and explicit refresh requests enter the same serialized reconciler. + +2. **Coalesce and bound all work.** At most one reconciliation cycle runs and one later invalidation is remembered. Polling is bounded and jittered; source consultation, candidate construction, server connection, list sizes, and retained runtimes have finite internal limits. Caller cancellation stops waiting but does not cancel shared work. Automatic work never opens browser/OAuth consent. Poll periods and bounds are implementation constants, not public configuration or persisted schema. + +3. **Keep source last-known-good state.** A source consultation failure retains that source's last successful snapshot and marks status stale. A successful snapshot, including an empty snapshot, is authoritative: a successful empty ToolHive observation withdraws its prior entries. The ordered resolver then computes one complete desired direct-server set. + +4. **Build candidates all-or-nothing.** A candidate creates a complete immutable direct runtime containing the manager, resource/prompt provider, resolved tools, source inventory/status snapshot, and one revision-tagged contribution consumed by `assembleCatalog`. Publication happens only after every desired server has connected, initialized, and listed successfully. A failed candidate closes all resources it created and leaves the previous runtime usable. This deliberately favors a simple coherent snapshot over partial-active publication: a valid removal can wait behind an unrelated failed addition, but a successful empty or otherwise complete candidate still publishes removals. A later notification, poll, or manual request retries degraded desired state. + +5. **Publish atomically and retire safely.** The reconciler atomically replaces one immutable current direct runtime. It never mutates an active catalog or manager. A bounded set of displaced runtimes may retire. Root runs and direct `RunTeam` operations pin the exact runtime revision they start with; delegation and generation-bound factories inherit that pin. Out-of-run resource/prompt operations take an operation pin. A retired runtime closes only after its pins drain. If publishing would exceed the retirement bound because an old runtime is still pinned, publication is deferred, the candidate closes, and the current runtime remains active; no runtime is force-closed. + +6. **Use revision tags, not engine-lifetime leases.** The shared engine and every cached normal/selector, no-FS, client-MCP, mode-specific, specialist, and debug engine are tagged with the direct runtime revision used to build them. Publication swaps the shared generation-bound factory contribution. At every actual operation boundary, a cached engine whose revision differs from the pinned runtime is evicted and rebuilt before use. Cached engines do not keep runtimes alive merely by existing. Failed-step retry, restored approval, prompt runs, direct `RunTeam`, delegation/reference MCP, and prompt expansion all resolve under the same pinned runtime; an operation never mixes schema projection, lookup, permission, dispatch, resources/prompts, or manager identity across revisions. + +7. **Availability filters durable name authority.** Current catalogs contain only tools from the published runtime. A granted direct name that is absent is therefore unadvertised and cannot execute, while its durable grant may remain unchanged. If the model emits a stale call whose exact name is granted but absent from the current catalog, the engine returns a permanent “currently unavailable; do not retry unless the catalog changes” tool error. An ungranted absent name retains ordinary unknown-tool behavior. When the exact name reappears, it is advertised and executable again under existing permission checks. Same-name source, endpoint, schema, and read-only drift follows this same current name-grant policy; no URL normalization, digest, or new endpoint credential policy is introduced. + +8. **Make additions explicit.** Automatic polling and notifications never widen a session's durable name ceiling. Owner-invoked direct `/mcp-refresh` requests reconciliation and then unions the current active direct tool/resource capability names into the session's existing tool ceiling. It preserves every unrelated capability and never performs durable direct-name subset replacement or provenance subtraction. Historical direct names may remain granted but unavailable until their exact names are current again. + +9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It validates and stable-unions names into a bound authority while preserving all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control. It does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, or digest API is added. + +10. **Mutate only when additions exist.** Direct refresh authenticates the owner before caller-selected coordination, uses `runEntryMu`, a real mutation lease/capability, a fresh authoritative load, durable taxonomy/state checks, and both lifecycle state and same-process liveness to prove quiescence. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions. If the current direct names add nothing, refresh returns without acquiring mutation authority or saving. Otherwise it calls `GrantToolAuthority` once and confirms one save before reporting success. A failed save leaves the prior durable authority authoritative; no live session or model history is changed. `Service.IsLive` is not used as the sole quiescence proof. + +11. **Keep direct and broker controls distinct.** Add direct-only `HarnessService.RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and `ServerCapabilities.mcp_refresh = 30` (free on the synchronized `origin/main` contract). The response reports the current runtime revision and whether either runtime publication or authority union changed state. `ListMcpSourcesResponse` adds current revision, stale, and reconciling fields over the reconciler's cached published/source status; it does not independently probe. Mecatui offers argument-free `/mcp-refresh`: direct capability routes only to `RefreshMcpSources`; broker-only `workspace_enrollment` routes only to `ConnectWorkspaceServices` with existing disclosure, consent, observation, and cancellation. Both capabilities or a missing matching collaborator fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains broker-only. + +## Consequences + +Stale removals take effect automatically at the next operation boundary without mutating durable authority. Existing sessions do not discover new names until an owner explicitly refreshes, but an already authorized name can disappear and later return without reauthorization. This is availability filtering, not permanent revocation. + +All-or-nothing candidates make runtime ownership and failure behavior small enough to audit. They can delay an otherwise valid removal while another desired server is failing; stale/degraded status makes that visible and bounded polling/notifications/manual refresh retry it. The previous complete runtime is preferable to a mixed generation. + +The process gains a polling goroutine/timer, one coalescing reconciler, source LKG/status state, one current runtime, bounded retiring runtimes, and run/operation pins. Implementation must inventory these resources and restart decisions in ADR 0027. No credential, URL, digest, durable direct-name ledger, or legacy migration is added. + +## Rejected alternatives + +- **Digest or binding-record authority.** This changes existing name semantics and exceeds the original stale-source scope. +- **Permanently revoke removed names.** Disappearance is availability, not revocation; exact-name reappearance remains authorized. +- **Replace the durable direct subset on refresh.** Existing authority has no direct-name provenance. Unioning active names is sufficient; availability handles removal. +- **Mutate catalogs/managers in place.** Active runs could mix schemas, dispatch targets, and closed connections. +- **Lease every engine for its useful lifetime.** Revision checks at operation boundaries plus run/operation pins protect use without letting idle caches retain old runtimes. +- **Force-close undrained runtimes.** This risks live users; bounded retirement instead defers publication. +- **Publish a partial active candidate.** Per-server ownership/reuse machinery is unnecessary for the original defect. Complete candidates provide one coherent failure boundary. +- **Treat source failure as empty.** A transient ToolHive or server failure would masquerade as removal. +- **Remove polling and rely on notifications/manual refresh.** ToolHive inventory changes are not guaranteed to arrive as MCP list notifications; bounded automatic polling is required. +- **Unify direct and broker server operations.** Their authority, consent, and lifecycle contracts differ even though the client spelling is shared. + +## See also + +- [MCP source reconciliation acceptance plan](../acceptance/mcp-source-reconciliation.md) +- [ADR 0057 — MCP client server notifications](./0057-mcp-server-notifications.md) +- [ADR 0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) +- [ADR 0027 — Cloud-native arc and resource inventory](./0027-cloud-native.md) +- [Extensibility architecture](../architecture/extensibility.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 85c6afc95e..afabbfff72 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -197,8 +197,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0342; notification transport, lazy-list, reconnect, and teardown decisions retained)* -- [0342 — Reconcile direct MCP through durable bindings and leased generations](./0342-mcp-source-reconciliation.md) *(proposed; supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0345; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0345 — Reconcile stale direct MCP source snapshots](./0345-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 709da2e5868d312f8f8371de6bd12504d168563b Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 16 Sep 2026 10:15:57 +0300 Subject: [PATCH 06/13] docs: clarify MCP reconciliation guarantees Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 46 +++++++++++-------- ...n.md => 0346-mcp-source-reconciliation.md} | 22 +++++---- docs/adr/README.md | 4 +- 3 files changed, 41 insertions(+), 31 deletions(-) rename docs/adr/{0345-mcp-source-reconciliation.md => 0346-mcp-source-reconciliation.md} (54%) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 38835c4286..83e5ffc495 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -2,9 +2,10 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. -**Decision record:** [ADR 0345](../adr/0345-mcp-source-reconciliation.md) +**Decision record:** [ADR 0346](../adr/0346-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. +**Baseline synchronization:** merged `origin/main` at `6c75a42d9cad502685a42f5418f2bc99b9ae6376`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions moved from 0345 to 0346 because incoming main allocated 0345. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -20,56 +21,61 @@ This amendment replaces the prior digest/binding/revocation design with the orig - [x] Additions — Decision: only explicit owner `/mcp-refresh` stable-unions current active direct MCP names into the existing ceiling, preserving unrelated names. Refresh is not durable direct-subset replacement. - [x] Triggers — Decision: bounded jittered polling is enabled only for ToolHive; MCP list-change notifications and manual requests share one serialized/coalesced bounded reconciler. - [x] Source failure — Decision: consultation failure retains source LKG; successful empty is authoritative withdrawal. -- [x] Candidate failure — Decision: candidates are complete/all-or-nothing. Any desired-server construction failure closes the candidate and retains the previous runtime; this may delay an unrelated removal, reported as stale/degraded, until a later retry. +- [x] Candidate failure — Decision: candidates are complete/all-or-nothing. Any desired-server construction failure closes the candidate and retains the previous runtime; this may delay an unrelated removal, reported through the existing `stale` flag, until a later retry. - [x] Runtime ownership — Decision: one immutable current direct runtime plus bounded retiring runtimes; operation/run pins protect active use and are inherited by delegation. Revision tags rebuild stale shared/cached engines; idle engine existence does not lease a runtime. - [x] Refresh lifecycle — Decision: idle and quiescent completed ordinary roots are eligible without reopening completed state. Failed/cancelled and every active/pending/non-root form are rejected. Mutation exclusion and confirmed save are required only when the union adds authority. - [x] Unified UX — Decision: `/mcp-refresh` capability-routes to direct `RefreshMcpSources` or existing broker `ConnectWorkspaceServices`; broker consent/cancellation/disclosure remains distinct, and `/tools-connect` remains a deprecated broker-only alias. ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`, where changed means the completed request either published a runtime revision or added session authority. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`2a0c9cb11bedc6ef88503f504bbd4f94a6d31690`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. -- **Exported Go APIs / interfaces:** Add only `engine/session.(*Session).GrantToolAuthority([]string) error`. It validates and stable-unions tool names into already-bound authority; preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`6c75a42d9cad502685a42f5418f2bc99b9ae6376`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. +- **Exported Go APIs / interfaces:** Add only `engine/session.(*Session).GrantToolAuthority([]string) error`. It clones the input and validates every added name as non-empty, valid UTF-8, control-free, and at most 256 bytes, then stable-unions it into already-bound authority; duplicate input or existing names are idempotent. A rejected batch is nonmutating, and unrelated existing names are neither revalidated nor altered. It preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. - **Tool schemas:** No model-visible tool is added or changed. Current catalog membership intersects existing exact-name authority. An authority-granted but catalog-absent call returns the exact permanent error `tool is currently unavailable; do not retry unless the catalog changes`; an ungranted absent name retains existing unknown-tool behavior. - **CLI / config:** Add no flag, key, URL rule, selector, credential input, or poll tuning. ToolHive discovery alone enables bounded automatic polling. Mecatui adds argument-free `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Both/neither incompatible modes or missing collaborators fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` is unchanged. -- **Events / persistence:** Add no event, snapshot revision, binding, revoked-name record, or migration. `Authority.CapabilitySet.Tools` remains the durable name ceiling. Explicit refresh performs at most one confirmed snapshot save only when its stable union adds names; runtime removal/status/revisions remain process state. -- **Security / authority:** Refresh authenticates ownership before caller-selected locking. Authority addition requires `runEntryMu`, mutation lease/capability, fresh authoritative load, ordinary-main taxonomy, eligible lifecycle state, same-process liveness exclusion, aggregate union, and successful save. A no-op refresh does not acquire mutation authority or save. Automatic reconciliation never widens authority or launches consent. Existing endpoint/credential validation and URL projection remain unchanged. +- **Events / persistence:** Add no event, snapshot revision, binding, revoked-name record, or migration. `Authority.CapabilitySet.Tools` remains the durable name ceiling. Explicit refresh performs at most one snapshot save only when its stable union adds names. The old and candidate session values remain detached from the published live aggregate. A successful save makes the exact candidate authoritative. Because a save error can be an ambiguous commit, the Service performs a bounded authoritative reload while still holding the same exclusion: exact candidate means durable success, exact old value means durable failure, and mismatch or unavailable confirmation returns an uncertain fail-closed result that never claims authority is unchanged. No speculative candidate is published into a live aggregate, and repeating the same stable union converges. Runtime removal/status/revisions remain process state. +- **Security / authority:** Refresh authenticates ownership before caller-selected locking. Every request takes `runEntryMu` and performs owner, ordinary-root, lifecycle, and same-process liveness checks at its local linearization point. Authority addition additionally requires mutation lease/capability, fresh authoritative load, aggregate union, and authoritative save confirmation. A no-op refresh acquires no mutation capability and performs zero writes. Before an authority-mutating save starts, caller cancellation stops session mutation while shared reconciliation continues. Once save starts it may commit despite cancellation; confirmation detaches from caller cancellation but remains bounded and under the same exclusion, and cancelled delivery never promises rollback. Automatic reconciliation never widens authority or launches consent. Existing endpoint/credential validation and URL projection remain unchanged. - **Compatibility / migration:** Existing sessions and snapshots require no migration. Existing granted names remain granted even while unavailable. Same-name source/endpoint/schema/read-only drift follows current name semantics. Additive protobuf fields are ignored by old clients. Broker/client MCP behavior and configured-over-ToolHive name precedence remain unchanged. ## In scope — 4 scenarios, in implementation order ### Scenario 1 — Ordered sources reconcile automatically and boundedly -The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0345](../adr/0345-mcp-source-reconciliation.md). +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0346](../adr/0346-mcp-source-reconciliation.md). **Acceptance:** - AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. - verify: `TestMCPSourceReconciliation_Scenario1_OrderedSourcesLKGAndEmpty` -- AC1.2: Polling is absent without ToolHive and bounded/jittered with it. Tool/resource/prompt notifications from the current runtime and manual requests serialize behind one active cycle with at most one coalesced successor; stale-runtime notifications are ignored and caller cancellation does not cancel shared work. +- AC1.2: Polling is absent without ToolHive and bounded/jittered with it. Tool/resource/prompt notifications from current servers mark their source dirty and trigger a list-based candidate refresh; an actual dirty event refreshes every relevant list and publication comparison includes tools, resources, and prompts. A stable ToolHive observation with no dirty signal reuses the existing live snapshot (apart from bounded health retry) and does not reconnect every server merely to inspect unchanged contracts; when ordered sources are unchanged and no source is dirty, the reconciler may no-op before candidate connection. Manual requests share the same serialized/coalesced path, stale-runtime notifications are ignored, and caller cancellation does not cancel shared work. + - named subcases: `unchanged-poll-no-reconnect`, `dirty-refreshes-all-lists`, `resources-prompts-affect-publication`, `stale-notification-ignored`, `caller-cancel-shared-work-continues` - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` -- AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics; automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. - - verify: `TestADR_0345_ReconciliationBoundsConsentAndShutdown` +- AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics. Finite Service/reconciler constants separately cap active-list cardinality, total candidate page count and bytes, per-refresh union grant count, and accumulated historical granted names before save; boundary behavior is tested without exporting another engine field or reviving a public/global 512-record bound. Automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. + - named subcases: `active-list-bound`, `candidate-page-byte-bound`, `union-grant-bound`, `historical-accumulation-bound`, `no-public-global-512-bound`, `consent-never-launched`, `shutdown-joins` + - verify: `TestADR_0346_ReconciliationBoundsConsentAndShutdown` ### Scenario 2 — Complete runtimes publish atomically and drain safely -A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0345](../adr/0345-mcp-source-reconciliation.md)'s consistency boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0346](../adr/0346-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** -- AC2.1: A complete candidate publishes additions and removals, including successful empty. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks status stale, and delays otherwise valid changes until a later successful retry. +- AC2.1: A complete candidate publishes additions and removals, including successful empty. Candidate equality compares bounded runtime metadata/config for tools, resources, prompts, and resolved source/server state—not a durable security/authority digest. An unchanged candidate leaves revision, published engine/factory contribution, caches, and retirement state byte-for-byte unchanged and is closed/discarded if one was built. Changed all-or-nothing state may reconnect the full desired server set at bounded cost. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks the same `stale` status, and delays otherwise valid changes until a later successful retry. + - named subcases: `unchanged-candidate-no-publication-side-effects`, `resource-only-change-publishes`, `prompt-only-change-publishes`, `changed-full-reconnect-allowed`, `failed-candidate-stale` - verify: `TestMCPSourceReconciliation_Scenario2_AllOrNothingPublication` - AC2.2: The real shared and session factory paths cover shared/default, selector, no-FS, client-MCP, mode-specific, specialist, and debug engines. Revision mismatch rebuilds before use; a debug name ceiling admits no additions and fails when selected names are absent. `assembleCatalog` remains the single complete registration path. - verify: `TestMCPSourceReconciliation_Scenario2_ProductionEngineRevisionMatrix` -- AC2.3: A production-path race matrix covers prompt start, failed-step retry, restored approval, direct `RunTeam`, Subagent, Parallel, Team member, named specialist, referenced-agent MCP, in-run prompt expansion, and out-of-run list/read/get resource/prompt calls. Each operation observes one runtime across schema, lookup, authority, permission, dispatch, provider, and manager; delegation inherits the root pin. Retiring runtimes close once after pins drain, and a full retirement set defers publication without force-close. +- AC2.3: A production-path race matrix covers prompt start, failed-step retry, restored approval, direct `RunTeam`, Subagent, Parallel, Team member, named specialist, referenced-agent MCP, in-run prompt expansion, and out-of-run list/read/get resource/prompt calls. Each operation observes one runtime across schema, lookup, authority, permission, dispatch, provider, and manager; delegation inherits the root pin. Retiring runtimes close once after pins drain. When a full retirement set prevents publication, the candidate is closed/discarded and no runtime is force-closed; the reconciler retains one coalesced successor, marks stale, and retries after drain without polling or allocating candidates without bound. + - named subcases: `retirement-full-defers-publish`, `unpublishable-candidate-closed`, `single-successor-after-drain`, `no-force-close`, `no-unbounded-candidate-allocation` - verify: `TestMCPSourceReconciliation_Scenario2_RuntimeConsistencyMatrix` ### Scenario 3 — Name authority filters availability and widens only explicitly -The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0345](../adr/0345-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0346](../adr/0346-mcp-source-reconciliation.md). **Acceptance:** - AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` -- AC3.2: `Session.GrantToolAuthority` stable-unions validated names into bound authority in idle and completed states, preserves unrelated names/axes, conversation/history, placement, owner, counters, and the exact lifecycle state, and rejects pending controls or other states without mutation. - - verify: `TestADR_0345_GrantToolAuthorityPreservesAggregateState` +- AC3.2: `Session.GrantToolAuthority` clones its input, rejects the whole batch without mutation unless every added name is non-empty, valid UTF-8, control-free, and at most 256 bytes, and stable-unions accepted names in input order. Duplicate input and already-present names are idempotent. It does not revalidate, reorder, or alter unrelated existing names or authority axes and preserves conversation/history, placement, owner, counters, and the exact lifecycle state; pending controls and states other than idle/completed are rejected without mutation. + - named subcases: `invalid-batch-atomic`, `input-cloned`, `duplicate-stable-union`, `unrelated-legacy-name-untouched`, `name-256-byte-boundary`, `state-preservation` + - verify: `TestADR_0346_GrantToolAuthorityPreservesAggregateState` - AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` @@ -78,9 +84,11 @@ The current runtime contributes only active direct names. Existing authority rem Direct refresh reconciles shared state first, then adds only missing active direct names to one eligible owned root. Broker refresh remains the existing enrollment operation in [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md). **Acceptance:** -- AC4.1: A Service-level matrix covers idle and quiescent completed success without reopening; no-op success with no lease/save; addition with owner preflight, `runEntryMu`, mutation lease/capability, fresh load, state plus liveness proof, one aggregate union, and one confirmed save; and rejection of active/running/awaiting/authorizing/failed/cancelled/child/schedule/debug/broker-conflicting sessions. Save failure leaves durable authority and model/history unchanged. The proof uses the real Service control, not direct aggregate calls or a fake pin-only harness. +- AC4.1: A Service-level matrix covers idle and quiescent completed success without reopening. Every path authenticates owner and takes `runEntryMu` for ordinary-root, state, and same-process liveness checks at the local linearization point. A precise no-op performs zero writes and needs no cross-host mutation lease/capability. An addition then acquires mutation authority, freshly loads detached old state, builds a detached candidate with one aggregate union, and starts at most one save. Direct save success makes the exact candidate durable. On save error, a bounded authoritative reload under the same exclusion distinguishes exact candidate (success), exact old (failure), mismatch (uncertain), and reload unavailable (uncertain); uncertain outcomes fail closed, never claim known-unchanged authority, never publish a speculative live aggregate, and leave model/history untouched. Repeating the same stable union converges. Cancellation before save begins stops session mutation while shared reconciliation continues; after save begins it may commit, bounded confirmation is detached from caller cancellation, and cancelled delivery promises no rollback. All active/running/awaiting/authorizing/failed/cancelled/child/schedule/debug/broker-conflicting sessions remain rejected. The proof uses the real Service control, not direct aggregate calls or a fake pin-only harness. + - named subcases: `no-op-zero-write-no-mutation-capability`, `save-direct-success`, `save-error-reload-exact-candidate`, `save-error-reload-exact-old`, `save-error-reload-mismatch-uncertain`, `save-error-reload-unavailable-uncertain`, `cancel-before-save`, `cancel-after-save-start`, `stable-union-retry-converges` - verify: `TestMCPSourceReconciliation_Scenario4_ServiceRefreshMutationMatrix` -- AC4.2: gRPC, HTTP, and mecatui direct paths return the current revision and exact changed semantics for unchanged, runtime-only change, authority-only change, both, stale candidate, cancellation, ownership concealment, and unsupported states. `ListMcpSources` returns cached revision/stale/reconciling and performs no probe. +- AC4.2: gRPC, HTTP, and mecatui direct paths return the revision of the successfully pinned active snapshot whose direct names that request considered/granted, not necessarily the latest revision at delivery. `changed` compares that request's observed snapshot before/after its shared cycle and its exact union; a coalesced successor may supersede the snapshot without changing the earlier response. The matrix covers unchanged, runtime-only change, authority-only change, both, stale candidate, pre-save and post-save-start cancellation, uncertain save confirmation, ownership concealment, and unsupported states. `ListMcpSources` returns cached revision/stale/reconciling, uses the single stale flag for degradation, and performs no probe. + - named subcases: `pinned-revision-response`, `superseded-response-stays-pinned`, `changed-request-local`, `cancelled-delivery-no-rollback-claim`, `stale-not-degraded-field` - verify: `TestMCPSourceReconciliation_Scenario4_DirectTransportStatusMatrix` - AC4.3: Mecatui routes direct-only `mcp_refresh` solely to `RefreshMcpSources` without consent and broker-only `workspace_enrollment` solely to `ConnectWorkspaceServices` with destructive-reconnection disclosure, consent/presentation, observation, cancellation, and existing outcomes. Both bits, neither bit, or a missing collaborator fail closed; `/tools-connect` remains broker-only even when direct refresh exists, and client-provided MCP is untouched. - verify: `TestMCPSourceReconciliation_Scenario4_UnifiedCommandRoutingMatrix` @@ -111,6 +119,6 @@ Direct refresh reconciles shared state first, then adds only missing active dire ## Deferred decisions and known risks - Exact poll, cooldown, source/server/tool/list, connect, and retirement bounds are finite tested implementation constants. -- Complete candidates can delay a successful removal while an unrelated desired server fails. Cached stale/degraded status and all three triggers make this visible and retryable; partial-active publication is intentionally not required. +- Complete candidates can delay a successful removal while an unrelated desired server fails. The cached `stale` status (also representing degradation) and all three triggers make this visible and retryable; partial-active publication is intentionally not required. - Cached engines may reference a retired runtime object but cannot use it: every root operation pins current runtime and verifies the engine revision first. Engine close paths must not own the shared direct manager; only runtime retirement closes it. - Refresh can grant names from a runtime superseded immediately after the pin is released. This is safe under name authority: the grant remains durable but unavailable until that exact name is current, and no live catalog is widened by the save. diff --git a/docs/adr/0345-mcp-source-reconciliation.md b/docs/adr/0346-mcp-source-reconciliation.md similarity index 54% rename from docs/adr/0345-mcp-source-reconciliation.md rename to docs/adr/0346-mcp-source-reconciliation.md index 2d7d9a52a5..5a9081d3da 100644 --- a/docs/adr/0345-mcp-source-reconciliation.md +++ b/docs/adr/0346-mcp-source-reconciliation.md @@ -1,4 +1,4 @@ -# ADR 0345 — Reconcile stale direct MCP source snapshots +# ADR 0346 — Reconcile stale direct MCP source snapshots - Status: Proposed - Date: 2026-09-16 @@ -18,31 +18,33 @@ Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `wor 1. **Reconcile the existing ordered source seam.** One Build-owned reconciler consults configured/static and ToolHive sources in their existing order and applies the existing first-name-wins resolution. Configured process-start input remains static. Automatic polling is enabled only when ToolHive discovery is configured. Tool/resource/prompt list-change notifications and explicit refresh requests enter the same serialized reconciler. -2. **Coalesce and bound all work.** At most one reconciliation cycle runs and one later invalidation is remembered. Polling is bounded and jittered; source consultation, candidate construction, server connection, list sizes, and retained runtimes have finite internal limits. Caller cancellation stops waiting but does not cancel shared work. Automatic work never opens browser/OAuth consent. Poll periods and bounds are implementation constants, not public configuration or persisted schema. +2. **Coalesce and bound all work.** At most one reconciliation cycle runs and one later invalidation is remembered. Polling is bounded and jittered; source consultation, candidate construction, server connection, active-list cardinality, total candidate page count and bytes, per-refresh union grants, historical grant accumulation before save, and retained runtimes have finite tested internal limits. These are Service/reconciler constants, not exported engine fields; there is no public/global 512-record revision bound. Caller cancellation stops waiting but does not cancel shared work. Automatic work never opens browser/OAuth consent. Poll periods and bounds are not public configuration or persisted schema. -3. **Keep source last-known-good state.** A source consultation failure retains that source's last successful snapshot and marks status stale. A successful snapshot, including an empty snapshot, is authoritative: a successful empty ToolHive observation withdraws its prior entries. The ordered resolver then computes one complete desired direct-server set. +3. **Keep source last-known-good state and refresh only dirty contracts.** A source consultation failure retains that source's last successful snapshot and marks status stale. A successful snapshot, including an empty snapshot, is authoritative: a successful empty ToolHive observation withdraws its prior entries. Tool/resource/prompt notifications from current servers mark the source dirty; a dirty refresh relists every relevant contract and publication comparison includes tools, resources, and prompts. A stable ToolHive observation with no dirty signal uses the existing live snapshot except for bounded health retry and need not reconnect servers merely to rediscover unchanged contracts; if ordered sources are unchanged and none is dirty, reconciliation may short-circuit before candidate connection. Stale-runtime notifications are ignored. The ordered resolver then computes one complete desired direct-server set. -4. **Build candidates all-or-nothing.** A candidate creates a complete immutable direct runtime containing the manager, resource/prompt provider, resolved tools, source inventory/status snapshot, and one revision-tagged contribution consumed by `assembleCatalog`. Publication happens only after every desired server has connected, initialized, and listed successfully. A failed candidate closes all resources it created and leaves the previous runtime usable. This deliberately favors a simple coherent snapshot over partial-active publication: a valid removal can wait behind an unrelated failed addition, but a successful empty or otherwise complete candidate still publishes removals. A later notification, poll, or manual request retries degraded desired state. +4. **Build candidates all-or-nothing.** A candidate creates a complete immutable direct runtime containing the manager, resource/prompt provider, resolved tools, source inventory/status snapshot, and one revision-tagged contribution consumed by `assembleCatalog`. Publication happens only after every desired server has connected, initialized, and listed successfully. Runtime equality compares bounded tool/resource/prompt and resolved source/server metadata/config, not a durable security or authority digest. An unchanged candidate leaves the revision, published engine/factory contribution, caches, and retirement state unchanged and is closed if it was built. A changed candidate may reconnect the complete desired set at bounded cost; premature connection sharing is not required. A failed candidate closes all resources it created and leaves the previous runtime usable. This deliberately favors a simple coherent snapshot over partial-active publication: a valid removal can wait behind an unrelated failed addition, but a successful empty or otherwise complete candidate still publishes removals. Candidate/source degradation uses the existing `stale` status—there is no separate degraded field—and a later notification, poll, or manual request retries it. -5. **Publish atomically and retire safely.** The reconciler atomically replaces one immutable current direct runtime. It never mutates an active catalog or manager. A bounded set of displaced runtimes may retire. Root runs and direct `RunTeam` operations pin the exact runtime revision they start with; delegation and generation-bound factories inherit that pin. Out-of-run resource/prompt operations take an operation pin. A retired runtime closes only after its pins drain. If publishing would exceed the retirement bound because an old runtime is still pinned, publication is deferred, the candidate closes, and the current runtime remains active; no runtime is force-closed. +5. **Publish atomically and retire safely.** The reconciler atomically replaces one immutable current direct runtime. It never mutates an active catalog or manager. A bounded set of displaced runtimes may retire. Root runs and direct `RunTeam` operations pin the exact runtime revision they start with; delegation and generation-bound factories inherit that pin. Out-of-run resource/prompt operations take an operation pin. A retired runtime closes only after its pins drain. If publishing would exceed the retirement bound because an old runtime is still pinned, publication is deferred, the unpublishable candidate is closed/discarded, status is stale, and the current runtime remains active; no runtime is force-closed. Exactly one coalesced successor is retained and retried after drain, without unbounded polling or candidate allocation. 6. **Use revision tags, not engine-lifetime leases.** The shared engine and every cached normal/selector, no-FS, client-MCP, mode-specific, specialist, and debug engine are tagged with the direct runtime revision used to build them. Publication swaps the shared generation-bound factory contribution. At every actual operation boundary, a cached engine whose revision differs from the pinned runtime is evicted and rebuilt before use. Cached engines do not keep runtimes alive merely by existing. Failed-step retry, restored approval, prompt runs, direct `RunTeam`, delegation/reference MCP, and prompt expansion all resolve under the same pinned runtime; an operation never mixes schema projection, lookup, permission, dispatch, resources/prompts, or manager identity across revisions. 7. **Availability filters durable name authority.** Current catalogs contain only tools from the published runtime. A granted direct name that is absent is therefore unadvertised and cannot execute, while its durable grant may remain unchanged. If the model emits a stale call whose exact name is granted but absent from the current catalog, the engine returns a permanent “currently unavailable; do not retry unless the catalog changes” tool error. An ungranted absent name retains ordinary unknown-tool behavior. When the exact name reappears, it is advertised and executable again under existing permission checks. Same-name source, endpoint, schema, and read-only drift follows this same current name-grant policy; no URL normalization, digest, or new endpoint credential policy is introduced. -8. **Make additions explicit.** Automatic polling and notifications never widen a session's durable name ceiling. Owner-invoked direct `/mcp-refresh` requests reconciliation and then unions the current active direct tool/resource capability names into the session's existing tool ceiling. It preserves every unrelated capability and never performs durable direct-name subset replacement or provenance subtraction. Historical direct names may remain granted but unavailable until their exact names are current again. +8. **Make additions explicit.** Automatic polling and notifications never widen a session's durable name ceiling. Owner-invoked direct `/mcp-refresh` requests reconciliation and then stable-unions only the successfully pinned active snapshot's direct tool names into the session's existing tool ceiling. It preserves every unrelated capability and never performs durable direct-name subset replacement or provenance subtraction. Historical direct names may remain granted but unavailable until their exact names are current again. -9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It validates and stable-unions names into a bound authority while preserving all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control. It does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, or digest API is added. +9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It clones caller input and requires every added name to be non-empty, valid UTF-8, control-free, and no longer than 256 bytes. It rejects an invalid batch without mutation and stable-unions accepted names in input order; duplicate input or already-present names are idempotent. Existing unrelated names are not revalidated, reordered, or altered. The operation preserves all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control and does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, digest API, or other engine/store API is added. -10. **Mutate only when additions exist.** Direct refresh authenticates the owner before caller-selected coordination, uses `runEntryMu`, a real mutation lease/capability, a fresh authoritative load, durable taxonomy/state checks, and both lifecycle state and same-process liveness to prove quiescence. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions. If the current direct names add nothing, refresh returns without acquiring mutation authority or saving. Otherwise it calls `GrantToolAuthority` once and confirms one save before reporting success. A failed save leaves the prior durable authority authoritative; no live session or model history is changed. `Service.IsLive` is not used as the sole quiescence proof. +10. **Mutate only when additions exist, and confirm ambiguous saves authoritatively.** Direct refresh authenticates the owner before caller-selected coordination and always takes `runEntryMu` to check durable taxonomy/state and same-process liveness at its local linearization point. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions; `Service.IsLive` is not the sole quiescence proof. If the pinned direct names add nothing, refresh performs zero writes and returns without cross-host mutation lease/capability. Otherwise it acquires mutation authority, freshly loads a detached old session, constructs a detached candidate, calls `GrantToolAuthority` once, and issues at most one `Save`. Direct save success makes the exact candidate authoritative. Because a save error may mean the write committed, the Service performs a bounded authoritative reload while holding the same exclusion: exact candidate is durable success, exact old state is durable failure, and mismatch or unavailable confirmation is uncertain and fails closed without claiming known unchanged state. Neither old nor candidate is published as a speculative live aggregate; model/history are unaffected, and retrying the same stable union converges. -11. **Keep direct and broker controls distinct.** Add direct-only `HarnessService.RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and `ServerCapabilities.mcp_refresh = 30` (free on the synchronized `origin/main` contract). The response reports the current runtime revision and whether either runtime publication or authority union changed state. `ListMcpSourcesResponse` adds current revision, stale, and reconciling fields over the reconciler's cached published/source status; it does not independently probe. Mecatui offers argument-free `/mcp-refresh`: direct capability routes only to `RefreshMcpSources`; broker-only `workspace_enrollment` routes only to `ConnectWorkspaceServices` with existing disclosure, consent, observation, and cancellation. Both capabilities or a missing matching collaborator fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains broker-only. + Cancellation before the authority-mutating `Save` starts stops session mutation, although shared reconciliation continues. Once `Save` starts it may commit; bounded confirmation is detached from caller cancellation and remains under the same exclusion. Delivery to a cancelled caller does not promise rollback. + +11. **Keep direct and broker controls distinct.** Add direct-only `HarnessService.RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and `ServerCapabilities.mcp_refresh = 30` (free on the synchronized `origin/main` contract). The response `revision` identifies the successfully pinned active snapshot whose direct names were considered/granted for that request, not necessarily the latest revision when delivery completes. `changed` compares that request's observed active snapshot before and after its shared reconciliation cycle and the exact stable union; a coalesced successor may supersede the pin without rewriting the earlier response. `ListMcpSourcesResponse` adds current revision, stale, and reconciling fields over the reconciler's cached published/source status; it does not independently probe, and `stale` also represents degradation. Mecatui offers argument-free `/mcp-refresh`: direct capability routes only to `RefreshMcpSources`; broker-only `workspace_enrollment` routes only to `ConnectWorkspaceServices` with existing disclosure, consent, observation, and cancellation. Both capabilities or a missing matching collaborator fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains broker-only. ## Consequences Stale removals take effect automatically at the next operation boundary without mutating durable authority. Existing sessions do not discover new names until an owner explicitly refreshes, but an already authorized name can disappear and later return without reauthorization. This is availability filtering, not permanent revocation. -All-or-nothing candidates make runtime ownership and failure behavior small enough to audit. They can delay an otherwise valid removal while another desired server is failing; stale/degraded status makes that visible and bounded polling/notifications/manual refresh retry it. The previous complete runtime is preferable to a mixed generation. +All-or-nothing candidates make runtime ownership and failure behavior small enough to audit. They can delay an otherwise valid removal while another desired server is failing; the existing `stale` status also represents this degradation and bounded polling/notifications/manual refresh retry it. The previous complete runtime is preferable to a mixed generation. The process gains a polling goroutine/timer, one coalescing reconciler, source LKG/status state, one current runtime, bounded retiring runtimes, and run/operation pins. Implementation must inventory these resources and restart decisions in ADR 0027. No credential, URL, digest, durable direct-name ledger, or legacy migration is added. diff --git a/docs/adr/README.md b/docs/adr/README.md index afabbfff72..4bb4b9e549 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -197,8 +197,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0345; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* -- [0345 — Reconcile stale direct MCP source snapshots](./0345-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0346; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0346 — Reconcile stale direct MCP source snapshots](./0346-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 910c46143899194bb0d8d9eddf768557e68c45d6 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 23 Sep 2026 09:35:56 +0300 Subject: [PATCH 07/13] docs: repair MCP reconciliation ADR number Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 16 ++++++++-------- ...tion.md => 0350-mcp-source-reconciliation.md} | 4 ++-- docs/adr/README.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename docs/adr/{0346-mcp-source-reconciliation.md => 0350-mcp-source-reconciliation.md} (91%) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 83e5ffc495..652946a7cc 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -2,10 +2,10 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. -**Decision record:** [ADR 0346](../adr/0346-mcp-source-reconciliation.md) +**Decision record:** [ADR 0350](../adr/0350-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. -**Baseline synchronization:** merged `origin/main` at `6c75a42d9cad502685a42f5418f2bc99b9ae6376`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions moved from 0345 to 0346 because incoming main allocated 0345. +**Baseline synchronization:** merged `origin/main` at `6c75a42d9cad502685a42f5418f2bc99b9ae6376`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0350, after the rebased stack allocated 0345–0349. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -40,7 +40,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ### Scenario 1 — Ordered sources reconcile automatically and boundedly -The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0346](../adr/0346-mcp-source-reconciliation.md). +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0350](../adr/0350-mcp-source-reconciliation.md). **Acceptance:** - AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. @@ -50,11 +50,11 @@ The production Build path constructs one reconciler over the existing ordered so - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` - AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics. Finite Service/reconciler constants separately cap active-list cardinality, total candidate page count and bytes, per-refresh union grant count, and accumulated historical granted names before save; boundary behavior is tested without exporting another engine field or reviving a public/global 512-record bound. Automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. - named subcases: `active-list-bound`, `candidate-page-byte-bound`, `union-grant-bound`, `historical-accumulation-bound`, `no-public-global-512-bound`, `consent-never-launched`, `shutdown-joins` - - verify: `TestADR_0346_ReconciliationBoundsConsentAndShutdown` + - verify: `TestADR_0350_ReconciliationBoundsConsentAndShutdown` ### Scenario 2 — Complete runtimes publish atomically and drain safely -A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0346](../adr/0346-mcp-source-reconciliation.md)'s consistency boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0350](../adr/0350-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** - AC2.1: A complete candidate publishes additions and removals, including successful empty. Candidate equality compares bounded runtime metadata/config for tools, resources, prompts, and resolved source/server state—not a durable security/authority digest. An unchanged candidate leaves revision, published engine/factory contribution, caches, and retirement state byte-for-byte unchanged and is closed/discarded if one was built. Changed all-or-nothing state may reconnect the full desired server set at bounded cost. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks the same `stale` status, and delays otherwise valid changes until a later successful retry. @@ -68,20 +68,20 @@ A candidate builds a complete manager/provider/tool/source snapshot and generati ### Scenario 3 — Name authority filters availability and widens only explicitly -The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0346](../adr/0346-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0350](../adr/0350-mcp-source-reconciliation.md). **Acceptance:** - AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` - AC3.2: `Session.GrantToolAuthority` clones its input, rejects the whole batch without mutation unless every added name is non-empty, valid UTF-8, control-free, and at most 256 bytes, and stable-unions accepted names in input order. Duplicate input and already-present names are idempotent. It does not revalidate, reorder, or alter unrelated existing names or authority axes and preserves conversation/history, placement, owner, counters, and the exact lifecycle state; pending controls and states other than idle/completed are rejected without mutation. - named subcases: `invalid-batch-atomic`, `input-cloned`, `duplicate-stable-union`, `unrelated-legacy-name-untouched`, `name-256-byte-boundary`, `state-preservation` - - verify: `TestADR_0346_GrantToolAuthorityPreservesAggregateState` + - verify: `TestADR_0350_GrantToolAuthorityPreservesAggregateState` - AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` ### Scenario 4 — Explicit direct refresh and unified UX preserve boundaries -Direct refresh reconciles shared state first, then adds only missing active direct names to one eligible owned root. Broker refresh remains the existing enrollment operation in [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md). +Direct refresh validates an eligible owned root under run-entry exclusion before reconciling shared state, then adds only missing active direct names. Broker refresh remains the existing enrollment operation in [ADR 0335](../adr/0335-idle-session-broker-workspace-refresh.md). **Acceptance:** - AC4.1: A Service-level matrix covers idle and quiescent completed success without reopening. Every path authenticates owner and takes `runEntryMu` for ordinary-root, state, and same-process liveness checks at the local linearization point. A precise no-op performs zero writes and needs no cross-host mutation lease/capability. An addition then acquires mutation authority, freshly loads detached old state, builds a detached candidate with one aggregate union, and starts at most one save. Direct save success makes the exact candidate durable. On save error, a bounded authoritative reload under the same exclusion distinguishes exact candidate (success), exact old (failure), mismatch (uncertain), and reload unavailable (uncertain); uncertain outcomes fail closed, never claim known-unchanged authority, never publish a speculative live aggregate, and leave model/history untouched. Repeating the same stable union converges. Cancellation before save begins stops session mutation while shared reconciliation continues; after save begins it may commit, bounded confirmation is detached from caller cancellation, and cancelled delivery promises no rollback. All active/running/awaiting/authorizing/failed/cancelled/child/schedule/debug/broker-conflicting sessions remain rejected. The proof uses the real Service control, not direct aggregate calls or a fake pin-only harness. diff --git a/docs/adr/0346-mcp-source-reconciliation.md b/docs/adr/0350-mcp-source-reconciliation.md similarity index 91% rename from docs/adr/0346-mcp-source-reconciliation.md rename to docs/adr/0350-mcp-source-reconciliation.md index 5a9081d3da..fca6c7d55f 100644 --- a/docs/adr/0346-mcp-source-reconciliation.md +++ b/docs/adr/0350-mcp-source-reconciliation.md @@ -1,4 +1,4 @@ -# ADR 0346 — Reconcile stale direct MCP source snapshots +# ADR 0350 — Reconcile stale direct MCP source snapshots - Status: Proposed - Date: 2026-09-16 @@ -34,7 +34,7 @@ Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `wor 9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It clones caller input and requires every added name to be non-empty, valid UTF-8, control-free, and no longer than 256 bytes. It rejects an invalid batch without mutation and stable-unions accepted names in input order; duplicate input or already-present names are idempotent. Existing unrelated names are not revalidated, reordered, or altered. The operation preserves all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control and does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, digest API, or other engine/store API is added. -10. **Mutate only when additions exist, and confirm ambiguous saves authoritatively.** Direct refresh authenticates the owner before caller-selected coordination and always takes `runEntryMu` to check durable taxonomy/state and same-process liveness at its local linearization point. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions; `Service.IsLive` is not the sole quiescence proof. If the pinned direct names add nothing, refresh performs zero writes and returns without cross-host mutation lease/capability. Otherwise it acquires mutation authority, freshly loads a detached old session, constructs a detached candidate, calls `GrantToolAuthority` once, and issues at most one `Save`. Direct save success makes the exact candidate authoritative. Because a save error may mean the write committed, the Service performs a bounded authoritative reload while holding the same exclusion: exact candidate is durable success, exact old state is durable failure, and mismatch or unavailable confirmation is uncertain and fails closed without claiming known unchanged state. Neither old nor candidate is published as a speculative live aggregate; model/history are unaffected, and retrying the same stable union converges. +10. **Mutate only when additions exist, and confirm ambiguous saves authoritatively.** Direct refresh authenticates the owner before caller-selected coordination, then takes `runEntryMu` and checks durable taxonomy/state and same-process liveness at its local linearization point before invoking shared reconciliation. Ineligible requests therefore perform no source work. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions; `Service.IsLive` is not the sole quiescence proof. If the pinned direct names add nothing, refresh performs zero writes and returns without cross-host mutation lease/capability. Otherwise it acquires mutation authority, freshly loads a detached old session, constructs a detached candidate, calls `GrantToolAuthority` once, and issues at most one `Save`. Direct save success makes the exact candidate authoritative. Because a save error may mean the write committed, the Service performs a bounded authoritative reload while holding the same exclusion: exact candidate is durable success, exact old state is durable failure, and mismatch or unavailable confirmation is uncertain and fails closed without claiming known unchanged state. Neither old nor candidate is published as a speculative live aggregate; model/history are unaffected, and retrying the same stable union converges. Cancellation before the authority-mutating `Save` starts stops session mutation, although shared reconciliation continues. Once `Save` starts it may commit; bounded confirmation is detached from caller cancellation and remains under the same exclusion. Delivery to a cancelled caller does not promise rollback. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4bb4b9e549..d70f79a8da 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -197,8 +197,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0346; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* -- [0346 — Reconcile stale direct MCP source snapshots](./0346-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0350; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0350 — Reconcile stale direct MCP source snapshots](./0350-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 01fa56675c70ed5c3e61ce8b994ae43467cd9ebc Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 23 Sep 2026 11:12:05 +0300 Subject: [PATCH 08/13] docs: amend MCP reconciliation lifecycle contract Co-Authored-By: mecatl --- .../scripts/check-acceptance-plan-test.sh | 7 +++++++ docs/acceptance/mcp-source-reconciliation.md | 9 ++++++--- docs/adr/0350-mcp-source-reconciliation.md | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.claude/skills/to-acceptance-plan/scripts/check-acceptance-plan-test.sh b/.claude/skills/to-acceptance-plan/scripts/check-acceptance-plan-test.sh index d031018428..e9d6519f80 100755 --- a/.claude/skills/to-acceptance-plan/scripts/check-acceptance-plan-test.sh +++ b/.claude/skills/to-acceptance-plan/scripts/check-acceptance-plan-test.sh @@ -100,6 +100,13 @@ sed_in_place 's/\*\*Expected tasks:\*\* deferred to orchestration/\*\*Expected t sed_in_place 's||**Combined rationale:** The fixture is one indivisible documentation check, so separate plan review adds no value.|' "$combined_valid" bash "$checker" "$combined_valid" >/dev/null +# Category extraction must survive an interface block larger than a pipe buffer. +for source in "$valid" "$combined_valid"; do + large="$root/acceptance/large-$(basename "$source")" + awk '/^### Scenario/ { for (i = 0; i < 4096; i++) print "Additional interface detail for the large-block fixture." } { print }' "$source" >"$large" + bash "$checker" "$large" >/dev/null +done + for case_name in missing-contract invalid-contract missing-category bare-none placeholder bad-delivery bad-status proposed-unchecked missing-human placeholder-human checked-without-decision; do cp "$valid" "$root/acceptance/$case_name.md" done diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 652946a7cc..c1f993dad7 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -29,7 +29,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ## Interface contract - **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`6c75a42d9cad502685a42f5418f2bc99b9ae6376`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. -- **Exported Go APIs / interfaces:** Add only `engine/session.(*Session).GrantToolAuthority([]string) error`. It clones the input and validates every added name as non-empty, valid UTF-8, control-free, and at most 256 bytes, then stable-unions it into already-bound authority; duplicate input or existing names are idempotent. A rejected batch is nonmutating, and unrelated existing names are neither revalidated nor altered. It preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. +- **Exported Go APIs / interfaces:** Add `engine/session.(*Session).GrantToolAuthority([]string) error`, additive idempotent `engine/agent.(*Supervisor).Close()`, and `engine/agent.ErrSupervisorClosed`. `GrantToolAuthority` clones the input and validates every added name as non-empty, valid UTF-8, control-free, and at most 256 bytes, then stable-unions it into already-bound authority; duplicate input or existing names are idempotent. A rejected batch is nonmutating, and unrelated existing names are neither revalidated nor altered. It preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. `Supervisor.Close` cancels an active run, waits for settlement, and performs fork, inline-MCP closer, cancellation, registry/liveness, and other enrolled-member cleanup exactly once across repeated or concurrent calls; `Run` uses the same close path. Enrolment after close or run start returns `ErrSupervisorClosed`. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. - **Tool schemas:** No model-visible tool is added or changed. Current catalog membership intersects existing exact-name authority. An authority-granted but catalog-absent call returns the exact permanent error `tool is currently unavailable; do not retry unless the catalog changes`; an ungranted absent name retains existing unknown-tool behavior. - **CLI / config:** Add no flag, key, URL rule, selector, credential input, or poll tuning. ToolHive discovery alone enables bounded automatic polling. Mecatui adds argument-free `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Both/neither incompatible modes or missing collaborators fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` is unchanged. - **Events / persistence:** Add no event, snapshot revision, binding, revoked-name record, or migration. `Authority.CapabilitySet.Tools` remains the durable name ceiling. Explicit refresh performs at most one snapshot save only when its stable union adds names. The old and candidate session values remain detached from the published live aggregate. A successful save makes the exact candidate authoritative. Because a save error can be an ambiguous commit, the Service performs a bounded authoritative reload while still holding the same exclusion: exact candidate means durable success, exact old value means durable failure, and mismatch or unavailable confirmation returns an uncertain fail-closed result that never claims authority is unchanged. No speculative candidate is published into a live aggregate, and repeating the same stable union converges. Runtime removal/status/revisions remain process state. @@ -62,7 +62,7 @@ A candidate builds a complete manager/provider/tool/source snapshot and generati - verify: `TestMCPSourceReconciliation_Scenario2_AllOrNothingPublication` - AC2.2: The real shared and session factory paths cover shared/default, selector, no-FS, client-MCP, mode-specific, specialist, and debug engines. Revision mismatch rebuilds before use; a debug name ceiling admits no additions and fails when selected names are absent. `assembleCatalog` remains the single complete registration path. - verify: `TestMCPSourceReconciliation_Scenario2_ProductionEngineRevisionMatrix` -- AC2.3: A production-path race matrix covers prompt start, failed-step retry, restored approval, direct `RunTeam`, Subagent, Parallel, Team member, named specialist, referenced-agent MCP, in-run prompt expansion, and out-of-run list/read/get resource/prompt calls. Each operation observes one runtime across schema, lookup, authority, permission, dispatch, provider, and manager; delegation inherits the root pin. Retiring runtimes close once after pins drain. When a full retirement set prevents publication, the candidate is closed/discarded and no runtime is force-closed; the reconciler retains one coalesced successor, marks stale, and retries after drain without polling or allocating candidates without bound. +- AC2.3: A production-path race matrix covers prompt start, failed-step retry, restored approval, direct `RunTeam`, Subagent, Parallel, Team member, named specialist, referenced-agent MCP, in-run prompt expansion, and out-of-run list/read/get resource/prompt calls. Each operation observes one runtime across schema, lookup, authority, permission, dispatch, provider, and manager; delegation inherits the root pin. Shared-engine construction captures its manager and revision under one construction-scoped pin, releases that pin when `Build` returns, and cannot publish a mismatched shared-engine tag if reconciliation advances concurrently. Revision zero carried by an operation pin is distinct from no operation pin. Retiring runtimes close once after pins drain. When a full retirement set prevents publication, the candidate is closed/discarded and no runtime is force-closed; the reconciler retains one coalesced successor, marks stale, and retries after drain without polling or allocating candidates without bound. - named subcases: `retirement-full-defers-publish`, `unpublishable-candidate-closed`, `single-successor-after-drain`, `no-force-close`, `no-unbounded-candidate-allocation` - verify: `TestMCPSourceReconciliation_Scenario2_RuntimeConsistencyMatrix` @@ -92,6 +92,9 @@ Direct refresh validates an eligible owned root under run-entry exclusion before - verify: `TestMCPSourceReconciliation_Scenario4_DirectTransportStatusMatrix` - AC4.3: Mecatui routes direct-only `mcp_refresh` solely to `RefreshMcpSources` without consent and broker-only `workspace_enrollment` solely to `ConnectWorkspaceServices` with destructive-reconnection disclosure, consent/presentation, observation, cancellation, and existing outcomes. Both bits, neither bit, or a missing collaborator fail closed; `/tools-connect` remains broker-only even when direct refresh exists, and client-provided MCP is untouched. - verify: `TestMCPSourceReconciliation_Scenario4_UnifiedCommandRoutingMatrix` +- AC4.4: Direct-team startup revalidates the exact registry entry while claiming `starting`, so successful concurrent cleanup cannot leave an orphan start or escape the team cap. Before enrollment, a configured atomic member creator must expose supported rollback deletion. A later-member failure invokes idempotent supervisor close without inference/synthesis, deletes every startup snapshot under a detached bounded context only while its mutation lease is still held, then releases leases; cancellation follows the same cleanup path, lease loss refuses deletion, and retry cleans each fork, inline closer, cancel, liveness registration, and snapshot exactly once. Creator-only and explicitly nondeleting stores fail before factory, fork, lease, or snapshot side effects. + - named subcases: `second-member-failure-rolls-back-first`, `cancelled-startup-rolls-back`, `lost-lease-refuses-delete`, `creator-only-rejected-before-enrollment`, `cleanup-run-start-identity-barrier` + - verify: `TestRunTeamConstructionFailureRetainsDeclarationsForRetry`, `TestRunTeamRejectsCreatorStoreWithoutRollbackBeforeEnrollment`, `TestSupervisorCloseIsConcurrentAndIdempotent` ## Out of scope @@ -110,7 +113,7 @@ Direct refresh validates an eligible owned root under run-entry exclusion before 1. Stacked implementation records the exact amended plan commit and targets `plan/mcp-source-reconciliation`; parent advisory review precedes any plan push. Plan merge remains approval and humans alone merge. 2. The named production-path tests prove all 12 ACs, including automatic ToolHive polling, notifications, LKG/empty, complete-candidate failure, runtime consistency, name reappearance, no-op versus widening refresh, transport parity, and broker/client isolation. -3. The sole exported engine addition, `Session.GrantToolAuthority`, is recorded by `task api:update` and as Added/minor in `engine/CHANGELOG.md`. No other engine API widening is needed. +3. The exported engine additions, `Session.GrantToolAuthority`, `Supervisor.Close`, and `ErrSupervisorClosed`, are recorded by `task api:update` and as Added/minor in `engine/CHANGELOG.md`. No other engine API widening is needed. 4. ADR 0027 inventories the poller/timer, reconciler/coalescing state, source LKG/status, immutable current and retiring runtimes, revision-tagged caches, and run/operation pins with restart decisions. 5. Implementation updates `docs/architecture.md`, `docs/architecture/extensibility.md`, `docs/design/IMPLEMENTATION-NOTES.md`, owning `user-docs/` MCP/mecatui pages, generated API/config references, and ADR 0057's index annotation. 6. `task generate`, `task lint`, `task test`, `task api:check`, `task docs`, `task site:build`, `task ac-trace-strict`, and `go run ./cmd/mecademo` pass at implementation completion. diff --git a/docs/adr/0350-mcp-source-reconciliation.md b/docs/adr/0350-mcp-source-reconciliation.md index fca6c7d55f..b338ef3f3d 100644 --- a/docs/adr/0350-mcp-source-reconciliation.md +++ b/docs/adr/0350-mcp-source-reconciliation.md @@ -32,7 +32,7 @@ Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `wor 8. **Make additions explicit.** Automatic polling and notifications never widen a session's durable name ceiling. Owner-invoked direct `/mcp-refresh` requests reconciliation and then stable-unions only the successfully pinned active snapshot's direct tool names into the session's existing tool ceiling. It preserves every unrelated capability and never performs durable direct-name subset replacement or provenance subtraction. Historical direct names may remain granted but unavailable until their exact names are current again. -9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It clones caller input and requires every added name to be non-empty, valid UTF-8, control-free, and no longer than 256 bytes. It rejects an invalid batch without mutation and stable-unions accepted names in input order; duplicate input or already-present names are idempotent. Existing unrelated names are not revalidated, reordered, or altered. The operation preserves all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control and does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, digest API, or other engine/store API is added. +9. **Add one narrow aggregate operation.** Add `Session.GrantToolAuthority([]string) error`, the minimum aggregate API needed because authority may not be mutated by composition directly. It clones caller input and requires every added name to be non-empty, valid UTF-8, control-free, and no longer than 256 bytes. It rejects an invalid batch without mutation and stable-unions accepted names in input order; duplicate input or already-present names are idempotent. Existing unrelated names are not revalidated, reordered, or altered. The operation preserves all non-tool axes, conversation, placement, ownership, counters, and lifecycle state. It is legal only in `StateIdle` or quiescent `StateCompleted` with no pending control and does not reopen a completed session. Ordinary-main/root eligibility and current-direct-name provenance remain Service/composition checks. No new `agent.RunRequest`, durable authority field, digest API, or store API is added. 10. **Mutate only when additions exist, and confirm ambiguous saves authoritatively.** Direct refresh authenticates the owner before caller-selected coordination, then takes `runEntryMu` and checks durable taxonomy/state and same-process liveness at its local linearization point before invoking shared reconciliation. Ineligible requests therefore perform no source work. It supports idle and quiescent completed ordinary roots and rejects running, awaiting, authorizing, failed, cancelled, child, schedule, debug, and broker-conflicting sessions; `Service.IsLive` is not the sole quiescence proof. If the pinned direct names add nothing, refresh performs zero writes and returns without cross-host mutation lease/capability. Otherwise it acquires mutation authority, freshly loads a detached old session, constructs a detached candidate, calls `GrantToolAuthority` once, and issues at most one `Save`. Direct save success makes the exact candidate authoritative. Because a save error may mean the write committed, the Service performs a bounded authoritative reload while holding the same exclusion: exact candidate is durable success, exact old state is durable failure, and mismatch or unavailable confirmation is uncertain and fails closed without claiming known unchanged state. Neither old nor candidate is published as a speculative live aggregate; model/history are unaffected, and retrying the same stable union converges. @@ -40,6 +40,8 @@ Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `wor 11. **Keep direct and broker controls distinct.** Add direct-only `HarnessService.RefreshMcpSources`, HTTP `POST /v1/sessions/{id}/mcp-refresh`, and `ServerCapabilities.mcp_refresh = 30` (free on the synchronized `origin/main` contract). The response `revision` identifies the successfully pinned active snapshot whose direct names were considered/granted for that request, not necessarily the latest revision when delivery completes. `changed` compares that request's observed active snapshot before and after its shared reconciliation cycle and the exact stable union; a coalesced successor may supersede the pin without rewriting the earlier response. `ListMcpSourcesResponse` adds current revision, stale, and reconciling fields over the reconciler's cached published/source status; it does not independently probe, and `stale` also represents degradation. Mecatui offers argument-free `/mcp-refresh`: direct capability routes only to `RefreshMcpSources`; broker-only `workspace_enrollment` routes only to `ConnectWorkspaceServices` with existing disclosure, consent, observation, and cancellation. Both capabilities or a missing matching collaborator fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` remains broker-only. +12. **Make direct-team construction rollback-capable.** Direct `RunTeam` materializes every member under the operation pin. Add an idempotent `Supervisor.Close` lifecycle endpoint and use it from `Run`; repeated or concurrent close cancels an active run, waits for settlement, and releases enrolled forks, inline managers, cancellation, registry, and liveness resources exactly once. A configured store that atomically creates member snapshots must support deletion before direct-team enrollment begins. A later construction failure closes the partial supervisor, deletes startup snapshots under a detached bounded context only while their mutation leases remain held, and releases those leases afterwards. The declaration stays retryable. The startup claim revalidates the exact registry entry under the registry lock so a successful concurrent cleanup cannot launch an orphan supervisor. + ## Consequences Stale removals take effect automatically at the next operation boundary without mutating durable authority. Existing sessions do not discover new names until an owner explicitly refreshes, but an already authorized name can disappear and later return without reauthorization. This is availability filtering, not permanent revocation. From e092b9c4cb89f509850a10549f029691c89e088b Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 23 Sep 2026 13:45:42 +0300 Subject: [PATCH 09/13] docs: repair MCP reconciliation ADR after restack Co-Authored-By: OpenAI Codex --- docs/acceptance/mcp-source-reconciliation.md | 16 ++++++++-------- ...tion.md => 0351-mcp-source-reconciliation.md} | 2 +- docs/adr/README.md | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) rename docs/adr/{0350-mcp-source-reconciliation.md => 0351-mcp-source-reconciliation.md} (99%) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index c1f993dad7..9ceef6cfcd 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -2,10 +2,10 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. -**Decision record:** [ADR 0350](../adr/0350-mcp-source-reconciliation.md) +**Decision record:** [ADR 0351](../adr/0351-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. -**Baseline synchronization:** merged `origin/main` at `6c75a42d9cad502685a42f5418f2bc99b9ae6376`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0350, after the rebased stack allocated 0345–0349. +**Baseline synchronization:** merged `origin/main` at `501e5a08b1b145259158124d65a049c6814887ea`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0351, after synchronized main allocated 0350. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -28,7 +28,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`6c75a42d9cad502685a42f5418f2bc99b9ae6376`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`501e5a08b1b145259158124d65a049c6814887ea`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. - **Exported Go APIs / interfaces:** Add `engine/session.(*Session).GrantToolAuthority([]string) error`, additive idempotent `engine/agent.(*Supervisor).Close()`, and `engine/agent.ErrSupervisorClosed`. `GrantToolAuthority` clones the input and validates every added name as non-empty, valid UTF-8, control-free, and at most 256 bytes, then stable-unions it into already-bound authority; duplicate input or existing names are idempotent. A rejected batch is nonmutating, and unrelated existing names are neither revalidated nor altered. It preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. `Supervisor.Close` cancels an active run, waits for settlement, and performs fork, inline-MCP closer, cancellation, registry/liveness, and other enrolled-member cleanup exactly once across repeated or concurrent calls; `Run` uses the same close path. Enrolment after close or run start returns `ErrSupervisorClosed`. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. - **Tool schemas:** No model-visible tool is added or changed. Current catalog membership intersects existing exact-name authority. An authority-granted but catalog-absent call returns the exact permanent error `tool is currently unavailable; do not retry unless the catalog changes`; an ungranted absent name retains existing unknown-tool behavior. - **CLI / config:** Add no flag, key, URL rule, selector, credential input, or poll tuning. ToolHive discovery alone enables bounded automatic polling. Mecatui adds argument-free `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Both/neither incompatible modes or missing collaborators fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` is unchanged. @@ -40,7 +40,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ### Scenario 1 — Ordered sources reconcile automatically and boundedly -The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0350](../adr/0350-mcp-source-reconciliation.md). +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0351](../adr/0351-mcp-source-reconciliation.md). **Acceptance:** - AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. @@ -50,11 +50,11 @@ The production Build path constructs one reconciler over the existing ordered so - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` - AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics. Finite Service/reconciler constants separately cap active-list cardinality, total candidate page count and bytes, per-refresh union grant count, and accumulated historical granted names before save; boundary behavior is tested without exporting another engine field or reviving a public/global 512-record bound. Automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. - named subcases: `active-list-bound`, `candidate-page-byte-bound`, `union-grant-bound`, `historical-accumulation-bound`, `no-public-global-512-bound`, `consent-never-launched`, `shutdown-joins` - - verify: `TestADR_0350_ReconciliationBoundsConsentAndShutdown` + - verify: `TestADR_0351_ReconciliationBoundsConsentAndShutdown` ### Scenario 2 — Complete runtimes publish atomically and drain safely -A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0350](../adr/0350-mcp-source-reconciliation.md)'s consistency boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0351](../adr/0351-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** - AC2.1: A complete candidate publishes additions and removals, including successful empty. Candidate equality compares bounded runtime metadata/config for tools, resources, prompts, and resolved source/server state—not a durable security/authority digest. An unchanged candidate leaves revision, published engine/factory contribution, caches, and retirement state byte-for-byte unchanged and is closed/discarded if one was built. Changed all-or-nothing state may reconnect the full desired server set at bounded cost. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks the same `stale` status, and delays otherwise valid changes until a later successful retry. @@ -68,14 +68,14 @@ A candidate builds a complete manager/provider/tool/source snapshot and generati ### Scenario 3 — Name authority filters availability and widens only explicitly -The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0350](../adr/0350-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0351](../adr/0351-mcp-source-reconciliation.md). **Acceptance:** - AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` - AC3.2: `Session.GrantToolAuthority` clones its input, rejects the whole batch without mutation unless every added name is non-empty, valid UTF-8, control-free, and at most 256 bytes, and stable-unions accepted names in input order. Duplicate input and already-present names are idempotent. It does not revalidate, reorder, or alter unrelated existing names or authority axes and preserves conversation/history, placement, owner, counters, and the exact lifecycle state; pending controls and states other than idle/completed are rejected without mutation. - named subcases: `invalid-batch-atomic`, `input-cloned`, `duplicate-stable-union`, `unrelated-legacy-name-untouched`, `name-256-byte-boundary`, `state-preservation` - - verify: `TestADR_0350_GrantToolAuthorityPreservesAggregateState` + - verify: `TestADR_0351_GrantToolAuthorityPreservesAggregateState` - AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` diff --git a/docs/adr/0350-mcp-source-reconciliation.md b/docs/adr/0351-mcp-source-reconciliation.md similarity index 99% rename from docs/adr/0350-mcp-source-reconciliation.md rename to docs/adr/0351-mcp-source-reconciliation.md index b338ef3f3d..931d81ada4 100644 --- a/docs/adr/0350-mcp-source-reconciliation.md +++ b/docs/adr/0351-mcp-source-reconciliation.md @@ -1,4 +1,4 @@ -# ADR 0350 — Reconcile stale direct MCP source snapshots +# ADR 0351 — Reconcile stale direct MCP source snapshots - Status: Proposed - Date: 2026-09-16 diff --git a/docs/adr/README.md b/docs/adr/README.md index d70f79a8da..b80a3ac295 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -197,8 +197,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0350; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* -- [0350 — Reconcile stale direct MCP source snapshots](./0350-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0351; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0351 — Reconcile stale direct MCP source snapshots](./0351-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 6ad42a98127e6a5bf18246dde8f83db909fb4b1a Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Wed, 23 Sep 2026 14:02:27 +0300 Subject: [PATCH 10/13] docs: refresh MCP reconciliation baseline Co-Authored-By: OpenAI Codex --- docs/acceptance/mcp-source-reconciliation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 9ceef6cfcd..cb4e51fb6c 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -5,7 +5,7 @@ **Decision record:** [ADR 0351](../adr/0351-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. -**Baseline synchronization:** merged `origin/main` at `501e5a08b1b145259158124d65a049c6814887ea`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0351, after synchronized main allocated 0350. +**Baseline synchronization:** merged `origin/main` at `ca993a6ea1b648e8e18a102b6810da5dea8c494f`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0351, after synchronized main allocated 0350. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -28,7 +28,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ## Interface contract -- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`501e5a08b1b145259158124d65a049c6814887ea`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. +- **gRPC / protobuf:** Add unary `HarnessService.RefreshMcpSources(RefreshMcpSourcesRequest) returns (RefreshMcpSourcesResponse)`. Request: `string session_id = 1`. Response: `uint64 revision = 1`, `bool changed = 2`. `revision` identifies the successfully pinned active runtime snapshot whose direct names were considered for the request (and, when needed, granted); it need not still be the latest revision when delivery completes. `changed` compares the request's observed active snapshot before and after its shared reconciliation cycle and reports whether that cycle published a different runtime or the precise authority union added names; a coalesced successor that supersedes the pinned snapshot does not rewrite this request's response. Add direct-only `ServerCapabilities.mcp_refresh = 30`; field 30 is unallocated on synchronized `origin/main` (`ca993a6ea1b648e8e18a102b6810da5dea8c494f`). Extend `ListMcpSourcesResponse` with `uint64 revision = 2`, `bool stale = 3`, and `bool reconciling = 4`; source rows remain the current published/pre-shadow inventory and the call performs no independent probe. `stale` also covers candidate/source degradation; no separate absent `degraded` field is implied. Add bodyless HTTP `POST /v1/sessions/{id}/mcp-refresh`. Existing broker RPC/HTTP/capability surfaces are unchanged. - **Exported Go APIs / interfaces:** Add `engine/session.(*Session).GrantToolAuthority([]string) error`, additive idempotent `engine/agent.(*Supervisor).Close()`, and `engine/agent.ErrSupervisorClosed`. `GrantToolAuthority` clones the input and validates every added name as non-empty, valid UTF-8, control-free, and at most 256 bytes, then stable-unions it into already-bound authority; duplicate input or existing names are idempotent. A rejected batch is nonmutating, and unrelated existing names are neither revalidated nor altered. It preserves unrelated authority axes and all other aggregate state; accepts only idle or completed state with no pending control; and never reopens completed state. `Supervisor.Close` cancels an active run, waits for settlement, and performs fork, inline-MCP closer, cancellation, registry/liveness, and other enrolled-member cleanup exactly once across repeated or concurrent calls; `Run` uses the same close path. Enrolment after close or run start returns `ErrSupervisorClosed`. Ordinary-root/provenance/owner checks stay in Service/composition. No new authority field, digest helper, agent run option, source port, or ToolHive import enters `engine/`. - **Tool schemas:** No model-visible tool is added or changed. Current catalog membership intersects existing exact-name authority. An authority-granted but catalog-absent call returns the exact permanent error `tool is currently unavailable; do not retry unless the catalog changes`; an ungranted absent name retains existing unknown-tool behavior. - **CLI / config:** Add no flag, key, URL rule, selector, credential input, or poll tuning. ToolHive discovery alone enables bounded automatic polling. Mecatui adds argument-free `/mcp-refresh`: direct-only capability invokes only `RefreshMcpSources`; broker-only capability invokes only `ConnectWorkspaceServices` with existing consent/cancellation/disclosure. Both/neither incompatible modes or missing collaborators fail closed. `/tools-connect` remains a deprecated broker-only alias and `/tools-cancel` is unchanged. From 4ff51f3274b25a60a7963bbff994fb93ba2129b9 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 23 Sep 2026 17:09:04 +0200 Subject: [PATCH 11/13] docs(adr): record shared-engine promotion in ADR 0351 The implementation keeps the Service-level shared engine at its build revision and promotes default-FS sessions to per-session engines after the first republication. Record that exception in decision 6 so the approved contract matches #1673; rebuilding the shared engine per revision is tracked in #1802. Co-Authored-By: mecatl --- docs/adr/0351-mcp-source-reconciliation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0351-mcp-source-reconciliation.md b/docs/adr/0351-mcp-source-reconciliation.md index 931d81ada4..013f70c600 100644 --- a/docs/adr/0351-mcp-source-reconciliation.md +++ b/docs/adr/0351-mcp-source-reconciliation.md @@ -26,7 +26,7 @@ Protected broker MCP is separate. ADR 0335 owns `ConnectWorkspaceServices`, `wor 5. **Publish atomically and retire safely.** The reconciler atomically replaces one immutable current direct runtime. It never mutates an active catalog or manager. A bounded set of displaced runtimes may retire. Root runs and direct `RunTeam` operations pin the exact runtime revision they start with; delegation and generation-bound factories inherit that pin. Out-of-run resource/prompt operations take an operation pin. A retired runtime closes only after its pins drain. If publishing would exceed the retirement bound because an old runtime is still pinned, publication is deferred, the unpublishable candidate is closed/discarded, status is stale, and the current runtime remains active; no runtime is force-closed. Exactly one coalesced successor is retained and retried after drain, without unbounded polling or candidate allocation. -6. **Use revision tags, not engine-lifetime leases.** The shared engine and every cached normal/selector, no-FS, client-MCP, mode-specific, specialist, and debug engine are tagged with the direct runtime revision used to build them. Publication swaps the shared generation-bound factory contribution. At every actual operation boundary, a cached engine whose revision differs from the pinned runtime is evicted and rebuilt before use. Cached engines do not keep runtimes alive merely by existing. Failed-step retry, restored approval, prompt runs, direct `RunTeam`, delegation/reference MCP, and prompt expansion all resolve under the same pinned runtime; an operation never mixes schema projection, lookup, permission, dispatch, resources/prompts, or manager identity across revisions. +6. **Use revision tags, not engine-lifetime leases.** The shared engine and every cached normal/selector, no-FS, client-MCP, mode-specific, specialist, and debug engine are tagged with the direct runtime revision used to build them. Publication swaps the shared generation-bound factory contribution. At every actual operation boundary, a cached engine whose revision differs from the pinned runtime is evicted and rebuilt before use. The Service-level shared engine is the one exception: it is not rebuilt. Once the current revision differs from the shared engine's build revision, a default-FS session is promoted to a per-session engine built under its pinned runtime and keeps that engine until the session closes; the shared engine then serves only operations that still pin its build revision. This is correct but costs one engine per open session that has run since the first republication, and each later republication rebuilds those engines at their sessions' next runs. Rebuilding the shared engine once per revision instead is tracked in [#1802](https://github.com/stacklok/mecatl/issues/1802). Cached engines do not keep runtimes alive merely by existing. Failed-step retry, restored approval, prompt runs, direct `RunTeam`, delegation/reference MCP, and prompt expansion all resolve under the same pinned runtime; an operation never mixes schema projection, lookup, permission, dispatch, resources/prompts, or manager identity across revisions. 7. **Availability filters durable name authority.** Current catalogs contain only tools from the published runtime. A granted direct name that is absent is therefore unadvertised and cannot execute, while its durable grant may remain unchanged. If the model emits a stale call whose exact name is granted but absent from the current catalog, the engine returns a permanent “currently unavailable; do not retry unless the catalog changes” tool error. An ungranted absent name retains ordinary unknown-tool behavior. When the exact name reappears, it is advertised and executable again under existing permission checks. Same-name source, endpoint, schema, and read-only drift follows this same current name-grant policy; no URL normalization, digest, or new endpoint credential policy is introduced. From 48044407feeb0a1d1f09f57b8774641b1602c01e Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 23 Sep 2026 17:26:17 +0200 Subject: [PATCH 12/13] docs: renumber MCP reconciliation ADR to 0353 Main allocated ADR 0351 (Mecatl Studio) and 0352 (jev delegated model router) after the plan's last baseline sync, so the reconciliation ADR, its index entry, and the two ADR-named verification functions move to the next free number. Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 14 +++++++------- ...iation.md => 0353-mcp-source-reconciliation.md} | 2 +- docs/adr/README.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) rename docs/adr/{0351-mcp-source-reconciliation.md => 0353-mcp-source-reconciliation.md} (99%) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index cb4e51fb6c..42fe6551e7 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -2,10 +2,10 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. -**Decision record:** [ADR 0351](../adr/0351-mcp-source-reconciliation.md) +**Decision record:** [ADR 0353](../adr/0353-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. -**Baseline synchronization:** merged `origin/main` at `ca993a6ea1b648e8e18a102b6810da5dea8c494f`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0351, after synchronized main allocated 0350. +**Baseline synchronization:** merged `origin/main` at `33a3747d9008691d4d51a872c9e82c050c43fafa`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0353, after synchronized main allocated 0351 and 0352. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -40,7 +40,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ### Scenario 1 — Ordered sources reconcile automatically and boundedly -The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0351](../adr/0351-mcp-source-reconciliation.md). +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0353](../adr/0353-mcp-source-reconciliation.md). **Acceptance:** - AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. @@ -50,11 +50,11 @@ The production Build path constructs one reconciler over the existing ordered so - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` - AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics. Finite Service/reconciler constants separately cap active-list cardinality, total candidate page count and bytes, per-refresh union grant count, and accumulated historical granted names before save; boundary behavior is tested without exporting another engine field or reviving a public/global 512-record bound. Automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. - named subcases: `active-list-bound`, `candidate-page-byte-bound`, `union-grant-bound`, `historical-accumulation-bound`, `no-public-global-512-bound`, `consent-never-launched`, `shutdown-joins` - - verify: `TestADR_0351_ReconciliationBoundsConsentAndShutdown` + - verify: `TestADR_0353_ReconciliationBoundsConsentAndShutdown` ### Scenario 2 — Complete runtimes publish atomically and drain safely -A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0351](../adr/0351-mcp-source-reconciliation.md)'s consistency boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0353](../adr/0353-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** - AC2.1: A complete candidate publishes additions and removals, including successful empty. Candidate equality compares bounded runtime metadata/config for tools, resources, prompts, and resolved source/server state—not a durable security/authority digest. An unchanged candidate leaves revision, published engine/factory contribution, caches, and retirement state byte-for-byte unchanged and is closed/discarded if one was built. Changed all-or-nothing state may reconnect the full desired server set at bounded cost. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks the same `stale` status, and delays otherwise valid changes until a later successful retry. @@ -68,14 +68,14 @@ A candidate builds a complete manager/provider/tool/source snapshot and generati ### Scenario 3 — Name authority filters availability and widens only explicitly -The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0351](../adr/0351-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0353](../adr/0353-mcp-source-reconciliation.md). **Acceptance:** - AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` - AC3.2: `Session.GrantToolAuthority` clones its input, rejects the whole batch without mutation unless every added name is non-empty, valid UTF-8, control-free, and at most 256 bytes, and stable-unions accepted names in input order. Duplicate input and already-present names are idempotent. It does not revalidate, reorder, or alter unrelated existing names or authority axes and preserves conversation/history, placement, owner, counters, and the exact lifecycle state; pending controls and states other than idle/completed are rejected without mutation. - named subcases: `invalid-batch-atomic`, `input-cloned`, `duplicate-stable-union`, `unrelated-legacy-name-untouched`, `name-256-byte-boundary`, `state-preservation` - - verify: `TestADR_0351_GrantToolAuthorityPreservesAggregateState` + - verify: `TestADR_0353_GrantToolAuthorityPreservesAggregateState` - AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` diff --git a/docs/adr/0351-mcp-source-reconciliation.md b/docs/adr/0353-mcp-source-reconciliation.md similarity index 99% rename from docs/adr/0351-mcp-source-reconciliation.md rename to docs/adr/0353-mcp-source-reconciliation.md index 013f70c600..31e38fd2ad 100644 --- a/docs/adr/0351-mcp-source-reconciliation.md +++ b/docs/adr/0353-mcp-source-reconciliation.md @@ -1,4 +1,4 @@ -# ADR 0351 — Reconcile stale direct MCP source snapshots +# ADR 0353 — Reconcile stale direct MCP source snapshots - Status: Proposed - Date: 2026-09-16 diff --git a/docs/adr/README.md b/docs/adr/README.md index 777a990da3..6fb9239327 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -199,8 +199,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0351; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* -- [0351 — Reconcile stale direct MCP source snapshots](./0351-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0353; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0353 — Reconcile stale direct MCP source snapshots](./0353-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md) From 0dfc064239e6380eed89ef2c5fad6cf85a0a35f5 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 23 Sep 2026 20:49:26 +0200 Subject: [PATCH 13/13] docs: renumber MCP reconciliation ADR to 0355 Main allocated ADR 0353 (session-scoped agent identity) and 0354 (returned auxiliary usage results) after the previous renumber, so the reconciliation ADR, its index entry, and the two ADR-named verification functions move to the next free number. Co-Authored-By: mecatl --- docs/acceptance/mcp-source-reconciliation.md | 14 +++++++------- ...iation.md => 0355-mcp-source-reconciliation.md} | 2 +- docs/adr/README.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) rename docs/adr/{0353-mcp-source-reconciliation.md => 0355-mcp-source-reconciliation.md} (99%) diff --git a/docs/acceptance/mcp-source-reconciliation.md b/docs/acceptance/mcp-source-reconciliation.md index 42fe6551e7..ec7aaad045 100644 --- a/docs/acceptance/mcp-source-reconciliation.md +++ b/docs/acceptance/mcp-source-reconciliation.md @@ -2,10 +2,10 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — changes process-wide MCP publication, runtime ownership, a public refresh control, and one importable aggregate API. -**Decision record:** [ADR 0353](../adr/0353-mcp-source-reconciliation.md) +**Decision record:** [ADR 0355](../adr/0355-mcp-source-reconciliation.md) **Phase:** Minimal stale direct/global MCP source reconciliation **Status:** proposed, 2026-09-16. Directing-human decisions are settled; ready for amended Plan / Interface review. -**Baseline synchronization:** merged `origin/main` at `33a3747d9008691d4d51a872c9e82c050c43fafa`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0353, after synchronized main allocated 0351 and 0352. +**Baseline synchronization:** merged `origin/main` at `36a618a621b5ec53b593c72930bc229e60c2adf7`; the proposed technical contract is unchanged, while its Proposed ADR and two ADR-named verification functions now use the next free number, 0355, after synchronized main allocated 0351 through 0354. **Delivery:** Split. Runtime publication and additive public controls require contract review. The directing human explicitly authorizes a stacked implementation PR before this Plan PR merges, based on the exact amended plan commit and targeting `plan/mcp-source-reconciliation`; this does not approve or merge either PR, and contract-drift gates remain. **Expected tasks:** deferred to orchestration after parent advisory review. **Issue:** [#1511](https://github.com/stacklok/mecatl/issues/1511) — tracks the work; this plan does not close it. @@ -40,7 +40,7 @@ This amendment replaces the prior digest/binding/revocation design with the orig ### Scenario 1 — Ordered sources reconcile automatically and boundedly -The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0353](../adr/0353-mcp-source-reconciliation.md). +The production Build path constructs one reconciler over the existing ordered source resolver. ToolHive polling, current-runtime list notifications, and manual requests enter the same bounded coalescing path defined by [ADR 0355](../adr/0355-mcp-source-reconciliation.md). **Acceptance:** - AC1.1: Configured/static entries retain precedence over ToolHive collisions and pre-shadow source rows remain visible. Source failure retains LKG; successful empty withdraws that source. The production source-resolution path, not a synthetic merge helper alone, proves all cases. @@ -50,11 +50,11 @@ The production Build path constructs one reconciler over the existing ordered so - verify: `TestMCPSourceReconciliation_Scenario1_ProductionTriggerMatrix` - AC1.3: Source, server, tool/list, cycle, and retained-runtime bounds fail stale with bounded secret-safe diagnostics. Finite Service/reconciler constants separately cap active-list cardinality, total candidate page count and bytes, per-refresh union grant count, and accumulated historical granted names before save; boundary behavior is tested without exporting another engine field or reviving a public/global 512-record bound. Automatic cycles never invoke OAuth/browser consent and shutdown joins polling/reconciliation. - named subcases: `active-list-bound`, `candidate-page-byte-bound`, `union-grant-bound`, `historical-accumulation-bound`, `no-public-global-512-bound`, `consent-never-launched`, `shutdown-joins` - - verify: `TestADR_0353_ReconciliationBoundsConsentAndShutdown` + - verify: `TestADR_0355_ReconciliationBoundsConsentAndShutdown` ### Scenario 2 — Complete runtimes publish atomically and drain safely -A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0353](../adr/0353-mcp-source-reconciliation.md)'s consistency boundary. +A candidate builds a complete manager/provider/tool/source snapshot and generation-bound `assembleCatalog` contribution. Publication atomically swaps current runtime only after candidate completion, preserving [ADR 0355](../adr/0355-mcp-source-reconciliation.md)'s consistency boundary. **Acceptance:** - AC2.1: A complete candidate publishes additions and removals, including successful empty. Candidate equality compares bounded runtime metadata/config for tools, resources, prompts, and resolved source/server state—not a durable security/authority digest. An unchanged candidate leaves revision, published engine/factory contribution, caches, and retirement state byte-for-byte unchanged and is closed/discarded if one was built. Changed all-or-nothing state may reconnect the full desired server set at bounded cost. Any connect/initialize/list/validation failure closes the entire candidate, retains the previous usable runtime, marks the same `stale` status, and delays otherwise valid changes until a later successful retry. @@ -68,14 +68,14 @@ A candidate builds a complete manager/provider/tool/source snapshot and generati ### Scenario 3 — Name authority filters availability and widens only explicitly -The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0353](../adr/0353-mcp-source-reconciliation.md). +The current runtime contributes only active direct names. Existing authority remains name-based and the aggregate supplies one narrow union operation under [ADR 0355](../adr/0355-mcp-source-reconciliation.md). **Acceptance:** - AC3.1: A removed granted name is absent from model specs and dispatch, and a stale generated call gets exactly `tool is currently unavailable; do not retry unless the catalog changes`. An ungranted absent name stays an unknown-tool error. Exact-name reappearance automatically becomes available under the existing grant; same-name endpoint/schema/read-only drift requires no regrant. - verify: `TestMCPSourceReconciliation_Scenario3_NameAuthorityAvailabilityMatrix` - AC3.2: `Session.GrantToolAuthority` clones its input, rejects the whole batch without mutation unless every added name is non-empty, valid UTF-8, control-free, and at most 256 bytes, and stable-unions accepted names in input order. Duplicate input and already-present names are idempotent. It does not revalidate, reorder, or alter unrelated existing names or authority axes and preserves conversation/history, placement, owner, counters, and the exact lifecycle state; pending controls and states other than idle/completed are rejected without mutation. - named subcases: `invalid-batch-atomic`, `input-cloned`, `duplicate-stable-union`, `unrelated-legacy-name-untouched`, `name-256-byte-boundary`, `state-preservation` - - verify: `TestADR_0353_GrantToolAuthorityPreservesAggregateState` + - verify: `TestADR_0355_GrantToolAuthorityPreservesAggregateState` - AC3.3: Delegated authority remains the existing name intersection. Children started before publication inherit the pinned old runtime; children started after publication use the new active set. Resume permits an already granted same name when currently available and returns the permanent unavailable result when absent, without digest or migration state. - verify: `TestMCPSourceReconciliation_Scenario3_DelegationAndResumeNameSemantics` diff --git a/docs/adr/0353-mcp-source-reconciliation.md b/docs/adr/0355-mcp-source-reconciliation.md similarity index 99% rename from docs/adr/0353-mcp-source-reconciliation.md rename to docs/adr/0355-mcp-source-reconciliation.md index 31e38fd2ad..bd575d96fb 100644 --- a/docs/adr/0353-mcp-source-reconciliation.md +++ b/docs/adr/0355-mcp-source-reconciliation.md @@ -1,4 +1,4 @@ -# ADR 0353 — Reconcile stale direct MCP source snapshots +# ADR 0355 — Reconcile stale direct MCP source snapshots - Status: Proposed - Date: 2026-09-16 diff --git a/docs/adr/README.md b/docs/adr/README.md index 43c048f917..352e9efee5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -202,8 +202,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE ### MCP - [0056 — MCP client reconnect](./0056-mcp-client-reconnect.md) -- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0353; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* -- [0353 — Reconcile stale direct MCP source snapshots](./0353-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* +- [0057 — MCP server notifications](./0057-mcp-server-notifications.md) *(deferred “no live catalog mutation” decision proposed to be superseded by 0355; notification transport, bounded lazy-list, reconnect, and teardown decisions retained)* +- [0355 — Reconcile stale direct MCP source snapshots](./0355-mcp-source-reconciliation.md) *(proposed; preserves exact-name authority and supersedes 0057 only for its deferred “no live catalog mutation” decision)* - [0063 — MCP structured results: fail-closed + CallMcpWithQuery](./0063-mcp-structured-failclosed-callmcpwithquery.md) - [0078 — MCP typed tool results](./0078-mcp-typed-tool-results.md) - [0218 — Internal encrypted credential-store substrate](./0218-credential-store.md)