mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-18 05:08:40 -04:00
feat: support stdio events, console, and UI (#9142)
This commit is contained in:
@@ -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>[] = [];
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user