Skip to content

feat: add MCP handshake test mode to Test Connection - #16

Merged
vishu-bh merged 1 commit into
contextforge-org:mainfrom
Altamimi-Dev:5649-mcp-handshake-test-mode
Aug 21, 2026
Merged

feat: add MCP handshake test mode to Test Connection#16
vishu-bh merged 1 commit into
contextforge-org:mainfrom
Altamimi-Dev:5649-mcp-handshake-test-mode

Conversation

@Altamimi-Dev

@Altamimi-Dev Altamimi-Dev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Depends on IBM/mcp-context-forge#5934

Summary

UI half of the MCP handshake test, split out of IBM/mcp-context-forge#5934 now that the client lives in this repo — same pattern as #15.

Test Connection currently only proves a URL answers HTTP. This adds a second mode that proves the target actually speaks MCP.

  • Mode toggleHTTP request keeps the existing raw-request behavior; MCP handshake calls the new POST /v1/mcp-servers/test-handshake. Method, content type and body inputs are hidden in handshake mode since they don't apply.
  • Detail rows on success — server name and version, protocol version, negotiation path (server/discover or initialize), and credential source (stored server credentials / form headers / none).
  • Count badges — first-page tools/resources/prompts counts, rendered as 3+ tools when the backend's countsPartial flags a truncated listing (nextCursor present).
  • Failure-class badge — Transport / Protocol negotiation / Authentication / Invalid response, alongside the backend's actionable error copy.
  • Raw response — collapsible, truncated preview of the negotiated payload, with the same copy button HTTP mode already had.
  • Cancellation — the in-flight request is aborted on unmount, on Cancel, and when switching modes, so a stale response can't land on the new mode.

i18n

All new user-facing copy goes through react-intl (useIntl + intl.formatMessage), with keys added to the mcpServer namespace for en-US, es-ES and pt-BR. The en-US messages are byte-identical to the inline strings they replace, so the ported tests assert unchanged output. Component counts use ICU plural forms ({count, plural, one {# tool} other {# tools}}).

Two things stayed inline deliberately:

  • The handshake Latency: … ms line — identical to the adjacent HTTP-mode line, which is not localized yet. Worth migrating together when this file gets a full localization pass rather than localizing one of the pair.
  • The negotiation-path values server/discover and initialize — protocol identifiers, not prose.

openapi.json

The snapshot is upstream's current API v1.0.8 file, with the same additive fragment re-applied on top of it after the rebase: .paths."/v1/mcp-servers/test-handshake" plus the GatewayHandshakeRequest / GatewayHandshakeResponse schemas, extracted from the gateway's app.openapi() on the #5934 branch. Everything else in the spec is untouched, and all $refs in the added fragment (HTTPValidationError) already existed in the snapshot. npm run generate picks up the new endpoint and emits the handshake types.

Verification

  • npm run generate — orval emits GatewayHandshakeRequest/GatewayHandshakeResponse types from the v1.0.8-based snapshot
  • npm run test — 2958 passed, 1 skipped (169 files); TestConnectionPanel.test.tsx alone is 40 passed, including 18 handshake tests (mode switch, tab/tabpanel wiring, success detail rows, credential-source variants, countsPartial badge, all four failure classes, raw-preview render and copy, path/headers forwarding, Cancel mid-handshake, abort-on-unmount, error-clearing on mode switch)
  • npm run lint and npm run format:check — clean
  • npm run build — generate + tsc -b + vite build clean
  • git diff --numstat upstream/main -- openapi.json280 0, purely additive

One note: npm run i18n:compile fails on this branch, but it fails identically on an untouched checkout of main (Error: No JSON file found in src/i18n/localescompile-folder is pointed at the parent directory rather than the per-locale directories). Pre-existing, and happy to fix it in a separate PR if that's useful.

Relates to IBM/mcp-context-forge#5649 — the backend half is IBM/mcp-context-forge#5934; together they complete the issue.

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Overall Assessment

High-quality, well-structured PR. The implementation is clean, the test coverage is thorough, and the i18n story is complete across three locales. The findings below are mostly minor, with one medium-severity bug and one WCAG failure worth addressing before merge.


🔴 Must Fix

1. handleTest — HTTP payload built unconditionally in handshake mode

File: src/components/servers/TestConnectionPanel.tsx

The GatewayTestRequest payload — including JSON.parse(body) — is fully assembled before the if (mode === "handshake") branch runs. In handshake mode the payload is never sent, but if a user typed an invalid JSON body in HTTP mode, then switched to handshake mode and hit Test, the unconditional JSON.parse(body) throws outside the handshake try/catch, leaving the component stuck with no error message.

// Runs even when mode === "handshake":
let parsedBody: string | Record<string, unknown> | undefined;
if (sendsBodyFor(method) && body.trim()) {
  parsedBody = contentType === "application/json" ? (JSON.parse(body) as ...) : body; // 💥
}
const payload: GatewayTestRequest = { ... };

Fix: Move the if (mode === "handshake") { ... return; } block above the payload construction, or guard the entire payload block with if (mode === "http").


🟡 Should Fix

2. headline — deeply nested ternary is fragile

File: src/components/servers/TestConnectionPanel.tsx

const headline =
  mode === "handshake"
    ? handshakeResponse
      ? handshakeResponse.success ? "Handshake succeeded" : "Handshake failed"
      : error || intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" })
    : response
      ? `Status: ${response.statusCode} ${status === "success" ? "OK" : "error"}`
      : error || "Connection failed";

When status === "success" but handshakeResponse is unexpectedly null, the headline silently shows "Handshake failed". Consider extracting into a named function:

function getHandshakeHeadline(
  resp: GatewayHandshakeResponse | null,
  err: string,
  intl: IntlShape,
): string {
  if (!resp) return err || intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" });
  return resp.success
    ? intl.formatMessage({ id: "mcpServer.testConnection.handshakeSucceeded" })
    : intl.formatMessage({ id: "mcpServer.testConnection.handshakeFailed" });
}

3. FAILURE_CLASS_MESSAGE_IDS / CREDENTIAL_SOURCE_MESSAGE_IDS — unchecked index access

File: src/components/servers/TestConnectionPanel.tsx

id: FAILURE_CLASS_MESSAGE_IDS[handshakeResponse.failureClass],       // may be undefined id: CREDENTIAL_SOURCE_MESSAGE_IDS[handshakeResponse.credentialSource ?? "none"],

Record<string, string> index access returns string | undefined at runtime. If the backend returns a future enum value not yet in the map, intl.formatMessage({ id: undefined }) will throw or render a raw key. Add a fallback:

id: FAILURE_CLASS_MESSAGE_IDS[handshakeResponse.failureClass]
    ?? `mcpServer.testConnection.failureClass.${handshakeResponse.failureClass}`,

4. openapi.jsonnullable: true is not valid OpenAPI 3.1

File: openapi.json

"GatewayHandshakeRequest": { ..., "nullable": true },
"GatewayHandshakeResponse": { ..., "nullable": true }

nullable is an OpenAPI 3.0 extension. The rest of this spec uses the 3.1 pattern (anyOf: [{...}, {type: "null"}]). Orval is tolerant today but a strict 3.1 parser will reject it. Backend concern for #5934

5. Copy button absent from handshake raw preview

File: src/components/servers/TestConnectionPanel.tsx

The copy-to-clipboard <Button> is gated on responseBodyText, so it is never rendered in handshake mode even when handshakeRawPreview is non-empty. Either add a copy button inside the <details> block or add a comment documenting the intentional omission.


🟢 Nits / Observations

6. DetailRow<dt>/<dd> outside <dl> would be invalid

The <dt> and <dd> elements in DetailRow are valid because they sit inside the <dl> parent in the caller. This is fine — just noting
that the outer <dl> is load-bearing for semantics and must not be removed.

7. countsPartial — non-ICU plural format is intentional but undocumented

"mcpServer.testConnection.countsPartial.tools": "{count}+ tools"

Plain substitution (not plural), so 1+ tools renders as plural even when count is 1. Correct per the PR description, but a short inline comment would prevent a future contributor from "fixing" it.

8. No test for Cancel button during a handshake in-flight request

The HTTP suite covers Cancel (shows during flight, aborts, returns to idle). The handshake suite tests unmount-cancellation but not the Cancel button itself. Low risk — same code path — but easy to add.

9. useCallback dep on intl is safe

useIntl() returns a stable reference in react-intl v6+, so listing intl in the useCallback dependency array will not cause spurious re-renders. No action needed.

10. ⚠️ Missing TabsContent / broken tabtabpanel ARIA relationship

File: src/components/servers/TestConnectionPanel.tsx

<Tabs value={mode} onValueChange={...}>
  <TabsList>
    <TabsTrigger value="http">HTTP request</TabsTrigger>
    <TabsTrigger value="handshake">MCP handshake</TabsTrigger>
  </TabsList>
  {/* No <TabsContent> — content rendered outside the Tabs tree */}
</Tabs>

The tab ARIA role requires each <TabsTrigger> to be associated with a tabpanel via aria-controls. Radix UI generates that relationship automatically when <TabsContent> is present. Without it, screen readers can navigate to the tabs but cannot find the controlled content. This fails WCAG 4.1.2 — Name, Role, Value (Level AA).

Fix — wrap the form content in <TabsContent> panels:

<Tabs value={mode} onValueChange={...}>
  <TabsList>...</TabsList>
  <TabsContent value="http">
    {/* existing left/right grid */}
  </TabsContent>
  <TabsContent value="handshake">
    {/* same grid, handshake mode */}
  </TabsContent>
</Tabs>

📊 Test Coverage Summary

Scenario Covered
Mode toggle — UI fields hidden/shown
Stored-credentials hint visible
Success: identity rows + count badges
Partial counts plural enforcement
Error clearing on mode switch
All 4 failure classes ✅ (it.each)
Unmount cancellation
Cancel button during handshake
rawPreview collapsible renders
Credential source label variants (stored, form)
Path forwarded in handshake payload
Headers forwarded in handshake payload

Summary

Severity # Description
🔴 Must fix 1 JSON.parse throws outside try/catch when switching from HTTP mode
🟡 Should fix 4 Headline ternary, unchecked map access, OpenAPI nullable, missing copy button
🟢 Nit / a11y 5 Missing TabsContent ARIA wiring is the most important of these

Block on: 1 (reproducible bug) and 10 (WCAG 4.1.2 failure). Everything else is polish and can follow in a separate PR.

@marekdano marekdano self-assigned this Aug 18, 2026
@marekdano

Copy link
Copy Markdown
Contributor

@Altamimi-Dev - do you have capacity to look at the issues?

@Altamimi-Dev
Altamimi-Dev force-pushed the 5649-mcp-handshake-test-mode branch from 99e718c to e772acf Compare August 20, 2026 04:18
@Altamimi-Dev

Copy link
Copy Markdown
Contributor Author

@marekdano thanks for the thorough review, and sorry for the wait — this is picked back up now. Both blocking findings are fixed, the should-fixes are in, and the branch is rebased onto current main so it shows MERGEABLE again. Walking your list in order:

Rebase. Rebased onto main (v1.0.8 openapi.json regen + the locale drift). Conflicts were only in the spec snapshot and the three mcpServer.json locale files: the spec is now upstream's v1.0.8 file with our handshake fragment re-applied additively on top (same content as before, just on a newer base), and each locale is upstream's file plus our 25 new testConnection.* keys — no upstream key was modified or dropped.

#1 — unconditional JSON.parse in handshake mode (blocking). Fixed. handleTest now computes only parsedHeaders (validated in both modes) before branching; the body parse and the whole GatewayTestRequest payload moved below the handshake branch's return, so they only run on the HTTP path. Your exact repro is now a test: type invalid JSON into Body in HTTP mode, switch to handshake, run — it previously threw synchronously before setStatus("testing") and left the panel silently idle. I confirmed the new test fails against the pre-fix component and passes after, so it's actually pinning the behavior.

#10 — missing TabsContent (blocking). Fixed. The form/response grid is extracted into a formGrid const and rendered inside <TabsContent value="http"> / <TabsContent value="handshake">, which restores the Radix tab → tabpanel aria-controls wiring. Radix only mounts the active panel, so the shared node renders once, and field values survive tab switches since they live in React state. The outer space-y-6 became gap-6 on the Tabs root (it's flex flex-col), so the spacing is unchanged. There's a new a11y test asserting each trigger's aria-controls matches the rendered tabpanel id — it fails on the old structure since no tabpanel role existed at all. Heads-up on diff size: extracting the grid reindented that whole block, so TestConnectionPanel.tsx looks larger than it is; the behavioral changes are handleTest, the headline, the two map lookups, the copy button, and the new panels.

#2 — headline ternary. Extracted to getHandshakeHeadline(response, error, intl) as suggested; the HTTP arm of the ternary is unchanged.

#3 — unchecked map index access. Both lookups are guarded now (failureClass badge and the credential-source row). One small deviation from your sketch: instead of falling back to a constructed message id, an unknown value renders the raw backend string. That way react-intl never logs a missing-translation error, and a future enum value can't be mislabeled as an existing class. Happy to switch to the constructed-id form if you'd rather keep it uniform — it's a one-line change at each site.

#5 — copy button missing in handshake mode. Fixed. The button is now gated on a single copyText that switches on mode (responseBodyText for HTTP, the pretty-printed raw preview for handshake), keeping the same aria-label.

#7 — the {count}+ format. Added the comment: it's deliberately plain substitution rather than ICU plural, because the + reads as "at least", so 1+ tools is correct even when the first page holds a single item.

#8 + the coverage-gap table. Eight tests added to the handshake block: the #1 regression, the tab/tabpanel a11y check, Cancel mid-handshake (asserting the request aborts and the panel returns to idle), raw-preview render plus copy payload, credentialSource: stored and form labels, and path/headers forwarding in the handshake payload.

#4nullable: true in an OpenAPI 3.1 document. You're right that it's not valid 3.1, but it isn't introduced here, so I've left the snapshot alone rather than hand-editing it away from what the gateway actually emits. It comes from the backend's shared BaseModelWithConfigDict (model_config = ConfigDict(..., json_schema_extra={"nullable": True}) in mcpgateway/utils/base_models.py), so every schema derived from it carries the key — 38 occurrences in the snapshot before this PR, including GatewayTestRequest and GatewayTestResponse on the endpoint this feature sits next to. Our two handshake schemas just match that existing convention. A real fix is a repo-wide change on the gateway side (drop the extra, or emit type: [..., "null"]); happy to file a backend issue for it if that sounds right to you.

Verification on the rebased branch: npm run test 2958 passed / 1 skipped (169 files), TestConnectionPanel.test.tsx 40 passed (18 handshake tests), npm run lint and npm run format:check clean, npm run build clean, and npm run generate still emits the handshake types from the v1.0.8-based snapshot. The PR body is refreshed too — the old "pinned at v1.0.7" note no longer applied.

@Altamimi-Dev
Altamimi-Dev requested a review from marekdano August 20, 2026 04:47

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Altamimi-Dev - thanks for addressing the issues

We have more findings

🔴 High — Correctness

src/components/servers/TestConnectionPanel.tsx:610
Handshake diagnostic fields (serverName, protocolVersion, negotiationPath, credentialSource) only render when handshakeResponse.success is true, hiding them on every failure.

Scenario: A handshake against a server using stored credentials is rejected (401), returning { success: false, failureClass: 'auth', credentialSource: 'stored', error: '...' }. The UI shows only a generic "Handshake failed" headline and an "Authentication" badge - the credentialSource row is suppressed by the handshakeResponse?.success && gate, so the user can't tell whether stored creds, form headers, or no creds were used. This defeats the purpose of credentialSource for exactly the case (auth failure) it exists to explain.


🟡 Medium — i18n

src/components/servers/TestConnectionPanel.tsx:645
negotiationPath's display value is hardcoded as raw English literals (server/discover / initialize) instead of going through intl.formatMessage, unlike every other new string in this feature.

Scenario: A user on locale es-ES or pt-BR runs a handshake test; every other new label is translated, but "Negotiation path" always reads in English - inconsistent with the rest of the newly localized UI, even though en-US/es-ES/pt-BR locale files were all updated for this feature.


🟡 Medium — Test Coverage

src/api/servers.ts:138
The new serversApi.testHandshake method has no direct unit test in src/api/servers.test.ts, unlike its sibling testConnectivity.

Scenario: A future change to the endpoint path, request/response shape, or signal-forwarding for testHandshake (e.g. a typo in /v1/mcp-servers/test-handshake) would only be caught indirectly via TestConnectionPanel's MSW-mocked component tests, not by a focused API-layer test — making the regression harder to isolate.

@Altamimi-Dev
Altamimi-Dev force-pushed the 5649-mcp-handshake-test-mode branch from e772acf to da804cc Compare August 20, 2026 10:34
@Altamimi-Dev

Altamimi-Dev commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @marekdano — all three fixed in da804cc, each with a test that fails if the fix is reverted.

  1. Diagnostic rows on failure. Gate is now handshakeResponse &&. Rows already had their own presence guards, so nothing else changed. Count chips keep the success gate — counts are meaningless on a failure. Test mocks your exact case (401, credentialSource: "stored").
  2. negotiationPath literals. Now a message-id map like the two next to it. Unknown values render raw instead of being mislabelled initialize, which the old ternary did. Values are identical in all three locales because server/discover and initialize are protocol method names — routing them through formatMessage is what makes them translatable if you'd rather they read as prose. Two lines per locale file, say the word.
  3. testHandshake test. Added, mirroring testConnectivity, plus an assertion that the AbortSignal reaches fetch since you called that out.

While in there I fixed four more things I'd expect you to flag next round, each pinned:

  • A validation-failed re-test left a green check next to "Handshake failed" — handleTest now resets status.
  • A rejected request rendered the API error as both headline and detail, so the live region said it twice.
  • The stored-credentials hint wasn't in the Headers aria-describedby.
  • validateHeaders accepted {"X-Retry": 3} and posted it — guaranteed 422. Fixed in the shared validator since HTTP mode has the same hole.

Left alone: the Latency: {n} ms literal mirrors the identical pre-existing line in the HTTP branch on main. Localising one leaves them inconsistent; localising both touches untouched code. Separate PR if you want it.

Tests 2966 passed / 1 skipped (+8). Lint, format, build clean. With TestConnectionPanel.tsx reverted, exactly the seven new pins fail.

@Altamimi-Dev
Altamimi-Dev requested a review from marekdano August 20, 2026 10:52

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Altamimi-Dev - thank you for addressing all issues.

The PR is solid and can be LGTM 🚀

@marekdano

Copy link
Copy Markdown
Contributor

@Altamimi-Dev - Can you please resolve the conflicts? The backend part of the issue has been merged.

UI half of the MCP handshake test, split out of
IBM/mcp-context-forge#5934 now that the client lives in this repo.

Test Connection gains a mode toggle. HTTP request keeps the existing
raw-request behavior; MCP handshake calls the new
POST /v1/mcp-servers/test-handshake and reports whether the target
actually speaks MCP:

- Detail rows for server name/version, protocol version, negotiation
  path (server/discover or initialize) and credential source
- Count badges for first-page tools/resources/prompts, rendered as
  "3+ tools" when countsPartial marks the listing truncated
- A failure-class badge (transport / protocol negotiation /
  authentication / invalid response) with the backend's actionable copy
- A collapsible raw-response preview
- Method, content type and body inputs are hidden in handshake mode;
  the in-flight request is aborted on unmount, cancel, and mode switch

New user-facing copy goes through react-intl, with keys added to the
en-US, es-ES and pt-BR mcpServer namespaces. The en-US messages are
byte-identical to the strings they replace. Component counts use ICU
plural forms.

openapi.json gains only the new /v1/mcp-servers/test-handshake path
plus the GatewayHandshakeRequest/GatewayHandshakeResponse schemas,
extracted from the gateway's app.openapi(). The snapshot stays pinned
at API v1.0.7 otherwise, so the generated types pick up the handshake
endpoint without dragging in unrelated spec drift.

Relates to IBM/mcp-context-forge#5649

Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
@Altamimi-Dev
Altamimi-Dev force-pushed the 5649-mcp-handshake-test-mode branch from da804cc to 3545bc9 Compare August 21, 2026 12:54
@Altamimi-Dev

Altamimi-Dev commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main and force-pushed, conflicts are gone.

Only the Test Connection panel, its test file and the three mcpServer.json files clashed. I moved the copy control over to the shared CopyButton from #61 instead of keeping the inline button this branch had, since that refactor clearly meant to replace it. Handshake mode gets the Copied! feedback for free that way. The test now uses the same navigator.clipboard spy as copy-button.test.tsx, and I kept your copyResponseBody translations as they were.

Also re-checked the openapi fragment now that the backend is in, it still matches schemas.py, so the spec and generated types didn't need touching. Tests, lint and build are green.

One unrelated thing I ran into: .husky/post-checkout just contains the line .husky/_/post-checkout, and since the shim runs the hook with sh -e, that line calls the shim again. Every git checkout or rebase loops until you kill it. Looks like it wants to be . "$(dirname "$0")/_/post-checkout". Can open a separate PR if you want.

@vishu-bh

Copy link
Copy Markdown
Contributor

Thanks @Altamimi-Dev for resolving conflict, yes please raise a PR for updating .husky/post-checkout.

@marekdano marekdano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Altamimi-Dev - thanks for your contribution!

The pull request is safe to merge. It is extremely clean, highly tested, fully localized, and accessible.

LGTM 🚀

@Altamimi-Dev

Altamimi-Dev commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@marekdano Opened #68 for the husky hook recursion.

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

@vishu-bh
vishu-bh merged commit deca912 into contextforge-org:main Aug 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants