mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-05 20:43:52 -05:00
show primitive error detail
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user