Skip to content

feat(connection-info): wire Connection Info modal to the connection handshake#1377

Open
cliffhall wants to merge 13 commits into
v2/mainfrom
feat/1364-server-info-modal
Open

feat(connection-info): wire Connection Info modal to the connection handshake#1377
cliffhall wants to merge 13 commits into
v2/mainfrom
feat/1364-server-info-modal

Conversation

@cliffhall
Copy link
Copy Markdown
Member

@cliffhall cliffhall commented May 29, 2026

Summary

  • Add ConnectionInfoModal wrapping the existing presentational ConnectionInfoContent, mirroring ServerSettingsModal's layout (Modal + Mantine Title + CloseButton). Title: Connection Info.
  • Gate the Connection Info button on ServerCard so it only renders when connection.status === "connected"; Settings continues to render in all states.
  • Expose getClientCapabilities() on InspectorClient (snapshotted from the initialize-time capability build), thread it through InspectorClientProtocol, FakeInspectorClient, and useInspectorClient, then render it in the modal alongside the live initializeResult, transport, and OAuth details.
  • In App.tsx, derive the active server's transport from ServerEntry.config.type and OAuth details synchronously from inspectorClient.getOAuthState() (auth URL, access token) and persisted settings.oauthScopes. Drop the modal-open flag on the InspectorClient disconnect event so a future reconnect doesn't auto-reopen the modal.
  • Render server instructions through ContentViewer (copyable + wrapping Code variant), wrapped in ScrollArea.Autosize mah={280} so a long instructions payload scrolls in place instead of pushing OAuth + modal chrome off-screen.
  • Add a Server Implementation heading above the name / version / protocol / transport grid to balance the existing capability section headings.

Rename: Server Info → Connection Info

The popup covers server identity AND client capabilities AND OAuth state — it's a snapshot of the live connection, not just the server. To match what it actually shows:

  • ServerInfoContentConnectionInfoContent (directory, file, exports)
  • ServerInfoModalConnectionInfoModal (directory, file, exports)
  • ServerCard button label "Server Info" → "Connection Info"; prop onServerInfoonConnectionInfo
  • Modal title "Server Info" → "Connection Info"
  • Wiring updated through App.tsx, ServerListScreen, InspectorView

Connection errors → toast (not InlineError on the ServerCard)

While testing OAuth-backed connections, the inline error on the ServerCard either flashed for a moment then vanished (the active card flips through error → disconnected → error during a failing handshake) or required a 5s visibility contract that fought InspectorView's status pipeline. Replaced it with a Mantine notifications.show({ title: \Failed to connect to "${name}"`, message, color: "red" })` call in App.tsx's connect-catch path:

  • App.tsx: toast on the connect-catch path; drop errorMessage state + the in-flight ref that was only there to keep the alert visible past the transport's close event.
  • InspectorView: drop the errorMessage prop and the connectionStatus === "error" && errorMessage mapping branch.
  • ServerCard: drop the InlineError rendering + visibility state machine; the card is back to a pure status indicator.
  • InlineError deleted (no other consumers).
  • main.tsx: anchor <Notifications> at bottom-right so toasts don't overlap the ViewHeader/tabs row.

Follow-up issue (#1378) tracks the related "missing auth token → silent 401" dev-UX problem: the only way the browser learns MCP_INSPECTOR_API_TOKEN today is via the URL the launcher prints, so a reload at the bare URL still 401s.

Closes #1364

Screenshot

Screenshot 2026-05-29 at 6 35 41 PM

Test plan

  • npm run validate (format, lint, build, unit + integration with coverage gate)
  • npm run test:storybook
  • Manual: connect a stdio server → Connection Info button visible, opens modal with serverInfo/capabilities/protocol/instructions/transport → close via X/Escape/click-outside
  • Manual: disconnected/connecting/error states → Connection Info button hidden, Settings still present
  • Manual: OAuth-backed HTTP server → OAuth section populated with auth URL / scopes / access token; failed handshake fires a bottom-right toast with the underlying error
  • Manual: server with a very long instructions payload → instructions section scrolls in place; OAuth section + close button stay visible

🤖 Generated with Claude Code

Closes #1364

- Add ServerInfoModal wrapping the existing ServerInfoContent, mirroring the
  ServerSettingsModal layout.
- Gate the Server Info button on ServerCard to render only when the
  connection is "connected".
- Expose `getClientCapabilities()` on InspectorClient (and protocol +
  FakeInspectorClient), and surface it through useInspectorClient so the
  modal can render the client side of the capability handshake without
  poking the SDK Client's private state.
- App.tsx: open the modal from the gated Server Info button, derive the
  active server's transport and OAuth details (synchronously, from the
  guided-OAuth state snapshot + persisted settings), and reset the open
  flag on InspectorClient `disconnect` so the modal does not auto-reopen
  on a future reconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label May 29, 2026
@cliffhall cliffhall self-assigned this May 29, 2026
cliffhall and others added 3 commits May 29, 2026 16:54
…entViewer

- Drop the in-content "Server Information" Title from ServerInfoContent and
  rename the modal title to "Server Information" so the modal renders one
  heading instead of two stacked ones.
- Swap the instructions Blockquote for ContentViewer (copyable, with the
  existing wrapping Code variant) so long instructions wrap and the user
  can copy the full payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The popup covers server identity AND client capabilities AND OAuth state —
it's a snapshot of the live connection, not just the server. Rename to match
what it actually shows. Also add a "Server Implementation" heading above
the top section (name/version/protocol/transport) to balance the existing
capability headings.

- ServerInfoContent -> ConnectionInfoContent (directory, file, exports)
- ServerInfoModal -> ConnectionInfoModal (directory, file, exports)
- ServerCard button label "Server Info" -> "Connection Info";
  prop onServerInfo -> onConnectionInfo
- Modal title -> "Connection Info"
- App.tsx / ServerListScreen / InspectorView wiring updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…place

A long server prompt (e.g. an Everything-style instruction document) was
pushing the OAuth section and the modal chrome off-screen — users had to
scroll the whole modal to reach the close button. Wrap the ContentViewer
in ScrollArea.Autosize with mah=280 so the instructions section scrolls
internally and the rest of the modal stays put.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall cliffhall changed the title feat(server-info): wire Server Info modal to the connection handshake feat(connection-info): wire Connection Info modal to the connection handshake May 29, 2026
cliffhall and others added 4 commits May 29, 2026 18:33
Add a `section` variant for Title that paints the heading in the existing
`--inspector-surface-subtle` token (same surface token already used for
modal call-out panels). Apply it to each section heading in
ConnectionInfoContent (Server Implementation, Server Capabilities, Client
Capabilities, Server Instructions, OAuth Details) via a SectionHeading
subcomponent constant, so the sections read as distinct cards against the
modal background instead of bleeding into one stream of text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prettier flagged the import in CI. The line exceeded printWidth=80 and
needed to wrap onto multiple lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a pre-commit instruction to run the auto-fixing `npm run format` ahead
of `npm run validate`. `validate` runs `format:check` (non-fixing) which
will fail CI on any unformatted file — running the auto-fixer first
eliminates that class of failure entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Point index.html's icon link at /src/theme/assets/MCP.svg so the same MCP
logo asset already imported by ViewHeader is also the favicon. Delete the
now-unused clients/web/public/favicon.svg. Vite resolves the source path
during build and emits a hashed copy under dist/assets/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 29, 2026

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


Claude PR Review

  • Read PR diff against origin/v2/main
  • Review ConnectionInfoModal and updated ConnectionInfoContent
  • Review App.tsx wiring (OAuth state, transport derivation, disconnect handling)
  • Review InspectorClient / protocol / fake changes (getClientCapabilities)
  • Review ServerCard gating + tests
  • Check theme + favicon/index.html changes
  • Post final review

Summary

Solid PR. The rename is mechanically clean and consistent across files, tests, stories, and props. The new getClientCapabilities() snapshot pattern is well-documented at every layer (InspectorClient, protocol, hook), and the disconnect handler that clears connectionInfoModalOpen is exactly the right fix for the "modal re-pops on reconnect" footgun. The ScrollArea.Autosize mah={280} around instructions, plus the new Title order={4} modal header + Title order={5} section headings, give the modal good vertical discipline. Tests cover the new gating, hook field, and modal open/close paths.

A few small things worth a look before merging:

Findings

1. clientCapabilities ?? {} returns a fresh {} on every render — core/react/useInspectorClient.ts:124

clientCapabilities: inspectorClient?.getClientCapabilities() ?? {},

The comment says "consumers can treat this as a stable object," but the fallback {} is a new literal each render when no client is attached. That can invalidate downstream useMemo/useEffect deps that key on clientCapabilities. Hoist a module-scope constant:

const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
// …
clientCapabilities: inspectorClient?.getClientCapabilities() ?? EMPTY_CLIENT_CAPABILITIES,

Fix this →

2. connectionInfoOAuth is memoized over inspectorClient but reads mutable OAuth state — clients/web/src/App.tsx:363-381

The useMemo deps are [connectionStatus, inspectorClient, activeServer]. None of those flip during an OAuth state transition mid-session (token refresh, scope change, late oauthComplete). If the modal is opened during a refresh, the displayed access token / auth URL will be stale until the next reconnect. Three options, in order of how invasive they are:

  • Tolerate it (snapshot-at-open semantics, which the modal already implies).
  • Compute inline (no useMemo) — cheap enough.
  • Subscribe to oauthStepChange / oauthComplete and re-derive.

If you intend snapshot semantics, the comment above this block should say so explicitly so a future reader doesn't try to "fix" the staleness.

3. Modal close animation is short-circuited by the outer {initializeResult && …} guard — clients/web/src/App.tsx:1010-1019

On disconnect, initializeResult becomes undefined and the modal is unmounted from the tree the same render as setConnectionInfoModalOpen(false), so Mantine's exit animation never plays. Mantine's <Modal opened={…}> is designed for the parent to keep it mounted and just toggle opened. Low-priority polish; if the snap-shut is intentional, ignore.

4. connectionInfoTransport falls back to "stdio" for a missing active server — clients/web/src/App.tsx:353-356

const connectionInfoTransport = useMemo(
  () => activeServer?.config.type ?? "stdio",
  [activeServer],
);

The modal is only rendered when initializeResult is truthy (which requires connectionStatus === "connected"), so activeServer should always be present here. The silent "stdio" default would mask a regression if that invariant ever breaks. Consider making transport optional on ConnectionInfoContentProps and rendering "—" when absent, or console.warn + omit the row.

5. Auth URL preference: metadata endpoint vs. constructed URL — clients/web/src/App.tsx:366-368

const authUrl =
  oauthState?.oauthMetadata?.authorization_endpoint ??
  oauthState?.authorizationUrl?.toString();

These are semantically different. authorization_endpoint is the AS's advertised endpoint; authorizationUrl is the full URL the user was redirected to (with state, code_challenge, etc.). For a "Connection Info" snapshot, the constructed URL is arguably more informative about what actually happened. Worth confirming which one matches user expectations; either is defensible.

6. Test uses raw class selector for the close button — clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx:80-83

const closeBtn = document.querySelector(
  "button.mantine-CloseButton-root",
) as HTMLButtonElement | null;

Couples the test to Mantine's internal class naming and uses a non-null assertion (!). screen.getByRole("button", { name: /close/i }) or screen.getByLabelText(/close/i) would survive a Mantine upgrade.

7. Scope splitter is broader than OAuth 2.1's SP separator — clients/web/src/App.tsx:370-372

const scopes = activeServer?.settings?.oauthScopes?.split(/\s+/).filter(Boolean);

OAuth 2.1 §3.3 specifies scopes are space-separated (%x20). /\s+/ also matches tabs / newlines. Harmless in practice (the input is config-controlled), just noting in case you want to be strict on the input.

Nits

  • ConnectionInfoContent.tsx: SERVER_CAPABILITY_KEYS and CLIENT_CAPABILITY_KEYS are identical to the pre-rename file — fine, just confirming nothing was lost in the move.
  • Capability check key in capabilities && capabilities[key] != null correctly distinguishes "absent" from "present-but-empty-object," which matches MCP spec semantics where {} means "supported, no sub-features."

What looks great

  • The disconnect handler clearing setConnectionInfoModalOpen(false) with the comment explaining the re-pop scenario.
  • The getClientCapabilities() JSDoc note that it's a snapshot, not a subscription on the SDK's mutable registerCapabilities().
  • Parameterized "omits Connection Info button when status is X" test in ServerCard.test.tsx:171-186.
  • Storybook coverage of MinimalCapabilities + WithOAuth + Closed states.
    · Branch: feat/1364-server-info-modal

Two related fixes so the user actually sees what went wrong when a
connect attempt fails (e.g. OAuth-backed server, bad URL, missing dev
auth token):

- App.tsx: add `connectInFlightRef` and skip clearing `activeServerId`
  in the InspectorClient `disconnect` listener while a connect attempt
  is in flight. The transport closes during a failing handshake and
  dispatches `disconnect` before the catch block sets `errorMessage`;
  without this guard InspectorView would map the card to plain
  "disconnected" and the InlineError would never render.

- InlineError: opt-in `autoDismissMs` prop that runs a Mantine `slide-up`
  Transition exit animation after the message has been visible for the
  given duration. Visibility is derived from a "dismissed message"
  state (rather than a mounted boolean + setMounted-in-effect), so the
  timer re-arms whenever `error.message` changes without tripping the
  react-hooks/set-state-in-effect rule. ServerCard passes
  autoDismissMs={5000}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cliffhall and others added 4 commits May 29, 2026 19:47
…iven removal

Move the 5s auto-dismiss timer and visibility state out of InlineError
and into ServerCard so the slide-up exit animation runs in both cases:

- Timer-driven: after 5s the alert animates out (existing behavior).
- Parent-driven: when `connection.error` is cleared mid-flight (e.g. a
  successful reconnect), the alert ALSO slides up instead of vanishing.

To keep the message painted during the parent-driven exit, ServerCard
tracks a `lastError` state that mirrors `connection.error` only on its
truthy edges. The conditional setState during render is the supported
React pattern for "remember the last seen value of a prop"; gated on
message inequality so it can't loop. The `useRef` version of this
pattern failed `react-hooks/refs` (no ref writes/reads during render).

InlineError is now a pure presentational component: `mounted` prop
controls a Mantine `slide-up` Transition. The internal `autoDismissMs`
prop is gone — its single caller has moved to the controlled `mounted`
pattern.

Closes the "error vanishes the moment the parent removes it" half of #1364
(the timer half landed in 6d147e0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ineError

Move handshake-failure messaging out of the ServerCard body and onto a
Mantine toast (`notifications.show`). The inline alert had a 5-second
visibility contract entangled with InspectorView's status pipeline, and
the active card was flipping through error → disconnected → error during
a failing handshake; surfacing the error as a toast sidesteps that whole
timing problem and keeps the card focused on the live status indicator.

- App.tsx: toast on the connect-catch path; drop `errorMessage` state +
  `connectInFlightRef` (the latter was only there to keep the inline
  alert visible past the transport's close event).
- InspectorView: drop the `errorMessage` prop and the
  `connectionStatus === "error" && errorMessage` mapping branch.
- ServerCard: drop the InlineError rendering + the displayedError /
  errorVisible visibility state machine.
- Delete the InlineError component — no other consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the Notifications portal from top-right to bottom-right so a
connection-failure toast doesn't overlap the ViewHeader / tabs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Hoist `EMPTY_CLIENT_CAPABILITIES = Object.freeze({})` to module scope
   in `useInspectorClient` so the fallback returns a stable reference
   (avoids invalidating downstream `useMemo`/`useEffect` deps when no
   client is attached).
4. Drop the `?? "stdio"` silent fallback on `connectionInfoTransport`
   in the JSX — gate the modal render on `activeServer` too. The
   "stdio" default still lives at the derivation site (config.type is
   schema-optional and `ServerType` is required by the modal prop),
   but the gate makes the invariant explicit.
6. Replace the raw `document.querySelector("button.mantine-CloseButton-root")`
   in `ConnectionInfoModal.test.tsx` with `screen.getByRole("button",
   { name: /close/i })`. Add `aria-label="Close"` to the
   CloseButton so the assertion (and screen readers) have a name to
   match on.
7. Switch the OAuth scope splitter from `/\s+/` to the literal `" "`
   per OAuth 2.1 §3.3.

Also clarify in comments:
2. Note that `connectionInfoOAuth` uses snapshot-at-open semantics on
   purpose — token refreshes mid-session won't re-render the modal.
5. Note that we prefer the AS-advertised `authorization_endpoint`
   over `authorizationUrl` because the latter is per-flight noise
   (state, code_challenge) rather than a stable identifier.

Finding 3 (modal exit animation short-circuited by the `{initializeResult
&& …}` guard) left as intentional — we want the snap-shut on disconnect
so a stale info dialog can't keep rendering with no live client behind it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cliffhall
Copy link
Copy Markdown
Member Author

Thanks for the review. Addressed in 05634b6a:

1. clientCapabilities ?? {} stability — ✅ Hoisted EMPTY_CLIENT_CAPABILITIES = Object.freeze({}) to module scope in core/react/useInspectorClient.ts. Fallback now returns a stable identity.

2. connectionInfoOAuth snapshot semantics — Tolerating it. Expanded the memo comment to say so explicitly: the modal is framed as "info about the connection at this moment," and subscribing a read-only dialog to oauthStepChange/oauthComplete is extra wiring for marginal benefit. If we later surface live token refresh state, we'll switch to subscribing on those events.

3. Modal close animation — Intentional. The {initializeResult && activeServer && …} guard snap-shuts the modal on disconnect because a stale dialog rendering OAuth/serverInfo/capabilities for a no-longer-live client is more confusing than a missed exit animation. Not changing this.

4. connectionInfoTransport "stdio" fallback — Tightened. Added activeServer to the modal-render guard ({initializeResult && activeServer && <ConnectionInfoModal … />}), so the only path that needs the ?? "stdio" default is the type narrower itself (config.type is schema-optional). Updated the comment to explain why the default still has to materialize here (the modal prop is a required ServerType).

5. Auth URL preference (metadata endpoint vs. constructed URL) — Kept the metadata endpoint first. Added a comment explaining: the constructed authorizationUrl carries per-flight noise (state, code_challenge) that's interesting for debugging but reads as noise in a connection summary; the AS-advertised endpoint is the stable "which AS is in use" identifier. Either is defensible; explicitly documenting the choice.

6. Close-button selector — ✅ Replaced document.querySelector("button.mantine-CloseButton-root") with screen.getByRole("button", { name: /close/i }). Added aria-label="Close" to CloseButton in ConnectionInfoModal.tsx so the test (and assistive tech) has a name to match.

7. Scope splitter (/\s+/" ") — ✅ Switched to the OAuth 2.1 §3.3 literal-space split. Added a comment noting the spec.

Re: nits — confirmed SERVER_CAPABILITY_KEYS / CLIENT_CAPABILITY_KEYS are unchanged from the pre-rename file, and the key in capabilities && capabilities[key] != null capability check is intentional (distinguishes "absent" from "present-but-empty-object" per MCP spec).

@cliffhall
Copy link
Copy Markdown
Member Author

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 30, 2026

Claude finished @cliffhall's task in 1m 20s —— View job


Re-review of follow-up changes (commit 05634b6a)

  • Pull latest and diff against origin/v2/main
  • Verify each of the 7 follow-up items in commit 05634b6
  • Check for any regressions or new issues

Verdict

LGTM. The follow-up commit cleanly addresses every actionable finding from the previous review, and the items left as "intentional" are well-justified with clear comments in the code itself (not just in the PR comment), so a future reader won't be tempted to "fix" them.

Per-finding check

1. EMPTY_CLIENT_CAPABILITIES stability — ✅ Resolved. core/react/useInspectorClient.ts:16 hoists Object.freeze({}) to module scope and the comment at lines 12–15 explains the staleness problem the constant solves. The fallback at line 130 now returns a stable identity, and Object.freeze defends against accidental mutation by a consumer.

2. connectionInfoOAuth snapshot semantics — ✅ Resolved as "tolerate-with-docs." clients/web/src/App.tsx:367–373 now spells out that token refreshes mid-session won't update the displayed token, and points at oauthStepChange/oauthComplete as the migration path if live state is ever needed. Fine — the memo behavior matches a snapshot dialog, the future-direction note is exactly the right thing to leave behind.

3. Modal exit animation short-circuited — ✅ Acknowledged as intentional. The "snap-shut on disconnect rather than render a stale info dialog" reasoning is defensible; the commit message captures the rationale. No change requested.

4. connectionInfoTransport "stdio" fallback — ✅ Resolved. Render guard at App.tsx:1037 now requires activeServer alongside initializeResult, making the invariant explicit. The ?? "stdio" default at line 358 remains, but only as the ServerType narrower for the optional config.type, and the new comment at 351–356 explains exactly why it lives there. Good.

5. Auth URL preference (endpoint vs. constructed URL) — ✅ Documented. The comment at App.tsx:375–380 makes the choice explicit and gives a one-line reason ("stable identifier of which AS is in use" vs. "per-flight noise"). That's the right resolution — either was defensible; documenting the trade-off in the source is the better outcome than just picking one.

6. Close-button selector — ✅ Resolved. ConnectionInfoModal.test.tsx:92 now uses screen.getByRole("button", { name: /close/i }), and ConnectionInfoModal.tsx:42 adds aria-label="Close" to give the button an accessible name. Bonus: this is a real a11y improvement for screen readers, not just a test-coupling fix.

7. Scope splitter — ✅ Resolved. App.tsx:394 now splits on the literal " " per OAuth 2.1 §3.3, with the rationale captured in the comment block at 382–385.

One small thing (non-blocking)

On the memo at App.tsx:404, the deps are [connectionStatus, inspectorClient, activeServer]. The comment frames this as "snapshot at modal open," but technically the memo re-runs whenever any of those three deps change — including when activeServer re-references after a settings edit (which would re-derive scopes). The current copy is close enough to be useful, but if you want to be precise the phrasing could be "snapshot at last derivation" rather than "snapshot at open." Not worth a re-spin.

Approval

All previously raised findings either fixed or explicitly accepted with documentation. Ready to merge from a review perspective.
· Branch: feat/1364-server-info-modal

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.

Implement Server Info modal wired to the connection handshake's InitializeResult

1 participant