mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-22 15:18:27 -04:00
feat: support stdio transport (#9135)
This commit is contained in:
@@ -2,6 +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 { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
type ClientRequest,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type JSONRPCResponse,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import electron, { BrowserWindow } from 'electron';
|
||||
import { parse } from 'shell-quote';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import type { z } from 'zod';
|
||||
|
||||
@@ -17,7 +19,6 @@ import { getAppVersion, getProductName } from '~/common/constants';
|
||||
import { getMcpMethodFromMessage } from '~/common/mcp-utils';
|
||||
import { generateId } from '~/common/misc';
|
||||
import * as models from '~/models';
|
||||
import 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';
|
||||
@@ -28,19 +29,30 @@ import { ipcMainHandle, ipcMainOn } from '../ipc/electron';
|
||||
|
||||
// Refer the SDK: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/shared/protocol.ts#L504
|
||||
// The Client type has missing transport property
|
||||
type McpClient = Client & { transport: StreamableHTTPClientTransport };
|
||||
type McpClient = Client & { transport: StreamableHTTPClientTransport | StdioClientTransport };
|
||||
// Mcp connection and request options
|
||||
interface CommonMcpOptions {
|
||||
requestId: string;
|
||||
}
|
||||
export interface OpenMcpClientConnectionOptions extends CommonMcpOptions {
|
||||
url: string;
|
||||
requestId: string;
|
||||
type OpenMcpHTTPClientConnectionOptions = CommonMcpOptions & {
|
||||
workspaceId: string;
|
||||
transportType: TransportType;
|
||||
url: string;
|
||||
transportType: 'streamable-http';
|
||||
headers: RequestHeader[];
|
||||
authentication: RequestAuthentication;
|
||||
}
|
||||
};
|
||||
type OpenMcpStdioClientConnectionOptions = CommonMcpOptions & {
|
||||
workspaceId: string;
|
||||
// TODO: should rename to command or urlOrCommand
|
||||
url: string;
|
||||
transportType: 'stdio';
|
||||
};
|
||||
export type OpenMcpClientConnectionOptions = OpenMcpHTTPClientConnectionOptions | OpenMcpStdioClientConnectionOptions;
|
||||
const isOpenMcpHTTPClientConnectionOptions = (
|
||||
options: OpenMcpClientConnectionOptions,
|
||||
): options is OpenMcpHTTPClientConnectionOptions => {
|
||||
return options.transportType === 'streamable-http';
|
||||
};
|
||||
export interface McpRequestOptions {
|
||||
requestId: string;
|
||||
request: ClientRequest;
|
||||
@@ -174,6 +186,7 @@ const _handleMcpConnectionError = (requestId: string, error: Error) => {
|
||||
|
||||
const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
|
||||
const method = getMcpMethodFromMessage(message);
|
||||
|
||||
const messageEvent: McpMessageEvent = {
|
||||
_id: mcpEventIdGenerator(),
|
||||
requestId,
|
||||
@@ -293,34 +306,7 @@ const fetchWithLogging = async (
|
||||
};
|
||||
|
||||
const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) => {
|
||||
const { transportType, url, requestId, workspaceId } = 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<string, string>, { name, value }: RequestHeader) => ({
|
||||
...acc,
|
||||
[name.toLowerCase() || '']: value || '',
|
||||
});
|
||||
const lowerCasedEnabledHeaders = options.headers
|
||||
.filter(({ name, disabled }) => Boolean(name) && !disabled)
|
||||
.reduce(reduceArrayToLowerCaseKeyedDictionary, {});
|
||||
const { requestId, workspaceId } = options;
|
||||
|
||||
// create response model and file streams
|
||||
const responseId = generateId('res');
|
||||
@@ -345,53 +331,113 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
|
||||
mcpClient.onclose = () => _handleCloseMcpConnection(requestId);
|
||||
mcpClient.onerror = _error => _handleMcpConnectionError(requestId, _error);
|
||||
const mcpStateChannel = getMcpStateChannel(requestId);
|
||||
let transport: StreamableHTTPClientTransport;
|
||||
|
||||
switch (transportType) {
|
||||
case 'streamable-http': {
|
||||
try {
|
||||
const mcpServerUrl = new URL(url);
|
||||
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);
|
||||
await mcpClient.connect(transport);
|
||||
} catch (error) {
|
||||
// Log error when connection fails with exception
|
||||
createErrorResponse({
|
||||
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<string, string>, { 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,
|
||||
message: error.message || 'Something went wrong',
|
||||
});
|
||||
console.error(`Failed to create Streamable HTTP transport: ${error}`);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported transport type: ${transportType}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
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<McpResponse> = {
|
||||
_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 mcpClient.connect(transport!);
|
||||
} catch (error) {
|
||||
// Log error when connection fails with exception
|
||||
createErrorResponse({
|
||||
requestId,
|
||||
responseId,
|
||||
environmentId: responseEnvironmentId,
|
||||
timelinePath,
|
||||
eventLogPath,
|
||||
message: error.message || 'Something went wrong',
|
||||
});
|
||||
console.error(`Failed to create ${options.transportType} transport: ${error}`);
|
||||
}
|
||||
mcpConnections.set(requestId, mcpClient as McpClient);
|
||||
const serverCapabilities = mcpClient.getServerCapabilities();
|
||||
const primitivePromises: Promise<any>[] = [];
|
||||
@@ -419,8 +465,11 @@ const closeMcpConnection = async (options: CommonMcpOptions) => {
|
||||
const { requestId } = options;
|
||||
const mcpClient = _getMcpClient(requestId);
|
||||
if (mcpClient) {
|
||||
await mcpClient.transport.terminateSession();
|
||||
mcpClient.close();
|
||||
// Only terminate session if transport is StreamableHTTPClientTransport
|
||||
if ('terminateSession' in mcpClient.transport) {
|
||||
await mcpClient.transport.terminateSession();
|
||||
}
|
||||
await mcpClient.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ export const prefix = 'mcp-req';
|
||||
export const canDuplicate = true;
|
||||
export const canSync = false;
|
||||
|
||||
export type TransportType = 'stdio' | 'streamable-http';
|
||||
export const TRANSPORT_TYPES = {
|
||||
STDIO: 'stdio',
|
||||
HTTP: 'streamable-http',
|
||||
} as const;
|
||||
export type TransportType = (typeof TRANSPORT_TYPES)[keyof typeof TRANSPORT_TYPES];
|
||||
|
||||
export interface BaseMcpRequest {
|
||||
name: string;
|
||||
@@ -17,14 +21,11 @@ export interface BaseMcpRequest {
|
||||
description: string;
|
||||
headers: RequestHeader[];
|
||||
authentication: RequestAuthentication | {};
|
||||
env: Record<string, string>;
|
||||
}
|
||||
export type McpServerPrimitiveTypes = 'tools' | 'resources' | 'prompts';
|
||||
|
||||
export const MCP_TRANSPORT_TYPES: TransportType[] = [
|
||||
'streamable-http',
|
||||
// TODO: Enable stdio transport type when implemented
|
||||
// 'stdio',
|
||||
];
|
||||
export const MCP_TRANSPORT_TYPES: TransportType[] = ['streamable-http', 'stdio'];
|
||||
|
||||
export type McpRequest = BaseModel & BaseMcpRequest & { type: typeof type };
|
||||
|
||||
@@ -40,6 +41,7 @@ export function init(): BaseMcpRequest {
|
||||
description: '',
|
||||
headers: [],
|
||||
authentication: {},
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ export const McpRequestPane: FC<Props> = ({ environment, readyState, selectedPri
|
||||
});
|
||||
};
|
||||
|
||||
const isStdio = activeRequest.transportType === 'stdio';
|
||||
|
||||
return (
|
||||
<Pane type="request">
|
||||
<header className="pane__header theme--pane__header !items-stretch">
|
||||
@@ -111,28 +113,40 @@ export const McpRequestPane: FC<Props> = ({ environment, readyState, selectedPri
|
||||
>
|
||||
<span>Params</span>
|
||||
</Tab>
|
||||
<Tab
|
||||
className="flex h-full flex-shrink-0 cursor-pointer select-none items-center justify-between gap-2 px-3 py-1 text-[--hl] outline-none transition-colors duration-300 hover:bg-[--hl-sm] hover:text-[--color-font] focus:bg-[--hl-sm] aria-selected:bg-[--hl-xs] aria-selected:text-[--color-font] aria-selected:hover:bg-[--hl-sm] aria-selected:focus:bg-[--hl-sm]"
|
||||
id="auth"
|
||||
>
|
||||
<span>Auth</span>
|
||||
{!isNoneOrInherited && (
|
||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-lg border border-solid border-[--hl] p-1 text-xs">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
</span>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab
|
||||
className="flex h-full flex-shrink-0 cursor-pointer select-none items-center justify-between gap-2 px-3 py-1 text-[--hl] outline-none transition-colors duration-300 hover:bg-[--hl-sm] hover:text-[--color-font] focus:bg-[--hl-sm] aria-selected:bg-[--hl-xs] aria-selected:text-[--color-font] aria-selected:hover:bg-[--hl-sm] aria-selected:focus:bg-[--hl-sm]"
|
||||
id="headers"
|
||||
>
|
||||
<span>Headers</span>
|
||||
{headersCount > 0 && (
|
||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-lg border border-solid border-[--hl] p-1 text-xs">
|
||||
{headersCount}
|
||||
</span>
|
||||
)}
|
||||
</Tab>
|
||||
{!isStdio && (
|
||||
<Tab
|
||||
className="flex h-full flex-shrink-0 cursor-pointer select-none items-center justify-between gap-2 px-3 py-1 text-[--hl] outline-none transition-colors duration-300 hover:bg-[--hl-sm] hover:text-[--color-font] focus:bg-[--hl-sm] aria-selected:bg-[--hl-xs] aria-selected:text-[--color-font] aria-selected:hover:bg-[--hl-sm] aria-selected:focus:bg-[--hl-sm]"
|
||||
id="auth"
|
||||
>
|
||||
<span>Auth</span>
|
||||
{!isNoneOrInherited && (
|
||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-lg border border-solid border-[--hl] p-1 text-xs">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
</span>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
{!isStdio && (
|
||||
<Tab
|
||||
className="flex h-full flex-shrink-0 cursor-pointer select-none items-center justify-between gap-2 px-3 py-1 text-[--hl] outline-none transition-colors duration-300 hover:bg-[--hl-sm] hover:text-[--color-font] focus:bg-[--hl-sm] aria-selected:bg-[--hl-xs] aria-selected:text-[--color-font] aria-selected:hover:bg-[--hl-sm] aria-selected:focus:bg-[--hl-sm]"
|
||||
id="headers"
|
||||
>
|
||||
<span>Headers</span>
|
||||
{headersCount > 0 && (
|
||||
<span className="flex h-6 min-w-6 items-center justify-center rounded-lg border border-solid border-[--hl] p-1 text-xs">
|
||||
{headersCount}
|
||||
</span>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
{isStdio && (
|
||||
<Tab
|
||||
className="flex h-full flex-shrink-0 cursor-pointer select-none items-center justify-between gap-2 px-3 py-1 text-[--hl] outline-none transition-colors duration-300 hover:bg-[--hl-sm] hover:text-[--color-font] focus:bg-[--hl-sm] aria-selected:bg-[--hl-xs] aria-selected:text-[--color-font] aria-selected:hover:bg-[--hl-sm] aria-selected:focus:bg-[--hl-sm]"
|
||||
id="env"
|
||||
>
|
||||
<span>Environment</span>
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel className="flex h-full w-full flex-1 flex-col overflow-y-auto" id="params">
|
||||
{!readyState ? (
|
||||
@@ -216,6 +230,21 @@ export const McpRequestPane: FC<Props> = ({ environment, readyState, selectedPri
|
||||
requestType="McpRequest"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel className="flex w-full flex-1 flex-col overflow-hidden" id="env">
|
||||
{readyState && <PaneReadOnlyBanner />}
|
||||
{/* TODO[MCP-STDIO] */}
|
||||
<p>WIP</p>
|
||||
<CodeEditor
|
||||
id="mcp-environment-editor"
|
||||
showPrettifyButton
|
||||
dynamicHeight
|
||||
uniquenessKey={uniqueKey}
|
||||
defaultValue={JSON.stringify(activeRequest.env, null, 2)}
|
||||
enableNunjucks
|
||||
mode="json"
|
||||
placeholder="{}"
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Pane>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user