feat: add MCP handshake test mode to Test Connection - #16
Conversation
marekdano
left a comment
There was a problem hiding this comment.
✅ 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.json — nullable: 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 tab → tabpanel 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.
|
@Altamimi-Dev - do you have capacity to look at the issues? |
99e718c to
e772acf
Compare
|
@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 Rebase. Rebased onto #1 — unconditional #10 — missing #2 — headline ternary. Extracted to #3 — unchecked map index access. Both lookups are guarded now ( #5 — copy button missing in handshake mode. Fixed. The button is now gated on a single #7 — the #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, #4 — Verification on the rebased branch: |
marekdano
left a comment
There was a problem hiding this comment.
@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.
e772acf to
da804cc
Compare
|
Thanks @marekdano — all three fixed in
While in there I fixed four more things I'd expect you to flag next round, each pinned:
Left alone: the Tests 2966 passed / 1 skipped (+8). Lint, format, build clean. With |
marekdano
left a comment
There was a problem hiding this comment.
@Altamimi-Dev - thank you for addressing all issues.
The PR is solid and can be LGTM 🚀
|
@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>
da804cc to
3545bc9
Compare
|
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 Also re-checked the openapi fragment now that the backend is in, it still matches One unrelated thing I ran into: |
|
Thanks @Altamimi-Dev for resolving conflict, yes please raise a PR for updating |
marekdano
left a comment
There was a problem hiding this comment.
@Altamimi-Dev - thanks for your contribution!
The pull request is safe to merge. It is extremely clean, highly tested, fully localized, and accessible.
LGTM 🚀
|
@marekdano Opened #68 for the husky hook recursion. |
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.
HTTP requestkeeps the existing raw-request behavior;MCP handshakecalls the newPOST /v1/mcp-servers/test-handshake. Method, content type and body inputs are hidden in handshake mode since they don't apply.server/discoverorinitialize), and credential source (stored server credentials / form headers / none).3+ toolswhen the backend'scountsPartialflags a truncated listing (nextCursorpresent).i18n
All new user-facing copy goes through
react-intl(useIntl+intl.formatMessage), with keys added to themcpServernamespace 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:
Latency: … msline — 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.server/discoverandinitialize— protocol identifiers, not prose.openapi.json
The snapshot is upstream's current
API v1.0.8file, with the same additive fragment re-applied on top of it after the rebase:.paths."/v1/mcp-servers/test-handshake"plus theGatewayHandshakeRequest/GatewayHandshakeResponseschemas, extracted from the gateway'sapp.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 generatepicks up the new endpoint and emits the handshake types.Verification
npm run generate— orval emitsGatewayHandshakeRequest/GatewayHandshakeResponsetypes from the v1.0.8-based snapshotnpm run test— 2958 passed, 1 skipped (169 files);TestConnectionPanel.test.tsxalone is 40 passed, including 18 handshake tests (mode switch, tab/tabpanel wiring, success detail rows, credential-source variants,countsPartialbadge, 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 lintandnpm run format:check— cleannpm run build— generate +tsc -b+ vite build cleangit diff --numstat upstream/main -- openapi.json—280 0, purely additiveOne note:
npm run i18n:compilefails on this branch, but it fails identically on an untouched checkout ofmain(Error: No JSON file found in src/i18n/locales—compile-folderis 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.