show primitive error detail

This commit is contained in:
Kent Wang committed 2026-10-08 11:17:07 +08:00
1 parent ab658aa7e4
commit f6d34280ff
4 files changed
+541 -77

No files matched your search

@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest';
import type { McpEvent } from '~/main/mcp/types';
import {
combineListErrors,
findFirstMatchEventData,
findLatestListResult,
METHOD_INITIALIZE,
METHOD_LIST_PROMPTS,
METHOD_LIST_RESOURCE_TEMPLATES,
METHOD_LIST_RESOURCES,
METHOD_LIST_TOOLS,
} from './mcp-utils';
const BASE = { _id: 'evt', requestId: 'req_1', timestamp: 0 };
const outgoing = (method: string, id: number): McpEvent =>
({
...BASE,
type: 'message',
direction: 'OUTGOING',
method,
data: { jsonrpc: '2.0', id, method, params: {} },
}) as McpEvent;
const incomingResult = (method: string, id: number, result: unknown): McpEvent =>
({
...BASE,
type: 'message',
direction: 'INCOMING',
method,
data: { jsonrpc: '2.0', id, result },
}) as McpEvent;
const incomingErrorEvent = (id: number, message: string): McpEvent =>
({
...BASE,
type: 'error',
message,
error: { code: -32_601, requestId: id, message },
}) as McpEvent;
const validTool = (name: string) => ({ name, inputSchema: { type: 'object' } });
describe('findFirstMatchEventData', () => {
it('returns undefined when no incoming event matches the method', () => {
const events = [incomingResult(METHOD_LIST_TOOLS, 1, { tools: [] })];
expect(findFirstMatchEventData(events, METHOD_INITIALIZE)).toBeUndefined();
});
it('returns the result payload of the matching incoming event', () => {
const events = [incomingResult(METHOD_INITIALIZE, 1, { capabilities: { tools: {} } })];
expect(findFirstMatchEventData(events, METHOD_INITIALIZE)).toEqual({ capabilities: { tools: {} } });
});
it('returns undefined when the matching event carries no result field', () => {
const events: McpEvent[] = [
{ ...BASE, type: 'message', direction: 'INCOMING', method: METHOD_INITIALIZE, data: {} } as McpEvent,
];
expect(findFirstMatchEventData(events, METHOD_INITIALIZE)).toBeUndefined();
});
it('picks the first matching event when there are multiple (latest-first ordering)', () => {
const events = [
incomingResult(METHOD_INITIALIZE, 2, { capabilities: { tools: {} } }),
incomingResult(METHOD_INITIALIZE, 1, { capabilities: { resources: {} } }),
];
expect(findFirstMatchEventData(events, METHOD_INITIALIZE)).toEqual({ capabilities: { tools: {} } });
});
});
describe('findLatestListResult', () => {
it('returns undefined when there is no outgoing request for the method', () => {
const events = [incomingResult(METHOD_LIST_TOOLS, 1, { tools: [] })];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toBeUndefined();
});
it('returns undefined while the response has not arrived yet', () => {
const events = [outgoing(METHOD_LIST_TOOLS, 1)];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toBeUndefined();
});
it('surfaces a JSON-RPC error response as an error', () => {
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingErrorEvent(1, 'Method not found')];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toEqual({ error: { title: 'Method not found' } });
});
it('falls back to a default message when the error event carries none', () => {
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingErrorEvent(1, '')];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toEqual({
error: { title: `MCP server returned an error for ${METHOD_LIST_TOOLS}` },
});
});
it('returns the result untouched when it matches the schema', () => {
const result = { tools: [validTool('a'), validTool('b')] };
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, result)];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toEqual({ data: result });
});
it('reports the whole response as invalid when the items are not an array', () => {
const result = { tools: 'not-an-array' };
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, result)];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toEqual({
error: { title: `Server returns ${METHOD_LIST_TOOLS} response that does not meet MCP schema` },
});
});
it('reports the whole response as invalid when the result itself is not an object', () => {
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, null)];
const outcome = findLatestListResult(events, METHOD_LIST_TOOLS);
expect(outcome?.data).toBeUndefined();
expect(outcome?.error?.title).toBe(`Server returns ${METHOD_LIST_TOOLS} response that does not meet MCP schema`);
});
it('drops a single malformed entry and keeps the valid ones', () => {
const result = { tools: [validTool('a'), { name: 'b' }] };
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, result)];
const outcome = findLatestListResult(events, METHOD_LIST_TOOLS);
expect(outcome?.data?.tools).toEqual([validTool('a')]);
expect(outcome?.error?.title).toBe('Drop 1 entry that fails the MCP schema.');
expect(outcome?.error?.entries).toHaveLength(1);
expect(outcome?.error?.entries?.[0].label).toBe('b');
});
it('lists every malformed entry, not just the first', () => {
const result = { tools: [validTool('a'), { name: 'b' }, { name: 'c' }] };
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, result)];
const outcome = findLatestListResult(events, METHOD_LIST_TOOLS);
expect(outcome?.data?.tools).toEqual([validTool('a')]);
expect(outcome?.error?.title).toBe('Drop 2 entries that fail the MCP schema.');
expect(outcome?.error?.entries?.map(entry => entry.label)).toEqual(['b', 'c']);
});
it('labels a malformed entry by its position when it has no identifying field', () => {
const result = { tools: [validTool('a'), 42] };
const events = [outgoing(METHOD_LIST_TOOLS, 1), incomingResult(METHOD_LIST_TOOLS, 1, result)];
const outcome = findLatestListResult(events, METHOD_LIST_TOOLS);
expect(outcome?.error?.entries?.[0]).toEqual({ label: 'index 1', reason: expect.any(String) });
});
it('passes the result through untouched for methods without a configured schema', () => {
const result = { anything: 'goes' };
const events = [outgoing('custom/method', 1), incomingResult('custom/method', 1, result)];
expect(findLatestListResult(events, 'custom/method')).toEqual({ data: result });
});
it('correlates the response using the request id, not just the method', () => {
const staleResult = { tools: [validTool('stale')] };
const freshResult = { tools: [validTool('fresh')] };
// Latest-first ordering, as produced by the main process's event log.
const events = [
outgoing(METHOD_LIST_TOOLS, 2),
incomingResult(METHOD_LIST_TOOLS, 2, freshResult),
outgoing(METHOD_LIST_TOOLS, 1),
incomingResult(METHOD_LIST_TOOLS, 1, staleResult),
];
expect(findLatestListResult(events, METHOD_LIST_TOOLS)).toEqual({ data: freshResult });
});
describe.each([
{
method: METHOD_LIST_RESOURCES,
itemsKey: 'resources',
valid: { name: 'a', uri: 'file:///a' },
malformed: { uri: 'file:///b' },
},
{
method: METHOD_LIST_RESOURCE_TEMPLATES,
itemsKey: 'resourceTemplates',
valid: { name: 'a', uriTemplate: 'file:///{id}' },
malformed: { uriTemplate: 'file:///{id}' },
},
{
method: METHOD_LIST_PROMPTS,
itemsKey: 'prompts',
valid: { name: 'a' },
malformed: {},
},
])('$method', ({ method, itemsKey, valid, malformed }) => {
it('accepts a fully valid list', () => {
const result = { [itemsKey]: [valid] };
const events = [outgoing(method, 1), incomingResult(method, 1, result)];
expect(findLatestListResult(events, method)).toEqual({ data: result });
});
it('drops malformed entries while keeping the valid ones', () => {
const result = { [itemsKey]: [valid, malformed] };
const events = [outgoing(method, 1), incomingResult(method, 1, result)];
const outcome = findLatestListResult(events, method);
expect(outcome?.data?.[itemsKey]).toEqual([valid]);
expect(outcome?.error?.title).toBe('Drop 1 entry that fails the MCP schema.');
expect(outcome?.error?.entries).toHaveLength(1);
});
});
});
+173
View File
@@ -24,11 +24,14 @@ import {
ProgressNotificationSchema,
type Prompt,
PromptListChangedNotificationSchema,
PromptSchema,
ReadResourceRequestSchema,
ReadResourceResultSchema,
type Resource,
ResourceListChangedNotificationSchema,
ResourceSchema,
type ResourceTemplate,
ResourceTemplateSchema,
ResourceUpdatedNotificationSchema,
type ServerCapabilities,
ServerNotificationSchema,
@@ -37,10 +40,13 @@ import {
TaskStatusNotificationSchema,
type Tool,
ToolListChangedNotificationSchema,
ToolSchema,
UnsubscribeRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { RJSFSchema } from '@rjsf/utils';
import type { McpEvent, McpMessageEvent } from '~/main/mcp/types';
// methods for server features
export const METHOD_INITIALIZE = InitializeRequestSchema.shape.method.value;
export const METHOD_LIST_TOOLS = ListToolsRequestSchema.shape.method.value;
@@ -180,6 +186,173 @@ export const getDefaultServerCapabilities = () => {
};
};
// One dropped entry from a partially-invalid list, e.g. a single malformed tool.
export interface McpListEntryError {
label: string;
reason: string;
}
// Structured description of why a list request failed or came back partially invalid, meant to be
// rendered directly (a one-line summary plus an optional per-entry breakdown) rather than a single
// flattened string.
export interface McpListValidationError {
title: string;
entries?: McpListEntryError[];
}
export interface McpListResult {
data?: any;
error?: McpListValidationError;
}
// Merges the (at most a couple) independent list errors for a primitive type - e.g. resources and
// resourceTemplates are two separate requests but share one root item - into a single error so the
// root item only ever carries one.
export const combineListErrors = (
...errors: (McpListValidationError | undefined)[]
): McpListValidationError | undefined => {
const present = errors.filter((error): error is McpListValidationError => error !== undefined);
if (present.length === 0) {
return undefined;
}
if (present.length === 1) {
return present[0];
}
return {
title: present.map(error => error.title).join(' / '),
entries: present.flatMap(error => error.entries || []),
};
};
interface ZodLikeSchema {
safeParse: (value: unknown) => {
success: boolean;
data?: unknown;
error?: { issues: { path: PropertyKey[]; message: string }[] };
};
}
const PRIMITIVE_LIST_CONFIG: Record<
string,
{ itemsKey: string; resultSchema: ZodLikeSchema; itemSchema: ZodLikeSchema }
> = {
[METHOD_LIST_TOOLS]: { itemsKey: 'tools', resultSchema: ListToolsResultSchema, itemSchema: ToolSchema },
[METHOD_LIST_RESOURCES]: {
itemsKey: 'resources',
resultSchema: ListResourcesResultSchema,
itemSchema: ResourceSchema,
},
[METHOD_LIST_RESOURCE_TEMPLATES]: {
itemsKey: 'resourceTemplates',
resultSchema: ListResourceTemplatesResultSchema,
itemSchema: ResourceTemplateSchema,
},
[METHOD_LIST_PROMPTS]: { itemsKey: 'prompts', resultSchema: ListPromptsResultSchema, itemSchema: PromptSchema },
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const labelForListItem = (item: unknown): string | undefined => {
if (!isRecord(item)) {
return undefined;
}
for (const key of ['name', 'uriTemplate', 'uri', 'title']) {
const candidate = item[key];
if (typeof candidate === 'string' && candidate.length > 0) {
return candidate;
}
}
return undefined;
};
// Validates a list result against its schema.
// If the whole envelope is invalid (e.g. the items are not even an array), the entire list fails to render.
// If only some entries are malformed, the valid entries are still returned and the malformed ones are summarized as an error.
const validateListResult = (method: string, result: unknown): McpListResult => {
const config = PRIMITIVE_LIST_CONFIG[method];
if (!config) {
return { data: result };
}
if (config.resultSchema.safeParse(result).success) {
return { data: result };
}
const items = isRecord(result) ? result[config.itemsKey] : undefined;
if (!Array.isArray(items)) {
// Cannot even identify the list of entries in the response - the whole response is invalid
return { error: { title: `Server returns ${method} response that does not meet MCP schema` } };
}
const validItems: unknown[] = [];
const entries: McpListEntryError[] = [];
items.forEach((item, index) => {
const parsed = config.itemSchema.safeParse(item);
if (parsed.success) {
validItems.push(parsed.data);
return;
}
const [issue] = parsed.error?.issues || [];
const reason = issue
? `${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`
: 'did not match the expected shape';
entries.push({ label: labelForListItem(item) || `index ${index}`, reason });
});
if (entries.length === 0) {
return { data: { ...(isRecord(result) ? result : {}), [config.itemsKey]: validItems } };
}
return {
data: { ...(isRecord(result) ? result : {}), [config.itemsKey]: validItems },
error: {
title: `Drop ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} that ${
entries.length === 1 ? 'fails' : 'fail'
} the MCP schema.`,
entries,
},
};
};
// Finds the result data of the latest incoming event for a method
export const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => {
const firstMatchEvent = mcpEvents.find(
event => 'method' in event && event.method === method && event.direction === 'INCOMING',
) as McpMessageEvent;
if (firstMatchEvent) {
return 'result' in firstMatchEvent.data ? firstMatchEvent.data.result : undefined;
}
return;
};
// Correlates the outgoing list request with its matching incoming response by request id, then validates the response
export const findLatestListResult = (mcpEvents: McpEvent[], method: string): McpListResult | undefined => {
const outgoingEvent = mcpEvents.find(
event => 'method' in event && event.method === method && event.direction === 'OUTGOING',
) as McpMessageEvent | undefined;
const outgoingRequestId = outgoingEvent && 'id' in outgoingEvent.data ? outgoingEvent.data.id : undefined;
if (outgoingRequestId === undefined) {
return undefined;
}
const incomingEvent = mcpEvents.find(event => {
if (event.type === 'message' && event.direction === 'INCOMING') {
return 'id' in event.data && event.data.id === outgoingRequestId;
}
if (event.type === 'error') {
return event.error?.requestId === outgoingRequestId;
}
return false;
});
if (!incomingEvent) {
// Response has not been received yet
return undefined;
}
if (incomingEvent.type === 'error') {
return { error: { title: incomingEvent.message || `MCP server returned an error for ${method}` } };
}
if (incomingEvent.type !== 'message') {
return undefined;
}
const result = 'result' in incomingEvent.data ? incomingEvent.data.result : undefined;
return validateListResult(method, result);
};
export const isResourceTemplate = (resource: Resource | ResourceTemplate): resource is ResourceTemplate => {
return 'uriTemplate' in resource && resource.uriTemplate !== undefined;
};
@@ -18,7 +18,11 @@ import { useParams } from 'react-router';
import { useLocalStorage } from 'react-use';
import {
combineListErrors,
findFirstMatchEventData,
findLatestListResult,
getDefaultServerCapabilities,
type McpListValidationError,
type McpServerData,
METHOD_INITIALIZE,
METHOD_LIST_PROMPTS,
@@ -26,7 +30,6 @@ import {
METHOD_LIST_RESOURCES,
METHOD_LIST_TOOLS,
} from '~/common/mcp-utils';
import type { McpEvent, McpMessageEvent } from '~/main/mcp/types';
import { useRootLoaderData } from '~/root';
import {
useWorkspaceLoaderData,
@@ -60,6 +63,29 @@ const emptyServerData: McpServerData = {
primitives: { tools: [], resources: [], resourceTemplates: [], prompts: [] },
};
const emptyPrimitiveErrors: Partial<Record<McpServerPrimitiveTypes, McpListValidationError>> = {};
// Base row height (matches --line-height-xs at the default font size).
const ITEM_ROW_HEIGHT = 32;
// A whole-response failure has no per-entry breakdown, just a one-line title, so it needs far less
// room than a partial failure's scrollable list of dropped entries.
const ERROR_PANEL_HEIGHT_TITLE_ONLY = 80;
const ERROR_PANEL_HEIGHT_WITH_ENTRIES = 120;
const getErrorPanelHeight = (error: McpListValidationError | undefined): number =>
error?.entries && error.entries.length > 0 ? ERROR_PANEL_HEIGHT_WITH_ENTRIES : ERROR_PANEL_HEIGHT_TITLE_ONLY;
// The embedded error panel is only shown while its root item is expanded
const shouldShowRootError = (
item: PrimitiveTypeItem | PrimitiveSubItem,
collapsedPrimitives: McpServerPrimitiveTypes[],
): boolean => {
if (item.itemLevel !== 0 || !('error' in item) || !item.error) {
return false;
}
return Boolean(item.isEmpty) || !collapsedPrimitives.includes(item.type);
};
export const McpPane = () => {
const { workspaceId } = useParams() as {
workspaceId: string;
@@ -70,6 +96,8 @@ export const McpPane = () => {
const [filter, setFilter] = useLocalStorage<string>(`${workspaceId}:mcp-list-filter`);
const { settings } = useRootLoaderData()!;
const [mcpServerData, setMcpServerData] = useState<McpServerData>(emptyServerData);
const [primitiveErrors, setPrimitiveErrors] =
useState<Partial<Record<McpServerPrimitiveTypes, McpListValidationError>>>(emptyPrimitiveErrors);
const [collapsedPrimitives, setCollapsedPrimitives] = useState<McpServerPrimitiveTypes[]>([]);
const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState<PrimitiveSubItem | null>(null);
const [primitiveNextCursor, setPrimitiveNextCursor] = useState<Partial<Record<McpServerPrimitiveTypes, string>>>({});
@@ -105,25 +133,30 @@ export const McpPane = () => {
filter ? Boolean(fuzzyMatchAll(filter, [prompt.name, prompt.description || ''])?.indexes) : true,
);
// Add primitive type item
if (tools.length > 0) {
if (tools.length > 0 || primitiveErrors.tools) {
collection.push({
type: 'tools',
name: 'Tools',
collapsed: collapsedPrimitives.includes('tools'),
itemLevel: 0,
hide: false,
error: primitiveErrors.tools,
isEmpty: tools.length === 0,
...(primitiveNextCursor.tools && { nextCursor: primitiveNextCursor.tools }),
});
const hide = collapsedPrimitives.includes('tools');
collection.push(...(tools.map(t => ({ ...t, type: 'tools', itemLevel: 1, hide })) as ToolItem[]));
}
if (resources.length > 0 || resourceTemplates.length > 0) {
const resourcesError = combineListErrors(primitiveErrors.resources, primitiveErrors.resourceTemplates);
if (resources.length > 0 || resourceTemplates.length > 0 || resourcesError) {
collection.push({
type: 'resources',
name: 'Resources',
collapsed: collapsedPrimitives.includes('resources'),
itemLevel: 0,
hide: false,
error: resourcesError,
isEmpty: resources.length === 0 && resourceTemplates.length === 0,
...(primitiveNextCursor.resources && { nextCursor: primitiveNextCursor.resources }),
});
const hide = collapsedPrimitives.includes('resources');
@@ -137,13 +170,15 @@ export const McpPane = () => {
})) as ResourceTemplateItem[]),
);
}
if (prompts.length > 0) {
if (prompts.length > 0 || primitiveErrors.prompts) {
collection.push({
type: 'prompts',
name: 'Prompts',
collapsed: collapsedPrimitives.includes('prompts'),
itemLevel: 0,
hide: false,
error: primitiveErrors.prompts,
isEmpty: prompts.length === 0,
...(primitiveNextCursor.prompts && { nextCursor: primitiveNextCursor.prompts }),
});
const hide = collapsedPrimitives.includes('prompts');
@@ -155,6 +190,10 @@ export const McpPane = () => {
collapsedPrimitives,
filter,
mcpServerData,
primitiveErrors.prompts,
primitiveErrors.resources,
primitiveErrors.resourceTemplates,
primitiveErrors.tools,
primitiveNextCursor.prompts,
primitiveNextCursor.resources,
primitiveNextCursor.tools,
@@ -245,7 +284,17 @@ export const McpPane = () => {
const virtualizer = useVirtualizer<HTMLDivElement, Element>({
getScrollElement: () => parentRef.current,
count: visibleCollection.length,
estimateSize: useCallback(() => 32, []),
estimateSize: useCallback(
(index: number) => {
const item = visibleCollection[index];
if (shouldShowRootError(item, collapsedPrimitives)) {
const itemError = 'error' in item ? item.error : undefined;
return ITEM_ROW_HEIGHT + getErrorPanelHeight(itemError);
}
return ITEM_ROW_HEIGHT;
},
[visibleCollection, collapsedPrimitives],
),
overscan: 20,
getItemKey: index => {
const item = visibleCollection[index];
@@ -275,38 +324,29 @@ export const McpPane = () => {
useEffect(() => {
const updateServerData = async () => {
const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => {
const firstMatchEvent = mcpEvents.find(
event => 'method' in event && event.method === method && event.direction === 'INCOMING',
) as McpMessageEvent;
if (firstMatchEvent) {
return 'result' in firstMatchEvent.data ? firstMatchEvent.data.result : undefined;
}
return;
};
const activeResponseId = activeResponse?._id;
if (activeResponseId) {
const allEvents = await window.main.mcp.event.findMany({ responseId: activeResponseId });
const allMessageEvents = allEvents.filter(
event => 'method' in event && event.direction === 'INCOMING',
) as McpMessageEvent[];
const serverCapabilities =
findFirstMatchEventData(allEvents, METHOD_INITIALIZE)?.capabilities || getDefaultServerCapabilities();
const latestToolListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_TOOLS);
const latestResourceListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_RESOURCES);
const latestResourceTemplateListEvent = findFirstMatchEventData(
allMessageEvents,
METHOD_LIST_RESOURCE_TEMPLATES,
);
const latestPromptListEvent = findFirstMatchEventData(allMessageEvents, METHOD_LIST_PROMPTS);
const tools = latestToolListEvent?.tools || [];
const resources = latestResourceListEvent?.resources || [];
const resourceTemplates = latestResourceTemplateListEvent?.resourceTemplates || [];
const prompts = latestPromptListEvent?.prompts || [];
const toolsResult = findLatestListResult(allEvents, METHOD_LIST_TOOLS);
const resourcesResult = findLatestListResult(allEvents, METHOD_LIST_RESOURCES);
const resourceTemplatesResult = findLatestListResult(allEvents, METHOD_LIST_RESOURCE_TEMPLATES);
const promptsResult = findLatestListResult(allEvents, METHOD_LIST_PROMPTS);
const tools = toolsResult?.data?.tools || [];
const resources = resourcesResult?.data?.resources || [];
const resourceTemplates = resourceTemplatesResult?.data?.resourceTemplates || [];
const prompts = promptsResult?.data?.prompts || [];
setPrimitiveErrors({
tools: toolsResult?.error,
resources: resourcesResult?.error,
resourceTemplates: resourceTemplatesResult?.error,
prompts: promptsResult?.error,
});
// Get nextCursor for each primitive type
const toolsNextCursor = latestToolListEvent?.nextCursor as string | undefined;
const resourcesNextCursor = latestResourceListEvent?.nextCursor as string | undefined;
const promptsNextCursor = latestPromptListEvent?.nextCursor as string | undefined;
const toolsNextCursor = toolsResult?.data?.nextCursor as string | undefined;
const resourcesNextCursor = resourcesResult?.data?.nextCursor as string | undefined;
const promptsNextCursor = promptsResult?.data?.nextCursor as string | undefined;
const primitiveNextCursor = {
...(toolsNextCursor && { tools: toolsNextCursor }),
...(resourcesNextCursor && { resources: resourcesNextCursor }),
@@ -333,6 +373,7 @@ export const McpPane = () => {
} else {
// Clear MCP server data when no active response
setMcpServerData(emptyServerData);
setPrimitiveErrors(emptyPrimitiveErrors);
}
}, [activeResponse?._id, readyState]);
@@ -522,6 +563,9 @@ const CollectionGridListItem = (props: {
const itemLevel = item.itemLevel;
const isRootTypeItem = itemLevel === 0;
const isResourceTypeItem = item.type === 'resources' && itemLevel === 1;
const isEmpty = isRootTypeItem && 'isEmpty' in item && Boolean(item.isEmpty);
const error = isRootTypeItem && 'error' in item ? item.error : undefined;
const showError = shouldShowRootError(item, collapsedPrimitives);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -539,58 +583,79 @@ const CollectionGridListItem = (props: {
style={style}
ref={triggerRef}
>
<div
onContextMenu={e => {
e.preventDefault();
setIsContextMenuOpen(true);
}}
className="relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden pr-2 pl-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) data-[selected=true]:text-(--color-font)"
style={{
paddingLeft: `${itemLevel}em`,
}}
>
<div className="relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden px-4 text-(--hl) outline-hidden transition-colors select-none">
<div className="flex h-full w-full flex-col overflow-hidden">
<div
onContextMenu={e => {
e.preventDefault();
setIsContextMenuOpen(true);
}}
className="relative flex h-(--line-height-xs) w-full shrink-0 items-center gap-2 overflow-hidden pr-2 pl-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) data-[selected=true]:text-(--color-font)"
style={{
paddingLeft: `${itemLevel}em`,
}}
>
<div className="relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden px-4 text-(--hl) outline-hidden transition-colors select-none">
{isRootTypeItem && !isEmpty && (
<Icon
className="w-4 shrink-0"
icon={collapsedPrimitives.includes(item.type) ? 'caret-right' : 'caret-down'}
/>
)}
{item.type === 'tools' && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-success-rgb),0.5)] text-[0.65rem] text-(--color-font-success)">
Tool
</span>
)}
{(item.type === 'resources' || item.type === 'resourceTemplates') && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-surprise-rgb),0.5)] text-[0.65rem] text-(--color-font-surprise)">
Res
</span>
)}
{item.type === 'prompts' && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-info-rgb),0.5)] text-[0.65rem] text-(--color-font-info)">
Prompt
</span>
)}
{label}
</div>
{isRootTypeItem && (
<Icon
className="w-4 shrink-0"
icon={collapsedPrimitives.includes(item.type) ? 'caret-right' : 'caret-down'}
<McpActionsDropdown
item={item}
isOpen={isContextMenuOpen}
onOpenChange={setIsContextMenuOpen}
triggerRef={triggerRef}
{...restProps}
/>
)}
{item.type === 'tools' && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-success-rgb),0.5)] text-[0.65rem] text-(--color-font-success)">
Tool
</span>
{isResourceTypeItem && allowSubscribeResources && (
<Button
data-testid={`Dropdown-${item.type}`}
aria-label="Mcp Actions"
className="h-6 items-center justify-center rounded-xs pr-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all"
onPress={() => handleSubscribe(item as ResourceItem)}
>
{subscribeResources.includes(item.name) ? 'Unsubscribe' : 'Subscribe'}
</Button>
)}
{(item.type === 'resources' || item.type === 'resourceTemplates') && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-surprise-rgb),0.5)] text-[0.65rem] text-(--color-font-surprise)">
Res
</span>
)}
{item.type === 'prompts' && item.itemLevel === 1 && (
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-info-rgb),0.5)] text-[0.65rem] text-(--color-font-info)">
Prompt
</span>
)}
{label}
</div>
{isRootTypeItem && (
<McpActionsDropdown
item={item}
isOpen={isContextMenuOpen}
onOpenChange={setIsContextMenuOpen}
triggerRef={triggerRef}
{...restProps}
/>
)}
{isResourceTypeItem && allowSubscribeResources && (
<Button
data-testid={`Dropdown-${item.type}`}
aria-label="Mcp Actions"
className="h-6 items-center justify-center rounded-xs pr-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all"
onPress={() => handleSubscribe(item as ResourceItem)}
{showError && error && (
<div
className="notice error no-pad mt-(--padding-sm) min-h-0 flex-1 overflow-hidden text-left text-(length:--font-size-sm)"
style={{ maxHeight: getErrorPanelHeight(error), margin: '1em' }}
>
{subscribeResources.includes(item.name) ? 'Unsubscribe' : 'Subscribe'}
</Button>
<div className="h-full overflow-y-auto py-1 pr-(--padding-md) pl-4 select-none">
<p className="no-margin font-medium wrap-break-word">{error.title}</p>
{error.entries && error.entries.length > 0 && (
<ul className="mt-1 flex flex-col gap-0.5 pl-4">
{error.entries.map(entry => (
<li key={`${entry.label}::${entry.reason}`} className="list-disc wrap-break-word">
<span className="text-sm font-medium">{entry.label}</span>: {entry.reason}
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
</GridListItem>
@@ -1,6 +1,8 @@
import type { Prompt, Resource, ResourceTemplate, Tool } from '@modelcontextprotocol/sdk/types.js';
import type { McpServerPrimitiveTypes } from 'insomnia-data';
import type { McpListValidationError } from '~/common/mcp-utils';
interface CommonItemProps {
itemLevel: number;
hide: boolean;
@@ -24,4 +26,8 @@ export interface PrimitiveTypeItem extends CommonItemProps {
type: McpServerPrimitiveTypes;
name: string;
nextCursor?: string;
// Error(s) encountered while fetching or validating this primitive type's list
error?: McpListValidationError;
// True when this primitive type has no tools/resources/prompts to show at all
isEmpty?: boolean;
}