From e268032a49e2f002bdb2600a427cfe1e628c32f8 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 9 Oct 2025 18:32:55 +0800 Subject: [PATCH] fix: UX improvement for mcp client (#9229) * fix call tool data issue * add api key auth --- packages/insomnia/src/main/network/mcp.ts | 3 +- .../components/editors/auth/api-key-auth.tsx | 7 +- .../components/editors/auth/auth-wrapper.tsx | 5 +- .../src/ui/components/mcp/event-view.tsx | 35 ++++++++- .../ui/components/mcp/mcp-request-pane.tsx | 74 ++++++++++++------- .../src/ui/components/mcp/mcp-url-bar.tsx | 60 +++++++-------- 6 files changed, 120 insertions(+), 64 deletions(-) diff --git a/packages/insomnia/src/main/network/mcp.ts b/packages/insomnia/src/main/network/mcp.ts index a7d62cbe8e..b512178439 100644 --- a/packages/insomnia/src/main/network/mcp.ts +++ b/packages/insomnia/src/main/network/mcp.ts @@ -20,6 +20,7 @@ import { import type { GetPromptRequest, Notification, ReadResourceRequest } from '@modelcontextprotocol/sdk/types.js'; import { type ClientRequest, + CompatibilityCallToolResultSchema, ElicitResultSchema, EmptyResultSchema, InitializeRequestSchema, @@ -1018,7 +1019,7 @@ const callTool = async (options: CallToolOptions) => { const { requestId, name, parameters = {} } = options; const mcpClient = _getMcpClient(requestId); if (mcpClient) { - const response = await mcpClient.callTool({ name, arguments: parameters }); + const response = await mcpClient.callTool({ name, arguments: parameters }, CompatibilityCallToolResultSchema); return response.content; } return null; diff --git a/packages/insomnia/src/ui/components/editors/auth/api-key-auth.tsx b/packages/insomnia/src/ui/components/editors/auth/api-key-auth.tsx index b236384a0e..ec1d314d3a 100644 --- a/packages/insomnia/src/ui/components/editors/auth/api-key-auth.tsx +++ b/packages/insomnia/src/ui/components/editors/auth/api-key-auth.tsx @@ -12,11 +12,14 @@ export const options = [ { name: 'Cookie', value: COOKIE }, ]; -export const ApiKeyAuth: FC<{ disabled?: boolean }> = ({ disabled = false }) => ( +export const ApiKeyAuth: FC<{ disabled?: boolean; addToHeaderOnly?: boolean }> = ({ + disabled = false, + addToHeaderOnly = false, +}) => ( - + {!addToHeaderOnly && } ); diff --git a/packages/insomnia/src/ui/components/editors/auth/auth-wrapper.tsx b/packages/insomnia/src/ui/components/editors/auth/auth-wrapper.tsx index e5c24045b4..9c985534ee 100644 --- a/packages/insomnia/src/ui/components/editors/auth/auth-wrapper.tsx +++ b/packages/insomnia/src/ui/components/editors/auth/auth-wrapper.tsx @@ -26,14 +26,15 @@ export const AuthWrapper: FC<{ hideOthers?: boolean; hideInherit?: boolean; showMcpAuthFlow?: boolean; -}> = ({ authentication, disabled = false, authTypes, hideOthers, hideInherit, showMcpAuthFlow }) => { + addToHeaderOnly?: boolean; +}> = ({ authentication, disabled = false, authTypes, hideOthers, hideInherit, showMcpAuthFlow, addToHeaderOnly }) => { const type = getAuthObjectOrNull(authentication)?.type || ''; let authBody: ReactNode = null; if (type === 'basic') { authBody = ; } else if (type === 'apikey') { - authBody = ; + authBody = ; } else if (type === 'oauth2') { authBody = ; } else if (type === 'hawk') { diff --git a/packages/insomnia/src/ui/components/mcp/event-view.tsx b/packages/insomnia/src/ui/components/mcp/event-view.tsx index 2b1b54a599..4ac7627466 100644 --- a/packages/insomnia/src/ui/components/mcp/event-view.tsx +++ b/packages/insomnia/src/ui/components/mcp/event-view.tsx @@ -1,6 +1,7 @@ import fs from 'node:fs'; -import React, { useCallback } from 'react'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import React, { useCallback, useRef } from 'react'; import { Button } from 'react-aria-components'; import { useParams } from 'react-router'; @@ -11,9 +12,10 @@ import { PREVIEW_MODE_SOURCE, PREVIEW_MODES, } from '../../../common/constants'; +import { METHOD_CALL_TOOL } from '../../../common/mcp-utils'; import type { McpEvent } from '../../../main/network/mcp'; import { useRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; -import { CodeEditor } from '../../components/.client/codemirror/code-editor'; +import { CodeEditor, type CodeEditorHandle } from '../../components/.client/codemirror/code-editor'; import { showError } from '../../components/modals'; import { useRequestMetaPatcher } from '../../hooks/use-request'; import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; @@ -24,8 +26,10 @@ interface Props { export const MessageEventView = ({ event }: Props) => { const { requestId } = useParams() as { requestId: string }; + const editorRef = useRef(null); const isErrorEvent = event.type === 'error'; + const isCallToolEvent = event.type === 'message' && event.method === METHOD_CALL_TOOL; const eventData = isErrorEvent ? event.error : 'data' in event ? event.data : ''; const raw = JSON.stringify(eventData); @@ -63,6 +67,27 @@ export const MessageEventView = ({ event }: Props) => { let pretty = raw; try { const parsed = JSON.parse(raw); + // If call tool response, try to parse the `result.content` field if it's JSON string + if (isCallToolEvent && 'result' in parsed) { + const callToolResult = parsed.result; + if ('content' in callToolResult) { + const callToolParsedResult = CallToolResultSchema.safeParse(callToolResult); + if (callToolParsedResult.success) { + const callToolResultContents = callToolParsedResult.data.content; + callToolResultContents.forEach((callToolResultContent, idx) => { + if (callToolResultContent.type === 'text') { + const callToolResultContentText = callToolResultContent.text; + // Try to parse JSON text content + try { + const callToolResultContentTextParsed = JSON.parse(callToolResultContentText); + callToolResultContent.text = callToolResultContentTextParsed; + } catch (err) {} + } + parsed.result.content[idx] = callToolResultContent; + }); + } + } + } pretty = JSON.stringify(parsed, null, '\t'); } catch { // Can't parse as JSON. @@ -87,7 +112,10 @@ export const MessageEventView = ({ event }: Props) => { patchRequestMeta(requestId, { previewMode: mode })} + onClick={() => { + patchRequestMeta(requestId, { previewMode: mode }); + editorRef.current?.setValue(mode === PREVIEW_MODE_FRIENDLY ? pretty : raw); + }} /> ))} @@ -109,6 +137,7 @@ export const MessageEventView = ({ event }: Props) => { mode={previewMode === PREVIEW_MODE_RAW ? 'text/plain' : 'text/json'} defaultValue={previewMode === PREVIEW_MODE_FRIENDLY ? pretty : raw} uniquenessKey={event._id} + ref={editorRef} readOnly /> diff --git a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx index 9e1de4a236..a7c274aec4 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx @@ -5,9 +5,12 @@ import { Button, Heading, Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-ar import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { useLatest } from 'react-use'; +import { docsBase } from '~/common/documentation'; import { buildResourceJsonSchema, fillUriTemplate } from '~/common/mcp-utils'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; +import { Link } from '~/ui/components/base/link'; import { EnvironmentKVEditor } from '~/ui/components/editors/environment-key-value-editor/key-value-editor'; +import { Icon } from '~/ui/components/icon'; import { InsomniaRjsfForm, type InsomniaRjsfFormHandle } from '~/ui/components/rjsf'; import { type AuthTypes } from '../../../common/constants'; @@ -26,7 +29,7 @@ import { McpRootsPanel } from './mcp-roots-panel'; import { McpUrlActionBar } from './mcp-url-bar'; import type { PrimitiveSubItem } from './types'; -const supportedAuthTypes: AuthTypes[] = ['basic', 'oauth2', 'bearer']; +const supportedAuthTypes: AuthTypes[] = ['basic', 'oauth2', 'bearer', 'apikey']; export type RequestPaneTabs = 'params' | 'auth' | 'headers' | 'roots'; const PaneReadOnlyBanner = () => { @@ -63,6 +66,7 @@ export const McpRequestPane: FC = ({ }) => { const primitiveId = `${selectedPrimitiveItem?.type}_${selectedPrimitiveItem?.name}`; const { activeRequest, activeRequestMeta, requestPayload } = useRequestLoaderData()! as McpRequestLoaderData; + const [isCalling, setIsCalling] = useState(false); const latestRequestPayloadRef = useLatest(requestPayload); const { activeProject } = useWorkspaceLoaderData()!; @@ -125,30 +129,37 @@ export const McpRequestPane: FC = ({ [primitiveId, selectedPrimitiveItem?.type], ); - const handleSend = () => { + const handleSend = async () => { rjsfFormRef.current?.validate(); - if (selectedPrimitiveItem?.type === 'tools') { - window.main.mcp.primitive.callTool({ - name: selectedPrimitiveItem?.name || '', - parameters: mcpParams[primitiveId], - requestId: requestId, - }); - } else if (selectedPrimitiveItem?.type === 'resources') { - window.main.mcp.primitive.readResource({ - requestId, - uri: selectedPrimitiveItem?.uri || '', - }); - } else if (selectedPrimitiveItem?.type === 'resourceTemplates') { - window.main.mcp.primitive.readResource({ - requestId, - uri: fillUriTemplate(selectedPrimitiveItem.uriTemplate, mcpParams[primitiveId] || {}), - }); - } else if (selectedPrimitiveItem?.type === 'prompts') { - window.main.mcp.primitive.getPrompt({ - requestId, - name: selectedPrimitiveItem?.name || '', - parameters: mcpParams[primitiveId], - }); + try { + setIsCalling(true); + if (selectedPrimitiveItem?.type === 'tools') { + await window.main.mcp.primitive.callTool({ + name: selectedPrimitiveItem?.name || '', + parameters: mcpParams[primitiveId], + requestId: requestId, + }); + } else if (selectedPrimitiveItem?.type === 'resources') { + await window.main.mcp.primitive.readResource({ + requestId, + uri: selectedPrimitiveItem?.uri || '', + }); + } else if (selectedPrimitiveItem?.type === 'resourceTemplates') { + await window.main.mcp.primitive.readResource({ + requestId, + uri: fillUriTemplate(selectedPrimitiveItem.uriTemplate, mcpParams[primitiveId] || {}), + }); + } else if (selectedPrimitiveItem?.type === 'prompts') { + await window.main.mcp.primitive.getPrompt({ + requestId, + name: selectedPrimitiveItem?.name || '', + parameters: mcpParams[primitiveId], + }); + } + } catch (err) { + console.warn('MCP primitive call error', err); + } finally { + setIsCalling(false); } }; @@ -272,9 +283,11 @@ export const McpRequestPane: FC = ({ {!readyState ? ( -
+
{/* Hint when mcp server is not connected*/} - Enter MCP server url to discover capabilities +

+ Connect to an MCP server URL to reveal capabilities.  Learn More +

) : ( @@ -289,11 +302,19 @@ export const McpRequestPane: FC = ({ onClick={handleSend} className="rounded bg-[--color-surprise] px-[--padding-md] text-center text-[--color-font-surprise]" > + {isCalling && } {sendButtonText}
)} + {!selectedPrimitiveItem && ( +
+

+ Select an MCP server primitive from the list to start. +

+
+ )} {jsonSchema && (

{selectedPrimitiveItem?.name}

@@ -350,6 +371,7 @@ export const McpRequestPane: FC = ({ authTypes={supportedAuthTypes} hideInherit showMcpAuthFlow + addToHeaderOnly /> diff --git a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx index 3e391838e9..cf63972a83 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx @@ -109,6 +109,9 @@ export const McpUrlActionBar = ({ } else if (authentication.type === 'bearer' && authentication.token) { const { token, prefix } = authentication; headers.push(getBearerAuthHeader(token, prefix)); + } else if (authentication.type === 'apikey') { + const { key, value } = authentication; + headers.push({ name: key, value }); } else if (authentication.type === 'oauth2') { const oAuth2Token = await getOAuth2Token(request._id, authentication as AuthTypeOAuth2); if (oAuth2Token) { @@ -213,38 +216,35 @@ export const McpUrlActionBar = ({ }, []); const isConnectingOrClosed = !readyState; + const isDropdownDisabled = isOpen || isConnecting; return ( <> - {!isOpen && ( -
- - {requestTransportTypeLabel} - - } - placement="bottom start" - > - - {MCP_TRANSPORT_TYPES.map(transportType => ( - - patchRequest(request._id, { transportType })} - /> - - ))} - - -
- )} - {isOpen && ( - - - CONNECTED - - )} +
+ + {requestTransportTypeLabel} + + } + placement="bottom start" + isDisabled={isDropdownDisabled} + > + + {MCP_TRANSPORT_TYPES.map(transportType => ( + + patchRequest(request._id, { transportType })} + /> + + ))} + + +
- Discover + Connect ) : (