mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-19 05:39:50 -04:00
feat: support mcp auth flow (#9178)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.');
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user