diff --git a/packages/insomnia/src/main/network/mcp.ts b/packages/insomnia/src/main/network/mcp.ts index c2cd3de40c..c81568bdf4 100644 --- a/packages/insomnia/src/main/network/mcp.ts +++ b/packages/insomnia/src/main/network/mcp.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { type ClientRequest, @@ -19,6 +19,7 @@ import { getAppVersion, getProductName } from '~/common/constants'; import { getMcpMethodFromMessage } from '~/common/mcp-utils'; import { generateId } from '~/common/misc'; import * as models from '~/models'; +import { TRANSPORT_TYPES, type TransportType } from '~/models/mcp-request'; import type { McpResponse } from '~/models/mcp-response'; import type { RequestAuthentication, RequestHeader } from '~/models/request'; import { getBasicAuthHeader } from '~/network/basic-auth/get-header'; @@ -37,7 +38,7 @@ interface CommonMcpOptions { type OpenMcpHTTPClientConnectionOptions = CommonMcpOptions & { workspaceId: string; url: string; - transportType: 'streamable-http'; + transportType: typeof TRANSPORT_TYPES.HTTP; headers: RequestHeader[]; authentication: RequestAuthentication; }; @@ -45,13 +46,14 @@ type OpenMcpStdioClientConnectionOptions = CommonMcpOptions & { workspaceId: string; // TODO: should rename to command or urlOrCommand url: string; - transportType: 'stdio'; + transportType: typeof TRANSPORT_TYPES.STDIO; + env: Record; }; export type OpenMcpClientConnectionOptions = OpenMcpHTTPClientConnectionOptions | OpenMcpStdioClientConnectionOptions; const isOpenMcpHTTPClientConnectionOptions = ( options: OpenMcpClientConnectionOptions, ): options is OpenMcpHTTPClientConnectionOptions => { - return options.transportType === 'streamable-http'; + return options.transportType === TRANSPORT_TYPES.HTTP; }; export interface McpRequestOptions { requestId: string; @@ -205,7 +207,8 @@ const createErrorResponse = async ({ environmentId, timelinePath, message, -}: ResponseEventOptions & { message: string }) => { + transportType, +}: ResponseEventOptions & { message: string; transportType: TransportType }) => { const settings = await models.settings.get(); const responsePatch = { _id: responseId, @@ -214,6 +217,7 @@ const createErrorResponse = async ({ timelinePath, statusMessage: 'Error', error: message, + transportType, }; const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses); models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); @@ -284,6 +288,7 @@ const fetchWithLogging = async ( elapsedTime: performance.now() - start, timelinePath, eventLogPath, + transportType: TRANSPORT_TYPES.HTTP, }; const settings = await models.settings.get(); const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses); @@ -305,6 +310,185 @@ const fetchWithLogging = async ( return response; }; +const createStreamableHTTPTransport = ( + options: OpenMcpHTTPClientConnectionOptions, + { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }: { + responseId: string; + responseEnvironmentId: string | null; + timelinePath: string; + eventLogPath: string; + }, +) => { + const { url, requestId } = options; + if (!url) { + throw new Error('MCP server url is required'); + } + + if (!options.authentication.disabled) { + if (options.authentication.type === 'basic') { + const { username, password, useISO88591 } = options.authentication; + const encoding = useISO88591 ? 'latin1' : 'utf8'; + options.headers.push(getBasicAuthHeader(username, password, encoding)); + } + if (options.authentication.type === 'apikey') { + const { key = '', value = '' } = options.authentication; + options.headers.push({ name: key, value: value }); + } + if (options.authentication.type === 'bearer' && options.authentication.token) { + const { token, prefix } = options.authentication; + options.headers.push(getBearerAuthHeader(token, prefix)); + } + } + const reduceArrayToLowerCaseKeyedDictionary = (acc: Record, { name, value }: RequestHeader) => ({ + ...acc, + [name.toLowerCase() || '']: value || '', + }); + const lowerCasedEnabledHeaders = options.headers + .filter(({ name, disabled }) => Boolean(name) && !disabled) + .reduce(reduceArrayToLowerCaseKeyedDictionary, {}); + + const mcpServerUrl = new URL(url); + const transport = new StreamableHTTPClientTransport(mcpServerUrl, { + requestInit: { + headers: lowerCasedEnabledHeaders, + }, + fetch: (url, init) => + fetchWithLogging(url, init || {}, { + requestId, + responseId, + environmentId: responseEnvironmentId, + timelinePath, + eventLogPath, + }), + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }); + transport.onmessage = message => _handleMcpMessage(message, requestId); + return transport; +}; + +const createStdioTransport = ( + options: OpenMcpStdioClientConnectionOptions, + { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }: { + responseId: string; + responseEnvironmentId: string | null; + timelinePath: string; + eventLogPath: string; + }, +) => { + const { url, requestId, env } = options; + const parseResult = parse(url); + if (parseResult.find(arg => typeof arg !== 'string')) { + throw new Error('Invalid command format'); + } + const [command, ...args] = parseResult as string[]; + + const initialTimelines = getInitialTimeline(`STDIO: ${url}`); + // Add stdio-specific timeline info + initialTimelines.push({ + value: `Run command: ${url}`, + name: 'HeaderOut', + timestamp: Date.now(), + }); + const stringifiedEnv = Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join(' ') + .trim(); + if (stringifiedEnv) { + initialTimelines.push({ + value: `With env: ${stringifiedEnv}`, + name: 'HeaderOut', + timestamp: Date.now(), + }); + } + initialTimelines.map(t => timelineFileStreams.get(requestId)?.write(JSON.stringify(t) + '\n')); + + const start = performance.now(); + const transport = new StdioClientTransport({ + command, + args, + env: { + ...getDefaultEnvironment(), + ...env, + }, + stderr: 'pipe', + }); + + // Capture stderr logs for debugging + const stderrStream = transport.stderr; + stderrStream?.on('data', (chunk: Buffer) => { + const stderrData = chunk.toString().trim(); + if (!stderrData) return; // Skip empty lines + + // Log stderr output to timeline with appropriate categorization + timelineFileStreams.get(requestId)?.write( + JSON.stringify({ + value: stderrData, + name: 'HeaderIn', + timestamp: Date.now(), + }) + '\n', + ); + }); + + // Wrap the original send method to log outgoing requests for stdio transport + const originalSend = transport.send.bind(transport); + transport.send = async (message: JSONRPCMessage & { method: string }) => { + const method = message.method || 'unknown'; + + // Create response model for initialize message and add process status timeline + if (method === 'initialize') { + // Add process started timeline (similar to HTTP response timeline) + timelineFileStreams + .get(requestId) + ?.write(JSON.stringify({ value: 'Process started and ready', name: 'Text', timestamp: Date.now() }) + '\n'); + + const responsePatch: Partial = { + _id: responseId, + parentId: requestId, + environmentId: responseEnvironmentId, + url, + elapsedTime: performance.now() - start, + timelinePath, + eventLogPath, + transportType: TRANSPORT_TYPES.STDIO, + }; + const settings = await models.settings.get(); + const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses); + models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); + } + + const requestEvent: McpRequestEvent = { + _id: mcpEventIdGenerator(), + method, + requestId, + type: 'message', + direction: 'OUTGOING', + timestamp: Date.now(), + data: message, + }; + eventLogFileStreams.get(requestId)?.write(JSON.stringify(requestEvent) + '\n'); + + return originalSend(message); + }; + + transport.onmessage = message => _handleMcpMessage(message, requestId); + return transport; +}; + const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) => { const { requestId, workspaceId } = options; @@ -332,99 +516,20 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) mcpClient.onerror = _error => _handleMcpConnectionError(requestId, _error); const mcpStateChannel = getMcpStateChannel(requestId); - const createStreamableHTTPTransport = (options: OpenMcpHTTPClientConnectionOptions) => { - const { url, requestId } = options; - if (!url) { - throw new Error('MCP server url is required'); - } - - if (!options.authentication.disabled) { - if (options.authentication.type === 'basic') { - const { username, password, useISO88591 } = options.authentication; - const encoding = useISO88591 ? 'latin1' : 'utf8'; - options.headers.push(getBasicAuthHeader(username, password, encoding)); - } - if (options.authentication.type === 'apikey') { - const { key = '', value = '' } = options.authentication; - options.headers.push({ name: key, value: value }); - } - if (options.authentication.type === 'bearer' && options.authentication.token) { - const { token, prefix } = options.authentication; - options.headers.push(getBearerAuthHeader(token, prefix)); - } - } - const reduceArrayToLowerCaseKeyedDictionary = (acc: Record, { name, value }: RequestHeader) => ({ - ...acc, - [name.toLowerCase() || '']: value || '', - }); - const lowerCasedEnabledHeaders = options.headers - .filter(({ name, disabled }) => Boolean(name) && !disabled) - .reduce(reduceArrayToLowerCaseKeyedDictionary, {}); - - const mcpServerUrl = new URL(url); - const transport = new StreamableHTTPClientTransport(mcpServerUrl, { - requestInit: { - headers: lowerCasedEnabledHeaders, - }, - fetch: (url, init) => - fetchWithLogging(url, init || {}, { - requestId, - responseId, - environmentId: responseEnvironmentId, - timelinePath, - eventLogPath, - }), - reconnectionOptions: { - maxReconnectionDelay: 30000, - initialReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }, - }); - transport.onmessage = message => _handleMcpMessage(message, requestId); - return transport; - }; - - const createStdioTransport = (options: OpenMcpClientConnectionOptions) => { - const { url } = options; - const parseResult = parse(url); - if (parseResult.find(arg => typeof arg !== 'string')) { - throw new Error('Invalid command format'); - } - const [command, ...args] = parseResult as string[]; - const transport = new StdioClientTransport({ - command, - args, - }); - transport.onmessage = async message => { - const method = getMcpMethodFromMessage(message); - - // TODO[MCP-STDIO]: try to find a better way to unify this with the http transport - if (method === 'initialize') { - const responsePatch: Partial = { - _id: responseId, - parentId: requestId, - environmentId: responseEnvironmentId, - url: url.toString(), - // elapsedTime: performance.now() - start, - timelinePath, - eventLogPath, - }; - const settings = await models.settings.get(); - const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses); - models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id }); - } - - _handleMcpMessage(message, requestId); - }; - return transport; - }; - try { const transport = isOpenMcpHTTPClientConnectionOptions(options) - ? await createStreamableHTTPTransport(options) - : await createStdioTransport(options); - + ? await createStreamableHTTPTransport(options, { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }) + : await createStdioTransport(options, { + responseId, + responseEnvironmentId, + timelinePath, + eventLogPath, + }); await mcpClient.connect(transport!); } catch (error) { // Log error when connection fails with exception @@ -435,10 +540,12 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) timelinePath, eventLogPath, message: error.message || 'Something went wrong', + transportType: options.transportType, }); console.error(`Failed to create ${options.transportType} transport: ${error}`); return; } + mcpConnections.set(requestId, mcpClient as McpClient); const serverCapabilities = mcpClient.getServerCapabilities(); const primitivePromises: Promise[] = []; diff --git a/packages/insomnia/src/models/mcp-request.ts b/packages/insomnia/src/models/mcp-request.ts index 32df3f1b28..c8aad292b7 100644 --- a/packages/insomnia/src/models/mcp-request.ts +++ b/packages/insomnia/src/models/mcp-request.ts @@ -1,4 +1,5 @@ import { database as db } from '../common/database'; +import { type EnvironmentKvPairData } from './environment'; import type { BaseModel } from './index'; import type { RequestAuthentication, RequestHeader } from './request'; @@ -21,11 +22,11 @@ export interface BaseMcpRequest { description: string; headers: RequestHeader[]; authentication: RequestAuthentication | {}; - env: Record; + env: EnvironmentKvPairData[]; } export type McpServerPrimitiveTypes = 'tools' | 'resources' | 'prompts'; -export const MCP_TRANSPORT_TYPES: TransportType[] = ['streamable-http', 'stdio']; +export const MCP_TRANSPORT_TYPES: TransportType[] = [TRANSPORT_TYPES.HTTP, TRANSPORT_TYPES.STDIO]; export type McpRequest = BaseModel & BaseMcpRequest & { type: typeof type }; @@ -36,12 +37,12 @@ export const isMcpRequestId = (id?: string | null) => id?.startsWith(`${prefix}_ export function init(): BaseMcpRequest { return { url: '', - transportType: 'streamable-http', + transportType: TRANSPORT_TYPES.HTTP, name: 'New MCP Client', description: '', headers: [], authentication: {}, - env: {}, + env: [], }; } diff --git a/packages/insomnia/src/models/mcp-response.ts b/packages/insomnia/src/models/mcp-response.ts index 6f79b07efc..7d9c4f03b2 100644 --- a/packages/insomnia/src/models/mcp-response.ts +++ b/packages/insomnia/src/models/mcp-response.ts @@ -4,6 +4,7 @@ import { database as db } from '../common/database'; import * as requestOperations from './helpers/request-operations'; import type { BaseModel } from './index'; import * as models from './index'; +import { TRANSPORT_TYPES, type TransportType } from './mcp-request'; import type { ResponseHeader } from './response'; export const name = 'Mcp Response'; @@ -25,6 +26,7 @@ export interface BaseMcpResponse { timelinePath: string; error: string; requestVersionId: string | null; + transportType: TransportType; } export type McpResponse = BaseModel & BaseMcpResponse; @@ -43,6 +45,7 @@ export function init(): BaseMcpResponse { statusMessage: '', requestVersionId: null, environmentId: null, + transportType: TRANSPORT_TYPES.HTTP, }; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx index 093f3f351c..eca3022398 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx @@ -4,7 +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 { isMcpRequest, TRANSPORT_TYPES, 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'; @@ -25,6 +25,7 @@ export interface ConnectActionParams { suppressUserAgent: boolean; transportType?: TransportType; query?: Record; + env?: Record; } export async function clientAction({ params, request }: Route.ClientActionArgs) { @@ -94,13 +95,14 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) }); } if (isMcpRequest(req)) { - window.main.mcp.connect({ + return window.main.mcp.connect({ requestId, workspaceId, - transportType: rendered.transportType || 'streamable-http', + transportType: rendered.transportType || TRANSPORT_TYPES.HTTP, url: rendered.url, headers: rendered.headers, authentication: rendered.authentication, + env: rendered.env || {}, }); } // HACK: even more elaborate hack to get the request to update diff --git a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts index 45f64dc166..46733b75e6 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts @@ -66,6 +66,7 @@ async function _highlightNunjucksTags( // Only mark up Nunjucks tokens that are in the viewport const vp = this.getViewport(); + const readOnly = this.isReadOnly(); for (let lineNo = vp.from; lineNo < vp.to; lineNo++) { const line = this.getLineTokens(lineNo); @@ -156,6 +157,7 @@ async function _highlightNunjucksTags( }); activeMarks.push(mark); el.addEventListener('click', async () => { + if (readOnly) return; // Define the dialog HTML showModal(NunjucksModal, { // @ts-expect-error not a known property of TextMarkerOptions diff --git a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx index 2138997667..38aac622fb 100644 --- a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx @@ -37,6 +37,8 @@ interface EditorProps { onChange: (newPair: EnvironmentKvPairData[]) => void; vaultKey?: string; isPrivate?: boolean; + textOnly?: boolean; + disabled?: boolean; } const cellCommonStyle = 'h-full px-2 flex items-center'; @@ -62,7 +64,14 @@ const ItemButton = (props: ButtonProps & { tabIndex?: number }) => { return { onChange([]); }} diff --git a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx index 5bc14422f5..e811f9556e 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx @@ -5,10 +5,11 @@ import React, { type FC, useEffect, useRef, useState } from 'react'; import { Button, Heading, Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { EnvironmentKVEditor } from '~/ui/components/editors/environment-key-value-editor/key-value-editor'; import { InsomniaRjsfForm } from '~/ui/components/rjsf'; import { type AuthTypes } from '../../../common/constants'; -import type { Environment } from '../../../models/environment'; +import type { Environment, EnvironmentKvPairData } from '../../../models/environment'; import { getAuthObjectOrNull } from '../../../network/authentication'; import { type McpRequestLoaderData, @@ -54,7 +55,7 @@ interface Props { } export const McpRequestPane: FC = ({ environment, readyState, selectedPrimitiveItem }) => { - const { activeRequest } = useRequestLoaderData()! as McpRequestLoaderData; + const { activeRequest, activeRequestMeta } = useRequestLoaderData()! as McpRequestLoaderData; const [formData, setFormData] = useState({}); const paramEditorRef = useRef(null); const requestId = activeRequest._id; @@ -63,7 +64,7 @@ export const McpRequestPane: FC = ({ environment, readyState, selectedPri const patchRequest = useRequestPatcher(); // Reset the response pane state when we switch requests, the environment gets modified - const uniqueKey = `${environment?.modified}::${requestId}`; + const uniqueKey = `${environment?.modified}::${requestId}::${activeRequestMeta?.activeResponseId}`; const requestAuth = getAuthObjectOrNull(activeRequest.authentication); const isNoneOrInherited = requestAuth?.type === 'none' || requestAuth === null; const toolsSchema = @@ -91,6 +92,10 @@ export const McpRequestPane: FC = ({ environment, readyState, selectedPri }); }; + const handleEnvChange = (data: EnvironmentKvPairData[]) => { + patchRequest(requestId, { env: data }); + }; + const isStdio = activeRequest.transportType === 'stdio'; return ( @@ -235,17 +240,12 @@ export const McpRequestPane: FC = ({ environment, readyState, selectedPri {readyState && } - {/* TODO[MCP-STDIO] */} -

WIP

-
diff --git a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx index 60110c5e20..bae89cd49e 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx @@ -9,7 +9,8 @@ import { import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor'; import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '~/ui/components/base/dropdown'; -import { MCP_TRANSPORT_TYPES, type McpRequest } from '../../../models/mcp-request'; +import { getDataFromKVPair } from '../../../models/environment'; +import { MCP_TRANSPORT_TYPES, type McpRequest, TRANSPORT_TYPES } from '../../../models/mcp-request'; import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; import { useRequestPatcher } from '../../hooks/use-request'; @@ -25,7 +26,7 @@ interface ActionBarProps { } const getTransportLabel = (transportType: McpRequest['transportType']) => - transportType === 'streamable-http' ? 'HTTP' : 'STDIO'; + transportType === TRANSPORT_TYPES.HTTP ? 'HTTP' : 'STDIO'; export const McpUrlActionBar = ({ request, environmentId, defaultValue, onChange, readyState }: ActionBarProps) => { const isOpen = readyState; @@ -70,6 +71,7 @@ export const McpUrlActionBar = ({ request, environmentId, defaultValue, onChange url: request.url, headers: request.headers, authentication: request.authentication, + env: getDataFromKVPair(request.env).data, }, }); return { @@ -79,6 +81,7 @@ export const McpUrlActionBar = ({ request, environmentId, defaultValue, onChange authentication: rendered.authentication, suppressUserAgent: rendered.suppressUserAgent, cookieJar: rendered.workspaceCookieJar, + env: rendered.env, }; }, [environmentId, request]); @@ -173,6 +176,7 @@ export const McpUrlActionBar = ({ request, environmentId, defaultValue, onChange {isConnectingOrClosed ? (