feat: support mcp auth flow (#9178)

This commit is contained in:
Bingbing
2025-09-26 17:48:49 +08:00
committed by Kent Wang
parent 223a6f692b
commit 270886bbc4
16 changed files with 587 additions and 172 deletions

View File

@@ -145,7 +145,14 @@ const ApiKeyAuthenticationSchema = z.object({
const OAuth2AuthenticationSchema = z.object({
type: z.literal('oauth2'),
disabled: z.boolean().optional(),
grantType: z.enum(['authorization_code', 'client_credentials', 'implicit', 'password', 'refresh_token']),
grantType: z.enum([
'authorization_code',
'client_credentials',
'implicit',
'password',
'refresh_token',
'mcp-auth-flow',
]),
accessTokenUrl: z.string().optional(),
authorizationUrl: z.string().optional(),
clientId: z.string().optional(),

View File

@@ -54,6 +54,7 @@ const mcp: McpBridgeAPI = {
connect: options => ipcRenderer.invoke('mcp.connect', options),
close: options => ipcRenderer.invoke('mcp.close', options),
closeAll: () => ipcRenderer.send('mcp.closeAll'),
authConfirmation: confirmed => ipcRenderer.send('mcp.authConfirmed', confirmed),
primitive: {
listTools: options => ipcRenderer.invoke('mcp.primitive.listTools', options),
callTool: options => ipcRenderer.invoke('mcp.primitive.callTool', options),

View File

@@ -173,7 +173,11 @@ export type RendererOnChannels =
| 'show-toast'
| 'toggle-preferences-shortcuts'
| 'toggle-preferences'
| 'toggle-sidebar';
| 'toggle-sidebar'
| 'show-oauth-authorization-modal'
| 'hide-oauth-authorization-modal'
| 'mcp-auth-confirmation'
| 'updaterStatus';
export const ipcMainOn = (
channel: MainOnChannels,

View File

@@ -1,9 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
import {
auth,
type AuthResult,
extractResourceMetadataUrl,
type OAuthClientProvider,
UnauthorizedError,
} from '@modelcontextprotocol/sdk/client/auth.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
type OAuthClientInformationFull,
OAuthClientInformationSchema,
type OAuthClientMetadata,
type OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { GetPromptRequest, Notification, ReadResourceRequest } from '@modelcontextprotocol/sdk/types.js';
import {
type ClientRequest,
@@ -23,12 +36,12 @@ import {
type SubscribeRequest,
type UnsubscribeRequest,
} from '@modelcontextprotocol/sdk/types.js';
import electron, { BrowserWindow } from 'electron';
import electron, { BrowserWindow, ipcMain } from 'electron';
import { parse } from 'shell-quote';
import { v4 as uuidV4 } from 'uuid';
import type { z } from 'zod';
import { getAppVersion, getProductName, REALTIME_EVENTS_CHANNELS } from '~/common/constants';
import { getAppVersion, getOauthRedirectUrl, getProductName, REALTIME_EVENTS_CHANNELS } from '~/common/constants';
import {
getMcpMethodFromMessage,
METHOD_ELICITATION_CREATE_MESSAGE,
@@ -38,9 +51,10 @@ import {
METHOD_UNSUBSCRIBE_RESOURCE,
} from '~/common/mcp-utils';
import { generateId } from '~/common/misc';
import { authorizeUserInDefaultBrowser } from '~/main/authorizeUserInDefaultBrowser';
import * as models from '~/models';
import { TRANSPORT_TYPES, type TransportType } from '~/models/mcp-request';
import type { McpResponse } from '~/models/mcp-response';
import { type McpRequest, TRANSPORT_TYPES, type TransportType } from '~/models/mcp-request';
import { type McpResponse, prefix as mcpResponsePrefix } from '~/models/mcp-response';
import type { RequestAuthentication, RequestHeader } from '~/models/request';
import { invariant } from '~/utils/invariant';
@@ -84,55 +98,61 @@ interface CallToolOptions extends CommonMcpOptions {
parameters: Record<string, any>;
}
interface McpCloseEvent {
interface McpEventBase {
_id: string;
requestId: string;
type: 'close';
timestamp: number;
}
interface McpCloseEventWithoutBase {
type: 'close';
reason: string;
}
export interface McpMessageEvent {
_id: string;
requestId: string;
interface McpMessageEventWithoutBase {
type: 'message';
direction: 'INCOMING';
timestamp: number;
data: JSONRPCResponse;
data: JSONRPCResponse | {};
method: string;
}
interface McpErrorEvent {
_id: string;
requestId: string;
timestamp: number;
export type McpMessageEvent = McpEventBase & McpMessageEventWithoutBase;
interface McpErrorEventWithoutBase {
type: 'error';
message: string;
error: any;
}
interface McpRequestEvent {
_id: string;
requestId: string;
interface McpRequestEventWithoutBase {
type: 'message';
timestamp: number;
direction: 'OUTGOING';
method: string;
data: any;
}
export interface McpNotificationEvent {
_id: string;
requestId: string;
interface McpNotificationEventWithoutBase {
type: 'notification';
timestamp: number;
method: string;
direction: 'INCOMING';
data: Notification;
}
export type McpEvent = McpMessageEvent | McpRequestEvent | McpCloseEvent | McpErrorEvent | McpNotificationEvent;
interface McpAuthEventWithoutBase {
type: 'message';
method: 'MCP Auth';
direction: 'OUTGOING' | 'INCOMING';
data: Record<string, any>;
}
export type McpNotificationEvent = McpEventBase & McpNotificationEventWithoutBase;
type McpEventWithoutBase =
| McpMessageEventWithoutBase
| McpRequestEventWithoutBase
| McpCloseEventWithoutBase
| McpErrorEventWithoutBase
| McpNotificationEventWithoutBase
| McpAuthEventWithoutBase;
export type McpEvent = McpEventBase & McpEventWithoutBase;
interface ResponseEventOptions {
responseId: string;
requestId: string;
environmentId: string | null;
timelinePath: string;
eventLogPath: string;
authProvider: McpOAuthClientProvider;
}
const mcpConnections = new Map<string, McpClient>();
@@ -144,18 +164,25 @@ const protocol = 'mcp';
const getMcpStateChannel = (requestId: string) => `${protocol}.${requestId}.${REALTIME_EVENTS_CHANNELS.READY_STATE}`;
const mcpEventIdGenerator = () => `mcp-${uuidV4()}`;
const writeEventLogAndNotify = ({
requestId,
data,
clearRequestIdMap = false,
newLine = true,
}: {
requestId: string;
data: any;
clearRequestIdMap?: boolean;
newLine?: boolean;
}) => {
const dataToWrite = newLine ? data + '\n' : data;
const writeEventLogAndNotify = (
requestId: string,
data: McpEventWithoutBase,
{
clearRequestIdMap = false,
newLine = true,
}: {
clearRequestIdMap?: boolean;
newLine?: boolean;
} = {},
) => {
const eventData: McpEvent = {
...data,
_id: mcpEventIdGenerator(),
requestId,
timestamp: Date.now(),
};
const stringifiedData = JSON.stringify(eventData);
const dataToWrite = newLine ? stringifiedData + '\n' : stringifiedData;
eventLogFileStreams.get(requestId)?.write(dataToWrite, () => {
// notify all renderers of new event has been received
for (const window of BrowserWindow.getAllWindows()) {
@@ -186,11 +213,9 @@ const _notifyMcpClientStateChange = (channel: string, isConnected: boolean) => {
}
};
const _clearMcpMaps = (requestId: string, timelineMessage: string, event?: McpEvent) => {
const _clearMcpMaps = (requestId: string, timelineMessage: string, event?: McpEventWithoutBase) => {
if (event) {
writeEventLogAndNotify({
requestId,
data: JSON.stringify(event),
writeEventLogAndNotify(requestId, event, {
clearRequestIdMap: true,
});
}
@@ -205,11 +230,8 @@ const _clearMcpMaps = (requestId: string, timelineMessage: string, event?: McpEv
};
const _handleCloseMcpConnection = (requestId: string) => {
const closeEvent: McpCloseEvent = {
_id: mcpEventIdGenerator(),
requestId,
const closeEvent: McpCloseEventWithoutBase = {
type: 'close',
timestamp: Date.now(),
reason: 'Mcp connection closed',
};
// clear in-memory store
@@ -221,27 +243,17 @@ const _handleCloseMcpConnection = (requestId: string) => {
};
const _handleMcpClientError = (requestId: string, error: Error, prefix?: string) => {
const messageEvent: McpErrorEvent = {
_id: mcpEventIdGenerator(),
requestId,
const messageEvent: McpErrorEventWithoutBase = {
type: 'error',
message: prefix || 'Unknown error',
error: error.message,
timestamp: Date.now(),
};
writeEventLogAndNotify({ requestId, data: JSON.stringify(messageEvent) });
writeEventLogAndNotify(requestId, messageEvent);
console.error(`MCP client error for ${requestId}`, error);
};
const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
const _id = mcpEventIdGenerator();
const timestamp = Date.now();
let messageEvent: McpMessageEvent | McpErrorEvent | McpNotificationEvent;
const commonEventProps = {
_id,
timestamp,
requestId,
};
let messageEvent: McpMessageEventWithoutBase | McpErrorEventWithoutBase | McpNotificationEventWithoutBase;
if (JSONRPCErrorSchema.safeParse(message).success) {
// Error message
const errorDetail = JSONRPCErrorSchema.parse(message).error;
@@ -252,7 +264,6 @@ const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
} catch (error) {}
messageEvent = {
...commonEventProps,
type: 'error',
error: errorMessage,
message: `MCP Error ${errorDetail.code}`,
@@ -260,7 +271,6 @@ const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
} else if (ServerNotificationSchema.safeParse(message).success) {
// Server notification message
messageEvent = {
...commonEventProps,
type: 'notification',
direction: 'INCOMING',
method: getMcpMethodFromMessage(message),
@@ -274,14 +284,13 @@ const _handleMcpMessage = (message: JSONRPCMessage, requestId: string) => {
}
const method = getMcpMethodFromMessage(message);
messageEvent = {
...commonEventProps,
type: 'message',
method,
data: message as JSONRPCResponse,
direction: 'INCOMING',
};
}
writeEventLogAndNotify({ requestId, data: JSON.stringify(messageEvent) });
writeEventLogAndNotify(requestId, messageEvent);
};
const parseAndLogMcpRequest = (requestId: string, message: any) => {
@@ -299,16 +308,13 @@ const parseAndLogMcpRequest = (requestId: string, message: any) => {
requestMethod = METHOD_UNKNOWN;
}
}
const requestEvent: McpRequestEvent = {
_id: mcpEventIdGenerator(),
const requestEvent: McpRequestEventWithoutBase = {
method: requestMethod,
requestId,
type: 'message',
direction: 'OUTGOING',
timestamp: Date.now(),
data: message,
};
writeEventLogAndNotify({ requestId, data: JSON.stringify(requestEvent) });
writeEventLogAndNotify(requestId, requestEvent);
}
};
@@ -319,7 +325,7 @@ const createErrorResponse = async ({
timelinePath,
message,
transportType,
}: ResponseEventOptions & { message: string; transportType: TransportType }) => {
}: Omit<ResponseEventOptions, 'authProvider'> & { message: string; transportType: TransportType }) => {
const settings = await models.settings.get();
const responsePatch = {
_id: responseId,
@@ -330,7 +336,8 @@ const createErrorResponse = async ({
error: message,
transportType,
};
const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses);
const res = await models.mcpResponse.updateOrCreate(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
};
@@ -361,10 +368,20 @@ const parseResponseAndBuildTimeline = (requestHeaderLogs: string, response: Resp
const fetchWithLogging = async (
url: string | URL,
init: RequestInit,
{ requestId, responseId, environmentId, timelinePath, eventLogPath }: ResponseEventOptions,
options: ResponseEventOptions,
calledByAuth?: boolean,
) => {
const { requestId, responseId, environmentId, timelinePath, eventLogPath, authProvider } = options;
const { method = 'GET' } = init;
const reqHeader = new Headers(init?.headers || {});
const tokens = await authProvider.tokens();
if (tokens) {
// Keep the same header case as the mcp-ts-sdk: https://github.com/modelcontextprotocol/typescript-sdk/blob/1d475bb3f75674a46d81dba881ea743a763cbc12/src/client/streamableHttp.ts#L175-L178
reqHeader.set('Authorization', `Bearer ${tokens.access_token}`);
init.headers = reqHeader;
}
const isJsonRequest = reqHeader.get('content-type')?.toLowerCase().includes('application/json');
const requestBody = isJsonRequest ? JSON.parse(init.body?.toString() || '{}') : init.body?.toString() || '';
const isMcpInitializeRequest = isJsonRequest && isInitializeRequest(requestBody);
@@ -405,24 +422,272 @@ const fetchWithLogging = async (
transportType: TRANSPORT_TYPES.HTTP,
};
const settings = await models.settings.get();
const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses);
const res = await models.mcpResponse.updateOrCreate(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
}
// Avoid infinite loop, only call auth flow once per request
// DELETE method is used to terminate the MCP request, it should not trigger auth flow to keep consistent with the SDK behavior.
// See: https://github.com/modelcontextprotocol/typescript-sdk/blob/058b87c163996b31d5cda744085ecf3c13c5c56a/src/client/streamableHttp.ts#L529-L537
if (!calledByAuth && statusCode === 401 && method !== 'DELETE') {
const resourceMetadataUrl = extractResourceMetadataUrl(response);
if (resourceMetadataUrl) {
authProvider.saveResourceMetadataUrl(resourceMetadataUrl);
}
const authEvent: McpAuthEventWithoutBase = {
type: 'message',
method: 'MCP Auth',
direction: 'INCOMING',
data: {
statusCode,
statusMessage,
resourceMetadataUrl: resourceMetadataUrl?.toString() || null,
},
};
writeEventLogAndNotify(requestId, authEvent);
let authResult: AuthResult;
let authPromiseResolve: (authorizationCode: string) => void = () => {};
const redirectPromise = new Promise<string>(res => (authPromiseResolve = res));
const unsubscribe = authProvider.onRedirectEnd(async (authorizationCode: string) => {
// Resolve the promise to continue the auth flow after user has completed authorization in default browser
authPromiseResolve(authorizationCode);
});
const authFetchFn = async (url: string | URL, init?: RequestInit) => {
const authRequestEvent: McpAuthEventWithoutBase = {
type: 'message',
method: 'MCP Auth',
direction: 'OUTGOING',
data: {
url: typeof url === 'string' ? url : url.toString(),
method: init?.method || 'GET',
headers: init?.headers || {},
body: init?.body || null,
},
};
writeEventLogAndNotify(requestId, authRequestEvent);
const response = await fetch(url, init);
const authResponseEvent: McpAuthEventWithoutBase = {
type: 'message',
method: 'MCP Auth',
direction: 'INCOMING',
data: {
statusCode: response.status,
statusMessage: response.statusText,
body: await response.clone().text(),
},
};
writeEventLogAndNotify(requestId, authResponseEvent);
return response;
};
try {
// Start auth flow
authResult = await auth(authProvider, {
serverUrl: url,
resourceMetadataUrl,
fetchFn: authFetchFn,
});
if (authResult === 'REDIRECT') {
// Wait for oauth authorization flow to complete in default browser
const authorizationCode = await redirectPromise;
// Exchange authorization code for tokens
authResult = await auth(authProvider, {
serverUrl: url,
resourceMetadataUrl,
authorizationCode,
fetchFn: authFetchFn,
});
}
if (authResult !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
return await fetchWithLogging(url, init, options, calledByAuth);
} finally {
unsubscribe();
}
}
return response;
};
const createStreamableHTTPTransport = (
class McpOAuthClientProvider implements OAuthClientProvider {
private _codeVerifier?: string;
private _resourceMetadataUrl?: URL;
private _redirectEndListener: ((authorizationCode: string) => void) | null = null;
constructor(private mcpRequest: McpRequest) {}
get redirectUrl() {
return getOauthRedirectUrl();
}
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: 'Insomnia MCP Client',
client_uri: 'https://github.com/Kong/insomnia',
scope: 'scope' in this.mcpRequest.authentication ? this.mcpRequest.authentication.scope : undefined,
};
}
private async refreshMcpRequest() {
const _mcpRequest = await models.mcpRequest.getById(this.mcpRequest._id);
invariant(_mcpRequest, 'MCP Request not found');
this.mcpRequest = _mcpRequest;
}
private isUsingMcpAuthFlow() {
const { authentication } = this.mcpRequest;
return 'grantType' in authentication && authentication.grantType === 'mcp-auth-flow' && !authentication.disabled;
}
private async updateAuthentication(auth: Partial<RequestAuthentication>) {
await models.mcpRequest.update(this.mcpRequest, {
authentication: {
...this.mcpRequest.authentication,
...auth,
},
});
await this.refreshMcpRequest();
}
// It's called when auth tries to get client information for authorization, use as a starting point for MCP Auth Flow
// See: https://github.com/modelcontextprotocol/typescript-sdk/blob/1d475bb3f75674a46d81dba881ea743a763cbc12/src/client/auth.ts#L349
async clientInformation() {
// If not using MCP Auth Flow, wait for user to confirm in the app UI
if (!this.isUsingMcpAuthFlow()) {
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('mcp-auth-confirmation');
});
await new Promise<void>((resolve, reject) => {
ipcMain.once('mcp.authConfirmed', async (_, confirmed: boolean) => {
if (!confirmed) {
reject(new Error('MCP authorization cancelled by user'));
} else {
await this.updateAuthentication({
type: 'oauth2',
grantType: 'mcp-auth-flow',
disabled: false,
});
resolve();
}
});
});
}
if ('clientId' in this.mcpRequest.authentication && this.mcpRequest.authentication.clientId) {
return {
client_id: this.mcpRequest.authentication.clientId,
client_secret: this.mcpRequest.authentication.clientSecret,
client_id_issued_at: this.mcpRequest.authentication.clientIdIssuedAt,
client_secret_expires_at: this.mcpRequest.authentication.clientSecretExpiresAt,
};
}
return undefined;
}
async saveClientInformation(clientInformation: OAuthClientInformationFull) {
const parsedClientInformation = OAuthClientInformationSchema.parse(clientInformation);
await models.mcpRequest.update(this.mcpRequest, {
authentication: {
...this.mcpRequest.authentication,
clientId: parsedClientInformation.client_id,
clientSecret: parsedClientInformation.client_secret,
clientIdIssuedAt: parsedClientInformation.client_id_issued_at,
clientSecretExpiresAt: parsedClientInformation.client_secret_expires_at,
},
});
await this.refreshMcpRequest();
}
async tokens(): Promise<OAuthTokens | undefined> {
const { authentication } = this.mcpRequest;
// Don't return tokens if not using MCP Auth Flow or if disabled
if (this.isUsingMcpAuthFlow()) {
const token = await models.oAuth2Token.getOrCreateByParentId(this.mcpRequest._id);
if (token.accessToken) {
return {
access_token: token.accessToken,
refresh_token: token.refreshToken,
id_token: token.identityToken,
expires_in: token.expiresAt ? Math.floor(token.expiresAt / 1000) : undefined,
token_type: ('tokenPrefix' in authentication && authentication.tokenPrefix) || 'Bearer',
};
}
}
return undefined;
}
async saveTokens(tokens: OAuthTokens) {
const token = await models.oAuth2Token.getOrCreateByParentId(this.mcpRequest._id);
await models.oAuth2Token.update(token, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || '',
identityToken: tokens.id_token || '',
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null,
});
await models.mcpRequest.update(this.mcpRequest, {
authentication: {
...this.mcpRequest.authentication,
scope: tokens.scope,
tokenPrefix: tokens.token_type,
},
});
await this.refreshMcpRequest();
}
saveResourceMetadataUrl(url: URL | undefined) {
this._resourceMetadataUrl = url;
}
get resourceMetadataUrl() {
return this._resourceMetadataUrl;
}
async redirectToAuthorization(authorizationUrl: URL) {
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('show-oauth-authorization-modal', authorizationUrl.toString());
});
const redirectedTo = await authorizeUserInDefaultBrowser({
url: authorizationUrl.toString(),
});
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('hide-oauth-authorization-modal', authorizationUrl.toString());
});
const redirectParams = Object.fromEntries(new URL(redirectedTo).searchParams);
const authorizationCode = redirectParams.code;
if (!authorizationCode) {
throw new Error('Authorization code not found');
}
await this._redirectEndListener?.(authorizationCode);
}
onRedirectEnd(listener: (authorizationCode: string) => void) {
this._redirectEndListener = listener;
return () => {
this._redirectEndListener = null;
};
}
async saveCodeVerifier(codeVerifier: string) {
this._codeVerifier = codeVerifier;
}
async codeVerifier() {
if (!this._codeVerifier) {
throw new Error('Code verifier not set');
}
return this._codeVerifier;
}
}
const createStreamableHTTPTransport = async (
options: OpenMcpHTTPClientConnectionOptions,
{
responseId,
responseEnvironmentId,
timelinePath,
eventLogPath,
authProvider,
}: {
responseId: string;
responseEnvironmentId: string | null;
timelinePath: string;
eventLogPath: string;
authProvider: McpOAuthClientProvider;
},
) => {
const { url, requestId } = options;
@@ -450,6 +715,7 @@ const createStreamableHTTPTransport = (
environmentId: responseEnvironmentId,
timelinePath,
eventLogPath,
authProvider,
}),
reconnectionOptions: {
maxReconnectionDelay: 30000,
@@ -555,7 +821,7 @@ const createStdioTransport = (
transportType: TRANSPORT_TYPES.STDIO,
};
const settings = await models.settings.get();
const res = await models.mcpResponse.create(responsePatch, settings.maxHistoryResponses);
const res = await models.mcpResponse.updateOrCreate(responsePatch, settings.maxHistoryResponses);
models.requestMeta.updateOrCreateByParentId(requestId, { activeResponseId: res._id });
}
// Log outgoing request
@@ -568,11 +834,38 @@ const createStdioTransport = (
return transport;
};
const createTransportAndConnect = async (
mcpClient: Client,
connectionOptions: OpenMcpClientConnectionOptions,
options: {
responseId: string;
responseEnvironmentId: string | null;
timelinePath: string;
eventLogPath: string;
},
) => {
if (!isOpenMcpHTTPClientConnectionOptions(connectionOptions)) {
const transport = await createStdioTransport(connectionOptions, options);
await mcpClient.connect(transport);
} else {
const mcpRequest = await models.mcpRequest.getById(connectionOptions.requestId);
invariant(mcpRequest, 'MCP Request not found');
const authProvider = new McpOAuthClientProvider(mcpRequest);
const transport = await createStreamableHTTPTransport(connectionOptions, {
...options,
authProvider,
});
// Use a longer timeout for initial connection to allow for auth flow to complete
await mcpClient.connect(transport, { timeout: 3 * 60 * 1000 });
}
};
const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions) => {
const { requestId, workspaceId } = options;
// create response model and file streams
const responseId = generateId('res');
const responseId = generateId(mcpResponsePrefix);
const responsesDir = path.join(process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData'), 'responses');
const eventLogPath = path.join(responsesDir, uuidV4() + '.response');
eventLogFileStreams.set(requestId, fs.createWriteStream(eventLogPath));
@@ -603,25 +896,17 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
},
},
);
mcpClient.onclose = () => _handleCloseMcpConnection(requestId);
mcpClient.onerror = _error => _handleMcpClientError(requestId, _error, 'MCP Client Error');
const mcpStateChannel = getMcpStateChannel(requestId);
try {
const transport = isOpenMcpHTTPClientConnectionOptions(options)
? await createStreamableHTTPTransport(options, {
responseId,
responseEnvironmentId,
timelinePath,
eventLogPath,
})
: await createStdioTransport(options, {
responseId,
responseEnvironmentId,
timelinePath,
eventLogPath,
});
await mcpClient.connect(transport!);
await createTransportAndConnect(mcpClient, options, {
responseId,
responseEnvironmentId,
timelinePath,
eventLogPath,
});
mcpClient.onclose = () => _handleCloseMcpConnection(requestId);
} catch (error) {
// Log error when connection fails with exception
createErrorResponse({
@@ -634,9 +919,9 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
transportType: options.transportType,
});
console.error(`Failed to create ${options.transportType} transport: ${error}`);
_handleCloseMcpConnection(requestId);
return;
}
// Support listing roots
mcpClient.setRequestHandler(ListRootsRequestSchema, async () => {
const mcpRequest = await models.mcpRequest.getById(requestId);
@@ -644,6 +929,10 @@ const openMcpClientConnection = async (options: OpenMcpClientConnectionOptions)
return { roots: mcpRequest.roots };
});
if (mcpConnections.has(requestId)) {
// close existing connection if any, avoid multiple connections with same requestId
await closeMcpConnection({ requestId });
}
mcpConnections.set(requestId, mcpClient as McpClient);
const serverCapabilities = mcpClient.getServerCapabilities();
const primitivePromises: Promise<any>[] = [];
@@ -764,16 +1053,13 @@ const subscribeResource = async (options: CommonMcpOptions & SubscribeRequest['p
if (mcpClient) {
const result = await mcpClient.subscribeResource(params);
// Subscribe resource do not have a formal response schema, so we log it manually
const messageEvent: Omit<McpMessageEvent, 'data'> & { data: {} } = {
const messageEvent: Omit<McpMessageEventWithoutBase, 'data'> & { data: {} } = {
type: 'message',
method: METHOD_SUBSCRIBE_RESOURCE,
_id: mcpEventIdGenerator(),
timestamp: Date.now(),
requestId,
data: result,
direction: 'INCOMING',
};
writeEventLogAndNotify({ requestId, data: JSON.stringify(messageEvent) });
writeEventLogAndNotify(requestId, messageEvent);
return result;
}
return null;
@@ -785,16 +1071,13 @@ const unsubscribeResource = async (options: CommonMcpOptions & UnsubscribeReques
if (mcpClient) {
const result = await mcpClient.unsubscribeResource(params);
// Unsubscribe resource do not have a formal response schema, so we log it manually
const messageEvent: Omit<McpMessageEvent, 'data'> & { data: {} } = {
const messageEvent: Omit<McpMessageEventWithoutBase, 'data'> & { data: {} } = {
type: 'message',
method: METHOD_UNSUBSCRIBE_RESOURCE,
_id: mcpEventIdGenerator(),
timestamp: Date.now(),
requestId,
data: result,
direction: 'INCOMING',
};
writeEventLogAndNotify({ requestId, data: JSON.stringify(messageEvent) });
writeEventLogAndNotify(requestId, messageEvent);
return result;
}
return null;
@@ -839,6 +1122,7 @@ export interface McpBridgeAPI {
connect: typeof openMcpClientConnection;
close: typeof closeMcpConnection;
closeAll: typeof closeAllMcpConnections;
authConfirmation: (confirmed: boolean) => void;
primitive: {
listTools: typeof listTools;
callTool: typeof callTool;

View File

@@ -129,6 +129,20 @@ export async function create(patch: Partial<McpResponse> = {}, maxResponses = 20
return db.docCreate(type, patch);
}
export async function updateOrCreate(patch: Partial<McpResponse>, maxResponses = 20) {
const id = patch._id;
if (!id) {
throw new Error('Cannot updateOrCreate McpResponse without _id');
}
const existing = await getById(id);
if (existing) {
return db.docUpdate(existing, patch);
}
return create(patch, maxResponses);
}
export async function getLatestForRequestId(requestId: string, environmentId: string | null) {
// Filter responses by environment if setting is enabled

View File

@@ -34,11 +34,13 @@ export interface AuthTypeAPIKey {
export interface AuthTypeOAuth2 {
type: 'oauth2';
disabled?: boolean;
grantType: 'authorization_code' | 'client_credentials' | 'password' | 'implicit' | 'refresh_token';
grantType: 'authorization_code' | 'client_credentials' | 'password' | 'implicit' | 'refresh_token' | 'mcp-auth-flow';
accessTokenUrl?: string;
authorizationUrl?: string;
clientId?: string;
clientSecret?: string;
clientIdIssuedAt?: number;
clientSecretExpiresAt?: number;
audience?: string;
scope?: string;
resource?: string;

View File

@@ -3,6 +3,7 @@ export const GRANT_TYPE_IMPLICIT = 'implicit';
export const GRANT_TYPE_PASSWORD = 'password';
export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
export const GRANT_TYPE_REFRESH = 'refresh_token';
export const GRANT_TYPE_MCP_AUTH_FLOW = 'mcp-auth-flow';
export type AuthKeys =
| 'access_token'
| 'id_token'

View File

@@ -56,6 +56,10 @@ export const getOAuth2Token = async (
forceRefresh = false,
): Promise<OAuth2Token | undefined> => {
try {
// If it's MCP Auth Flow, should leave it to be handled by the MCP auth provider
if (authentication.grantType === 'mcp-auth-flow') {
return undefined;
}
const { oAuth2Token, closestAuthId } = await getExistingAccessTokenAndRefreshIfExpired(
requestId,
authentication,

View File

@@ -25,7 +25,8 @@ export const AuthWrapper: FC<{
authTypes?: AuthTypes[];
hideOthers?: boolean;
hideInherit?: boolean;
}> = ({ authentication, disabled = false, authTypes, hideOthers, hideInherit }) => {
showMcpAuthFlow?: boolean;
}> = ({ authentication, disabled = false, authTypes, hideOthers, hideInherit, showMcpAuthFlow }) => {
const type = getAuthObjectOrNull(authentication)?.type || '';
let authBody: ReactNode = null;
@@ -34,7 +35,7 @@ export const AuthWrapper: FC<{
} else if (type === 'apikey') {
authBody = <ApiKeyAuth disabled={disabled} />;
} else if (type === 'oauth2') {
authBody = <OAuth2Auth />;
authBody = <OAuth2Auth showMcpAuthFlow={showMcpAuthFlow} disabled={disabled} />;
} else if (type === 'hawk') {
authBody = <HawkAuth />;
} else if (type === 'oauth1') {
@@ -80,6 +81,7 @@ export const AuthWrapper: FC<{
authTypes={authTypes}
hideOthers={hideOthers}
hideInherit={hideInherit}
disabled={disabled}
/>
</Toolbar>
<div className="flex-1 overflow-y-auto">{authBody}</div>

View File

@@ -51,7 +51,7 @@ export const AuthSelectRow: FC<Props> = ({ label, property, help, options, disab
return (
<AuthRow labelFor={toKebabCase(label)} label={label} help={help} disabled={disabled}>
<select id={toKebabCase(label)} onChange={onChange} value={selectedValue}>
<select id={toKebabCase(label)} onChange={onChange} value={selectedValue} disabled={disabled}>
{options.map(({ name, value }) => (
<option key={value} value={value}>
{name}

View File

@@ -11,6 +11,7 @@ import {
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
GRANT_TYPE_IMPLICIT,
GRANT_TYPE_MCP_AUTH_FLOW,
GRANT_TYPE_PASSWORD,
PKCE_CHALLENGE_PLAIN,
PKCE_CHALLENGE_S256,
@@ -58,6 +59,14 @@ const grantTypeOptions = [
},
];
const grantTypeOptionsWithMcpAuthFlow = [
...grantTypeOptions,
{
name: 'MCP Auth Flow',
value: GRANT_TYPE_MCP_AUTH_FLOW,
},
];
const pkceMethodOptions = [
{
name: 'SHA-256',
@@ -126,22 +135,7 @@ const getFields = (authentication: Extract<RequestAuthentication, { type: 'oauth
getAutocompleteConstants={getAccessTokenUrls}
/>
);
const redirectUri = (
<AuthInputRow
label="Redirect URL"
property="redirectUrl"
key="redirectUrl"
help={
authentication.useDefaultBrowser
? 'The callback URL is provided by Insomnia and cannot be modified when authorizing via the default browser.'
: 'This can be whatever you want or need it to be. Insomnia will automatically detect a redirect in the client browser window and extract the code from the redirected URL.'
}
disabled={authentication.useDefaultBrowser}
overrideValueWhenDisabled={getOauthRedirectUrl()}
copyBtn={authentication.useDefaultBrowser}
/>
);
const redirectUriWithoutDefaultBrowser = (
const defaultRedirectUri = (
<AuthInputRow
label="Redirect URL"
property="redirectUrl"
@@ -151,6 +145,18 @@ const getFields = (authentication: Extract<RequestAuthentication, { type: 'oauth
}
/>
);
const readonlyRedirectUri = (
<AuthInputRow
label="Redirect URL"
property="redirectUrl"
key="redirectUrl"
help={'The callback URL is provided by Insomnia and cannot be modified when authorizing via the default browser.'}
disabled
overrideValueWhenDisabled={getOauthRedirectUrl()}
copyBtn
/>
);
const redirectUri = authentication.useDefaultBrowser ? readonlyRedirectUri : defaultRedirectUri;
const useDefaultBrowser = (
<AuthToggleRow
label="Using default browser"
@@ -214,7 +220,8 @@ const getFields = (authentication: Extract<RequestAuthentication, { type: 'oauth
authorizationUrl,
accessTokenUrl,
redirectUri,
redirectUriWithoutDefaultBrowser,
defaultRedirectUri,
readonlyRedirectUri,
useDefaultBrowser,
state,
scope,
@@ -238,7 +245,8 @@ const getFieldsForGrantType = (authentication: Extract<RequestAuthentication, {
authorizationUrl,
accessTokenUrl,
redirectUri,
redirectUriWithoutDefaultBrowser,
defaultRedirectUri,
readonlyRedirectUri,
useDefaultBrowser,
state,
scope,
@@ -279,9 +287,12 @@ const getFieldsForGrantType = (authentication: Extract<RequestAuthentication, {
advanced = [scope, credentialsInBody, tokenPrefix, audience];
} else if (grantType === GRANT_TYPE_IMPLICIT) {
basic = [authorizationUrl, clientId, redirectUriWithoutDefaultBrowser];
basic = [authorizationUrl, clientId, defaultRedirectUri];
advanced = [responseType, scope, state, tokenPrefix, audience];
} else if (grantType === GRANT_TYPE_MCP_AUTH_FLOW) {
basic = [clientId, clientSecret, readonlyRedirectUri];
advanced = [];
}
return {
@@ -290,18 +301,43 @@ const getFieldsForGrantType = (authentication: Extract<RequestAuthentication, {
};
};
export const OAuth2Auth: FC = () => {
export const OAuth2Auth = ({ showMcpAuthFlow, disabled }: { showMcpAuthFlow?: boolean; disabled?: boolean }) => {
const reqData = useRequestLoaderData() as RequestLoaderData;
const groupData = useRequestGroupLoaderData() as RequestGroupLoaderData;
const { authentication } = reqData?.activeRequest || groupData.activeRequestGroup;
const { basic, advanced } = getFieldsForGrantType(authentication as AuthTypeOAuth2);
if ('grantType' in authentication && authentication.grantType === GRANT_TYPE_MCP_AUTH_FLOW) {
return (
<>
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthSelectRow
label="Grant Type"
property="grantType"
disabled={disabled}
options={showMcpAuthFlow ? grantTypeOptionsWithMcpAuthFlow : grantTypeOptions}
/>
{basic}
</AuthTableBody>
<div className="pad">
<OAuth2Tokens hideRefresh />
</div>
</>
);
}
return (
<>
<AuthTableBody>
<AuthToggleRow label="Enabled" property="disabled" invert />
<AuthSelectRow label="Grant Type" property="grantType" options={grantTypeOptions} />
<AuthToggleRow label="Enabled" property="disabled" invert disabled={disabled} />
<AuthSelectRow
label="Grant Type"
property="grantType"
disabled={disabled}
options={showMcpAuthFlow ? grantTypeOptionsWithMcpAuthFlow : grantTypeOptions}
/>
{basic}
<AuthAccordion accordionKey="OAuth2AdvancedOptions" label="Advanced Options">
{advanced}
@@ -468,7 +504,7 @@ const OAuth2Error: FC<{ token?: OAuth2Token }> = ({ token }) => {
return debugButton;
};
const OAuth2Tokens: FC = () => {
const OAuth2Tokens = ({ hideRefresh }: { hideRefresh?: boolean }) => {
const reqData = useRequestLoaderData() as RequestLoaderData;
const groupData = useRequestGroupLoaderData() as RequestGroupLoaderData;
const { authentication, _id } = reqData?.activeRequest || groupData.activeRequestGroup;
@@ -511,32 +547,33 @@ const OAuth2Tokens: FC = () => {
Clear
</button>
) : null}
&nbsp;&nbsp;
<button
className="h-[--line-height-xs] rounded-[--radius-md] border border-solid border-[--hl-lg] px-[--padding-md] hover:bg-[--hl-xs]"
onClick={async () => {
setError('');
setLoading(true);
{!hideRefresh && (
<button
className="ml-2 h-[--line-height-xs] rounded-[--radius-md] border border-solid border-[--hl-lg] px-[--padding-md] hover:bg-[--hl-xs]"
onClick={async () => {
setError('');
setLoading(true);
try {
const renderedAuthentication = (await handleRender(authentication)) as AuthTypeOAuth2;
const t = await getOAuth2Token(_id, renderedAuthentication, true);
setToken(t);
setLoading(false);
} catch (err) {
// Clear existing tokens if there's an error
if (token) {
setToken(undefined);
models.oAuth2Token.remove(token);
try {
const renderedAuthentication = (await handleRender(authentication)) as AuthTypeOAuth2;
const t = await getOAuth2Token(_id, renderedAuthentication, true);
setToken(t);
setLoading(false);
} catch (err) {
// Clear existing tokens if there's an error
if (token) {
setToken(undefined);
models.oAuth2Token.remove(token);
}
setError(err.message);
setLoading(false);
}
setError(err.message);
setLoading(false);
}
}}
disabled={loading}
>
{loading ? (token ? 'Refreshing...' : 'Fetching...') : token ? 'Refresh Token' : 'Fetch Tokens'}
</button>
}}
disabled={loading}
>
{loading ? (token ? 'Refreshing...' : 'Fetching...') : token ? 'Refresh Token' : 'Fetch Tokens'}
</button>
)}
</div>
</div>
);

View File

@@ -303,7 +303,7 @@ export const McpPane = () => {
event => 'method' in event && event.method === method && event.direction === 'INCOMING',
) as McpMessageEvent;
if (firstMatchEvent) {
return firstMatchEvent.data.result;
return 'result' in firstMatchEvent.data ? firstMatchEvent.data.result : undefined;
}
return undefined;
};

View File

@@ -344,6 +344,7 @@ export const McpRequestPane: FC<Props> = ({
disabled={readyState}
authTypes={supportedAuthTypes}
hideInherit
showMcpAuthFlow
/>
</TabPanel>
<TabPanel className="w-full flex-1 overflow-y-auto" id="headers">

View File

@@ -19,6 +19,8 @@ import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client
import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '~/ui/components/base/dropdown';
import { Modal, type ModalHandle } from '~/ui/components/base/modal';
import { ModalHeader } from '~/ui/components/base/modal-header';
import { showModal } from '~/ui/components/modals';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { Button } from '~/ui/components/themed-button';
import { getDataFromKVPair } from '../../../models/environment';
@@ -99,22 +101,26 @@ export const McpUrlActionBar = ({
const { authentication, headers } = rendered;
if (!authentication.disabled) {
if (authentication.type === 'basic') {
const { username, password, useISO88591 } = authentication;
const encoding = useISO88591 ? 'latin1' : 'utf8';
headers.push(getBasicAuthHeader(username, password, encoding));
} else if (authentication.type === 'bearer' && authentication.token) {
const { token, prefix } = authentication;
headers.push(getBearerAuthHeader(token, prefix));
} else if (authentication.type === 'oauth2') {
const oAuth2Token = await getOAuth2Token(request._id, authentication as AuthTypeOAuth2);
if (oAuth2Token) {
const token = oAuth2Token.accessToken;
const authHeader = _buildBearerHeader(token, authentication.tokenPrefix);
if (authHeader) {
headers.push(authHeader);
try {
if (authentication.type === 'basic') {
const { username, password, useISO88591 } = authentication;
const encoding = useISO88591 ? 'latin1' : 'utf8';
headers.push(getBasicAuthHeader(username, password, encoding));
} else if (authentication.type === 'bearer' && authentication.token) {
const { token, prefix } = authentication;
headers.push(getBearerAuthHeader(token, prefix));
} else if (authentication.type === 'oauth2') {
const oAuth2Token = await getOAuth2Token(request._id, authentication as AuthTypeOAuth2);
if (oAuth2Token) {
const token = oAuth2Token.accessToken;
const authHeader = _buildBearerHeader(token, authentication.tokenPrefix);
if (authHeader) {
headers.push(authHeader);
}
}
}
} catch (error) {
console.error('[mcp] Failed to get auth header', error);
}
}
@@ -174,6 +180,32 @@ export const McpUrlActionBar = ({
},
});
useEffect(() => {
const unsubscribe = window.main.on('mcp-auth-confirmation', async _ => {
let answered = false;
showModal(AskModal, {
title: 'MCP Authentication Confirmation',
message: 'The MCP server is requesting authentication to proceed. Type "confirm" to proceed.',
onDone: async (yes: boolean) => {
if (answered) {
console.error('Already answered MCP auth confirmation, this should not happen.');
return;
}
answered = true;
window.main.mcp.authConfirmation(yes);
},
onHide: () => {
if (answered) {
return;
}
answered = true;
window.main.mcp.authConfirmation(false);
},
});
});
return unsubscribe;
}, []);
const isConnectingOrClosed = !readyState;
return (

View File

@@ -11,11 +11,13 @@ interface State {
noText: string;
color: string;
onDone?: (success: boolean) => Promise<void>;
onHide?: () => void;
}
export interface AskModalOptions {
title?: string;
message: React.ReactNode;
onDone?: (success: boolean) => Promise<void>;
onHide?: () => void;
yesText?: string;
noText?: string;
color?: string;
@@ -41,7 +43,7 @@ export const AskModal = forwardRef<AskModalHandle, ModalProps>((_, ref) => {
hide: () => {
modalRef.current?.hide();
},
show: ({ title, message, onDone, yesText, noText, color }) => {
show: ({ title, message, onDone, onHide, yesText, noText, color }) => {
setState({
title: title || 'Confirm',
message: message || 'No message provided',
@@ -49,15 +51,16 @@ export const AskModal = forwardRef<AskModalHandle, ModalProps>((_, ref) => {
noText: noText || 'No',
color: color || 'surprise',
onDone,
onHide,
});
modalRef.current?.show();
},
}),
[],
);
const { message, title, yesText, noText, color, onDone } = state;
const { message, title, yesText, noText, color, onDone, onHide } = state;
return (
<Modal ref={modalRef}>
<Modal ref={modalRef} onHide={onHide}>
<ModalHeader>{title || 'Confirm?'}</ModalHeader>
<ModalBody className="wide pad">{message}</ModalBody>
<ModalFooter>
@@ -65,8 +68,8 @@ export const AskModal = forwardRef<AskModalHandle, ModalProps>((_, ref) => {
<button
className="btn"
onClick={() => {
modalRef.current?.hide();
onDone?.(false);
modalRef.current?.hide();
}}
>
{noText}
@@ -76,8 +79,8 @@ export const AskModal = forwardRef<AskModalHandle, ModalProps>((_, ref) => {
autoFocus
style={{ color: `var(--color-font-${color})`, backgroundColor: `var(--color-${color})` }}
onClick={() => {
modalRef.current?.hide();
onDone?.(true);
modalRef.current?.hide();
}}
>
{yesText}

View File

@@ -13,6 +13,27 @@ export const OAuthAuthorizationStatusModal: FC = () => {
const [authCodeUrlStr, setAuthCodeUrlStr] = useState<string | undefined>();
const [submitting, setSubmitting] = useState<boolean>(false);
useEffect(() => {
const unsubscribe = window.main.on('show-oauth-authorization-modal', (_, authCodeUrlStr: string) => {
uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, {
status: 'getting_code',
authCodeUrlStr,
});
});
return unsubscribe;
}, []);
useEffect(() => {
const unsubscribe = window.main.on('hide-oauth-authorization-modal', _ => {
uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, {
status: 'none',
});
});
return unsubscribe;
}, []);
useEffect(() => {
const handleStatusChange = ({
status: newStatus,
@@ -49,6 +70,8 @@ export const OAuthAuthorizationStatusModal: FC = () => {
centered
ref={modalRef}
onHide={() => {
setStatus('none');
setSubmitting(false);
window.main.cancelAuthorizationInDefaultBrowser('Canceled by user.');
}}
>