mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-18 05:08:40 -04:00
feat: Support server capability list (#9117)
* support server capability list * fix type issue
This commit is contained in:
@@ -21,15 +21,22 @@ export interface ConnectActionParams {
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
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[]) => {
|
||||
|
||||
@@ -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<ImperativePanelGroupHandle>(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<string>(`${workspaceId}:mcp-list-filter`);
|
||||
const { settings } = useRootLoaderData()!;
|
||||
const [mcpServerData, setMcpServerData] = useState<McpServerData | null>(null);
|
||||
const [collapsedPrimitives, setCollapsedPrimitives] = useState<McpServerPrimitiveTypes[]>([]);
|
||||
const [selectedPrimitiveItem, setSelectedPrimitiveItem] = useState<PrimitiveSubItemTypes | null>(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<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, Element>({
|
||||
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 (
|
||||
<PanelGroup
|
||||
ref={sidebarPanelRef}
|
||||
@@ -180,7 +325,13 @@ const McpPage = () => {
|
||||
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 = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto" ref={parentRef}>
|
||||
<span>MCP Server List</span>
|
||||
<GridList
|
||||
id="sidebar-mcp-gridlist"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
items={virtualizer.getVirtualItems()}
|
||||
className="relative"
|
||||
aria-label="Mcp Server Capabilities"
|
||||
onAction={key => {
|
||||
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 (
|
||||
<GridListItem
|
||||
id={uniqueId}
|
||||
className={`group absolute left-0 top-0 w-full select-none outline-none ${item.itemLevel === 0 ? 'data-[drop-target]:bg-[--hl-md]' : 'border-solid data-[drop-target]:border-b data-[drop-target]:border-[--color-surprise]'}`}
|
||||
textValue={label}
|
||||
data-testid={`test-${uniqueId}`}
|
||||
style={{
|
||||
height: `${virtualItem.size}`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[--line-height-xs] w-full select-none items-center gap-2 overflow-hidden pl-4 pr-2 text-[--hl] outline-none transition-colors 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 select-none items-center gap-2 overflow-hidden px-4 text-[--hl] outline-none transition-colors">
|
||||
{itemLevel === 0 && (
|
||||
<Icon
|
||||
className="w-4 flex-shrink-0"
|
||||
icon={collapsedPrimitives.includes(item.type) ? 'caret-right' : 'caret-down'}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'tools' && item.itemLevel === 1 && (
|
||||
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm 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.itemLevel === 1 && (
|
||||
<span className="flex w-10 flex-shrink-0 items-center justify-center rounded-sm 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 flex-shrink-0 items-center justify-center rounded-sm 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>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
{isEnvironmentModalOpen && <WorkspaceEnvironmentsEditModal onClose={() => setEnvironmentModalOpen(false)} />}
|
||||
@@ -215,7 +441,7 @@ const McpPage = () => {
|
||||
</Panel>
|
||||
<Panel id="mcp-response-pane" order={2} minSize={10} className="pane-two theme--pane">
|
||||
<ErrorBoundary showAlert>
|
||||
<div />
|
||||
<McpRealtimeResponsePane />
|
||||
</ErrorBoundary>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
|
||||
@@ -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 <RealtimeActiveResponsePane response={activeResponse} />;
|
||||
};
|
||||
|
||||
export const McpRealtimeResponsePane = () => {
|
||||
const { activeResponse } = useMcpRequestLoaderData()!;
|
||||
|
||||
if (!activeResponse) {
|
||||
return (
|
||||
<Pane type="response">
|
||||
<PaneHeader className="!justify-normal" />
|
||||
<PlaceholderResponsePane />
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
return <RealtimeActiveResponsePane response={activeResponse} />;
|
||||
};
|
||||
|
||||
const RealtimeActiveResponsePane: FC<{
|
||||
response: WebSocketResponse | Response | SocketIOResponse;
|
||||
}> = ({ response }) => {
|
||||
|
||||
Reference in New Issue
Block a user