feat: support stdio events, console, and UI (#9142)

This commit is contained in:
Bingbing
2025-09-16 14:21:26 +08:00
committed by GitHub
parent 1de36515a4
commit f4df197cf6
10 changed files with 308 additions and 163 deletions

View File

@@ -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<string, string>;
};
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<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,
}),
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<McpResponse> = {
_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<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,
}),
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 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<any>[] = [];

View File

@@ -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<string, string>;
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: [],
};
}

View File

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

View File

@@ -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<string, string>;
env?: Record<string, string>;
}
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

View File

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

View File

@@ -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 <Button {...restProps} ref={btnRef} />;
};
export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate = false }: EditorProps) => {
export const EnvironmentKVEditor = ({
data,
onChange,
vaultKey = '',
isPrivate = false,
textOnly = false,
disabled = false,
}: EditorProps) => {
const kvPairs: EnvironmentKvPairData[] = useMemo(
() => (data.length > 0 ? [...data] : [createNewPair()]),
// Ensure same array data will not generate different kvPairs to avoid flash issue
@@ -78,11 +87,13 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
id: EnvironmentKvPairDataType.STRING,
name: 'Text',
},
{
];
if (!textOnly) {
commonItemTypes.push({
id: EnvironmentKvPairDataType.JSON,
name: 'JSON',
},
];
});
}
const secretItemType = [{ id: EnvironmentKvPairDataType.SECRET, name: 'Secret' }];
// Use private environment to store vault secrets if vault key is available
const kvPairItemTypes = isPrivate && !!vaultKey ? commonItemTypes.concat(secretItemType) : commonItemTypes;
@@ -206,19 +217,21 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
const isValidJSONString = checkValidJSONString(value);
return (
<>
<div
slot="drag"
className={`${cellCommonStyle} flex w-6 flex-shrink-0 items-center justify-end border-l border-r-0`}
style={{ padding: 0 }}
>
<Icon icon="grip-vertical" className="mr-1 cursor-grab" />
</div>
{!disabled && (
<div
slot="drag"
className={`${cellCommonStyle} flex w-6 flex-shrink-0 items-center justify-end border-l border-r-0`}
style={{ padding: 0 }}
>
<Icon icon="grip-vertical" className="mr-1 cursor-grab" />
</div>
)}
<div className={`${cellCommonStyle} relative flex h-full w-[30%] flex-grow pl-1`}>
<OneLineEditor
id={`environment-kv-editor-name-${id}`}
placeholder={'Input Name'}
defaultValue={name}
readOnly={!enabled}
readOnly={!enabled || disabled}
onChange={newName => {
// check filed names for invalid '$' for '.' sign
const error = ensureKeyIsValid(newName, true);
@@ -253,7 +266,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
id={`environment-kv-editor-value-${id}`}
placeholder={'Input Value'}
defaultValue={value.toString()}
readOnly={!enabled}
readOnly={!enabled || disabled}
onChange={newValue => handleItemChange(id, 'value', newValue)}
/>
)}
@@ -261,7 +274,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
<ItemButton
className="flex w-full flex-1 items-center justify-center gap-2 overflow-hidden rounded-sm px-2 py-1 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]"
tabIndex={-1}
isDisabled={!enabled}
isDisabled={!enabled || disabled}
onPress={() => {
if (codeModalRef.current) {
const modalRef = codeModalRef.current;
@@ -296,7 +309,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
{type === EnvironmentKvPairDataType.SECRET && (
<PasswordInput
itemId={id}
enabled={enabled}
enabled={enabled && !disabled}
placeholder="Input Secret"
value={decryptSecretValue(value, symmetricKey)}
onChange={newValue => {
@@ -312,6 +325,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
className="flex w-full flex-1 items-center justify-between rounded-sm px-[--padding-sm] py-1 text-sm font-bold text-[--color-font] hover:bg-[--hl-xs] aria-pressed:bg-[--hl-sm]"
tabIndex={-1}
aria-label="Type Selection"
isDisabled={disabled}
>
<span className="flex items-center justify-center gap-2 truncate">
{kvPairItemTypes.find(t => t.id === type)?.name}
@@ -352,6 +366,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
className="flex aspect-square h-7 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]"
tabIndex={-1}
aria-label={enabled ? 'Disable Row' : 'Enable Row'}
isDisabled={disabled}
onPress={() => handleItemChange(id, 'enabled', !enabled)}
>
<Icon icon={enabled ? 'check-square' : 'square'} />
@@ -363,6 +378,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
doneMessage=""
ariaLabel="Delete Row"
tabIndex={-1}
disabled={disabled}
onClick={() => handleDeleteItem(id)}
>
<Icon icon="trash-can" />
@@ -378,6 +394,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
<Button
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs 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]"
aria-label="Add Row"
isDisabled={disabled}
onPress={() => {
handleAddItem();
}}
@@ -385,7 +402,7 @@ export const EnvironmentKVEditor = ({ data, onChange, vaultKey = '', isPrivate =
<Icon icon="plus" /> Add
</Button>
<PromptButton
disabled={kvPairs.length === 0}
disabled={disabled || kvPairs.length === 0}
onClick={() => {
onChange([]);
}}

View File

@@ -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<Props> = ({ environment, readyState, selectedPrimitiveItem }) => {
const { activeRequest } = useRequestLoaderData()! as McpRequestLoaderData;
const { activeRequest, activeRequestMeta } = useRequestLoaderData()! as McpRequestLoaderData;
const [formData, setFormData] = useState({});
const paramEditorRef = useRef<CodeEditorHandle>(null);
const requestId = activeRequest._id;
@@ -63,7 +64,7 @@ export const McpRequestPane: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ environment, readyState, selectedPri
</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="{}"
<EnvironmentKVEditor
key={uniqueKey}
data={activeRequest.env}
disabled={readyState}
textOnly
onChange={handleEnvChange}
/>
</TabPanel>
</Tabs>

View File

@@ -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 ? (
<button
className="rounded-sm bg-[--color-surprise] px-[--padding-md] text-center text-[--color-font-surprise] hover:brightness-75"
disabled={connectRequestFetcher.state === 'submitting' || connectRequestFetcher.state === 'loading'}
type="submit"
>
Discover

View File

@@ -10,6 +10,7 @@ import type { ResponseTimelineEntry } from '../../../main/network/libcurl-promis
import type { McpEvent } from '../../../main/network/mcp';
import type { SocketIOEvent } from '../../../main/network/socket-io';
import type { WebSocketEvent } from '../../../main/network/websocket';
import { TRANSPORT_TYPES } from '../../../models/mcp-request';
import { isMcpResponse, type McpResponse } from '../../../models/mcp-response';
import type { RequestVersion } from '../../../models/request-version';
import type { Response } from '../../../models/response';
@@ -156,13 +157,18 @@ const RealtimeActiveResponsePane: FC<{
};
}, [response.timelinePath, events.length]);
const cookieHeaders =
!isSocketIOResponse(response) && !isMcpResponse(response) ? getSetCookieHeaders(response.headers) : [];
const isLongRunning = isSocketIOResponse(response) || isMcpResponse(response);
const hideCookies = isSocketIOResponse(response) || isMcpResponse(response);
const hideHeaders =
isSocketIOResponse(response) || (isMcpResponse(response) && response.transportType === TRANSPORT_TYPES.STDIO);
const cookieHeaders = hideCookies ? [] : getSetCookieHeaders(response.headers);
return (
<Pane type="response">
<PaneHeader className="row-spaced">
<div className="no-wrap scrollable scrollable--no-bars pad-left">
{isSocketIOResponse(response) ? (
{isLongRunning ? (
<div data-testid="response-status-tag" className={`${readyState ? 'bg-success' : 'bg-danger'} px-2 py-1`}>
{readyState ? 'Connected' : 'Disconnected'}
</div>
@@ -187,31 +193,31 @@ const RealtimeActiveResponsePane: FC<{
>
Events
</Tab>
{!isSocketIOResponse(response) && (
<>
<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"
>
Headers
{response.headers.length > 0 && (
<span className="shadow-small flex aspect-square items-center justify-between overflow-hidden rounded-lg border border-solid border-[--hl-md] p-2 text-xs">
{response.headers.length}
</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="cookies"
>
Cookies
{cookieHeaders.length > 0 && (
<span className="shadow-small flex aspect-square items-center justify-between overflow-hidden rounded-lg border border-solid border-[--hl-md] p-2 text-xs">
{cookieHeaders.length}
</span>
)}
</Tab>
</>
{!hideHeaders && (
<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"
>
Headers
{response.headers.length > 0 && (
<span className="shadow-small flex aspect-square items-center justify-between overflow-hidden rounded-lg border border-solid border-[--hl-md] p-2 text-xs">
{response.headers.length}
</span>
)}
</Tab>
)}
{!hideCookies && (
<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="cookies"
>
Cookies
{cookieHeaders.length > 0 && (
<span className="shadow-small flex aspect-square items-center justify-between overflow-hidden rounded-lg border border-solid border-[--hl-md] p-2 text-xs">
{cookieHeaders.length}
</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]"

View File

@@ -1441,6 +1441,9 @@ html {
.editor.editor--readonly .CodeMirror-cursors {
visibility: hidden;
}
.editor.editor--readonly .nunjucks-tag {
cursor: default;
}
.editor.editor--dynamic-height .CodeMirror {
position: static;
}