From f6d34280ff12a98adeebdde48bdf54bced6e2bf8 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Wed, 23 Sep 2026 17:22:53 +0800 Subject: [PATCH] show primitive error detail --- .../insomnia/src/common/mcp-utils.test.ts | 220 ++++++++++++++++++ packages/insomnia/src/common/mcp-utils.ts | 173 ++++++++++++++ .../src/ui/components/mcp/mcp-pane.tsx | 219 +++++++++++------ .../insomnia/src/ui/components/mcp/types.ts | 6 + 4 files changed, 541 insertions(+), 77 deletions(-) create mode 100644 packages/insomnia/src/common/mcp-utils.test.ts diff --git a/packages/insomnia/src/common/mcp-utils.test.ts b/packages/insomnia/src/common/mcp-utils.test.ts new file mode 100644 index 0000000000..6235f2bfbf --- /dev/null +++ b/packages/insomnia/src/common/mcp-utils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/insomnia/src/common/mcp-utils.ts b/packages/insomnia/src/common/mcp-utils.ts index dd74243453..af61c523e7 100644 --- a/packages/insomnia/src/common/mcp-utils.ts +++ b/packages/insomnia/src/common/mcp-utils.ts @@ -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 => + 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; }; diff --git a/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx index 2bcecb0a49..1a1cfe6e07 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx @@ -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> = {}; + +// 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(`${workspaceId}:mcp-list-filter`); const { settings } = useRootLoaderData()!; const [mcpServerData, setMcpServerData] = useState(emptyServerData); + const [primitiveErrors, setPrimitiveErrors] = + useState>>(emptyPrimitiveErrors); const [collapsedPrimitives, setCollapsedPrimitives] = useState([]); const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState(null); const [primitiveNextCursor, setPrimitiveNextCursor] = useState>>({}); @@ -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({ 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(null); @@ -539,58 +583,79 @@ const CollectionGridListItem = (props: { style={style} ref={triggerRef} > -
{ - 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`, - }} - > -
+
+
{ + 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`, + }} + > +
+ {isRootTypeItem && !isEmpty && ( + + )} + {item.type === 'tools' && item.itemLevel === 1 && ( + + Tool + + )} + {(item.type === 'resources' || item.type === 'resourceTemplates') && item.itemLevel === 1 && ( + + Res + + )} + {item.type === 'prompts' && item.itemLevel === 1 && ( + + Prompt + + )} + {label} +
{isRootTypeItem && ( - )} - {item.type === 'tools' && item.itemLevel === 1 && ( - - Tool - + {isResourceTypeItem && allowSubscribeResources && ( + )} - {(item.type === 'resources' || item.type === 'resourceTemplates') && item.itemLevel === 1 && ( - - Res - - )} - {item.type === 'prompts' && item.itemLevel === 1 && ( - - Prompt - - )} - {label}
- {isRootTypeItem && ( - - )} - {isResourceTypeItem && allowSubscribeResources && ( - +
+

{error.title}

+ {error.entries && error.entries.length > 0 && ( +
    + {error.entries.map(entry => ( +
  • + {entry.label}: {entry.reason} +
  • + ))} +
+ )} +
+
)}
diff --git a/packages/insomnia/src/ui/components/mcp/types.ts b/packages/insomnia/src/ui/components/mcp/types.ts index c952f5cfe4..456e160677 100644 --- a/packages/insomnia/src/ui/components/mcp/types.ts +++ b/packages/insomnia/src/ui/components/mcp/types.ts @@ -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; }