mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-19 05:39:50 -04:00
Chore: Move MCP request page into debug router path (#9140)
* refactor mcp page router path * fix lint issue
This commit is contained in:
@@ -437,6 +437,7 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
|
||||
message: error.message || 'Something went wrong',
|
||||
});
|
||||
console.error(`Failed to create ${options.transportType} transport: ${error}`);
|
||||
return;
|
||||
}
|
||||
mcpConnections.set(requestId, mcpClient as McpClient);
|
||||
const serverCapabilities = mcpClient.getServerCapabilities();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { href } from 'react-router';
|
||||
import type { ChangeBufferEvent } from '~/common/database';
|
||||
import type { CookieJar } from '~/models/cookie-jar';
|
||||
import * as requestOperations from '~/models/helpers/request-operations';
|
||||
import { isMcpRequest, type TransportType } from '~/models/mcp-request';
|
||||
import type { RequestAuthentication, RequestHeader } from '~/models/request';
|
||||
import { isEventStreamRequest, isGraphqlSubscriptionRequest } from '~/models/request';
|
||||
import { isRequestMeta } from '~/models/request-meta';
|
||||
@@ -22,6 +23,7 @@ export interface ConnectActionParams {
|
||||
authentication: RequestAuthentication;
|
||||
cookieJar: CookieJar;
|
||||
suppressUserAgent: boolean;
|
||||
transportType?: TransportType;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -91,6 +93,16 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
|
||||
query: rendered.query || {},
|
||||
});
|
||||
}
|
||||
if (isMcpRequest(req)) {
|
||||
window.main.mcp.connect({
|
||||
requestId,
|
||||
workspaceId,
|
||||
transportType: rendered.transportType || 'streamable-http',
|
||||
url: rendered.url,
|
||||
headers: rendered.headers,
|
||||
authentication: rendered.authentication,
|
||||
});
|
||||
}
|
||||
// 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[]) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type GrpcRequest, isGrpcRequestId } from '~/models/grpc-request';
|
||||
import type { GrpcRequestMeta } from '~/models/grpc-request-meta';
|
||||
import * as requestOperations from '~/models/helpers/request-operations';
|
||||
import { isMcpRequest, type McpRequest } from '~/models/mcp-request';
|
||||
import type { McpResponse } from '~/models/mcp-response';
|
||||
import type { MockRoute } from '~/models/mock-route';
|
||||
import type { MockServer } from '~/models/mock-server';
|
||||
import { isGraphqlSubscriptionRequest } from '~/models/request';
|
||||
@@ -46,6 +47,14 @@ export interface GrpcRequestLoaderData {
|
||||
responses: [];
|
||||
requestVersions: RequestVersion[];
|
||||
}
|
||||
|
||||
export interface McpRequestLoaderData {
|
||||
activeRequest: McpRequest;
|
||||
activeRequestMeta: RequestMeta;
|
||||
activeResponse: McpResponse;
|
||||
responses: McpResponse[];
|
||||
requestVersions: RequestVersion[];
|
||||
}
|
||||
export interface RequestLoaderData {
|
||||
activeRequest: Request;
|
||||
activeRequestMeta: RequestMeta;
|
||||
@@ -155,6 +164,18 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
requestPayload: socketIOPayload,
|
||||
} as SocketIORequestLoaderData;
|
||||
}
|
||||
|
||||
if (isMcpRequest(activeRequest)) {
|
||||
// TODO - add mcp request payload model
|
||||
return {
|
||||
activeRequest,
|
||||
activeRequestMeta,
|
||||
activeResponse,
|
||||
responses,
|
||||
requestVersions: await models.requestVersion.findByParentId(requestId),
|
||||
} as McpRequestLoaderData;
|
||||
}
|
||||
|
||||
return {
|
||||
activeRequest,
|
||||
activeRequestMeta,
|
||||
|
||||
@@ -93,6 +93,7 @@ import { EnvironmentPicker } from '~/ui/components/environment-picker';
|
||||
import { ErrorBoundary } from '~/ui/components/error-boundary';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder';
|
||||
import { McpPane } from '~/ui/components/mcp/mcp-pane';
|
||||
import { showModal } from '~/ui/components/modals';
|
||||
import { AskModal } from '~/ui/components/modals/ask-modal';
|
||||
import { CookiesModal } from '~/ui/components/modals/cookies-modal';
|
||||
@@ -230,6 +231,15 @@ const RequestTiming = ({ requestId }: { requestId: string }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const DebugEntry = () => {
|
||||
const { activeWorkspace } = useWorkspaceLoaderData()!;
|
||||
if (activeWorkspace.scope === 'mcp') {
|
||||
// MCP request under mcp workspace has different layout so we need to render a different component
|
||||
return <McpPane />;
|
||||
}
|
||||
return <Debug />;
|
||||
};
|
||||
|
||||
const Debug = () => {
|
||||
const {
|
||||
activeWorkspace,
|
||||
@@ -1265,7 +1275,7 @@ const Debug = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
export default DebugEntry;
|
||||
|
||||
const ScratchPadTutorialPanel = () => {
|
||||
const [signUpTipDismissedState, setSignUpTipDismissedState] = useLocalStorage<{
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import type { ChangeBufferEvent } from '~/common/database';
|
||||
import type { CookieJar } from '~/models/cookie-jar';
|
||||
import * as requestOperations from '~/models/helpers/request-operations';
|
||||
import { type TransportType } from '~/models/mcp-request';
|
||||
import type { RequestAuthentication, RequestHeader } from '~/models/request';
|
||||
import { isRequestMeta } from '~/models/request-meta';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
import { createFetcherSubmitHook } from '~/utils/router';
|
||||
|
||||
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect';
|
||||
|
||||
export interface ConnectActionParams {
|
||||
url: string;
|
||||
headers: RequestHeader[];
|
||||
authentication: RequestAuthentication;
|
||||
cookieJar: CookieJar;
|
||||
suppressUserAgent: boolean;
|
||||
transportType?: TransportType;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
window.main.mcp.connect({
|
||||
requestId,
|
||||
workspaceId,
|
||||
transportType: rendered.transportType || 'streamable-http',
|
||||
url: rendered.url,
|
||||
headers: rendered.headers,
|
||||
authentication: rendered.authentication,
|
||||
});
|
||||
|
||||
// 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[]) => {
|
||||
for (const change of changes) {
|
||||
const [event, doc] = change;
|
||||
if (isRequestMeta(doc) && doc.parentId === requestId && event === 'update') {
|
||||
resolve(null);
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const useRequestConnectActionFetcher = createFetcherSubmitHook(
|
||||
submit =>
|
||||
({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
requestId,
|
||||
connectParams,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectParams: ConnectActionParams;
|
||||
}) => {
|
||||
const url = href(
|
||||
'/organization/:organizationId/project/:projectId/workspace/:workspaceId/mcp/request/:requestId/connect',
|
||||
{
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
requestId,
|
||||
},
|
||||
);
|
||||
|
||||
return submit(JSON.stringify(connectParams), {
|
||||
action: url,
|
||||
method: 'POST',
|
||||
encType: 'application/json',
|
||||
});
|
||||
},
|
||||
clientAction,
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Outlet, useRouteLoaderData } from 'react-router';
|
||||
|
||||
import type { BaseModel } from '~/models';
|
||||
import * as models from '~/models';
|
||||
import * as requestOperations from '~/models/helpers/request-operations';
|
||||
import type { McpRequest } from '~/models/mcp-request';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
|
||||
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
|
||||
export default Outlet;
|
||||
|
||||
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
const { requestId, workspaceId } = params;
|
||||
|
||||
const activeRequest = (await requestOperations.getById(requestId)) as McpRequest;
|
||||
const activeWorkspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
|
||||
invariant(activeWorkspaceMeta, 'Active workspace meta not found');
|
||||
const activeRequestMeta = await models.requestMeta.updateOrCreateByParentId(requestId, { lastActive: Date.now() });
|
||||
invariant(activeRequestMeta, 'Request meta not found');
|
||||
const { filterResponsesByEnv } = await models.settings.get();
|
||||
|
||||
const activeResponse = activeRequestMeta.activeResponseId
|
||||
? await models.mcpResponse.getById(activeRequestMeta.activeResponseId)
|
||||
: await models.mcpResponse.getLatestForRequestId(requestId, activeWorkspaceMeta.activeEnvironmentId);
|
||||
const allResponses = await models.mcpResponse.findByParentId(requestId);
|
||||
const filteredResponses = allResponses.filter(r => r.environmentId === activeWorkspaceMeta.activeEnvironmentId);
|
||||
const responses = (filterResponsesByEnv ? filteredResponses : allResponses).sort((a: BaseModel, b: BaseModel) =>
|
||||
a.created > b.created ? -1 : 1,
|
||||
);
|
||||
|
||||
return {
|
||||
activeRequest,
|
||||
activeRequestMeta,
|
||||
activeResponse,
|
||||
responses,
|
||||
requestVersions: await models.requestVersion.findByParentId(requestId),
|
||||
};
|
||||
}
|
||||
|
||||
export function useMcpRequestLoaderData() {
|
||||
return useRouteLoaderData<typeof clientLoader>(
|
||||
'routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId',
|
||||
);
|
||||
}
|
||||
@@ -1,509 +1,24 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
GridList,
|
||||
GridListItem,
|
||||
Input,
|
||||
SearchField,
|
||||
ToggleButton,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components';
|
||||
import { type ImperativePanelGroupHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { NavLink, redirect, useParams } from 'react-router';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants';
|
||||
import {
|
||||
getDefaultServerCapabilities,
|
||||
type McpServerData,
|
||||
METHOD_INITIALIZE,
|
||||
METHOD_LIST_PROMPTS,
|
||||
METHOD_LIST_RESOURCE_TEMPLATES,
|
||||
METHOD_LIST_RESOURCES,
|
||||
METHOD_LIST_TOOLS,
|
||||
} from '~/common/mcp-utils';
|
||||
import type { McpEvent, McpMessageEvent } from '~/main/network/mcp';
|
||||
import * as models from '~/models';
|
||||
import type { McpRequest, 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';
|
||||
import { McpActionsDropdown } from '~/ui/components/dropdowns/mcp-actions-dropdown';
|
||||
import { WorkspaceDropdown } from '~/ui/components/dropdowns/workspace-dropdown';
|
||||
import { EnvironmentPicker } from '~/ui/components/environment-picker';
|
||||
import { ErrorBoundary } from '~/ui/components/error-boundary';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { McpRequestPane } from '~/ui/components/mcp/mcp-request-pane';
|
||||
import {
|
||||
type PrimitiveSubItem,
|
||||
type PrimitiveTypeItem,
|
||||
type PromptItem,
|
||||
type ResourceItem,
|
||||
type ResourceTemplateItem,
|
||||
type ToolItem,
|
||||
} from '~/ui/components/mcp/types';
|
||||
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';
|
||||
|
||||
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
if (!params.requestId) {
|
||||
const { projectId, workspaceId, organizationId } = params;
|
||||
invariant(workspaceId, 'Workspace ID is required');
|
||||
invariant(projectId, 'Project ID is required');
|
||||
const activeWorkspace = await models.workspace.getById(workspaceId);
|
||||
invariant(activeWorkspace, 'Workspace not found');
|
||||
// Mcp collection only have one request
|
||||
const activeRequest = await models.mcpRequest.getByParentId(workspaceId);
|
||||
invariant(activeRequest, 'MCP Request not found');
|
||||
|
||||
if (activeRequest) {
|
||||
return redirect(
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mcp/request/${activeRequest._id}`,
|
||||
);
|
||||
}
|
||||
const { projectId, workspaceId, organizationId } = params;
|
||||
invariant(workspaceId, 'Workspace ID is required');
|
||||
invariant(projectId, 'Project ID is required');
|
||||
const activeWorkspace = await models.workspace.getById(workspaceId);
|
||||
invariant(activeWorkspace, 'Workspace not found');
|
||||
// Mcp collection only have one request
|
||||
const activeRequest = await models.mcpRequest.getByParentId(workspaceId);
|
||||
invariant(activeRequest, 'MCP Request not found');
|
||||
// Redirect to the debug page of the only request in the MCP workspace
|
||||
if (activeRequest) {
|
||||
return redirect(
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${activeRequest._id}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const McpPage = () => {
|
||||
const { organizationId, projectId, workspaceId } = useParams() as {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
const { activeRequest, activeResponse } = useMcpRequestLoaderData()!;
|
||||
const sidebarPanelRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false);
|
||||
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = 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<PrimitiveSubItem | null>(null);
|
||||
|
||||
const getPrimitiveCollection = () => {
|
||||
const collection: (PrimitiveTypeItem | PrimitiveSubItem)[] = [];
|
||||
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 = getDefaultServerCapabilities();
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout && layout[0] > 0) {
|
||||
layout[0] = 0;
|
||||
} else {
|
||||
layout[0] = DEFAULT_SIDEBAR_SIZE;
|
||||
}
|
||||
|
||||
sidebarPanelRef.current?.setLayout(layout);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.main.on('toggle-sidebar', toggleSidebar);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(
|
||||
settings.forceVerticalLayout ? 'vertical' : 'horizontal',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.forceVerticalLayout) {
|
||||
setDirection('vertical');
|
||||
return () => {};
|
||||
}
|
||||
// Listen on media query changes
|
||||
const mediaQuery = window.matchMedia('(max-width: 880px)');
|
||||
setDirection(mediaQuery.matches ? 'vertical' : 'horizontal');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setDirection(e.matches ? 'vertical' : 'horizontal');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [settings.forceVerticalLayout, direction]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateServerData = async () => {
|
||||
const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => {
|
||||
const firstMatchEvent = mcpEvents.find(
|
||||
event => 'method' in event && event.method === method,
|
||||
) as McpMessageEvent;
|
||||
if (firstMatchEvent) {
|
||||
return firstMatchEvent.data.result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const activeResponseId = activeResponse?._id;
|
||||
if (activeResponseId) {
|
||||
const allEvents = await window.main.mcp.event.findMany({ responseId: activeResponseId });
|
||||
const serverCapabilities =
|
||||
findFirstMatchEventData(allEvents, METHOD_INITIALIZE)?.capabilities || getDefaultServerCapabilities();
|
||||
const tools = findFirstMatchEventData(allEvents, METHOD_LIST_TOOLS)?.tools || [];
|
||||
const resources = findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCES)?.resources || [];
|
||||
const resourceTemplates =
|
||||
findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCE_TEMPLATES)?.resourceTemplates || [];
|
||||
const prompts = findFirstMatchEventData(allEvents, METHOD_LIST_PROMPTS)?.prompts || [];
|
||||
const mcpServerData = {
|
||||
serverCapabilities: serverCapabilities,
|
||||
primitives: {
|
||||
tools,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
prompts,
|
||||
},
|
||||
} as McpServerData;
|
||||
setMcpServerData(mcpServerData);
|
||||
}
|
||||
};
|
||||
if (readyState) {
|
||||
// Get MCP server data when connection is ready
|
||||
updateServerData();
|
||||
}
|
||||
}, [readyState, activeResponse?._id]);
|
||||
|
||||
return (
|
||||
<PanelGroup
|
||||
ref={sidebarPanelRef}
|
||||
autoSaveId="insomnia-sidebar"
|
||||
id="wrapper"
|
||||
className="new-sidebar h-full w-full text-[--color-font]"
|
||||
direction="horizontal"
|
||||
>
|
||||
<Panel id="sidebar" className="sidebar theme--sidebar" maxSize={40} minSize={10} collapsible>
|
||||
<div className="flex flex-1 flex-col divide-y divide-solid divide-[--hl-md] overflow-hidden">
|
||||
<div className="flex flex-col items-start divide-y divide-solid divide-[--hl-md]">
|
||||
<div className={`flex w-full h-[${INSOMNIA_TAB_HEIGHT}px]`}>
|
||||
<Breadcrumbs className="m-0 flex h-[--line-height-sm] w-full list-none items-center gap-2 px-[--padding-sm] font-bold">
|
||||
<Breadcrumb className="flex h-full select-none items-center gap-2 text-[--color-font] outline-none data-[focused]:outline-none">
|
||||
<NavLink
|
||||
data-testid="project"
|
||||
className="flex aspect-square h-7 flex-shrink-0 items-center justify-center gap-2 rounded-sm px-1 py-1 text-sm text-[--color-font] outline-none ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm] data-[focused]:outline-none"
|
||||
to={`/organization/${organizationId}/project/${projectId}`}
|
||||
>
|
||||
<Icon className="text-xs" icon="chevron-left" />
|
||||
</NavLink>
|
||||
<span aria-hidden role="separator" className="h-4 text-[--hl-lg] outline outline-1" />
|
||||
</Breadcrumb>
|
||||
<Breadcrumb className="flex h-full select-none items-center gap-2 truncate text-[--color-font] outline-none data-[focused]:outline-none">
|
||||
<WorkspaceDropdown />
|
||||
</Breadcrumb>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2 p-[--padding-sm]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<EnvironmentPicker
|
||||
isOpen={isEnvironmentPickerOpen}
|
||||
onOpenChange={setIsEnvironmentPickerOpen}
|
||||
onOpenEnvironmentSettingsModal={() => setEnvironmentModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex justify-between gap-1 p-[--padding-sm]">
|
||||
<SearchField
|
||||
aria-label="Server Capability filter"
|
||||
className="group relative flex-1"
|
||||
value={filter ?? ''}
|
||||
onChange={setFilter}
|
||||
>
|
||||
<Input
|
||||
placeholder="Filter"
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 flex h-full items-center px-2">
|
||||
<Button className="flex aspect-square w-5 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] aria-pressed:bg-[--hl-sm] group-data-[empty]:hidden">
|
||||
<Icon icon="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</SearchField>
|
||||
<TooltipTrigger>
|
||||
<ToggleButton
|
||||
aria-label="Expand All/Collapse all"
|
||||
defaultSelected={allExpanded}
|
||||
onChange={() => {
|
||||
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]"
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<Icon
|
||||
icon={isSelected ? 'down-left-and-up-right-to-center' : 'up-right-and-down-left-from-center'}
|
||||
/>
|
||||
)}
|
||||
</ToggleButton>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] px-4 py-2 text-sm text-[--color-font] shadow-lg focus:outline-none"
|
||||
>
|
||||
<span>{allExpanded ? 'Collapse all' : 'Expand all'}</span>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto" ref={parentRef}>
|
||||
<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 PrimitiveSubItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{virtualItem => {
|
||||
const item = visibleCollection[virtualItem.index];
|
||||
return (
|
||||
<CollectionGridListItem
|
||||
activeRequest={activeRequest}
|
||||
item={item}
|
||||
collapsedPrimitives={collapsedPrimitives}
|
||||
style={{
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
{isEnvironmentModalOpen && <WorkspaceEnvironmentsEditModal onClose={() => setEnvironmentModalOpen(false)} />}
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="h-full w-[1px] bg-[--hl-md]" />
|
||||
<Panel className="flex flex-col">
|
||||
<OrganizationTabList currentPage="mcp" />
|
||||
<PanelGroup autoSaveId="insomnia-panels" id="insomnia-panels" direction={direction}>
|
||||
<Panel id="mcp-request-pane" order={1} minSize={10} className="pane-one theme--pane">
|
||||
<McpRequestPane
|
||||
selectedPrimitiveItem={
|
||||
selectedPrimitiveItem?.itemLevel === 1 ? (selectedPrimitiveItem as PrimitiveSubItem) : null
|
||||
}
|
||||
environment={activeEnvironment}
|
||||
readyState={readyState}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel id="mcp-response-pane" order={2} minSize={10} className="pane-two theme--pane">
|
||||
<ErrorBoundary showAlert>
|
||||
<McpRealtimeResponsePane />
|
||||
</ErrorBoundary>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionGridListItem = ({
|
||||
activeRequest,
|
||||
style,
|
||||
item,
|
||||
collapsedPrimitives,
|
||||
}: {
|
||||
activeRequest: McpRequest;
|
||||
item: PrimitiveTypeItem | PrimitiveSubItem;
|
||||
style: React.CSSProperties;
|
||||
collapsedPrimitives: McpServerPrimitiveTypes[];
|
||||
}) => {
|
||||
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;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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={style}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<div
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setIsContextMenuOpen(true);
|
||||
}}
|
||||
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>
|
||||
<McpActionsDropdown
|
||||
item={item}
|
||||
request={activeRequest}
|
||||
isOpen={isContextMenuOpen}
|
||||
onOpenChange={setIsContextMenuOpen}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpPage;
|
||||
|
||||
@@ -198,7 +198,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
});
|
||||
|
||||
return redirect(
|
||||
href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/mcp/request/:requestId', {
|
||||
href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId', {
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId: workspace._id,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PREVIEW_MODES,
|
||||
} from '../../../common/constants';
|
||||
import type { McpEvent } from '../../../main/network/mcp';
|
||||
import { useMcpRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
|
||||
import { useRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
|
||||
import { CodeEditor } from '../../components/.client/codemirror/code-editor';
|
||||
import { showError } from '../../components/modals';
|
||||
import { useRequestMetaPatcher } from '../../hooks/use-request';
|
||||
@@ -64,7 +64,7 @@ export const MessageEventView = ({ event }: Props) => {
|
||||
} catch {
|
||||
// Can't parse as JSON.
|
||||
}
|
||||
const { activeRequestMeta } = useMcpRequestLoaderData()!;
|
||||
const { activeRequestMeta } = useRequestLoaderData()!;
|
||||
const previewMode = ('previewMode' in activeRequestMeta && activeRequestMeta.previewMode) || PREVIEW_MODE_SOURCE;
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
489
packages/insomnia/src/ui/components/mcp/mcp-pane.tsx
Normal file
489
packages/insomnia/src/ui/components/mcp/mcp-pane.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
GridList,
|
||||
GridListItem,
|
||||
Input,
|
||||
SearchField,
|
||||
ToggleButton,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components';
|
||||
import { type ImperativePanelGroupHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants';
|
||||
import {
|
||||
getDefaultServerCapabilities,
|
||||
type McpServerData,
|
||||
METHOD_INITIALIZE,
|
||||
METHOD_LIST_PROMPTS,
|
||||
METHOD_LIST_RESOURCE_TEMPLATES,
|
||||
METHOD_LIST_RESOURCES,
|
||||
METHOD_LIST_TOOLS,
|
||||
} from '~/common/mcp-utils';
|
||||
import type { McpEvent, McpMessageEvent } from '~/main/network/mcp';
|
||||
import type { McpRequest, McpServerPrimitiveTypes } from '~/models/mcp-request';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
|
||||
import {
|
||||
type McpRequestLoaderData,
|
||||
useRequestLoaderData,
|
||||
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
|
||||
import { McpActionsDropdown } from '~/ui/components/dropdowns/mcp-actions-dropdown';
|
||||
import { WorkspaceDropdown } from '~/ui/components/dropdowns/workspace-dropdown';
|
||||
import { EnvironmentPicker } from '~/ui/components/environment-picker';
|
||||
import { ErrorBoundary } from '~/ui/components/error-boundary';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { McpRequestPane } from '~/ui/components/mcp/mcp-request-pane';
|
||||
import {
|
||||
type PrimitiveSubItem,
|
||||
type PrimitiveTypeItem,
|
||||
type PromptItem,
|
||||
type ResourceItem,
|
||||
type ResourceTemplateItem,
|
||||
type ToolItem,
|
||||
} from '~/ui/components/mcp/types';
|
||||
import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal';
|
||||
import { OrganizationTabList } from '~/ui/components/tabs/tab-list';
|
||||
import { RealtimeResponsePane } from '~/ui/components/websockets/realtime-response-pane';
|
||||
import { INSOMNIA_TAB_HEIGHT } from '~/ui/constant';
|
||||
import { useReadyState } from '~/ui/hooks/use-ready-state';
|
||||
|
||||
export const McpPane = () => {
|
||||
const requestData = useRequestLoaderData()!;
|
||||
const { activeResponse, activeRequest } = requestData as McpRequestLoaderData;
|
||||
const { organizationId, projectId, workspaceId } = useParams() as {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
const sidebarPanelRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false);
|
||||
const [isEnvironmentModalOpen, setEnvironmentModalOpen] = 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<PrimitiveSubItem | null>(null);
|
||||
|
||||
const getPrimitiveCollection = () => {
|
||||
const collection: (PrimitiveTypeItem | PrimitiveSubItem)[] = [];
|
||||
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 = getDefaultServerCapabilities();
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout && layout[0] > 0) {
|
||||
layout[0] = 0;
|
||||
} else {
|
||||
layout[0] = DEFAULT_SIDEBAR_SIZE;
|
||||
}
|
||||
|
||||
sidebarPanelRef.current?.setLayout(layout);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.main.on('toggle-sidebar', toggleSidebar);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(
|
||||
settings.forceVerticalLayout ? 'vertical' : 'horizontal',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.forceVerticalLayout) {
|
||||
setDirection('vertical');
|
||||
return () => {};
|
||||
}
|
||||
// Listen on media query changes
|
||||
const mediaQuery = window.matchMedia('(max-width: 880px)');
|
||||
setDirection(mediaQuery.matches ? 'vertical' : 'horizontal');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setDirection(e.matches ? 'vertical' : 'horizontal');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [settings.forceVerticalLayout, direction]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateServerData = async () => {
|
||||
const findFirstMatchEventData = (mcpEvents: McpEvent[], method: string) => {
|
||||
const firstMatchEvent = mcpEvents.find(
|
||||
event => 'method' in event && event.method === method,
|
||||
) as McpMessageEvent;
|
||||
if (firstMatchEvent) {
|
||||
return firstMatchEvent.data.result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const activeResponseId = activeResponse?._id;
|
||||
if (activeResponseId) {
|
||||
const allEvents = await window.main.mcp.event.findMany({ responseId: activeResponseId });
|
||||
const serverCapabilities =
|
||||
findFirstMatchEventData(allEvents, METHOD_INITIALIZE)?.capabilities || getDefaultServerCapabilities();
|
||||
const tools = findFirstMatchEventData(allEvents, METHOD_LIST_TOOLS)?.tools || [];
|
||||
const resources = findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCES)?.resources || [];
|
||||
const resourceTemplates =
|
||||
findFirstMatchEventData(allEvents, METHOD_LIST_RESOURCE_TEMPLATES)?.resourceTemplates || [];
|
||||
const prompts = findFirstMatchEventData(allEvents, METHOD_LIST_PROMPTS)?.prompts || [];
|
||||
const mcpServerData = {
|
||||
serverCapabilities: serverCapabilities,
|
||||
primitives: {
|
||||
tools,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
prompts,
|
||||
},
|
||||
} as McpServerData;
|
||||
setMcpServerData(mcpServerData);
|
||||
}
|
||||
};
|
||||
if (readyState) {
|
||||
// Get MCP server data when connection is ready
|
||||
updateServerData();
|
||||
}
|
||||
}, [readyState, activeResponse?._id]);
|
||||
|
||||
return (
|
||||
<PanelGroup
|
||||
ref={sidebarPanelRef}
|
||||
autoSaveId="insomnia-sidebar"
|
||||
id="wrapper"
|
||||
className="new-sidebar h-full w-full text-[--color-font]"
|
||||
direction="horizontal"
|
||||
>
|
||||
<Panel id="sidebar" className="sidebar theme--sidebar" maxSize={40} minSize={10} collapsible>
|
||||
<div className="flex flex-1 flex-col divide-y divide-solid divide-[--hl-md] overflow-hidden">
|
||||
<div className="flex flex-col items-start divide-y divide-solid divide-[--hl-md]">
|
||||
<div className={`flex w-full h-[${INSOMNIA_TAB_HEIGHT}px]`}>
|
||||
<Breadcrumbs className="m-0 flex h-[--line-height-sm] w-full list-none items-center gap-2 px-[--padding-sm] font-bold">
|
||||
<Breadcrumb className="flex h-full select-none items-center gap-2 text-[--color-font] outline-none data-[focused]:outline-none">
|
||||
<NavLink
|
||||
data-testid="project"
|
||||
className="flex aspect-square h-7 flex-shrink-0 items-center justify-center gap-2 rounded-sm px-1 py-1 text-sm text-[--color-font] outline-none ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm] data-[focused]:outline-none"
|
||||
to={`/organization/${organizationId}/project/${projectId}`}
|
||||
>
|
||||
<Icon className="text-xs" icon="chevron-left" />
|
||||
</NavLink>
|
||||
<span aria-hidden role="separator" className="h-4 text-[--hl-lg] outline outline-1" />
|
||||
</Breadcrumb>
|
||||
<Breadcrumb className="flex h-full select-none items-center gap-2 truncate text-[--color-font] outline-none data-[focused]:outline-none">
|
||||
<WorkspaceDropdown />
|
||||
</Breadcrumb>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-2 p-[--padding-sm]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<EnvironmentPicker
|
||||
isOpen={isEnvironmentPickerOpen}
|
||||
onOpenChange={setIsEnvironmentPickerOpen}
|
||||
onOpenEnvironmentSettingsModal={() => setEnvironmentModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex justify-between gap-1 p-[--padding-sm]">
|
||||
<SearchField
|
||||
aria-label="Server Capability filter"
|
||||
className="group relative flex-1"
|
||||
value={filter ?? ''}
|
||||
onChange={setFilter}
|
||||
>
|
||||
<Input
|
||||
placeholder="Filter"
|
||||
className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 flex h-full items-center px-2">
|
||||
<Button className="flex aspect-square w-5 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] aria-pressed:bg-[--hl-sm] group-data-[empty]:hidden">
|
||||
<Icon icon="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</SearchField>
|
||||
<TooltipTrigger>
|
||||
<ToggleButton
|
||||
aria-label="Expand All/Collapse all"
|
||||
defaultSelected={allExpanded}
|
||||
onChange={() => {
|
||||
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]"
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<Icon
|
||||
icon={isSelected ? 'down-left-and-up-right-to-center' : 'up-right-and-down-left-from-center'}
|
||||
/>
|
||||
)}
|
||||
</ToggleButton>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] px-4 py-2 text-sm text-[--color-font] shadow-lg focus:outline-none"
|
||||
>
|
||||
<span>{allExpanded ? 'Collapse all' : 'Expand all'}</span>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto" ref={parentRef}>
|
||||
<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 PrimitiveSubItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{virtualItem => {
|
||||
const item = visibleCollection[virtualItem.index];
|
||||
return (
|
||||
<CollectionGridListItem
|
||||
activeRequest={activeRequest}
|
||||
item={item}
|
||||
collapsedPrimitives={collapsedPrimitives}
|
||||
style={{
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
{isEnvironmentModalOpen && <WorkspaceEnvironmentsEditModal onClose={() => setEnvironmentModalOpen(false)} />}
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="h-full w-[1px] bg-[--hl-md]" />
|
||||
<Panel className="flex flex-col">
|
||||
<OrganizationTabList currentPage="mcp" />
|
||||
<PanelGroup autoSaveId="insomnia-panels" id="insomnia-panels" direction={direction}>
|
||||
<Panel id="mcp-request-pane" order={1} minSize={10} className="pane-one theme--pane">
|
||||
<McpRequestPane
|
||||
selectedPrimitiveItem={
|
||||
selectedPrimitiveItem?.itemLevel === 1 ? (selectedPrimitiveItem as PrimitiveSubItem) : null
|
||||
}
|
||||
environment={activeEnvironment}
|
||||
readyState={readyState}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel id="mcp-response-pane" order={2} minSize={10} className="pane-two theme--pane">
|
||||
<ErrorBoundary showAlert>
|
||||
<RealtimeResponsePane />
|
||||
</ErrorBoundary>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionGridListItem = ({
|
||||
activeRequest,
|
||||
style,
|
||||
item,
|
||||
collapsedPrimitives,
|
||||
}: {
|
||||
activeRequest: McpRequest;
|
||||
item: PrimitiveTypeItem | PrimitiveSubItem;
|
||||
style: React.CSSProperties;
|
||||
collapsedPrimitives: McpServerPrimitiveTypes[];
|
||||
}) => {
|
||||
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;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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={style}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<div
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setIsContextMenuOpen(true);
|
||||
}}
|
||||
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>
|
||||
<McpActionsDropdown
|
||||
item={item}
|
||||
request={activeRequest}
|
||||
isOpen={isContextMenuOpen}
|
||||
onOpenChange={setIsContextMenuOpen}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpPane;
|
||||
@@ -10,7 +10,10 @@ import { InsomniaRjsfForm } from '~/ui/components/rjsf';
|
||||
import { type AuthTypes } from '../../../common/constants';
|
||||
import type { Environment } from '../../../models/environment';
|
||||
import { getAuthObjectOrNull } from '../../../network/authentication';
|
||||
import { useMcpRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
|
||||
import {
|
||||
type McpRequestLoaderData,
|
||||
useRequestLoaderData,
|
||||
} from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
|
||||
import { useRequestPatcher } from '../../hooks/use-request';
|
||||
import { CodeEditor, type CodeEditorHandle } from '../.client/codemirror/code-editor';
|
||||
import { AuthWrapper } from '../editors/auth/auth-wrapper';
|
||||
@@ -51,7 +54,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const McpRequestPane: FC<Props> = ({ environment, readyState, selectedPrimitiveItem }) => {
|
||||
const { activeRequest } = useMcpRequestLoaderData()!;
|
||||
const { activeRequest } = useRequestLoaderData()! as McpRequestLoaderData;
|
||||
const [formData, setFormData] = useState({});
|
||||
const paramEditorRef = useRef<CodeEditorHandle>(null);
|
||||
const requestId = activeRequest._id;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams } from 'react-router';
|
||||
import {
|
||||
type ConnectActionParams,
|
||||
useRequestConnectActionFetcher,
|
||||
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId.connect';
|
||||
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect';
|
||||
import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor';
|
||||
import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '~/ui/components/base/dropdown';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { OverlayContainer } from 'react-aria';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import type { McpRequest } from '~/models/mcp-request';
|
||||
import { useProjectListWorkspacesLoaderFetcher } from '~/routes/organization.$organizationId.project.$projectId.list-workspaces';
|
||||
import { useRequestDuplicateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.duplicate';
|
||||
|
||||
@@ -22,7 +23,7 @@ import { HelpTooltip } from '../help-tooltip';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
export interface RequestSettingsModalOptions {
|
||||
request: Request | GrpcRequest | WebSocketRequest | SocketIORequest;
|
||||
request: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest;
|
||||
}
|
||||
|
||||
export const RequestSettingsModal = ({ request, onHide }: ModalProps & RequestSettingsModalOptions) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { type FC, memo } from 'react';
|
||||
|
||||
import { isMcpRequest, type McpRequest } from '~/models/mcp-request';
|
||||
|
||||
import { CONTENT_TYPE_GRAPHQL, METHOD_DELETE, METHOD_OPTIONS } from '../../../common/constants';
|
||||
import { type GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
|
||||
import { isEventStreamRequest, isRequest, type Request } from '../../../models/request';
|
||||
@@ -37,7 +39,9 @@ export function formatMethodName(method: string) {
|
||||
return methodName;
|
||||
}
|
||||
|
||||
export const getRequestMethodShortHand = (doc?: Request | WebSocketRequest | GrpcRequest | SocketIORequest) => {
|
||||
export const getRequestMethodShortHand = (
|
||||
doc?: Request | WebSocketRequest | GrpcRequest | SocketIORequest | McpRequest,
|
||||
) => {
|
||||
if (!doc) {
|
||||
return '';
|
||||
}
|
||||
@@ -57,6 +61,10 @@ export const getRequestMethodShortHand = (doc?: Request | WebSocketRequest | Grp
|
||||
return 'IO';
|
||||
}
|
||||
|
||||
if (isMcpRequest(doc)) {
|
||||
return 'MCP';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { Response } from '../../../models/response';
|
||||
import { isSocketIOResponse, type SocketIOResponse } from '../../../models/socket-io-response';
|
||||
import { type WebSocketResponse } from '../../../models/websocket-response';
|
||||
import { useRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
|
||||
import { useMcpRequestLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mcp.request.$requestId';
|
||||
import { deserializeNDJSON } from '../../../utils/ndjson';
|
||||
import { useReadyState } from '../../hooks/use-ready-state';
|
||||
import { useRealtimeConnectionEvents } from '../../hooks/use-realtime-connection-events';
|
||||
@@ -38,7 +37,7 @@ import { ResponseTimelineViewer } from '../viewers/response-timeline-viewer';
|
||||
import { EventLogView } from './event-log-view';
|
||||
import { EventView } from './event-view';
|
||||
|
||||
export const RealtimeResponsePane: FC<{ requestId: string }> = () => {
|
||||
export const RealtimeResponsePane: FC<{ requestId?: string }> = () => {
|
||||
const { activeResponse, responses, requestVersions } = useRequestLoaderData()!;
|
||||
|
||||
if (!activeResponse) {
|
||||
@@ -54,22 +53,6 @@ export const RealtimeResponsePane: FC<{ requestId: string }> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const McpRealtimeResponsePane = () => {
|
||||
const { activeResponse, responses, requestVersions } = useMcpRequestLoaderData()!;
|
||||
|
||||
if (!activeResponse) {
|
||||
return (
|
||||
<Pane type="response">
|
||||
<PaneHeader className="!justify-normal" />
|
||||
<PlaceholderResponsePane />
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RealtimeActiveResponsePane response={activeResponse} responses={responses} requestVersions={requestVersions} />
|
||||
);
|
||||
};
|
||||
|
||||
type ResponseType = WebSocketResponse | Response | SocketIOResponse | McpResponse;
|
||||
type EventType = CurlEvent | WebSocketEvent | SocketIOEvent | McpEvent;
|
||||
const RealtimeActiveResponsePane: FC<{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { matchPath, useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import type { McpRequest } from '~/models/mcp-request';
|
||||
|
||||
import type { GrpcRequest } from '../../models/grpc-request';
|
||||
import type { MockRoute } from '../../models/mock-route';
|
||||
import type { Project } from '../../models/project';
|
||||
@@ -22,7 +24,7 @@ interface InsomniaTabProps {
|
||||
workspaceId: string;
|
||||
activeProject: Project;
|
||||
activeWorkspace: Workspace;
|
||||
activeRequest?: Request | GrpcRequest | WebSocketRequest | SocketIORequest;
|
||||
activeRequest?: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest;
|
||||
activeRequestGroup?: RequestGroup;
|
||||
activeMockRoute?: MockRoute;
|
||||
unitTestSuite?: UnitTestSuite;
|
||||
|
||||
Reference in New Issue
Block a user