diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect.tsx index 46fe4de98d..e5c29a25f3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect.tsx @@ -21,15 +21,22 @@ export interface ConnectActionParams { query?: Record; } -export async function clientAction({ params }: Route.ClientActionArgs) { +export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId, workspaceId } = params; const req = await requestOperations.getById(requestId); invariant(req, 'Request not found'); invariant(workspaceId, 'Workspace ID is required'); - //const rendered = (await request.json()) as ConnectActionParams; + const rendered = (await request.json()) as ConnectActionParams; + + window.main.mcp.connect({ + requestId, + transportType: rendered.transportType || 'streamable-http', + url: rendered.url, + headers: rendered.headers, + authentication: rendered.authentication, + }); - // TODO: Integrate with mcp ipc main // HACK: even more elaborate hack to get the request to update return new Promise(resolve => { const unsubscribe = window.main.on('db.changes', async (_, changes: ChangeBufferEvent[]) => { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx index 6491fc8088..673e28a723 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.tsx @@ -1,8 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import type { Prompt, Resource, ResourceTemplate, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Breadcrumb, Breadcrumbs, Button, + GridList, + GridListItem, Input, SearchField, ToggleButton, @@ -14,7 +18,9 @@ import { NavLink, redirect, useParams } from 'react-router'; import { useLocalStorage } from 'react-use'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; +import type { McpServerData } from '~/main/network/mcp'; import * as models from '~/models'; +import type { McpServerPrimitiveTypes } from '~/models/mcp-request'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useMcpRequestLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId'; @@ -25,12 +31,27 @@ import { Icon } from '~/ui/components/icon'; import { McpRequestPane } from '~/ui/components/mcp/mcp-request-pane'; import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal'; import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; +import { McpRealtimeResponsePane } from '~/ui/components/websockets/realtime-response-pane'; import { INSOMNIA_TAB_HEIGHT } from '~/ui/constant'; import { useReadyState } from '~/ui/hooks/use-ready-state'; import { invariant } from '~/utils/invariant'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp'; +interface CommonItemProps { + itemLevel: number; + hide: boolean; +} +type ToolItem = Tool & { type: 'tools' } & CommonItemProps; +type ResourceItem = Resource & { type: 'resources' } & CommonItemProps; +type ResourceTemplateItem = ResourceTemplate & { type: 'resources' } & CommonItemProps; +type PromptItem = Prompt & { type: 'prompts' } & CommonItemProps; +type PrimitiveSubItemTypes = ToolItem | ResourceItem | ResourceTemplateItem | PromptItem; +interface PrimitiveTypeItem extends CommonItemProps { + type: McpServerPrimitiveTypes; + name: string; +} + export async function clientLoader({ params }: Route.ClientLoaderArgs) { if (!params.requestId) { const { projectId, workspaceId, organizationId } = params; @@ -61,15 +82,126 @@ const McpPage = () => { const sidebarPanelRef = useRef(null); const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false); const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false); - const [allExpanded, setAllExpanded] = useState(false); + const [allExpanded, setAllExpanded] = useState(true); const [filter, setFilter] = useLocalStorage(`${workspaceId}:mcp-list-filter`); const { settings } = useRootLoaderData()!; + const [mcpServerData, setMcpServerData] = useState(null); + const [collapsedPrimitives, setCollapsedPrimitives] = useState([]); + const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState(null); + const getPrimitiveCollection = () => { + const collection: (PrimitiveTypeItem | PrimitiveSubItemTypes)[] = []; + if (mcpServerData) { + const { primitives } = mcpServerData; + const { tools, resources, resourceTemplates, prompts } = primitives; + if (tools.length > 0) { + collection.push({ + type: 'tools', + name: 'Tools', + collapsed: collapsedPrimitives.includes('tools'), + itemLevel: 0, + hide: false, + }); + 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) { + collection.push({ + type: 'resources', + name: 'Resources', + collapsed: collapsedPrimitives.includes('resources'), + itemLevel: 0, + hide: false, + }); + const hide = collapsedPrimitives.includes('resources'); + collection.push(...(resources.map(r => ({ ...r, type: 'resources', itemLevel: 1, hide })) as ResourceItem[])); + collection.push( + ...(resourceTemplates.map(rt => ({ + ...rt, + type: 'resources', + itemLevel: 1, + hide, + })) as ResourceTemplateItem[]), + ); + } + if (prompts.length > 0) { + collection.push({ + type: 'prompts', + name: 'Prompts', + collapsed: collapsedPrimitives.includes('prompts'), + itemLevel: 0, + hide: false, + }); + const hide = collapsedPrimitives.includes('prompts'); + collection.push(...(prompts.map(p => ({ ...p, type: 'prompts', itemLevel: 1, hide })) as PromptItem[])); + } + } + return collection; + }; + + const getServerCapabilities = () => { + const serverCapabilities = { + tools: { + enabled: true, + listChanged: false, + }, + resources: { + enabled: true, + listChanged: false, + subscribe: true, + }, + prompts: { + enabled: true, + listChanged: false, + }, + }; + if (mcpServerData) { + const { tools, resources, prompts } = mcpServerData.serverCapabilities; + if (tools) { + serverCapabilities.tools.enabled = true; + serverCapabilities.tools.listChanged = !!tools.listChanged; + } + if (resources) { + serverCapabilities.resources.enabled = true; + serverCapabilities.resources.listChanged = !!resources.listChanged; + serverCapabilities.resources.subscribe = !!resources.subscribe; + } + if (prompts) { + serverCapabilities.prompts.enabled = true; + serverCapabilities.prompts.listChanged = !!prompts.listChanged; + } + } + return serverCapabilities; + }; + + // TODO Support filter + const visibleCollection = getPrimitiveCollection().filter(item => !item.hide); + const serverCapabilities = getServerCapabilities(); + const allowSubscribeResources = serverCapabilities.resources.enabled && serverCapabilities.resources.subscribe; + const enableNotification = + serverCapabilities.tools.listChanged || + serverCapabilities.resources.listChanged || + serverCapabilities.prompts.listChanged; + // TODO Use these variables to enable notification + console.log('enableNotification', enableNotification); + console.log('allowSubscribeResources', allowSubscribeResources); + // TODO Use this for showing details + console.log('selectedPrimitiveItem', selectedPrimitiveItem); const requestId = activeRequest._id; const { activeEnvironment } = useWorkspaceLoaderData()!; const readyState = useReadyState({ requestId, protocol: 'mcp' }); const parentRef = useRef(null); + const virtualizer = useVirtualizer({ + getScrollElement: () => parentRef.current, + count: visibleCollection.length, + estimateSize: useCallback(() => 32, []), + overscan: 20, + getItemKey: index => { + const item = visibleCollection[index]; + return `${item.itemLevel}::${item.type}::${item.name}`; + }, + }); function toggleSidebar() { const layout = sidebarPanelRef.current?.getLayout(); @@ -117,6 +249,19 @@ const McpPage = () => { }; }, [settings.forceVerticalLayout, direction]); + useEffect(() => { + const updateServerData = async () => { + const serverData = await window.main.mcp.getServerData({ requestId }); + setMcpServerData(serverData!); + }; + if (readyState) { + // Get MCP server data when connection is ready + updateServerData(); + } else { + setMcpServerData(null); + } + }, [readyState, requestId]); + return ( { aria-label="Expand All/Collapse all" defaultSelected={allExpanded} onChange={() => { - setAllExpanded(!allExpanded); + const newState = !allExpanded; + if (newState) { + setCollapsedPrimitives([]); + } else { + setCollapsedPrimitives(['tools', 'resources', 'prompts']); + } + setAllExpanded(newState); }} className="flex aspect-square h-full items-center justify-center rounded-sm text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md]" > @@ -200,7 +351,82 @@ const McpPage = () => {
- MCP Server List + { + const id = key.toString(); + if (id.startsWith('root_')) { + // Click on primitive type item + const primitiveType = id.split('root_')[1] as McpServerPrimitiveTypes; + setCollapsedPrimitives(prev => { + if (prev.includes(primitiveType)) { + return prev.filter(p => p !== primitiveType); + } + return [...prev, primitiveType]; + }); + } else { + // Click a specified primitive + const [type, name] = id.split('_'); + const item = visibleCollection.find(i => i.itemLevel === 1 && i.type === type && i.name === name); + setSelectedPrimitiveItem(item as PrimitiveSubItemTypes); + } + }} + > + {virtualItem => { + const item = visibleCollection[virtualItem.index]; + const label = 'title' in item ? item.title : item.name; + const uniqueId = item.itemLevel === 0 ? `root_${item.type}` : `${item.type}_${item.name}`; + const itemLevel = item.itemLevel; + return ( + +
+
+ {itemLevel === 0 && ( + + )} + {item.type === 'tools' && item.itemLevel === 1 && ( + + Tool + + )} + {item.type === 'resources' && item.itemLevel === 1 && ( + + Res + + )} + {item.type === 'prompts' && item.itemLevel === 1 && ( + + Prompt + + )} + {label} +
+
+
+ ); + }} +
{isEnvironmentModalOpen && setEnvironmentModalOpen(false)} />} @@ -215,7 +441,7 @@ const McpPage = () => { -
+ diff --git a/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx index 67a4f25f07..8994dd40fb 100644 --- a/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx +++ b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx @@ -4,6 +4,7 @@ import React, { type FC, useEffect, useMemo, useState } from 'react'; import { Button, Input, SearchField, Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useMcpRequestLoaderData } from '../../..//routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId'; import { getSetCookieHeaders } from '../../../common/misc'; import type { CurlEvent } from '../../../main/network/curl'; import type { ResponseTimelineEntry } from '../../../main/network/libcurl-promise'; @@ -47,6 +48,20 @@ export const RealtimeResponsePane: FC<{ requestId: string }> = () => { return ; }; +export const McpRealtimeResponsePane = () => { + const { activeResponse } = useMcpRequestLoaderData()!; + + if (!activeResponse) { + return ( + + + + + ); + } + return ; +}; + const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response | SocketIOResponse; }> = ({ response }) => {