GoogleCloudPlatform / GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK
RFC: MCP Apps (SEP-1865) host support in ADK Python — stable-subset host seam: dual-era advertisement, catalog policy, render bootstrap, governed app invocations
- Dominant language
- Python
- Stars
- 47
- Forks
- 21
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 33
Description
> **Revision 4.19 (2026-08-13) — upstream-draft revision, resolving the four P0 blockers and four P1 corrections from the [Revision 4.18 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5277329397).** Changes: every operation gets a **progress-delivery gate** (terminal response atomically closes intake and **drains admitted progress callbacks before result approval, ledger freeze, Event append, and Runner-loop release**); batch discovery becomes a **class-neutral deterministic two-class algorithm** (app-safe listing first; explicit same-name disagreement rule; UI/non-UI disagreement fails closed — never silent reuse of a stale class); the refresh record becomes the exact **`RefreshInvocationRecord`** — a purpose-separated record inside the same opaque sidecar envelope reached by `sealedResultRef`, with immutable-create keys, envelope-before-append write ordering, crash recovery, GC, and incarnation-cascade deletion; and **export gains its matrix row, a through-release permit (streaming prohibited in v1), and a named audit-store contract** in the host profile (a sixth deployment contract), with the durable row honestly described as an authorization/release-attempt record, not delivery proof. P1 corrections: the superseded §1 cleanup paragraph is replaced by the lease-token model, the byte-identity claim is scoped to non-UI paths, ratification 7(k)'s labels now match the adopted per-Session default, and the fixture version, PR contents, and provider count are synchronized. **Per the 4.18 review's presentation guidance, this issue is the durable design ledger — the upstream google/adk-python submission will be a ~15K-character decision-oriented distillation in the #6357 shape, not a copy of this body.**
>
> *(Prior revision note, retained for context:)* **Revision 4.18** — resolving the seven P0 blockers and four P1 tightenings from the [Revision 4.17 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5277166316). Changes: the mixed-batch rejection Event is **core-owned and skips the ordinary event callback** (resolving 4.17's runs-and-forbids contradiction); **user callbacks never execute on the registry thread** — model-path progress (and filters, header providers, trace, cancellation) marshal back to the owning operation/Runner loop, with transport I/O alone on the registry loop; Runner cleanup releases a **Runner-scoped registry lease token** (an argument-free shared `proxy.close()` cannot identify its caller); **legacy/stateful generation keys include the Session incarnation by default** (cross-Session sharing only by explicit profile opt-in with a declared server-handle contract); cold refresh gains a **host-private durable refresh-invocation record** (native tool, canonical argument bytes/locator, schema/catalog generation, classification revision — never public); batch admission becomes **two-phase** (an immutable batch-wide classification snapshot before any task, catching stale UI/non-UI metadata); and **export joins the governed-operation matrices** with an incarnation-bound permit, a final pre-release recheck, and **durable-before-release audit** (fail closed on audit failure). P1s: the `run()` wording is reconciled (pre-spawn rejection applies to same-session *reentry* only), production binding **rejects** fence-less backends (reduced durability is dev-only with its own acceptance matrix), the modern logical-binding claim is 7(f)-qualified everywhere including the success sentence, and id-order precedence is scoped to state deltas only. This remains a **draft for google/adk-python maintainers**; ratification items are in open question 7.
>
> *(Prior revision note, retained for context:)* **Revision 4.17** — resolving the six P0 blockers and three P1 tightenings from the [Revision 4.16 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5276961136). Changes: the **registry owns a dedicated long-lived event loop for app-safe generations** (every listing/call/read/bootstrap/close marshals onto it, so a synchronous `run()`'s short-lived worker loop can no longer destroy the connection its View needs); app-safe generations are keyed by a **host-derived opaque security-domain key** (no legacy/stateful generation is ever shared across resolved principals); Session creation gains a **true write fence** (`create_if_fence_current` — a co-located fence row updated in the same transaction as the Session row, so a retired creator's late commit *fails*, not merely gets detected); mixed-batch rejection gains a **complete model-visible response contract** (one persisted Event with a terminal error FunctionResponse per original call id); cold **refresh is separated from rerun** (one-click refresh only for profile-classified replay-safe tools; otherwise an explicit "Run tool again" flow with confirmation and separate attribution); and cold non-View access moves to a **dedicated purpose-bound export resolver** (the View-bound resolver is unchanged). Stale 4.15 batch language is purged; registry-vs-Runner cleanup ownership is defined (non-owning proxies, reference release on `Runner.close()`); and the modern logical-binding reading of the same-connection rule is explicitly ratification-gated. This remains a **draft for google/adk-python maintainers**; ratification items are in open question 7. Earlier context from the Revision 4.16 note remains accurate below.
>
> *(Prior revision note, retained for context:)* **Revision 4.16** — resolving the six P0 blockers and four P1 tightenings from the [Revision 4.15 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5276791815). Changes: connection affinity gains an **era-and-lifecycle matrix** (legacy generations are concrete client/session/process bindings; **modern stateless HTTP generations are host-side logical bindings — the sessionless 2026-07-28 core makes backend-instance affinity impossible by design**; cold replay after restart/eviction is defined as **explicit refresh/re-execution, not replay**, with sealed records remaining resolvable for authorized non-View consumption); the model's tool declaration is **pinned to the app-safe execution generation via a connection-bound tool handle** with pre-call re-listing; Session creation gains a **durable creation fence/receipt** persisted in the same transaction as the Session row (plus a distinct adoption receipt for migration); **mixed UI/non-UI parallel batches are rejected before fan-out** (replacing the backend-dependent non-UI narrowing — ledgering-all and batch-receipt designs are ratification 7(h)); interactive app-call results of UI-associated tools get an **Event-level sealed-result map** (`sealedResultRefs`, keyed by function-call id) alongside the per-widget reference; and readiness rebind becomes **monotonic** — the old proxy drives `REVOKING → CLOSED` and the registry atomically installs a newly constructed proxy under a new binding generation. This RFC remains a **draft for google/adk-python maintainers**; ratification items are in open question 7. The body is fully self-contained.
## Summary
ADK Python agents can *detect* an MCP App tool — `McpTool` parses `_meta.ui.resourceUri` / `_meta.ui.visibility` and emits a `UiWidget` event action correlated to its FunctionResponse — but cannot *host* one under the stable MCP Apps contract ([SEP-1865, `2026-01-26`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). ADK advertises the `io.modelcontextprotocol/ui` extension on neither protocol era, enforces no visibility or catalog policy, cannot complete a modern-core multi-round request, persists a widget payload that is not a self-contained render reference, feeds View-only result fields into model context, has no governed, coordinated, or audience-isolated path for app-originated calls or reads, and its stock API server has no trusted identity or authorization source.
This RFC proposes a narrowly scoped Python host seam — the **stable Apps subset required by BQAA** — sufficient for the `agent_events` dashboard (#396) to render and stay interactive through ADK. Four pillars: **registry-owned capability binding** with accurate dual-era advertisement, a defined host profile, readiness-gated binding with revocable readiness, and a complete proxy lifecycle; **unconditional visibility and catalog composition policy** with connection-generation admission; a **minimal persisted descriptor** carrying an exact sealed-result reference schema, resolved through credential-aware connection-affine binding, a profile-conditional confidential sidecar with an honest recovery contract, and a single logical render read; and **governed app-originated tool calls and resource reads** under a loop-neutral coordinator — MRTR-aware, admission-gated at request, response, and post-queue execution, audience-isolated with batch-scoped effect authority, mutation-safe across every callback surface, reentry-safe, incarnation-safe from creation through deletion, and honest about effects, durability, recovery, and telemetry delivery.
**Behavioral changes for existing deployments are exactly three, all intentional:** unconditional visibility enforcement; the unconditional UI-result **projection and batch authority** (model-facing projection everywhere; under a bound profile, **mixed UI/non-UI parallel batches are rejected before fan-out with a typed error prompting sequential calls**; all-non-UI batches and non-UI standalone calls are byte-identical, while profile-bound UI calls intentionally gain descriptors, references, projection, and sealing — pipeline-defined, not byte-identical); and additive provenance stamping. Everything else — including the `SealedResultStore` and `SessionLifecycleStore` — is inert without a bound profile.
**Target reviewed:** `google/adk-python` `main` pinned at `a3088489`, verified through **`18903cad`** (2026-08-13; no pin drift at review time). Apps wire types verified against the released SDK (`@modelcontextprotocol/ext-apps` v1.7.5, spec commit `92f46a57`); MCP core `2026-07-28@b25c0874`; MCP Python SDK v2.0.0. Interacting ADK commits cited inline (`beb66ee1` credential redaction, `9cd59753` tools/list caching, `04b8b727` BQAA delivery/termination observability, `a61d8ecf` config-stdio rejection, reserved-MCP-tool-name rejection). Downstream consumer: #396 (three-tool fixture + interactive-read amendment v6), supplemented by a connection-stateful conformance fixture (below).
## Protocol baseline
Stable MCP Apps `2026-01-26` **combined with** MCP core `2026-07-28` through MCP Python SDK v2. The core revision [retired `initialize`/`initialized` and `Mcp-Session-Id`](https://blog.modelcontextprotocol.io/posts/2026-07-28/); modern requests carry capabilities in per-request `_meta`; legacy connections still use `initialize`. [SDK **v2.0.0** (stable GA)](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.0.0) abstracts both eras. Advertisement **must carry the settings map — `mimeTypes` is REQUIRED**:
```python
advertise(
"io.modelcontextprotocol/ui",
{"mimeTypes": ["text/html;profile=mcp-app"]},
)
```
The complete settings map participates in the profile fingerprint and both-era negotiation tests. ADK pins `mcp>=1.24,<2`, so **SDK v2.0.0 migration is the stated prerequisite** (delivery step 0), with three step-0 traps: **alias serialization** (ADK dumps without `by_alias=True`; its telemetry detector keys on `isError` — dump and detector change together); **MRTR** (modern core permits `InputRequiredResult` on any `tools/call` or `resources/read`; ADK's low-level call site rejects intermediates without `allow_input_required=True`); and **construction-time capability derivation** (legacy `initialize` capabilities are derived from the client's constructor callback table — sampling/elicitation/roots are advertised iff their callbacks are configured, and an initialized connection cannot retract its advertisement in place).
**The stable visibility contract is connection-scoped:** an `"app"` tool is "callable by the app **from the same server connection only**", and cross-server calls are always blocked for app-only tools. Any pooling design must preserve exact connection affinity between the View and its app calls (§6).
Servers **SHOULD** check client capabilities. Stated precisely: *ADK is not interoperable with capability-gated servers and cannot receive capability-adapted behavior.*
## What ADK already has (verified at the pins)
- `McpTool.mcp_app_resource_uri` / `McpTool.visibility` — `_meta.ui` parsing, nothing consuming visibility. **Result correlation exists** (widget + FunctionResponse share one persisted event). ADK now **rejects framework-reserved MCP tool names** (`adk_request_credential`, `adk_request_confirmation`, `adk_request_input`, `transfer_to_agent`) at registration and skips them in model toolset construction.
- **`McpTool` calls `session.call_tool` directly and dumps the entire result** into the persisted, model-visible FunctionResponse; it evaluates callable `require_confirmation` inside the tool with the live mutable `ToolContext` injected. Spec best practices: `content` for model; `structuredContent`/`_meta` not for model; `is_error` "so the LLM can see and self-correct"; `result_type` is a real discriminator — a terminal tool result must remain `complete`.
- **ADK executes parallel function calls concurrently** — one task and one `ToolContext` per call — waits for all, and **merges all FunctionResponses and UI widgets into one Event** before Runner invokes the event callback. **Each state write mutates the live value immediately as well as the per-context delta.**
- **Declared Event fields are publicly serialized** (Session API returns the complete `Session`; SSE/WebSocket `model_dump_json` whole events).
- **`after_tool_callback` is a documented result-replacement seam**; **`on_event_callback` is a documented event-modification seam** whose ABI passes a full `InvocationContext` exposing live session/service handles; the Runner merge accepts wholesale replacement of any non-identity field; the plugin chain early-exits and raises on first exception; some Runner appends bypass the plugin merge; `rewind_async` creates no `InvocationContext`; the database session service persists before delegating — **and commits Session creation in its own transaction**.
- **`ToolContext` is the full mutable `Context`**; `ReadonlyContext` is not deep-read-only; Session state is `dict[str, Any]`.
- **MCP-owned callback surfaces reach live context today** (progress factory → state-mutable `CallbackContext`; `header_provider` → shallow `ReadonlyContext`; callable `tool_filter` likewise); **SDK v2 owns three input-request callbacks (sampling, elicitation, `list_roots_callback`)** dispatched by both eras, plus `logging_callback` and a general message handler — **all fixed at `ClientSession` construction, with legacy capability advertisement derived from them**. **ADK creates one callback-configured session manager per toolset and pools sessions by effective headers**; **a stdio client launches a subprocess per managed session** — two audience managers would launch two server processes.
- **First-party Session HTTP APIs mutate directly** (delete → `session_service.delete_session`; patch and create-with-events append directly). **ADK permits client-supplied Session ids, allows an id again after deletion, and `Session` has no generation field.**
- **The released Apps capability surface**: `McpUiHostCapabilities` includes `serverTools`, `serverResources`, `message` and `updateModelContext` (modality granularity), plus `openLinks`, `downloadFile`, `logging`, `sandbox`, and View-initiated `ui/notifications/request-teardown`. **The released `ui/message` request schema is strict** (`additionalProperties: false`; `role` + `content` only).
- **View-originated `resources/read` is in the standard subset**; modern reads return a `contents` array whose items each carry their own URI. **ADK sessions are scoped by `(app_name, user_id, session_id)`.**
- **Raw-event consumers beyond the content builder:** both compaction modes, the summarizer, `Runner._find_agent_to_run`, memory ingestion. `Event` is mutable with `extra='ignore'`. `_find_active_task_scope` treats the newest unclosed non-empty `isolation_scope` as the active paused task. MCP request ids are correlation ids; independent Views commonly both start at id `1`. Public Runner session mutators: `run_async`; `run_debug`; `run()` (worker thread, own loop); `run_live`; `rewind_async`.
- **BQAA's `on_event_callback` records `STATE_DELTA` from persisted actions**; before-tool telemetry predates any Event; ADK has no audit-observer callback today. State and artifact effects apply immediately; rewind excludes `app:`/`user:` state and user artifacts. `EventActions` spans fourteen fields. `McpToolset.__init__` constructs its `MCPSessionManager`. `tool_name_prefix` exists. Runner cleanup walks `agent.tools`. `from_config` rejects config-supplied stdio servers by default. The stock API server documents itself as unauthenticated. Credential redaction must be preserved. adk-python#3190 was closed on the basis that `UiWidget` emission already provides support.
## Why the current surface is not enough
1. **No capability advertisement on either era, no MRTR handling, and no legacy-implementable per-audience callback policy** that also preserves the same-connection rule.
2. **No visibility or catalog enforcement** — and no connection-generation admission for app calls.
3. **No safe result contract** — View-only leakage; no survival of replacement, mutation, structural corruption, parallel batching, or restart; no declared reference schema for multiple sealed results.
4. **No governed app-originated operation path** — no readiness-gated (and readiness-revocable) construction seam; no coordination covering all mutators including first-party Session HTTP mutation; no crash-safe incarnation lifecycle from creation through deletion; no batch-scoped effect authority; no non-recursive fallback; no honest recovery/telemetry; no admission gate on replay; no response-side policy; no key/storage lifecycle.
5. **Resource reads and semantics break on compliant servers** — unlisted UI resources; replacement-state context; multi-round reads; multi-content responses.
6. **No trusted identity or authorization source** in the stock API server — and nothing stops or retracts Apps advertisement when readiness is absent or withdrawn.
Related: #3190 (closed), google/adk-python#3449 (tracking), `adk-web` renderer work, #396 (consumer).
## Proposed contract (names directional)
### 1. Registry-owned capability binding + the host profile + revocable readiness + complete proxy lifecycle
**`McpAppsHostProfile` — the named, immutable, load-bearing contract:**
| Field group | Contents |
|---|---|
| Extension settings | The complete advertisement map (`mimeTypes`, …) and the four `HostCapabilities` with exact modalities |
| Renderer registration | The attached conforming renderer identity |
| Identity & authorization | `PrincipalResolver` and `SessionAuthorizer` references — binding prerequisites |
| Storage & keys | `SealedResultStore` reference; **`SessionLifecycleStore` reference**; **`ExportAuditStore` reference**; binding-revision key-provider reference |
| Replay classification | The **replay-safe/read-only classification authority (source and revision)** consumed by the cold refresh-vs-rerun split and recorded in every `RefreshInvocationRecord` |
| Callback & connection policy | The §4 MCP-callback matrix selections and the **connection-affinity policy** (§6) |
| Resource policy | Policy entries (exact / prefix-inclusive) + per-scheme matchers, with a policy revision |
| Limits | Sealing size/depth, register caps, MRTR round/time caps, discovery bounds, delivery limits — with a limits revision |
| Fingerprint subset | Extension settings + modalities + callback/connection policy + policy revision + limits revision + **sealed-store namespace/revision + lifecycle-store namespace/revision** — baked into `binding_revision`, client construction, pool keys, discovery, and the tools/list cache key. **Ephemeral generations (binding/readiness generation, connection generation) are deliberately *not* in the persisted digest — they are checked independently at every barrier** so descriptors survive reconnects and provider replacement while stale Views, leases, and cache entries cannot (P1.2). |
**Readiness-gated, readiness-revocable binding — with a monotonic rebind (P0.6).** "Successfully bound" requires the complete host service — renderer, `PrincipalResolver`, `SessionAuthorizer`, `SealedResultStore`, `SessionLifecycleStore`, **`ExportAuditStore`**, key provider, and the replay-classification authority — at registry materialization; the store namespaces/revisions and the classification revision participate in the fingerprint and persistence-compatibility validation. Missing providers leave the toolset `MODEL_ONLY`; the Apps client extension is never advertised. The local-development adapter installs explicit loopback-only providers. **Withdrawal:** the proxy lifecycle's `REVOKING` transition is **one-way to `CLOSED`**. On withdrawal: (1) bump the **binding generation** and revoke Views, leases, queued permits, and pending result commits; (2) drain or cancel in-flight work; (3) **close every Apps-advertising client pool** — an initialized MCP connection cannot retract its advertisement in place, so the connections themselves must go; (4) drive the old proxy `REVOKING → CLOSED`. **Rebind is never a same-object transition:** the registry **atomically installs a newly constructed proxy under a new binding generation** — the at-most-one-construction invariant holds per proxy object, and construction across a rebind is generation-scoped at the registry. `MODEL_ONLY` remains terminal for its proxy. Model-only traffic continues uninterrupted through the replacement; old Views and cached declarations cannot reach the new proxy (generation checks at every barrier). Conformance: successful and failed rebind; rebind racing `get_tools`/call/close; model-only traffic during replacement; provider replacement while old clients drain; registry lookup before/after the swap; old-View/old-declaration isolation from the new proxy. Conformance: missing resolver/authorizer; renderer detached with an idle reused connection; loss during initial UI tool execution; loss after seal but before Event append; loss during bootstrap; reconnect after loss; provider replacement while old leases exist; loopback configuration; no Apps advertisement or `HostCapabilities` from an unready host — **including on live pooled connections after revocation**.
- **`McpToolsetSpec`** — lossless, connection-only (connection params, auth references, tool filter, name prefix, cache TTL, confirmation, resource, progress, sampling, elicitation, roots, dynamic-header configuration). Config-loaded specs apply the stdio rejection gate; code-constructed specs are trusted-code-only.
- **`HostBoundMcpToolset(BaseToolset)`** — lifecycle `UNMATERIALIZED → (MATERIALIZING) → PROFILE_BOUND | MODEL_ONLY → (REVOKING) → CLOSED`, latch-linearized, **at most one** construction; close-before-materialize → zero constructions; materialize-then-close → close waits, closes exactly once; close idempotent; post-`CLOSED` use typed-error; late binding after `MODEL_ONLY` rejected. Filter/prefix applied by the runtime toolset only. Race conformance asserts the losing operation, construction count, close count, post-close use.
- **`McpAppBindingRegistry`** — from configuration at startup: `stable toolset_id → spec → live toolset(s) → profile → server`. Colliding registrations rejected; missing binding → typed `McpAppBindingUnavailable`; restart rebuilds from configuration. Direct construction stays permanently model-only; second-profile bindings rejected; overrides only narrow; registry shutdown closes what it materialized. No path mutates a toolset after its client exists. **Apps-enabled routing is decided at App/Runner/host configuration** — never by inspecting a not-yet-created session. **Cleanup ownership:** the proxy handed to an Agent is **non-owning**, and release is governed solely by the **Runner-scoped registry lease tokens of §6** — `Runner.close()` releases its own token directly from its side table; the argument-free shared `proxy.close()` releases nothing and never infers caller identity; the registry closes a generation at last lease release or registry shutdown and owns token-idempotent retries of timed-out releases.
### 2. Unconditional visibility + catalog composition + URI canonicalization
Visibility normalized centrally: absent → `["model", "app"]`; **malformed → deny-all** (both catalogs, logged). Enforcement regardless of renderer capability:
- **model catalog** = developer tool selection ∩ `"model"` visibility — ADK-prefixed names; framework-reserved MCP tool names rejected/skipped;
- **app catalog** = app-call policy ∩ `"app"` visibility ∩ **originating server connection generation** — native names; default: the originating connection's app-visible tools, narrowable to deny-all; framework-reserved names excluded here too; **admission is connection-generation-scoped (P0.1)**: the stable configured server identity is retained for recovery, but an app call is admitted only against the exact live connection generation bound to its View;
- **resource-read policy** = deny by default except the descriptor's declared widget URI, unless the profile expands it (exact-match or segment-bounded prefix-inclusive).
**One URI canonicalization contract everywhere** — scheme-aware (lowercase scheme/host with IDNA, default-port removal, unreserved-only percent-decoding, dot-segment rejection, fragment stripped, order-sensitive query), used identically by policy matching, returned-item validation, operation identity, descriptor matching, and bootstrap comparison; pluggable per scheme. Adversarial conformance: dot-segment, encoded-slash, authority-swap, boundary-splice, case/port/IDNA; reserved-name cases in both catalogs, with and without prefixes.
**Authorization freshness with bounded discovery:** fully paginated fresh `tools/list` at admission — **on the View's bound connection** — bounded by pages/items/bytes/time with repeated-cursor detection; violations deny without consulting the partial catalog; listing failure denies; the TTL cache serves model catalogs only. Cross-server calls raise `McpAppVisibilityError`. The legacy opt-out exists only in Apps-disabled compatibility mode, documented non-conformant.
### 3. Descriptor, sidecar, lifecycle store, keys, recovery, and bootstrap
**Persisted descriptor** (stable references and one opaque locator):
```python
McpAppWidgetDescriptor(
schema_version=1,
resource_uri="ui://bqaa/dashboard.html",
correlation={"invocation_id": ..., "function_call_id": ...,
"agent_path": ..., "branch": ...},
server_id=..., toolset_id=..., # stable, configured, opaque
binding_revision=..., # versioned keyed digest + key id
sealed_result_ref=None, # optional {recordId, schemaVersion} — profile-bound only
)
```
**Exact sealed-result reference schema — two placements (P0.4 + interactive results).** For **render-producing** calls, `sealed_result_ref` (wire alias `sealedResultRef`) lives **on each profile-bound widget descriptor**, cardinality **0..1 per widget**; the widget's immutable function-call correlation supplies the deterministic reference→FunctionResponse map, including after restart. For **interactive, non-rendering** results — a View-originated call to a UI-associated tool whose nested widget emission is suppressed (a normal path: the stable spec expressly permits app-visible UI-associated follow-up tools) — the audit Event carries an **Event-level map**: `sealed_result_refs` (wire alias `sealedResultRefs`), keyed by immutable function-call id, one entry per sealed non-rendering result. A new descriptor + widget-level reference is emitted only when a nested View is independently negotiated. Rules for both placements: profile-less events omit them; two widgets sharing one resource URI each get their **own** record and reference; at append validation, a reference without a matching widget/FunctionResponse — or, under a bound profile with successful sealing, a UI result without its reference in the correct placement — fails the Event; duplicates rejected. **An opaque locator is not authority and not result bytes** — the grant and the content digest stay private; public serialization is safe by construction; the descriptor invariant restated: *no result bytes, no security metadata, no authority — an opaque locator is none of these*. Legacy descriptors carry no reference; the resolver returns a typed legacy error → read-only render; missing/corrupt references likewise. Conformance: one and several references in each placement; a suppressed-nested interactive call; the same call with an independently negotiated nested View; repeated calls from one View; reordered FunctionResponse parts; duplicate/missing ids; two widgets sharing one URI; legacy descriptor; profile-less Event; public Session/SSE serialization; restart mapping; deletion/teardown before resolution.
**`binding_revision` key lifecycle:** produced under the profile's key provider; envelope carries key id + digest version; the active key survives restart and is shared by sticky-compatible workers; rotation is a bounded verify-old/sign-new window (or explicit descriptor invalidation); key loss fails closed. Conformance: same-key restart, missing-key restart, rotation, retired-key, two-worker consistency.
**"Read-only render", defined:** a **host-owned error placeholder** — no server access, no sealed-result release, no View grant.
**The `SealedResultStore` sidecar (profile-conditional).** Key scope `(app_name, user_id, session_id, incarnation, event_id, function_call_id, record_id)`; public reference `{record_id, schema_version}` with `record_id` random and opaque (≥128 bits); content digest store-private, verified at resolve. **Threat boundary (declared):** trusted backend — confidentiality is application-surface isolation; at-rest encryption delegated; corruption detected by the store-private digest; hostile-store authenticated encryption out of scope. **Resolver rule:** the gated resolver loads the authoritative persisted Event and verifies reference, session scope, incarnation, function call, widget, and View binding before releasing bytes. **Operation-instance identity:** every admitted outer request mints a unique operation-instance id, distinct from the replay key; internal retries reuse it; identical no-key calls never collide. **State machine:** immutable-create keyed by operation instance; same-instance same-bytes retry succeeds; same-instance different-bytes → typed conflict; GC touches only expired, unreferenced pending records after checking the authoritative Event and active-operation registry; append timeout with late success → event + seal stand as durable history, cached outcome remains the timeout error, no re-execution within the retained boundary. **Honest crash recovery:** v1 keeps in-memory idempotency — after a crash the Event and seal remain mutually resolvable, but a client retry may re-execute (a store row is not an idempotency record; the durable alternative is ratification 7(d)). **Persistence compatibility:** binding validates durable immutable create, tenant-scoped lookup, digest verification, retention/deletion, and a stable namespace/revision (an empty swapped sidecar cannot silently issue valid-looking descriptors); restart guarantees are qualified to durable backend pairs. Conformance: tenant/incarnation scoping; reference swapping; crash after store-write; crash after append; retry before/after GC; same-instance/different-bytes conflict; append-timeout-late-commit; restart with/without replay key; deletion cascade; digest-equality on public serialization; namespace-mismatch rejection; missing/corrupt typed and read-only.
**The `SessionLifecycleStore` — crash-safe incarnations from creation through deletion (P0.2).** A deployment-supplied durable store (a binding/readiness prerequisite; it may share a backend with the sealed-result store under a **distinct stable namespace/revision**), keyed by `(app_name, user_id, session_id)`, holding the **monotonic incarnation number**, lifecycle state, and cascade progress, with durable compare-and-set:
- **States:** `ABSENT → CREATING(n) → ACTIVE(n) → DELETING(n) → DELETED(n) → CREATING(n+1) → …`
- **Creation, with a durable fence (P0.3):** a unique creation-operation id and the expected prior state (`ABSENT` or `DELETED(n)`) accompany the durable CAS to `CREATING(n)`. The Session row is then created **together with an internal creation receipt — operation id, incarnation, and finalized-import digest/revision — persisted in the same transaction as the Session row** (the session-service creation-receipt contract; the database service's independent creation transaction is exactly why the fence must ride inside it). Imported events are finalized (protected fields stripped/stamped) before the receipt digest is computed. **The CAS to `ACTIVE(n)` compares the receipt** — a Session row without the matching receipt is never adopted by recovery, so a row created by a different or late operation cannot be activated under the wrong incarnation. **`CREATING` denies app admission; no operation observes `ACTIVE` until the receipted Session row and its finalized imported events are durable.**
- **Crash recovery, with a true write fence (P0.3):** a receipt alone *detects* a stale creator; it does not *prevent* the stale commit (creator A stalls in `CREATING`, reclaim retires A, creator B reserves — A's independent Session transaction could still land, and a pre-commit read of the other store is a TOCTOU). The contract therefore requires a **write fence at the Session store**: a **co-located fence row** in the Session backend, updated through a Session-service **`create_if_fence_current`** primitive — the Session row, its creation receipt, and the fence check commit **in one transaction** against the fence row's expected value. Reclaim advances the fence row (retiring A) *before* B reserves, so **A's late commit fails inside its own transaction** — it does not merely get detected later. Crash after a fenced commit but before `ACTIVE` → recovery verifies the receipt (operation id, incarnation, import digest — count alone is insufficient) and completes the transition; abandoned-`CREATING` recovery is idempotent. **A production profile binding rejects a Session backend that cannot host the transactional fence.** The reduced-durability mode (e.g., in-memory backends, orphan-row quarantine: mismatched rows quarantined and deleted, B retries) exists **only for the explicit local-development configuration**, carries its own weaker acceptance matrix, and none of this document's crash-safety or cannot-commit claims apply to it. **Migration uses a distinct adoption receipt** — an existing row is adopted as `ACTIVE(1)` with an adoption marker, never disguised as a fresh creation. Conformance: A-stalls/reclaim/B-reserves/A-commits (fails in-transaction), then the reverse commit order; crash during orphan cleanup; imported-event rows partially committed under a retired receipt.
- **Recreation:** `DELETED(n) → CREATING(n+1)`; old incarnations' leases, permits, tokens, sidecar records, and resolver requests always fail typed (chosen over permanent id unavailability — ratification 7(g)).
- **Migration:** a preexisting Session with no lifecycle row is migrated lazily on first app-enabled touch — a verified CAS creates `ACTIVE(1)`.
- **Deletion:** durable CAS to `DELETING` (new admission fails) → phase-1 revoke (Views, leases, permits, queued operations) → acquire coordination → delete/mark the Session row → cascade (sidecar, registers, idempotency, host state) → durable mark complete; **partial cascade retries idempotently after restart**, driven by the lifecycle record.
Conformance: crash before/after lifecycle reservation; before/after Session commit; before/after imported-event finalization; concurrent duplicate create; recreate racing deletion completion; abandoned-`CREATING` recovery; migration of an existing Session; crash at every deletion phase; delete-then-recreate; late remote result and late append from an old incarnation; old sealed reference vs the new incarnation; create racing `DELETING`/`DELETED`.
**Canonicalization:** **ADK Canonical JSON v1** — RFC 8785 with the I-JSON number domain (|int| > 2^53−1 rejected per purpose; negative zero normalized), byte size on final canonical UTF-8, depth post-serialization, defaults 8 MiB / depth 64 (profile-configurable), non-finite rejected; purpose-tagged envelopes (`sealed-result`, `confirmation-arguments`, `binding-revision`, `replay-identity`); the binding-revision input is a declared serializable identity subset; cross-language golden vectors. Over-limit results fail through the tool-error path, only under a bound profile.
**Credential-aware, connection-affine resolution:** every operation derives its MCP session through the same context-derived header and credential-keyed pooling path as model calls — **within the connection policy of §6** — never a singleton privileged client.
**Render bootstrap — explicit order:** (1) authorize principal/session/widget/channel via `SessionAuthorizer`; (2) reserve the View generation server-side, **bound to the originating connection generation**; (3) resolve the persisted Event and seal into host-private memory (pre-lease resolver rule — bootstrap's named exception; interactive replay uses the post-lease rule); (4) perform the one logical `resources/read` on the bound connection (terminal; state-only MRTR rounds in v1); (5) exact-one content selection (canonical-URI equality with the descriptor; Apps MIME; zero/multiple → typed error) and encoding/size validation; (6) activate and return the host-held grant only on success; revoke the reservation on every failure. The `RenderDocument` carries the exact HTML + matching `_meta.ui` from the same response, resolved input/full result, scoped grant. ADK never synthesizes or widens `csp`/`permissions`. Grant secrecy: never in HTML, `hostContext`, or iframe-visible data.
**Compatibility matrix:**
| Situation | Behavior |
|---|---|
| No Apps profile bound | Legacy `{resource_uri, tool, tool_args}` payload emitted unchanged; no sidecar or lifecycle-store dependency; no sealing-limit failures — byte-identical except the three declared exceptions |
| Apps profile bound | Versioned `McpAppWidgetDescriptor` with `sealedResultRef` (+ Event-level `sealedResultRefs` for interactive results); sidecar sealing active; **mixed UI/non-UI parallel batches rejected before fan-out (§4/§5)** |
| Replaying old persisted events | New renderer dual-reads the legacy form (window: open question 3) |
| Old consumers reading new events | Unaffected unless a profile is knowingly bound |
### 4. Audience, protected fields, effects, callbacks, and the event pipeline
**Durable discriminator.** `Event.audience: Optional[Literal["app_audit"]] = None` — declared, persisted, protected, default-`None`, distinct from `invocation_origin`. Audit events carry no `isolation_scope` (originating scope preserved as provenance metadata), keep agent path and branch, and remain fully persisted, exported, telemetry-visible.
**Central audience predicate.** `app_audit` events are non-model-context **and** non-control-plane across content construction, both compaction modes, agent routing/rehydration, and model-feeding memory ingestion. **Accepted `ui/update-model-context` is the only direct conversational-context injection path from app state.**
**Exhaustive `EventActions` classification (audit events):** `state_delta`/`artifact_delta` allowed under the batch ledger; `transfer_to_agent`/`escalate`/`route`/`agent_state`/`end_of_agent`/`rewind_before_invocation_id` stripped with typed policy error; `compaction`/`set_model_response` core-owned, stripped/rejected; `skip_summarization` normalized away; `render_ui_widgets` under the nested-widget rule; `requested_auth_configs`/`requested_tool_confirmations` never persisted on audit events.
**Applied-effect model.**
1. **Sealed one-shot confirmation permit** (declared app-path compatibility difference): evaluated exactly once against **`ReadonlyConfirmationContext`** (deep-independent snapshot; no live Session/InvocationContext/credentials/actions/artifact service); `ToolContext`-requiring predicates fail closed; the permit binds tool identity, server identity, binding revision, and the canonical arguments digest; sealed arguments execute; callbacks receive deep-independent argument copies; no re-evaluation in `McpTool`. Conformance: call-counting and toggling predicates; all four mutation classes; post-approval argument mutation.
2. **`EventBatchEffects` — the batch-scoped effect authority (P0.3).** ADK fans out one task and one `ToolContext` per parallel call, then merges one Event, so effect authority is **batch-scoped, created before fan-out**:
- one `EventBatchEffects` record per merged Event, containing a **child ledger per bound function-call id** plus an **event-policy ledger**;
- **every UI-associated call in the batch is bound to a child ledger** — its restricted context's delta-aware operations atomically record-and-apply into that child;
- the batch stays **OPEN** until all tool callbacks and the single event-policy callback finish, then **freezes once**; merged deltas, the audit Event, reconciliation, fallback, and telemetry derive only from the frozen batch snapshot;
- **deterministic conflict semantics — id-order precedence (P1.2):** when ledgered siblings write the same state key, the winner at freeze is chosen by **deterministic function-call-id order (lexicographic byte-order comparator)** — not wall-clock commit order; each child's writes are preserved in the ledger for audit. For artifact collisions, **freeze records which already-applied write won and does not rewrite bytes** — the audit order and the stored artifact contents agree by construction because the recorded winner is the applied one;
- **mixed batches are rejected before fan-out, with a complete response contract (P0.4):** under a bound profile, a parallel batch mixing UI-associated and non-UI calls is rejected **before any task starts** — no tool, callback, confirmation, widget, ledger, or remote work begins. The 4.15 narrowing (unledgered non-UI siblings surviving a dropped Event) created backend-dependent phantom state; rejection eliminates it without restricting non-UI model tools elsewhere. **The rejection is one persisted, model-visible Event containing exactly one terminal error `FunctionResponse` for every call in the rejected batch** — each original function-call id preserved (ADK populates missing ids per its existing conventions), deterministic order (the model's emission order) — so every provider-side correlation is satisfied and the Runner **automatically continues to the model through the standard function-response flow**, enabling the promised sequential retry. **The rejection Event is core-owned and skips the ordinary event callback entirely (P0.1 of the 4.17 review):** a live-ABI callback could mutate services, remove or duplicate responses, or change call ids on an Event whose whole purpose is exact per-call correlation with zero effects — so no plugin or agent callback observes it before persistence; the finalizer stamps protected fields; the error responses project through compaction as ordinary function responses. (A restricted rejection-policy callback on a deep-independent copy is the named alternative.) All-UI and all-non-UI batches proceed. Full sibling ledgering and a durable batch receipt are the alternatives — ratification 7(h), an implementation gate. **Batch classification is a two-phase admission with class-neutral discovery (P0.6 of the 4.17 review; P0.2 of the 4.18 review):** "resolve on its candidate class" was circular — selecting the app-safe class requires the very UI classification fresh discovery is meant to establish, and the stable spec expressly permits capability-adapted tool variants that differ between client profiles. Discovery is therefore a **deterministic two-class algorithm**: (1) boundedly list the **app-safe profile first**; (2) list the fully-callbacked profile for the remaining/non-UI declarations; (3) apply the explicit same-name disagreement rule — **a UI/non-UI disagreement between classes fails closed (or forces a fresh model catalog/turn); a stale class is never silently reused**; (4) select and snapshot the exact execution handles into one **immutable batch-wide classification snapshot**; schemas, credentials, and generations validate against that snapshot; then the whole batch either rejects as mixed or fans out **against those exact handles**. The same algorithm runs at model-catalog construction and again at batch admission. The promise is stated precisely: **no tool execution, callback, confirmation, widget, ledger, or effect** — fresh listing is bounded remote discovery and is permitted. Conformance: two- and three-call batches; reordered names; missing ids populated; provider request validation on the following turn; compaction/replay; **an adversarial plugin attempting to edit the rejection Event (removal, duplication, id change, state mutation, direct append/delete, mutate-then-raise — all without effect)**; metadata changes in both directions between the model turn and execution; a tool present on only one class; tool removal; schema/resource-URI/visibility changes; same-name UI/non-UI disagreement failing closed; one listing failing or overflowing while the other succeeds; credential/generation change between the two listings; connection loss between snapshot and fan-out; proof that no tool or callback ran.
- Defined interactions: effect-then-replacement (effect stands; replacement merges per §5); effect-then-exception (effect stands; failure path runs on the frozen batch); artifact-save-then-merge-rejection (save stands; reconciled); cancellation mid-write (atomic: recorded-and-applied or neither); **write after freeze → typed error**.
- Conformance: two UI siblings writing the same key; **mixed-batch rejection (no task, no callback, no effect — asserted against in-memory and database services alike)**; artifact-name collision; one callback failing while another writes; cancellation during fan-out; event-policy effect after all child callbacks; reconciliation ordering after restart; the delta-map adversaries; out-of-API mutation failing closed.
3. **The event-policy seam is restricted (direct-app audit events):** the ordinary event callback receives a **restricted invocation-context facade** — identifiers, a read-only state snapshot, and the batch's event-policy ledger APIs — with no live `Session`, `SessionService`, `ArtifactService`, `CredentialService`, `Runner`, or mutable actions; unavailable access raises typed errors mapping to the callback's terminal outcome; the guarantee is bounded to supplied APIs. Normal events keep the existing ABI. Conformance: the eight facade cases (facade mutation ledgered; nested mutation; direct service append/delete; artifact save; credential access; same-session reentry; mutate-then-return; mutate-then-raise).
4. **Governed MCP-owned callback matrix (v1):**
| MCP callback | Model-originated calls | Direct app calls / reads / bootstrap | Context (app paths) | Mutation | Reentry |
|---|---|---|---|---|---|
| `tool_filter` (callable) | Current behavior | Runs at catalog composition | Deep-read-only snapshot | Fails closed | Rejected |
| `header_provider` | Current behavior | Runs at session derivation | Deep-read-only snapshot | Fails closed | Rejected |
| Progress callback / factory | Current behavior | Notification-only | Read-only | Fails closed | Rejected |
| Sampling / Elicitation / `list_roots_callback` | Per the **connection policy (§6)**: available only on connections that advertise them; **UI-associated calls run on app-safe connections where all three are absent** | **Disabled** — input-request-bearing rounds fail closed; capabilities never advertised on app-safe connections | — | — | — |
| SDK `logging_callback` / general message handler | Internal defaults | Internal defaults — not retained configuration surfaces in v1 | — | — | — |
Conformance: one mutation and one reentry test per enabled family; ListRoots MRTR negative; no-input-capability-advertisement assertions on app-safe connections.
**"Deep-read-only snapshot" — closed value contract.** Structural transcription over a defined domain — `str`, `bool`, `None`, `int`/`float` under the ADK Canonical JSON v1 numeric rules (I-JSON range; non-finite and unsupported numeric subclasses rejected), `list`, and `dict` with string keys only (coercion and post-coercion collisions rejected); tuples rejected; cycles rejected; profile size/depth limits; never `copy.deepcopy`. One shared golden property table across the filter, header, confirmation, observer, and event-policy facades. Credential needs met purpose-bound via the existing auth-config path.
5. **Uniform reconciliation + terminal-outcome matrix:**
| Terminal path | Wire response | Durable record | Cached outcome |
|---|---|---|---|
| Success | Terminal `CallToolResult` | Full audit event (frozen-batch deltas + `sealedResultRef`s when profile-bound) | Terminal result |
| Confirmation denied / timeout (sealed preflight — before callbacks; no effects by construction) | JSON-RPC error | None | Error |
| Remote/tool error | JSON-RPC error or `isError` result | Audit event (frozen-batch deltas + error) | Error/result |
| Cancellation / disconnect / teardown | No late response; `tool-cancelled` where channel survives | Reconciliation record | Cancellation |
| Tool-callback or MCP-callback failure | JSON-RPC error | Reconciliation record | Error |
| `on_event` failure in the audit pipeline | JSON-RPC error | Core-only fallback from the frozen batch | Error |
| Short-circuit (valid) | Converted `CallToolResult` | Audit event | Terminal result |
| Mixed allowed-delta + forbidden-control | Typed policy error | Audit event (deltas committed; controls stripped) | Error |
Conformance: before-tool state mutation and artifact save followed by cancellation, disconnect, remote error, after-tool failure, and event-pipeline failure (denial/timeout as no-callback/no-effect paths) — five-surface assertions.
**Scope-aware rewind (core rewind unchanged).** **Core event finalizer** at the pre-persistence seam of every `SessionService.append_event` on every backend, with per-path origin sources (turns/early exits → execution context; `ui/message` user event → permit; host-service ops → operation context; rewind/compaction → `system`; first-party Session create/import/patch → `system` with `emitter="session-api"`); core-selected `audience` enforced; protected fields snapshotted and reasserted on every exit. Stored-row conformance on every backend.
**Canonical audit append pipeline** — construct + pre-sanitize; ordinary plugin chain exactly once on the restricted facade + deep-independent copy; merge allowed changes; finalize (protected fields; frozen-batch restore; sanitizer re-run); append; observer phase. Pipeline/append failure → the **core-only fallback built from the frozen batch**, appended once, recursion-guarded (exactly one fallback per operation). **Honest durability:** conditional on successful session append; backend rejection after bounded retry → cached error + critical redacted log/metric (outbox/WAL out of scope). Conformance: permanently-throwing plugin (one fallback, no loop); backend-failure escalation.
**Audit observer phase** — `on_audit_event_callback(readonly_context, event)`: non-replacing; `ReadonlyAuditContext` excludes mutable state/actions and mutation access; deep-independent immutable snapshot input (never the `session.events` object; nested-mutation-after-append tests on both backend classes); after append, audit/reconciliation events only, serial registration order, per-observer failure isolation. **Single-phase routing:** BQAA's ordinary callback skips state-delta telemetry for `app_audit` events; the observer hook owns it. **Delivery is best-effort; the durable record is authoritative** — zero-or-one BQAA rows per audit event; acceptance asserts the durable session event directly; a backfill adapter is named future work. Conformance: crash-after-append/before-observer; observer exception; emit-then-crash; restart; the four chain orderings.
### 5. Result pipeline — one validated authority, event-batch-aware, sealed, confidential
For results of **UI-associated tools** (whatever the caller):
1. **Callback approval:** before/after/error callback returns are honored per documented replacement semantics; short-circuit conversion and validation apply (a complete `CallToolResult` needs meaningful nonempty `content`; ordinary values get a meaningful JSON text block with `outputSchema` adding validated `structuredContent`, never replacing `content`; validation failure → `on_tool_error_callback` chain → JSON-RPC error); accidental in-place mutation is discarded. Per UI-associated call: a **candidate result record keyed by the immutable function-call correlation**, held operation-private.
2. **Event-batch semantics:** mixed UI/non-UI parallel batches are **rejected before fan-out** (§4 — typed error; the model retries sequentially); for all-UI batches: per-call candidates; **one public Event projection** over all sibling responses; ordinary event policy invoked **exactly once** per Event (restricted facade on direct-app audit events); **every UI correlation validated independently** (exactly one matching FunctionResponse; removal, duplication, id/name/correlation changes, or a non-terminal discriminator rejects that candidate); each candidate's approved public fields (`content`, `isError`, `resultType`) merged with its hidden fields; each complete merged `CallToolResult` **parsed and re-validated against the negotiated era** (meaningful nonempty `content`, valid blocks, boolean `isError`, terminal `resultType`); **whole-Event atomicity** — any candidate's failure fails the append before any reference becomes resolvable, with sibling effects reconciled from the frozen batch; on success, **all** candidates sealed and their references attached per the §3 two-placement schema (widget-level `sealedResultRef` for render-producing calls; Event-level `sealedResultRefs` for suppressed interactive results), appended once, with deterministic post-restart mapping by function-call id.
3. **Model projection:** wire keys `_meta` and `structuredContent` removed; `content`, `isError`, `resultType` preserved; LLM requests and both compaction modes consume exactly this projection.
**Profile-conditional split and compatibility wording:** the model projection and callback-approved authority are unconditional; **under a bound profile, mixed UI/non-UI parallel batches are rejected before fan-out** (typed error; sequential retry); all-non-UI batches and non-UI standalone calls are byte-identical to current behavior, while profile-bound UI calls intentionally gain descriptors, references, projection, and sealing (pipeline-defined, not byte-identical); sidecar sealing, the reference schema, render replay, and sealing limits exist only under a bound profile — profile-less deployments emit the legacy widget form with no store dependency (both negative paths tested). View delivery, bootstrap, and **live** replay derive from the sealed records through the gated resolver; **cold access after restart follows the §6 matrix** — sealed records stay resolvable for authorized non-View consumption, and the user-visible affordance is explicit refresh/re-execution. The SDK v2 migration adopts the canonical dump and fixes the `isError` telemetry detector as one change.
Conformance: exact-key projection tests (v2 results and replayed v1 events, incl. the telemetry detector); the error-flag case; redacting/replacing/mutate-then-return/mutate-then-raise/short-circuit; returned-replacement and in-place `on_event` redaction on both origins; the nine single-result structural rejections; the eight batch cases; `on_event` failure before any resolvable record; model/View/seal/restart-replay/telemetry agreement.
### 6. Governed operations, connection affinity, coordination, sessions, and teardown
**Connection affinity (P0.1) — the same-connection rule, made era-coherent.** The stable contract scopes `"app"` tools to **the same server connection** that produced the View, and blocks cross-server calls outright. Split audience pools violate this (and for stdio would split the server across processes). The v1 policy (alternatives are ratification 7(f)):
- **UI-associated initial tool calls — including model-originated ones — route to an app-safe connection**: constructed with all three input-request callbacks omitted, so neither era ever advertises input capabilities the app path won't fulfill. **Declared consequence:** a model-originated call of a UI-associated tool cannot use sampling, elicitation, or roots (their MRTR rounds fail closed, exactly as on direct app calls). Non-UI model calls keep their fully-callbacked connections and current behavior.
- **Era-and-lifecycle matrix — what "connection generation" means:**
| Era / phase | Meaning of the bound generation |
|---|---|
| Legacy stdio / SSE / stateful HTTP | The **concrete client/session/process**. Transport-level affinity is real; the View dies when the connection dies. |
| Modern stateless HTTP (2026-07-28) | A **host-side logical binding** — the configured endpoint plus the credential/capability (app-safe) profile and security domain. The sessionless core makes backend-instance affinity **impossible by design** (any request may land on any backend instance); server-side application state must travel as **explicit server handles** (`requestState`, tool-returned handles), which ADK echoes opaquely. **Interpretation flag (P1.3):** reading the same-connection rule as satisfied at the logical-binding level is an *extension interpretation*, not stable-spec wording — it is **ratification-gated (7(f))** and should be confirmed with the MCP Apps/core maintainers upstream; the round-robin and explicit-handle tests prove the selected behavior, not the extension authors' intent. Modern same-connection conformance is not claimed settled. |
| Live replay | Interactive Views operate only while their bound generation is live. |
| **Cold replay (restart / eviction)** | **Defined as explicit refresh/re-execution, not replay — with rerun safety and a durable invocation record.** Cold activation needs exact inputs the public descriptor deliberately omits: therefore every render-producing call also writes a **`RefreshInvocationRecord`** — a **purpose-separated record inside the same opaque sidecar envelope reached by `sealedResultRef`** (one public locator; purpose separation lives inside the confidential envelope, so no second public field exists). The record binds server/toolset/native tool identity, canonical argument bytes (or a protected locator plus digest), schema/catalog generation, binding revision, the replay-classification **source and revision** (both now named in the host-profile table), initiating origin, and the immutable correlation — never exposed on public Event/Session/SSE surfaces; nested Views created by direct app calls get the same record. **Exact lifecycle:** immutable-create keyed by the operation instance and correlation, written **in the same envelope create as the sealed result — before Event append, before outer success** (extending the store-write→append→success chain); crash before the envelope create → the append fails per the write ordering (no Event advertises a cold refresh it cannot honor); crash after create/before append → the orphan-GC protocol collects it; later corruption or loss → the Event's cold affordance **fails closed to the placeholder-only state**; retention and deletion follow the incarnation cascade; the envelope namespace revision participates in persistence-compatibility validation. At activation the host reauthorizes and revalidates current tool existence, visibility, schema, arguments, classification (against the current classification-source revision), and confirmation against live state; missing or mismatched → placeholder. Conformance: crash before/after refresh-record create, seal create, and Event append; append timeout with late success; restart and GC; deletion/recreation; argument-locator swap; classification-source loss. Re-execution repeats the initial tool, and an arbitrary UI-associated tool may have side effects (a purchase, a deployment, a write) — explicit activation and fresh authorization do not disclose that the effect will repeat. Therefore: a **one-click refresh** affordance is permitted **only when host policy or profile-declared tool metadata classifies the initial call as replay-safe/read-only** and current arguments pass fresh validation; **otherwise** the cold state is the error placeholder with an explicit **"Run tool again"** action that names the tool, warns that effects may repeat, runs the normal confirmation policy, creates a **separately attributed** invocation, and never reuses the old idempotency outcome. Either path creates a new Event, descriptor, and View — never a resurrection of the old binding. The old sealed record remains accessible **only through the purpose-bound export resolver (§7)** — no interactive grant, template fetch, or View is minted from it (no HTML is ever persisted; persisted-template cold rendering is a named out-of-scope alternative). |
- **The descriptor's widget and the View generation bind to that exact (era-appropriate) generation.** App `tools/call`, `resources/read`, catalog listing, and bootstrap reads run on the bound generation; admission is generation-scoped (§2).
- **Reconnect or pool eviction invalidates the dependent View generations** — typed errors; re-render is the cold-replay path above. No silent migration. The stable configured server identity is retained for recovery and descriptor validation.
- **Connection ownership, the registry loop, and multi-View lifetime:** the **registry owns and pins** each app-safe generation, reference-counted by live Views, reservations, and in-flight operations; idle eviction, maximum lifetime, and credential-expiry policies come from the profile; one View's teardown releases only its reference (siblings unaffected); the last release makes the generation evictable; readiness revocation, credential rotation, and transport failure fan out invalidation to every dependent View. **The registry owns a dedicated long-lived event loop for app-safe generations:** anyio/MCP clients are loop-bound and ADK's session manager replaces a session touched from a different loop, while a synchronous `Runner.run()` executes on a short-lived worker loop that dies with the run — so **every app-safe listing, call, read, bootstrap, and close marshals onto the registry loop** via thread-safe futures, and the generation survives any Runner loop's death. `Runner.run()` therefore works normally for app-enabled sessions (its matrix row stands; the pre-spawn rejection elsewhere in this document applies **only to same-session reentry from within callbacks**); rejecting `run()` entirely is the named alternative (7(j)). **User code never executes on the registry thread (P0.2 of the 4.17 review):** the SDK schedules progress callbacks in the connection dispatcher's task group — under this design, the registry loop — while ADK's documented progress-factory contract hands out a live, state-mutating callback context. Therefore **transport I/O alone lives on the registry loop; model-path progress delivery is marshaled back to the owning operation/Runner loop before user code is invoked**, and the same rule governs tool filters, header providers, trace-context propagation, and cancellation — none may receive a live Runner context on the registry thread. **Progress-delivery gate (P0.1 of the 4.18 review):** the SDK spawns each progress handler as an independent task and resolves the response waiter without draining them — marshaling fixes thread affinity, not terminal ordering. Every operation therefore owns a progress gate: when the terminal response arrives, **intake closes atomically, and every already-admitted progress callback is drained — on the owning loop — before result approval, effect-ledger freeze, Event append, and Runner-loop release** (required by the existing state-mutating factory contract; narrowing progress to an immutable notification-only contract, which would permit cancel/drop instead, is the ratifiable alternative). Defined semantics: a failing callback is logged and never affects the result; cancellation during drain is bounded; the delivery queue is bounded with drop-newest-plus-log backpressure; context variables are captured at operation admission; teardown and registry shutdown drain boundedly, then cancel. Conformance: initial UI execution through `run()`; bootstrap while the worker is live; bootstrap after the generator finishes; an app call from the host loop; worker-loop shutdown — each asserting connection-generation survival; a progress factory using Runner-loop state and an `asyncio.Lock`; **a progress notification admitted before the terminal response whose handler deliberately blocks until after the response (drained before freeze/append)**; callback raise; cancellation during drain; late progress after the terminal result (rejected at the closed gate); `run()` and `run_async` variants; registry shutdown during delivery.
- **Runner-scoped registry leases (P0.3 of the 4.17 review):** `BaseToolset.close()` takes no caller argument and Runner cleanup deduplicates toolset objects in a set — an object-global close cannot distinguish one Runner's idempotent retry from another Runner's first release when two Runners share one Agent/proxy. Therefore **Runner binding acquires a Runner-scoped lease token from the registry, held in a Runner-owned side table; `Runner.close()` releases that token directly**; `proxy.close()` on a non-owning proxy releases nothing and never infers caller identity; the registry closes a generation at last lease release or registry shutdown, **owns retries of timed-out releases** (token-idempotent), and the closes-exactly-once invariant applies to the final registry-driven close. Conformance: the same Agent and proxy object in two Runners, both close orders; duplicate close on either Runner; timeout/cancellation with registry-side retry; one Runner closing while both have Views; final release; registry shutdown.
- **Security-domain keying of app-safe generations:** ADK's current pools key HTTP sessions by effective header values and stdio by a per-manager constant — a static service-account header would share one stateful connection across users. App-safe generations are therefore additionally keyed by a **host-derived opaque security-domain key** (an HMAC of the resolved principal identity under a host key — never raw principal data in pool keys or logs): **a legacy/stateful generation is never shared across resolved security principals.** **Within one principal, legacy/stateful generation keys additionally include the Session incarnation by default (P0.4 of the 4.17 review)** — two conversations of one principal must not alias implicit state inside one MCP process or HTTP session, and legacy servers have no modern handle rule to lean on. **Cross-Session sharing within a principal exists only as an explicit profile opt-in whose server-handle contract is declared in the profile, carried on the generation, and checked at admission.** (Per-principal-only keying is the ratifiable alternative — 7(k).) Conformance: two principals with identical HTTP headers, and the stdio constant-key path — each initial call writes distinct connection state; each View calls an app-only follow-up; neither observes the other's state; **two Sessions of one principal each setting a different connection nonce, with app-only follow-ups isolated by default**; two simultaneous Views; Session teardown/recreation; credential rotation; principal remapping; the explicit-share opt-in path with its handle contract.
- **Initial-UI-tool retry policy (P1.3):** ADK's automatic once-retry-with-fresh-session is **disabled for UI-producing initial calls** — the remote effect may have committed before the response was lost, and a silent retry would both risk duplication and cross generations. The failure surfaces to the model; any second attempt is a separately attributed new call; the View binds only to the generation that returned the accepted result.
Conformance: a stdio server minting a per-process nonce in the initial UI tool and requiring it in an app-only follow-up; the equivalent legacy stateful-HTTP session; **modern round-robin across backend instances behind one logical binding; an explicit-server-handle flow**; an app call after pool eviction/reconnect (typed invalidation, then refresh rebinds); two configured connections to the same server; restart-after-result and restart-before-bootstrap (cold-replay affordance asserted); two Views on one generation with single-View teardown and last-View release; credential rotation and transport-failure fan-out; the retry-disabled initial-call cases; the full #396 model-tool → View-tool path.
**Connection-bound tool handles (P0.2) — the model's declaration is pinned to the execution generation.** Current ADK lists tools and binds each `McpTool` to the same session manager that will execute it; 4.15's per-tool routing broke that unity. The contract restores it: the **model catalog for UI-associated tools is listed on the app-safe client class** — the declaration shown to the model comes from the class that will execute the call — while non-UI tools list on the fully-callbacked class; when the same native name differs between classes, the app-safe declaration wins for UI-associated tools and the divergence is logged. Every catalog entry is a **`BoundToolHandle`**: the exact raw declaration, catalog generation, callback-profile class, credential context, and execution connection class. **Before a UI-associated initial call, ADK re-lists on the target app-safe generation and fails closed** if name, input/output schema, visibility, or resource metadata no longer matches the declaration supplied to the model; connection loss forces a new model catalog/turn — never name-only dispatch; transport retries cannot cross generations. Conformance: capability-adapted metadata differing by client capabilities; the same name with different schemas/resource URIs on the two classes; removal between listing and call; credential change; a transport retry that would cross generations.
**`tools/call`.** One outer pending request; one terminal response while live (standard `CallToolResult` or JSON-RPC error). **Cancellation is not an error response** — no late response; `ui/notifications/tool-cancelled` where the channel survives. Sealed confirmation preflight; resume/cancel host-only; approved → final result; denied/timeout → JSON-RPC error; disconnect → cancellation with atomic first-terminal-wins.
**`resources/read`** (backs `serverResources`): same admission gate; request URI checked against §2 policy; byte/rate/time limits; one outer read across MRTR legs **on the bound connection**; terminal `ReadResourceResult` only — after **response-side policy validation** (every returned item's canonical URI passes the matcher; per-item and aggregate limits; **mixed-policy responses rejected whole**). Conformance: terminal read; state-only MRTR; input-request-bearing round fails closed; revoked lease; out-of-policy request URI; extra out-of-policy item; prefix-inclusive subresources; duplicate/mixed contents; aggregate overflow; teardown during suspension.
**Post-queue freshness barrier — every governed operation, including register commits.** Structural/rate preflight only before queueing; after the coordinator grants ownership and immediately before any callback, remote call, cache delivery, bootstrap read, or register commit: principal/session authorization, channel/View generation, **connection generation**, live lease, binding revision **and binding/readiness generation**, current tool visibility or resource policy (or advertised modality), **Session incarnation**, and limits. The out-of-lock paginated `tools/list` binds to a short-lived admission permit whose revocation inputs are rechecked inside. Conformance: queued-call and queued-read races against lease revocation, visibility removal, policy narrowing, binding change, connection loss, and teardown.
**`ui/update-model-context` — permit-gated atomic commit.** Channel/View-bound permit revalidated after acquisition and immediately before commit (authorization, View generation, lease, binding revision, advertised modality, size limits, teardown state, incarnation); register write + monotonic commit-sequence allocation are one atomic step; stale updates change neither. Conformance: queued-update races against lease revocation, View teardown (both orders), binding change, modality withdrawal, limit shrink, and a newer accepted update — no stale register reaches a resumed or internal model turn.
**MRTR contract.** ADK drives the loop internally at the session level (`allow_input_required=True`) while the outer request stays pending until terminal: `requestState` opaque, echoed exactly; fresh MCP id per leg, legs internal to one operation record; round and total-time caps; no result before a terminal result; **intermediates modern-only, legacy single-round** (negative-tested). Suspension: direct operations and bootstrap release during waits and re-acquire with the full barrier; a model-originated MCP tool nested in a Runner turn keeps the turn's lock (bounded by caps) with out-of-band completion required — same-session reentrant completion → typed error. On app-safe connections, all input-request rounds fail closed.
**Coordinator-wide reentry rule.** Ownership tracked in loop/thread-neutral execution context; any same-session invocation of a public Runner mutator or host-service acquisition from within **any** callback — plugin/agent tool callbacks, agent callbacks during a `ui/message` turn (including before-run), the event chain, audit observers, MRTR completions, every enabled MCP-owned callback — is rejected with a typed error before it waits. **A same-session reentrant invocation of synchronous `run()` from within a callback** is rejected caller-side, before the worker thread spawns — ordinary top-level `run()` on an app-enabled session works normally via the registry loop. Ownership tokens operation-scoped and cleared on release — outliving callback tasks are not falsely rejected. Cross-session calls proceed. Conformance: one reentry test per family; reentrant sync-`run()` pre-spawn rejection (with ordinary top-level `run()` succeeding); outliving-task; nested-elicitation-while-waiting.
**Live JSON-RPC id scope.** In-flight registry per channel and View generation: a second live equal id on the same channel is a protocol error (no exceptions); different channels proceed; post-completion reuse is legal. Conformance: same-id/same-channel rejection and different-channel success for calls, reads, and messages.
**Method-aware operation identity and the idempotency boundary.** Identity: `tools/call` — method + server/toolset + tool + canonical arguments digest; `resources/read` — method + server/toolset + canonical URI/parameters; namespace binds principal, View, binding, identity, incarnation, **connection generation**, and — for reads — the current resource-policy and delivery-limit revision. **Key wire schema (calls and reads only):** `_meta["com.google.adk/idempotency-key"]` — opaque, 1–128 chars, `[A-Za-z0-9._-]`; malformed → typed rejection; same key + same identity → the one retained terminal outcome; conflicts rejected; legacy/modern wire tests. `ui/message` has no replay key in v1. After TTL/eviction/process loss or with no key, execution may repeat; ADK-owned effects dedupe only within the retained-operation boundary; remote effects retain server semantics; for reads, retention never exceeds a server `ttlMs`, and `cacheScope: "private"` binds records to the credential context. MRTR legs never create a second record; the operation-instance id keeps internal retries from colliding with independent identical calls. **Fresh admission (the post-queue barrier) precedes any retained-outcome lookup**, including re-running the item-level matcher and delivery limits against cached reads. Conformance: cached-replay denial after authorizer denial, lease revocation, visibility removal, binding change, teardown, policy removal/expansion change, returned-child-only policy change, limit shrink, TTL expiry, private-scope mismatch, **connection-generation change**; cross-method and cross-URI same-key conflicts; no-key, expired-key, reconnect, restart, MRTR-leg cases.
**Coordination matrix (app-enabled sessions; Apps routing decided at host configuration):**
| Entrypoint | v1 contract |
|---|---|
| `run_async` (and `run_debug`) | Acquires; covers message append and the entire turn |
| `run()` | Same primitive; same-session reentry rejected caller-side pre-spawn |
| Governed app `tools/call` / `resources/read` | Acquires; post-queue barrier; suspension rules; bound connection |
| **Export (purpose-bound resolver)** | **Acquires; incarnation-bound permit held through the release commit; final recheck immediately before release; streaming prohibited in v1 (single materialized response under held authority); covered by the freshness barrier, teardown races, and reentry rule** |
| Context-update ingestion | Acquires; permit revalidated immediately before the atomic commit |
| Teardown | Two-phase; phase 2 acquires |
| `run_live` | Rejected, typed fail-closed, before any session read/callback/append |
| `rewind_async` | Acquires around its full read/compute/write sequence |
| First-party Session create/import (with events) | Routed through the coordinator, the **lifecycle-store creation states**, and the core finalizer — forged protected fields stripped/stamped |
| First-party Session patch (append) | Routed through the coordinator and finalizer — same stamping |
| First-party Session delete | The incarnation-safe deletion contract (§3) |
| Direct external `SessionService.append_event` | Outside the coordinated contract — documented unsupported |
Conformance races: live-vs-app-call; rewind-vs-app-delta; update-vs-turn; update-vs-revocation/teardown (both orders); queued-waiter cancellation; cross-loop `run()` vs `run_async`; message-append no-interleave; both `ui/message`-vs-teardown orders; patch-vs-turn and patch-vs-app-call; forged-field import; create racing `DELETING`/`DELETED`.
**`ui/message` — Runner-owned composite; correlation-only ids; no v1 replay.** Host validates/authorizes without acquiring; delegates once to `Runner.run_async(new_message=...)` with a protected permit (lease, View identity, binding revision, confirmation outcome, canonical payload, incarnation); after acquisition and before any user-message callback or append, the permit is revalidated — stale → terminal JSON-RPC error, nothing appended; teardown atomically invalidates queued permits. Request ids: live correlation only, channel-scoped; no terminal-response cache; post-completion reuse starts a new governed turn; extra request fields rejected by schema upstream. Confirmation by default as host policy (host attestation or explicit ADK confirmation), configurable; exactly one governed turn per accepted message. Conformance: same-id different-message after completion → new turn; same-id same-channel while live → protocol error.
**Two-phase teardown with an honest effect boundary.** Phase 1 — out-of-band terminal signal (no lock, no session mutation): revokes the View generation, cancels active/queued tokens including context-update permits. Phase 2 — under the coordinator: clears registers, revokes grants, invalidates queued permits, removes persisted host state, reconciles interrupted operations per §4. After callbacks began: partial-commit — teardown suppresses the terminal result and forbidden controls only; frozen-batch effects reconciled, never rolled back or silently lost. Remote side-effect cancellation best-effort. **`ui/notifications/request-teardown`** is channel/View-bound, routes to the host's teardown decision, never directly triggers phase 1; malformed/duplicate notifications ignored idempotently with logging; races with host-initiated teardown resolve into the same two-phase path. Conformance: teardown after before-tool state mutation, after artifact save, and around an on-event exception (five-surface, both orders); request-teardown authorization/malformed/duplicate/race.
**Execution ownership and callbacks.** Plugin tool phases and the originating agent's canonical chains run (replacement semantics per §5); model and agent-lifecycle callbacks do not; MCP-owned callbacks per the §4 matrix; auth, sealed confirmation preflight, cancellation, retry are model-call-equivalent. Nested `UiWidget` emission suppressed unless independently negotiated. Missing originating agent → typed error, read-only render.
### 7. `McpAppHostService` — durable vs process-local state, and the export resolver
`PrincipalResolver`, `SessionAuthorizer`, the key provider, the `SealedResultStore`, and the `SessionLifecycleStore` are deployment-supplied contracts **referenced by the host profile and required for binding**. The HTTP adapter owns Origin/CSRF/cookie/bearer mechanics; denials are terminal and audit-logged (secrets/content excluded); stock `adk web` routes are local-development only with explicit loopback providers. Leases: short-lived, bound to principal + session + incarnation + widget + `view_instance_id` + toolset + server + binding revision + **connection generation**, with expiry, phase-1 revocation, replay protection, limits, cross-server rejection. **State classes are explicit: the sealed-result and session-lifecycle records are durable v1 binding prerequisites; leases, registers, coordinator state, pending confirmations, and idempotency records are process-local in the v1 reference adapter** — single process or sticky sessions required; a shared store for the *process-local* class is the horizontal-scaling question (open question 5).
**The purpose-bound export resolver (P0.6).** The View-bound resolver requires a live View binding — which cannot exist cold — so cold non-View access is a **separate, dedicated export resolver**, never a weakening of the View rule:
- **authorization:** a named `export` purpose on `SessionAuthorizer` (a distinct capability a deployment must grant explicitly), evaluated for the **current** principal at call time — old credential contexts and revoked principals are denied;
- **checks:** tenant scope, Session, incarnation, Event, and function-call correlation all verified against the authoritative persisted Event — cross-tenant locator swapping, deleted/recreated Sessions (old incarnations), and expired binding keys fail typed;
- **output:** the sealed result bytes under a configurable export projection; **never HTML, never a View grant, never a template fetch** — export cannot bootstrap a View;
- **limits and audit:** byte/rate/retention limits; **the redacted audit record (principal, target identifiers, decision, status — no content) is persisted durably *before* any bytes are released, and export fails closed if the audit write fails.** The audit sink is a **named `ExportAuditStore` contract in `McpAppsHostProfile` readiness — the sixth deployment contract** — with defined record keys and idempotency (operation-instance-keyed), a status vocabulary (`authorized`, `released`, `denied`, `failed`), redaction rules, retention, write-failure behavior (deny), and incarnation-cascade deletion. **The durable row is an authorization/release-attempt record, not proof of client receipt** — a crash after audit and before network delivery is an expected state the record vocabulary represents; the record is never model-visible;
- **linearization (P0.7 of the 4.17 review):** export is a **governed operation in the coordination and lifecycle matrices** — it acquires an incarnation-bound, purpose-specific permit, and **immediately before release** rechecks principal authorization, lifecycle state and incarnation, the binding key, locator correlation, the projection, and limits, so a revoke, deletion/recreation, key rotation, or limit change that wins the race denies the export; teardown and deletion races cover queued exports.
Conformance: cross-tenant locator swapping; deleted/recreated Sessions; principal revocation; expired binding keys; old credential context; bulk-export limits; observer/log redaction; **revoke/delete/recreate/key-rotation/limit-shrink after store load but before handoff; teardown while queued and during handoff; duplicate retries (idempotent by operation instance); audit write success followed by transport failure (record shows `released`, no delivery claim); audit-store outage (export denied); crash before/after the audit append and before/after response release; streaming rejection; the record never entering model context**; proof that export cannot mint a View or fetch a template.
### 8. Conversation, lifecycle, and limits
- **`ui/update-model-context`** — replacement-state registers per View instance: retained until overwritten by that View or its teardown; every turn snapshots and **reads** them ("consumed" ≠ cleared); mid-turn updates apply from the next turn; permit-gated atomic commits; host-assigned monotonic commit sequence (never View-supplied); highest live sequence wins per widget; winner-teardown promotes the next. **LLM projection:** a single dedicated **`user`-role context block**, delimited and provenance-labeled per widget (`[MCP App context — widget , view ]`), widgets ordered by commit sequence; `structuredContent` serialized as ADK Canonical JSON v1 text; explicit empty update clears that View's register. **Budget:** per-register cap (over-cap updates rejected at commit — the winner is never silently truncated) and aggregate cap (whole-register eviction, oldest commit sequence first, explicit marker listing evicted widget ids — never byte-level truncation). Included on every model turn while live, including resumed and internal model-only turns. Untrusted data, never system instruction. Conformance: concurrent updates; winner teardown; prompt-injection sentinel; deterministic order; per-register rejection; whole-register eviction with marker.
- **`ui/message`** — per §6.
- **Teardown** — frontend handles `ui/resource-teardown` ack/timeout; `request-teardown` per §6; backend follows the two-phase contract.
- **Limits** — queue depth, rate, byte, and content limits on every View-originated operation; MRTR round/time caps; discovery bounds; register caps.
### v1 scope — stable Apps subset, with accurate capability advertisement
```jsonc
{
"serverTools": {},
"serverResources": {},
"message": { "text": {} },
"updateModelContext": { "text": {}, "structuredContent": {} }
}
```
Unsupported members and modalities omitted; input-request capabilities never advertised on app-safe connections. Conformance: a capability-gated View enables all four; undeclared modalities rejected. Cross-repo baseline: `ui/initialize` → `McpUiInitializeResult` (capabilities above) → `ui/notifications/initialized`; `ping`; complete `ui/notifications/tool-input` (once, post-initialization, before any result); initial and interactive `ui/notifications/tool-result` (terminal only); `ui/notifications/tool-cancelled`; `ui/notifications/size-changed`; `ui/resource-teardown` with acknowledgment (+ `request-teardown` routing); the sandbox-proxy sequence `ui/notifications/sandbox-proxy-ready` / `ui/notifications/sandbox-resource-ready`; initialization negative tests (malformed/missing `appCapabilities`; unsupported View protocol versions). **Deferred:** `tool-input-partial`, preloading, `ui/open-link`, `ui/download-file`, display-mode changes, logging, theming, `listChanged`, app-path input requests. **`run_live` is out of v1 scope for app-enabled sessions.**
### Provenance: ingress-only `invocation_origin`, derived immediate caller, session-API authorship
`invocation_origin = user | mcp_app | system` (ingress; `model` is not a value — the caller dimension is the derived read-only `immediate_caller` (`model | app | host`) on `ToolContext`).
| Event / operation | `invocation_origin` | `immediate_caller` / `emitter` |
|---|---|---|
| Normal user turn (all events) | `user` | nested tools: `model` |
| Accepted `ui/message` — all its events | `mcp_app` | nested tools: `model` |
| Direct app `tools/call` / `resources/read` (audit events) | `mcp_app` | `app` |
| `bootstrap_render` | `mcp_app` | `host` |
| Rewind, compaction | `system` | — |
| Reconciliation records | the interrupted operation's ingress | `emitter="system"` |
| First-party Session create/import/patch events | `system` | `emitter="session-api"` (declared author preserved on imported content; forged protected fields stripped/stamped) |
| Deletion intent / cascade completion / cascade failure | — | Recorded in the durable lifecycle record + critical redacted log/metric — never as session events |
Set once at operation admission on the execution context; read-only via `ToolContext`; finalizer-stamped on every append path; not settable from app input, `custom_metadata`, replacement, or mutation. **Stamping on normal sessions is the third declared compatibility exception.** The BQAA fixture asserts both dimensions at before-tool time and on persisted events, including first-party patched rows.
### Draft-watch, conformance reproduction, and non-goals
**Draft-watch (non-normative):** listing-level `_meta.ui` fallback with content-item precedence (adopted proactively), app-provided tools, expanded `ui/initialize` exchange — out of v1; the design must not preclude them.
**Conformance reproduction (for the upstream issue; motivated by #3190's closure):** a capability-gated server withholding `_meta.ui` from non-advertising clients; an unlisted `ui://` resource the name-addressed helper cannot read; a modern server returning `InputRequiredResult` the current call site cannot complete; a View-originated `resources/read` the current surface cannot serve despite `serverResources`; a multi-content read the request-URI check cannot bound; an app-only tool exposed to the model; a UI-tool result leaking `_meta`/`structuredContent` into model context and public Session/SSE surfaces; an ungoverned app `tools/call`; **an app call routed to a different connection than its View's** (the same-connection rule).
**Non-goals:** frontend sandbox/CSP/postMessage enforcement (cross-repo); A2A widget propagation; Agent Engine managed rendering; app-provided tools; display-mode semantics; `ui/download-file`; `run_live` with bound profiles; core rewind changes; staged/transactional state-artifact machinery; outbox/WAL; a durable observer cursor or BQAA backfill adapter (future work); a `ui/message` replay extension; app-path input-request governance (follow-up); hostile-store authenticated encryption (future); a privileged pre-policy diagnostics record; full non-UI sibling ledgering (ratification 7(h)); BQAA dashboard logic.
## Delivery stack
0. **Prerequisite:** MCP Python SDK v2.0.0 migration (canonical dump + `isError` detector + session-level MRTR adoption, as one change).
1. **PR 1 — visibility/catalog + URI canonicalization + reserved-name classification + direct URI reads + bounded paginated admission + the unconditional projection.**
2. **PR 2 — profile/spec/proxy/registry/descriptor/stores:** the named `McpAppsHostProfile` with readiness-gated, **revocable** binding, proxy lifecycle including `REVOKING`, registry-owned construction, key-provider lifecycle, the `SealedResultStore` state machine with operation-instance identity and honest recovery, the **`RefreshInvocationRecord` envelope lifecycle**, the **`SessionLifecycleStore` with crash-safe creation and incarnation states**, persistence-compatibility validation, ADK Canonical JSON v1 with golden vectors, **connection-affine pool construction**, the **exact `sealedResultRef` schema**, the ordered `bootstrap_render`, compatibility matrix.
3. **PR 3 — audience, finalizer, batch ledger, callbacks, result authority:** discriminator, action matrix, terminal-outcome matrix, sealed confirmation permit, the **`EventBatchEffects` batch ledger with child ledgers, two-phase batch admission, and the core-owned rejection Event**, restricted app-call and event-policy contexts, the governed MCP-callback matrix, the closed snapshot contract, the **batch-aware validated merge pipeline with reference attachment**, core-only fallback, honest durability, observer API, core event finalizer, provenance, adversarial conformance.
4. **PR 4 — governed operations and sessions:** MRTR-aware calls/reads on **bound connections**, the post-queue barrier with connection-generation and incarnation checks, channel-scoped ids, coordinator-wide reentry, method-aware idempotency, the extended mutation matrix with first-party create/patch/delete through the lifecycle store, Runner-owned `ui/message`, two-phase teardown with `request-teardown` routing, register budgets.
5. **PR 5 — host service:** the **six** deployment contracts referenced from the profile (identity, authorization, key provider, sealed-result store, lifecycle store, export audit store), leases + grant secrecy + phase-1 revocation, the export permit/audit pipeline, per-View semantics, teardown, limits, single-process reference FastAPI adapter (dev-only, loopback providers).
6. **Cross-repo milestone — `adk-web`:** authentication story, sandbox renderer, full View-lifecycle baseline with four-capability advertisement, conformance fixtures, #396 acceptance.
## Definition of done — split
### ADK Python conformance (provable in-repo)
- **Advertisement/binding/profile/readiness:** settings map on both eras passing `client_supports_apps`; the profile field table with its fingerprint subset driving `binding_revision` and all keys, and ephemeral generations checked independently at every barrier; readiness cases including **revocation on live pooled connections** (idle reused connection; loss during initial UI execution; loss after seal before append; loss during bootstrap; reconnect after loss; provider replacement with old leases); direct construction never advertises; proxy lifecycle invariants including `REVOKING`; four-capability advertisement with modality rejection; no input-capability advertisement on app-safe connections.
- **Visibility/catalog/URI/connection:** the full case list including malformed→deny-all, adversarial URIs, bounded-discovery violations, reserved-name classification in both catalogs, and **connection-generation admission** (the stdio per-process-nonce fixture; the legacy stateful-HTTP equivalent; app call after eviction/reconnect; two configured connections to one server; the #396 model-tool → View-tool path).
- **Descriptor/reference/stores/bootstrap:** no sealed bytes on any public surface; the Event-verifying gated resolver; the **`sealedResultRef` schema cases** (one and several references; reordered parts; duplicate/missing ids; two widgets sharing one URI; legacy descriptor; profile-less Event; public serialization; restart mapping); tenant/incarnation scoping and reference swapping; the sidecar state machine and recovery cases; identical-no-key independence and same-replay-key convergence; persistence-compatibility validation; the **lifecycle-store creation/recreation/migration/deletion cases** (crash before/after reservation, Session commit, and import finalization; concurrent duplicate create; recreate racing deletion; abandoned-`CREATING` recovery; migration; crash at every deletion phase; delete-then-recreate; late work from old incarnations; old references vs new incarnations); key-provider cases; the ordered bootstrap; exact-one selection; read-only render as the defined placeholder; canonicalization I-JSON cases and golden vectors; credential redaction.
- **Profile-conditional behavior:** both profile-less negative paths; profile-bound sealing/replay/limits active; **mixed-batch pre-fan-out rejection with the complete per-call response Event tested; all-non-UI batches and non-UI standalone calls byte-identical to current ADK; profile-bound UI calls intentionally gain descriptors, references, and the projection — their behavior is defined by this pipeline, not claimed byte-identical**.
- **Audience/finalizer/batch ledger/callbacks:** stored-row discriminator round-trips; sentinel non-leak; routing non-influence; the action and terminal-outcome matrices with physically possible histories; finalizer stamping on plugin-merged, plugin-free, and first-party appends; **the batch cases (same-key sibling writes under id-order precedence; mixed-batch rejection with no task/callback/effect on both backend classes and the complete per-call response Event; artifact collision with the applied-winner rule; callback failure during sibling writes; cancellation during fan-out; post-children event-policy effects; restart reconciliation ordering)** plus the delta-map adversaries and out-of-API failures; the restricted event-policy facade's eight cases; the MCP-callback matrix with the ListRoots negative; the shared snapshot golden property table; scope-aware rewind unchanged.
- **Pipeline/fallback/durability/observers:** one core-only frozen-batch fallback; backend-failure escalation; observer deep-snapshot input; zero-or-one BQAA rows across crash/exception/restart; acceptance asserts the durable session event directly.
- **Sealed confirmation:** exactly-one evaluation; all mutation classes defeated; fail-closed `ToolContext` predicates; sealed arguments execute.
- **Result authority (batch-aware):** the nine single-result structural rejections; the eight batch cases including whole-Event atomicity and deterministic post-restart mapping; valid replacement and in-place redaction on both origins; hidden fields unreachable; post-merge re-validation enforces terminal `resultType`; model/View/seal/restart-replay/telemetry agreement.
- **Reentry:** typed rejection from every callback family; reentrant sync-`run()` pre-spawn rejection with ordinary top-level `run()` succeeding via the registry loop; outliving-task no-false-rejection.
- **Operations/sessions:** the full operation case lists; post-queue barrier races including connection loss; queued-update races with atomic commit-or-reject; channel-scoped id cases; method-aware idempotency and replay-admission cases (including connection-generation change) under the honest recovery contract; the extended coordination matrix; `ui/message` contract; teardown five-surface outcomes; request-teardown cases.
- **Registers:** ordering, promotion, snapshot-read, the specified projection with sentinel and deterministic order, per-register rejection, whole-register eviction with markers.
- **Provenance:** the expected-value table including session-API rows; reconciliation ingress + `emitter`; deletion progress in the lifecycle record; protected everywhere.
- **Authorization:** binding-gated providers; denials audit-logged; grants never iframe-visible; no authority survives persistence, replay, an old incarnation, or an old connection generation.
- **Compatibility:** with no profile bound, behavior identical to the pin except the three declared exceptions.
### Cross-repo product acceptance
- A conforming `adk-web` renderer registered before advertisement, with a real authentication story for non-local deployments.
- The View lifecycle baseline (four-capability initialize handshake, `ping`, tool-input/result/cancelled, size-changed, teardown ack/timeout + `request-teardown` routing, sandbox-proxy sequence, initialization negative tests) plus separate-origin sandboxing, message source/origin validation, initialization ordering, domain partitioning, CSP/Permissions-Policy enforcement.
- **An independent connection-stateful conformance server (P1.4):** per-connection state and a per-process nonce, exercising same-connection affinity, reconnect invalidation, audience policy, MRTR-bearing tools (model path), a state-only MRTR interactive read, a multi-content read, a capability-gated View using `message`/`updateModelContext`, and parallel UI tool calls through the batch pipeline. **#396 remains the product-acceptance fixture** (its BigQuery operations are naturally stateless and cannot detect cross-pool state loss); the conformance server owns the connection-affinity and crash-creation contracts.
- #396 proves the BQAA flow with the three-tool fixture plus interactive-read amendment v6 — asserting visibility, provenance per the table, the batch-aware merged-authority sealed projection, **the defined cold behavior (one-click refresh permitted because `show_agent_dashboard` is profile-classified read-only — not because every App tool is refreshable — with old sealed records accessible only through the export resolver)**, and zero-or-one observer telemetry asserted against the durable session event.
## Resolved design decisions (revisions 1–4.19 reviews)
1. Host-owned, accurate, readiness-gated **and readiness-revocable** named-profile capability binding (the `REVOKING` transition closes every Apps-advertising pool); registry-owned construction; direct construction model-only; Apps routing decided at host configuration.
2. Profile-conditional confidential `SealedResultStore` (tenant- and incarnation-scoped; complete state machine; operation-instance identity; trusted-backend boundary; honest recovery; persistence-compatibility validation) **plus the `SessionLifecycleStore` with crash-safe creation, migration, and deletion incarnation states**; key-provider lifecycle; ADK Canonical JSON v1 with golden vectors; the ordered bootstrap; "read-only render" = error placeholder; **the exact 0..1-per-widget `sealedResultRef` schema with append-time cross-validation**.
3. **Connection-affine governed operations:** UI-associated initial calls route to app-safe connections; Views bind to exact connection generations; reconnect invalidates and re-render rebinds; admission, idempotency, and leases are connection-generation-scoped; two governed View operations plus permit-gated register commits; response-side scheme-aware policy; the post-queue barrier everywhere; channel-scoped live ids; coordinator-wide reentry with reentrant-`run()` pre-spawn rejection (top-level `run()` served by the registry loop); Runner-scoped registry lease tokens released by `Runner.close()`; first-party Session create/patch/delete through the lifecycle store; `ui/message` Runner-owned with no v1 replay; two-phase teardown with `request-teardown` routed.
4. Audit isolation: protected `audience`, exhaustive action matrix, the **batch-scoped `EventBatchEffects` ledger** (child ledgers per bound call; one freeze; id-order precedence for state deltas with artifact outcomes following the applied-write order; mixed batches rejected via the core-owned per-call response Event after two-phase admission), restricted app-call and event-policy facades bounded to supplied APIs, the governed MCP-callback matrix, the closed shared snapshot contract, sealed confirmation permit, core-only fallback, append-conditional durability, best-effort observer delivery with the durable record authoritative, core event finalizer.
5. One validated, event-batch-aware result authority with whole-Event atomicity and the exact reference schema; the projection unconditional; the sidecar profile-conditional; **mixed-batch fate declared as part of the second compatibility exception**.
6. Visibility unconditional; malformed deny-all; reserved names excluded from both catalogs; bounded paginated admission on the bound connection; method-aware identity and full-response-contract admission precede any retained-outcome lookup; the idempotency key for calls and reads only.
7. SDK v2.0.0 migration prerequisite; connection-affine callback policy (input capabilities absent on app-safe connections, with the declared model-path consequence for UI-associated calls); `ui/message` confirmation as host policy; provenance ingress-only + derived immediate caller — the third compatibility exception; the register projection fully specified.
8. v1 = stable Apps subset with accurate advertisement; identity, authorization, key provider, sealed-result store, and lifecycle store are profile-referenced binding prerequisites; durable-vs-process-local state classes explicit; single-process/sticky reference adapter.
## Open questions for maintainers
1. Should the governed tool-only entrypoint be public API or internal to the host service for v1?
2. What shape should the `PrincipalResolver` / `SessionAuthorizer` / key-provider / store contracts take, and do they live in ADK core or the host adapter package?
3. How long a dual-read window for the legacy `UiWidget` payload before descriptor-only?
4. Is subset-level cross-repo acceptance sufficient for the first upstream merge, or must complete stable-contract coverage precede any default advertisement?
5. **Horizontal scaling of the process-local state class only** (leases, registers, coordinator state, pending confirmations, idempotency records): v2 concern, or required before the reference adapter leaves dev-only status? (The sealed-result and lifecycle stores are durable v1 prerequisites and are not part of this question.)
6. Should the audience machinery, finalizer, batch ledger, restricted contexts, observer API, stores, and two-view projection be general-purpose core mechanisms or Apps-scoped until a second consumer appears?
7. **Ratification of proposed defaults:** (a) profile-conditional sealing vs a universal store dependency; (b) the v1 MCP-callback narrowing; (c) the merged single-authority result pipeline with post-merge re-validation; (d) in-memory idempotency with may-re-execute recovery vs a durable terminal-operation record; (e) the restricted event-policy facade vs a dedicated result/event-policy callback; (f) **connection-affinity policy: app-safe routing with the era-and-lifecycle matrix (adopted) vs one all-input-disabled pool vs a correlation-safe dispatcher — including confirmation from the MCP Apps/core maintainers that the modern logical-binding reading of the same-connection rule is acceptable**; (g) recreation-with-new-incarnation vs permanent id unavailability; (h) **mixed-batch handling: pre-fan-out rejection with the per-call response Event (adopted) vs full sibling ledgering vs a durable batch receipt** — an implementation gate; (i) **cold-replay behavior: refresh-vs-rerun split with profile-declared replay-safe classification (adopted) vs an error-placeholder-only cold state vs persisted-template cold rendering**; (j) **the registry-owned app-loop (adopted) vs rejecting synchronous `run()` for app-enabled sessions**; (k) **the security-domain generation key: per-principal × per-Session-incarnation isolation (adopted) vs per-principal-only keying with intra-principal Session sharing as the default**.
## Definition of success
An ADK deployment with a readiness-complete registry-bound `McpAppsHostProfile` completes the stable-subset Apps loop — the full View lifecycle with accurate four-capability advertisement, both governed View operations on their **bound generations**, and permit-gated register commits — against an unmodified compliant server: the server accepts the advertised capability from a host that can actually produce a View, never receives input-capability promises the serving connection cannot fulfill, and **never sees an app call arrive outside the View's bound generation — concrete client/session/process affinity on legacy transports; on the sessionless modern core, the logical endpoint-and-profile binding with explicit server handles, **an interpretation pending ratification 7(f)**; the model's declarations are pinned to the app-safe execution generation through connection-bound tool handles with pre-call re-listing; catalogs (reserved names excluded) and the scheme-aware resource policy hold on request, on every returned content item, and again at post-queue execution with incarnation and generation checks, with fresh method-aware admission before any cached replay; a tool call — alone or in an all-UI parallel batch, mixed batches being rejected before fan-out with a complete per-call response Event — persists a minimal descriptor carrying its reference per the two-placement schema and, under a bound profile, confidential, tenant- and incarnation-scoped sealed records of every structurally validated merged result, appended atomically as one Event and **accessible after restart only through the purpose-bound export resolver, with the refresh-vs-rerun split (replay-safe classification, else explicit confirmed re-execution) as the defined cold affordance**; every model turn receives the exact-key projection with error semantics intact, and model and View always agree on the callback- and event-policy-approved public fields; confirmation decisions are sealed and evaluated exactly once; applied effects live in a batch-scoped ledger that freezes once — id-order precedence for state deltas, artifact outcomes following the applied-write order — with out-of-API mutation failing closed on every callback surface; sessions are incarnation-safe **from receipt-fenced crash-safe creation through idempotent cascade deletion**, and late work from an old incarnation, an old connection generation, or a retired creation operation always fails; recovery is honest; reconciliation flows through a non-recursive core-only fallback with append-conditional durability and best-effort observer telemetry whose durable record is authoritative; every public Runner mutator and first-party Session mutation path is coordinated, finalized, or fail-closed; readiness withdrawal revokes advertisement by closing the connections that carry it, and **rebind installs a new proxy under a new generation rather than resurrecting the old one**; `ui/message` uses channel-scoped correlation-only ids; registers reach the model only as a delimited, budgeted, whole-register-evicted untrusted block; teardown leaves no live authority; and **no result bytes, security metadata, or authority ever enter the widget descriptor — an opaque locator being none of these** — with the #396 dashboard (product) and the connection-stateful conformance server (legacy-transport protocol affinity) as the working proof.
Contributor guide
Research direction
Read the Protocol baseline and the referenced google/adk-python main commit, starting with the stated MCP Python SDK v2.0.0 migration prerequisite. Then trace the existing McpTool and UiWidget behavior described in the Summary and review downstream consumer #396. Done means the stable Apps host seam, dual-era advertisement, policy, rendering, and governed app interactions are implemented with the specified tests and contracts.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100