fix: UX improvement for mcp client (#9229)

* fix call tool data issue
* add api key auth
This commit is contained in:
Kent Wang
2025-10-09 18:32:55 +08:00
parent ab8ac3d983
commit 2b214664e8
6 changed files with 120 additions and 64 deletions

View File

@@ -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;

View File

@@ -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,
}) => (
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthInputRow label="Key" property="key" disabled={disabled} />
<AuthInputRow label="Value" property="value" mask disabled={disabled} />
<AuthSelectRow label="Add to" property="addTo" options={options} disabled={disabled} />
{!addToHeaderOnly && <AuthSelectRow label="Add to" property="addTo" options={options} disabled={disabled} />}
</AuthTableBody>
);

View File

@@ -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 = <BasicAuth disabled={disabled} />;
} else if (type === 'apikey') {
authBody = <ApiKeyAuth disabled={disabled} />;
authBody = <ApiKeyAuth disabled={disabled} addToHeaderOnly={addToHeaderOnly} />;
} else if (type === 'oauth2') {
authBody = <OAuth2Auth showMcpAuthFlow={showMcpAuthFlow} disabled={disabled} />;
} else if (type === 'hawk') {

View File

@@ -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<CodeEditorHandle>(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) => {
<ItemContent
icon={previewMode === mode ? 'check' : 'empty'}
label={getPreviewModeName(mode, true)}
onClick={() => patchRequestMeta(requestId, { previewMode: mode })}
onClick={() => {
patchRequestMeta(requestId, { previewMode: mode });
editorRef.current?.setValue(mode === PREVIEW_MODE_FRIENDLY ? pretty : raw);
}}
/>
</DropdownItem>
))}
@@ -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
/>
</div>

View File

@@ -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<Props> = ({
}) => {
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<Props> = ({
[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<Props> = ({
</TabList>
<TabPanel className="flex h-full w-full flex-1 flex-col overflow-y-auto" id="params">
{!readyState ? (
<div className="flex h-full w-full flex-col items-center gap-3 pt-[5%] text-center">
<div className="flex h-full w-full flex-col items-center p-5 text-center">
{/* Hint when mcp server is not connected*/}
<span className="text-md">Enter MCP server url to discover capabilities</span>
<p className="notice info text-md no-margin-top w-full">
Connect to an MCP server URL to reveal capabilities. &nbsp;<Link href={docsBase}>Learn More</Link>
</p>
</div>
) : (
<PanelGroup className="flex-1 overflow-hidden" direction={'vertical'}>
@@ -289,11 +302,19 @@ export const McpRequestPane: FC<Props> = ({
onClick={handleSend}
className="rounded bg-[--color-surprise] px-[--padding-md] text-center text-[--color-font-surprise]"
>
{isCalling && <Icon className="mr-1 animate-spin" icon="spinner" />}
{sendButtonText}
</Button>
</div>
)}
</Toolbar>
{!selectedPrimitiveItem && (
<div className="flex h-full w-full flex-col items-center p-5 text-center">
<p className="notice info text-md no-margin-top w-full">
Select an MCP server primitive from the list to start.
</p>
</div>
)}
{jsonSchema && (
<div className="overflow-auto p-4">
<p>{selectedPrimitiveItem?.name}</p>
@@ -350,6 +371,7 @@ export const McpRequestPane: FC<Props> = ({
authTypes={supportedAuthTypes}
hideInherit
showMcpAuthFlow
addToHeaderOnly
/>
</TabPanel>
<TabPanel className="w-full flex-1 overflow-y-auto" id="headers">

View File

@@ -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 && (
<div className="flex items-center">
<Dropdown
triggerButton={
<RaButton className="pl-2" aria-label="Request Method">
<span>{requestTransportTypeLabel}</span> <i className="fa fa-caret-down space-left" />
</RaButton>
}
placement="bottom start"
>
<DropdownSection>
{MCP_TRANSPORT_TYPES.map(transportType => (
<DropdownItem key={transportType}>
<ItemContent
label={getTransportLabel(transportType)}
onClick={() => patchRequest(request._id, { transportType })}
/>
</DropdownItem>
))}
</DropdownSection>
</Dropdown>
</div>
)}
{isOpen && (
<span className="text-success flex items-center pl-[--padding-md]">
<span className="mr-[--padding-sm] h-2.5 w-2.5 rounded-[50%] bg-[--color-success]" />
CONNECTED
</span>
)}
<div className="flex items-center">
<Dropdown
triggerButton={
<RaButton
className={`pl-2 ${isDropdownDisabled ? 'cursor-not-allowed opacity-30' : ''}`}
aria-label="Request Method"
>
<span>{requestTransportTypeLabel}</span> <i className="fa fa-caret-down space-left" />
</RaButton>
}
placement="bottom start"
isDisabled={isDropdownDisabled}
>
<DropdownSection>
{MCP_TRANSPORT_TYPES.map(transportType => (
<DropdownItem key={transportType}>
<ItemContent
label={getTransportLabel(transportType)}
onClick={() => patchRequest(request._id, { transportType })}
/>
</DropdownItem>
))}
</DropdownSection>
</Dropdown>
</div>
<form
className="flex flex-1"
aria-disabled={isOpen}
@@ -273,7 +273,7 @@ export const McpUrlActionBar = ({
disabled={connectRequestFetcher.state === 'submitting' || connectRequestFetcher.state === 'loading'}
type="submit"
>
Discover
Connect
</button>
) : (
<DisconnectButton requestId={request._id} />