Skip to content

web+cli+docs: Wave 4 — deep-link auto-connect + programmatic MCP App review (rollup of #1576–#1578, #1665, #1666)#1676

Merged
cliffhall merged 15 commits into
v2/mainfrom
v2/1579-wave4-rollup
Jul 12, 2026
Merged

web+cli+docs: Wave 4 — deep-link auto-connect + programmatic MCP App review (rollup of #1576–#1578, #1665, #1666)#1676
cliffhall merged 15 commits into
v2/mainfrom
v2/1579-wave4-rollup

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #1576
Closes #1577
Closes #1578
Closes #1665
Closes #1666

Wave 4 rollup — deep-link auto-connect + programmatic MCP App review path

Tracking issue: #1675. Rollup of the five Wave 4 sub-issues from the #1579 decomposition of PR #1510, so the whole programmatic-review surface (URL deep-link → rendered widget, stored-auth handoff with refresh) can be smoke-tested and merged as one cumulative diff against v2/main. Each sub-issue shipped and was reviewed on its own branch (@claude review → LGTM), then merged base→tip into this branch.

Included (base→tip)

Verification

Per AGENTS.md the Closes lines won't auto-fire on v2/main — the sub-issues are closed and their board cards moved to Done on merge of this rollup; #1675 stays In Review behind it.

🤖 Generated with Claude Code

cliffhall and others added 14 commits July 11, 2026 22:48
Closes #1576

Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven
auto-connect so a driver can reach a connected inspector with one navigate.

- `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only
  serverUrl; `autoConnect` CSRF-gated on the per-launch API token),
  `deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID`
  (stable `deep-link` row so reloads reconnect to the same catalog row).
- App.tsx: ensure/connect effects (two-phase upsert-then-connect against the
  hydrated list), `serversRef` read in `onToggleConnection`,
  `connectErrorMessage` / `recordConnectError` recording connection-level
  failures.
- InspectorView header: `data-testid="connection-status"` with `data-status`,
  `data-error-message`, `data-deeplink` so drivers can `waitForSelector` and
  read failure reasons without scraping toasts.
- Docs: deep-link scheme + `data-*` contract in clients/web/README.md.

Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417
lifted-tab architecture and dropping the non-existent `normalizeServerUrl`
import in favor of `URL.href` canonicalization. Coverage gate green
(deepLink.ts 97.8/95.1/100/97.4).

Part of the #1579 decomposition (Wave 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
…p from the URL (#1577)

Closes #1577

Extends the deep link (#1576) to land on a rendered MCP App:

- `&openApp=<toolName>` — once connected and the tool appears in the app list,
  InspectorView switches to the Apps tab and pre-selects it.
- `&appArgs=<base64url(JSON)>` — pre-fills the tool form; values are merged
  **over** `collectSchemaDefaults()` so required-with-default fields don't
  disable "Open App".
- `&autoOpen=<token>` — token-gated auto-click of "Open App".

Adapted to the current architecture (differs from PR #1510's reference):
- Tab state is lifted to App.tsx (#1417), so the openApp effect lives in
  InspectorView and drives `onActiveTabChange("Apps")` + `onAppsUiChange` seed
  + `onSelectApp`, guarded by `deepLinkOpenAppRef` to fire once.
- AppsScreen owns the `running`/iframe state, so `autoOpen` is threaded to it
  as a prop; a fire-once effect defers the open one microtask past the
  synchronous effect body (satisfying `react-hooks/set-state-in-effect`,
  matching App.tsx's connect effect). `resetAppChannels` memoized so the
  effect's deps don't churn.

Tests: InspectorView (tab-switch + pre-select + appArgs-over-defaults merge +
non-app openApp ignored), AppsScreen (autoOpen fires once with seeded values;
no-selection stays idle). README documents the params. Coverage gate green.

Part of the #1579 decomposition (Wave 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
…1666)

Closes #1666

#1575 shipped `--print-handoff` with an interim deep-link shape (hardcoded
`transport: "http"`, marked `TODO(#1576)`). Now that #1576 owns the canonical
`?serverUrl&transport&autoConnect` format, `--print-handoff` emits exactly it:

- New `deepLinkTransport(serverUrl, transport)` helper derives `http`|`sse`
  from the resolved server — explicit `--transport` wins, else auto-detect from
  the URL path (`/sse` → sse), else default `http`. Unlike the core resolver it
  defaults instead of throwing (the handoff is best-effort; the web parser
  defaults an unknown transport to http too).
- `buildHandoff` takes the transport, emits the normalized serverUrl +
  derived transport in the query, and keeps `autoConnect=<token>` (which #1576
  adopted as the canonical CSRF gate, so the interim guess was already correct).
- Removed the `TODO(#1576)` marker; updated the doc comment + CLI README.

Tests: handoff emits `transport=http` for `/mcp`, `transport=sse` for `/sse`
and for explicit `--transport sse`; `deepLinkTransport` unit tests cover the
explicit/auto/default/stdio/unparseable branches. CLI coverage gate green
(cli.ts 100/92.8/100/100).

Part of the #1579 decomposition (Wave 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
#1665)

Closes #1665

`--use-stored-auth` previously injected the stored access token blindly, so an
expired token failed even with a valid refresh_token on disk. Now, when a
`refresh_token` is stored, the CLI runs the OAuth refresh grant and injects the
fresh access token, persisting the rotation.

- New exported `refreshStoredAuthToken(serverUrl, statePath, deps?)`: reuses the
  SDK's `refreshAuthorization` (not a hand-rolled token request) with the stored
  `clientInformation` + `serverMetadata` (discovered via core
  `getAuthorizationServerUrl` + SDK `discoverAuthorizationServerMetadata` when
  the metadata wasn't persisted). Rotated tokens are written back through
  `serializeOAuthPersistBlob` under the same `{servers,idpSessions}` key, so web
  and CLI stay consistent. Missing refresh_token/clientInformation or a failed
  grant throw `CliExitCodeError(AUTH_REQUIRED)` with a clear message + envelope
  code (`no_stored_token` / `refresh_failed`).
- `--use-stored-auth` prefers refresh when a refresh_token is present (covers
  both absent and expired access tokens — the stored blob has no expiry, so the
  refresh token is the durable credential); falls back to injecting the stored
  access token otherwise (unchanged).
- `deps` are injectable so the refresh/discovery are unit-tested without a live
  token endpoint; an e2e test drives the real SDK path against a mock token
  endpoint and asserts the refreshed token is injected + the rotation persisted.
- CLI README updated (removed the "blind injection" caveat).

CLI coverage gate green (cli.ts 100/92/100/100).

Part of the #1579 decomposition (Wave 4, CLI lane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
Closes #1578

Adds docs/mcp-app-review.md — the CLI-first → one-shot-web recipe for automated
MCP App review: `--app-info` probe → deep-link navigate → rendered widget, plus
OAuth-gated servers (stored-auth handoff, now honoring refresh_token per #1665),
SSH port-forward (both ports), and HTTP proxy support.

Adapted from PR #1510's reference doc to only document what actually merged and
to match the current implementation:
- Correct driver contract: `data-testid="connection-status"` + `data-status`
  /`data-error-message`/`data-deeplink`, `data-testid="open-app"`, and the
  `data-app-status="ready"` app lifecycle (not the reference's stale selectors).
- The zero-click `autoOpen` path (#1577) alongside the click-to-open variant.
- `--use-stored-auth` now refreshes a stored refresh_token (#1665), so the
  "token is not refreshed" caveat is replaced.

The per-client READMEs (web deep-link scheme + data-* contract, CLI handoff +
refresh + proxy) were updated in their own Wave 4 PRs; this adds the root README
pointer to docs/. Proxy/undici is already documented in the CLI README.

Part of the #1579 decomposition (Wave 4, docs — lands last).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
…tale-config connect

- The ensure-phase addServer catch now records non-409 failures (read-only
  catalog, backend 5xx) on data-error-message instead of swallowing every
  error and leaving the deep link permanently stuck at the guard with no
  signal (review finding #1).
- Split the update and connect phases across renders (new deepLinkUpdateRef):
  connect only once servers reflects the updated config, so onToggleConnection
  (which reads serversRef, synced by an earlier passive effect) builds the
  client from the fresh transport rather than the pre-update config (finding
  #2 — the exact case deepLinkConfigEquals was added for).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- Refresh failure now falls back to injecting a still-present stored access
  token instead of hard-exiting 3, so a transient auth-server hiccup doesn't
  regress a previously-working invocation (review #1). Only a failure with no
  access token to fall back on surfaces as exit 3.
- Persist rotated tokens via the shared writeStoreFile (0o600 + mkdir -p)
  instead of a raw writeFile, so the secrets file keeps owner-only perms
  identical to the web backend (review #2).
- Missing clientInformation now throws the distinct no_client_information
  envelope code rather than reusing no_stored_token (review #5a).
- Docs: --wait-for-auth no longer claims parity with --use-stored-auth (it
  injects the freshly-landed token without refreshing); noted the
  always-refresh rotation/concurrency caveat (review #3, #4).
- Tests: discovery-returns-undefined metadata path, no_client_information
  code, and an e2e refresh-failure → access-token fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
…verUrl

- The JSDoc referenced a non-existent `resolveServerConfig`; name the real
  `resolveServerConfigs` (core/mcp/node/config.ts) in plain text (it isn't
  imported here, so an @link wouldn't resolve).
- Hoist the doubly-computed `normalizeServerUrl(serverUrl)` in buildHandoff to
  a single `normalizedUrl` reused for the query param and the serverUrl field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
DeepLink.autoOpen is a non-optional boolean, so the `?? false` was dead once
openApp was truthy. Collapse to Boolean(deepLink?.openApp && deepLink.autoOpen)
— still false when there's no deep link, still gated on openApp so a malformed
autoOpen-without-openApp link can't fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
- Root README no longer links to docs/mcp-server-configuration.md (not yet
  ported to v2); keep only the mcp-app-review.md pointer (review #3).
- Collapse the Local-fixture parenthetical onto one line so prettier no longer
  renders '- resource preset …' as a mid-sentence bullet (review #5).

Findings #1/#2 (documents unmerged #1576/#1577/#1665 behavior) and #4 (the
web-README #deep-link-auto-connect anchor) resolve in the Wave 4 rollup, which
lands this doc together with those code PRs — the reviewer noted the same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
# Conflicts:
#	clients/cli/__tests__/stored-auth.test.ts
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Jul 12, 2026
@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 2m 17s —— View job


Review: Wave 4 rollup (#1676)

  • Read the PR diff against origin/v2/main
  • Review web deep-link (deepLink.ts, App.tsx, InspectorView.tsx, AppsScreen.tsx)
  • Review CLI changes (cli.ts, stored-auth refresh)
  • Review docs & READMEs
  • Post review feedback

Reviewed the full cumulative diff. This is a well-executed rollup — the security gating is thoughtfully documented, the CLI refresh path reuses the SDK rather than hand-rolling a token request, and the machine-readable data-* contract is a clean automation surface. Each piece was already LGTM'd on its own branch, and it shows. Comments below are minor/nits; nothing blocking.

Strengths

  • parseDeepLink CSRF gating is solid. autoConnect/autoOpen must both equal the per-launch API token; serverUrl is restricted to http(s): via validateServerUrl, and decodeAppArgs fails closed to {} on any atob/JSON error. The security rationale is captured in-code rather than left implicit.
  • CLI refresh reuses refreshAuthorization + writeStoreFile (0o600). Persisting rotation through the shared store writer keeps web/CLI on-disk format identical, and the graceful fallback to the stored access token when refresh fails avoids turning a transient auth-server hiccup into a hard failure. The distinct error codes (no_stored_token / no_client_information / refresh_failed) are a nice touch for automated callers.
  • onToggleConnection now reads serversRef.current instead of the closure's servers, and drops servers from its deps — this correctly fixes the stale-list race the deep-link IIFE would otherwise hit, and stabilizes the callback identity.
  • Test coverage is thorough (transport derivation, refresh end-to-end, fallback, each error code) and the data-* contract is documented in clients/web/README.md with a matching anchor for the docs cross-link.

Minor observations (non-blocking)

1. Deep-link connect is skipped when the deep-link server is already activeServerId but disconnectedApp.tsx (connect phase):

deepLinkConnectRef.current = true;
if (activeServerId !== deepLink.serverId) {
  void onToggleConnection(deepLink.serverId).catch(...);
}

If activeServerId is persisted/restored to "deep-link" from a prior session while the connection is disconnected, this marks the ref consumed and never connects, so a reload of the same deep-link URL silently won't auto-connect. The guard is there to avoid toggling off an already-live connection — but the intent is "connect if not connected," not "do nothing if this is the active id." Consider gating on connectionStatus/activeServer.connected rather than on activeServerId identity. Low likelihood (requires activeServerId to survive a reload as deep-link), hence non-blocking.

2. Double snapshot read on the refresh path. In parseArgs, findStoredServerState is computed from readOAuthServers(...), then refreshStoredAuthToken() calls readOAuthSnapshot(...) again internally. Correct (the second read is the authoritative one it writes back), just a redundant file read. Not worth restructuring unless you're already touching it.

3. openApp without autoOpen bypasses the no-input auto-launch. When a deep link pre-selects an app via InspectorViewonAppsUiChange/onSelectApp, it bypasses AppsScreen.handleSelect, whose no-input-app branch auto-launches on selection. So a no-input app deep-linked with autoOpen=false lands pre-selected but not running (the user must click Open App), which differs from clicking it in the sidebar. Arguably fine — but a UX inconsistency worth a comment or a deliberate decision.

4. Doc/verification note. The PR body states npm run ci is green and typecheck passes. I could not independently run the SDK-typed refreshAuthorization call locally (deps not installed in this checkout), so I'm trusting CI on the metadata?: OAuthMetadata | undefined and second-arg shape. Worth a glance that the green run predates the final tip commit of this branch.

None of these rise to a change request. Nice work threading the deep-link phases and the stored-auth refresh together cleanly.
· branch v2/1579-wave4-rollup

…eServerId

Gate the deep-link connect phase on 'not already connected' rather than on
activeServerId identity: if a prior session restored activeServerId to the
'deep-link' id while disconnected, the old guard marked the ref consumed and
never connected, so reloading the same deep-link URL silently didn't auto-
connect. onToggleConnection only disconnects when the id is active AND status
is connected, so the new condition also avoids toggling a live connection off.

Also documents (rollup review #3) that pre-selecting a deep-linked app
deliberately bypasses AppsScreen.handleSelect's no-input auto-launch — a URL
must not fire a tool call without the token-gated autoOpen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
@cliffhall

Copy link
Copy Markdown
Member Author

Thanks for the thorough rollup review. Addressed the one correctness edge case in 7411fc88:

  • Progress notifications #1 (reload doesn't reconnect when activeServerId restored to deep-link while disconnected): the connect phase now gates on !(activeServerId === deep-link && connectionStatus === "connected") instead of id identity alone, so a reload of the same deep-link URL connects whenever it isn't already connected — while still never toggling a live connection off.
  • Completion requests #3 (no-input app pre-selected but not launched without autoOpen): added a comment documenting this as deliberate and security-aligned — a crafted URL must not fire a tool call against the target server without the token-gated autoOpen, so pre-select-without-launch is the intended behavior even for no-input apps (it bypasses handleSelect's auto-launch on purpose).

Dispositions on the rest:

  • Request logging #2 (double snapshot read on refresh): left as-is — the second read inside refreshStoredAuthToken is the authoritative one it writes back, and de-duping would thread state through for a negligible saving, as you noted.
  • SSE transport support #4 (verification): npm run ci was run on the fully-merged rollup tip and is green (per-file ≥90 coverage gate incl. deepLink.ts/InspectorView.tsx/AppsScreen.tsx/cli.ts, smoke, Storybook). This tip commit re-runs CI on push. Also manually smoke-tested end-to-end: CLI --print-handoff/--app-info, and a live browser deep-link (?serverUrl&transport&autoConnect&openApp&autoOpen) reaching data-status=connected + data-app-status=ready with zero clicks, plus the wrong-token → data-deeplink=rejected path.

Leaving this rollup unmerged for maintainer review.

@cliffhall cliffhall merged commit e2a97c1 into v2/main Jul 12, 2026
3 checks passed
@cliffhall cliffhall deleted the v2/1579-wave4-rollup branch July 12, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tracking: Wave 4 rollup — deep-link auto-connect + programmatic MCP App review integration (#1576–#1578, #1665, #1666)

1 participant