feat: support stdio transport (#9135)

This commit is contained in:
Bingbing
2025-09-12 16:08:39 +08:00
committed by Kent Wang
parent 31dcf83da1
commit 81e85239a4
3 changed files with 185 additions and 105 deletions

View File

@@ -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();
}
};

View File

@@ -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: {},
};
}

View File

@@ -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>
);