first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { McpAuthenticationOption, McpServerTransport } from '../types';
|
||||
import { connectMcpClient, getAuthHeaders, tryRefreshOAuth2Token } from '../utils';
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
||||
jest.mock('@modelcontextprotocol/sdk/client/sse.js');
|
||||
|
||||
const MockedClient = Client as jest.MockedClass<typeof Client>;
|
||||
const MockedHTTPTransport = StreamableHTTPClientTransport as jest.MockedClass<
|
||||
typeof StreamableHTTPClientTransport
|
||||
>;
|
||||
const MockedSSETransport = SSEClientTransport as jest.MockedClass<typeof SSEClientTransport>;
|
||||
|
||||
describe('utils', () => {
|
||||
describe('tryRefreshOAuth2Token', () => {
|
||||
it('should refresh an OAuth2 token without headers', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
});
|
||||
|
||||
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
|
||||
|
||||
expect(headers).toEqual({ Authorization: 'Bearer new-access-token' });
|
||||
});
|
||||
|
||||
it('should refresh an OAuth2 token with headers', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
});
|
||||
|
||||
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api', {
|
||||
Foo: 'bar',
|
||||
Authorization: 'Bearer old-access-token',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
Foo: 'bar',
|
||||
Authorization: 'Bearer new-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if the authentication method is not oAuth2Api', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
|
||||
const headers = await tryRefreshOAuth2Token(ctx, 'headerAuth');
|
||||
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if the refreshOAuth2Token returns no access_token', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.helpers.refreshOAuth2Token.mockResolvedValue({
|
||||
access_token: null,
|
||||
});
|
||||
|
||||
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
|
||||
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if the refreshOAuth2Token throws an error', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.helpers.refreshOAuth2Token.mockRejectedValue(new Error('Failed to refresh OAuth2 token'));
|
||||
|
||||
const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');
|
||||
|
||||
expect(headers).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthHeaders', () => {
|
||||
it('should return the headers for mcpOAuth2Api', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getCredentials.mockResolvedValue({
|
||||
oauthTokenData: {
|
||||
access_token: 'access-token',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');
|
||||
|
||||
expect(result).toEqual({ headers: { Authorization: 'Bearer access-token' } });
|
||||
});
|
||||
|
||||
it('should return the headers for headerAuth', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getCredentials.mockResolvedValue({
|
||||
name: 'Foo',
|
||||
value: 'bar',
|
||||
});
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'headerAuth');
|
||||
|
||||
expect(result).toEqual({ headers: { Foo: 'bar' } });
|
||||
});
|
||||
|
||||
it('should return the headers for bearerAuth', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getCredentials.mockResolvedValue({
|
||||
token: 'access-token',
|
||||
});
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'bearerAuth');
|
||||
|
||||
expect(result).toEqual({ headers: { Authorization: 'Bearer access-token' } });
|
||||
});
|
||||
|
||||
it('should return the headers for multipleHeadersAuth', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getCredentials.mockResolvedValue({
|
||||
headers: {
|
||||
values: [
|
||||
{ name: 'Foo', value: 'bar' },
|
||||
{ name: 'Test', value: '123' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'multipleHeadersAuth');
|
||||
|
||||
expect(result).toEqual({ headers: { Foo: 'bar', Test: '123' } });
|
||||
});
|
||||
|
||||
it('should return an empty object for none', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'none');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return an empty object for an unknown authentication method', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
|
||||
const result = await getAuthHeaders(ctx, 'unknown' as McpAuthenticationOption);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'headerAuth',
|
||||
'bearerAuth',
|
||||
'mcpOAuth2Api',
|
||||
'multipleHeadersAuth',
|
||||
] as McpAuthenticationOption[])(
|
||||
'should return an empty object for %s when it fails',
|
||||
async (authentication) => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getCredentials.mockRejectedValue(new Error('Failed to get credentials'));
|
||||
|
||||
const result = await getAuthHeaders(ctx, authentication);
|
||||
|
||||
expect(result).toEqual({});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('connectMcpClient', () => {
|
||||
const mockClient = {
|
||||
connect: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
const mockHttpTransport = {} as unknown as StreamableHTTPClientTransport;
|
||||
const mockSSETransport = {} as unknown as SSEClientTransport;
|
||||
MockedClient.mockImplementation(() => mockClient as unknown as Client);
|
||||
MockedHTTPTransport.mockImplementation(() => mockHttpTransport);
|
||||
MockedSSETransport.mockImplementation(() => mockSSETransport);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['httpStreamable', StreamableHTTPClientTransport],
|
||||
['sse', SSEClientTransport],
|
||||
] as Array<
|
||||
[McpServerTransport, typeof StreamableHTTPClientTransport | typeof SSEClientTransport]
|
||||
>)('%s transport', (transport, Transport) => {
|
||||
it('should retry on 401 and succeed', async () => {
|
||||
const unauthorizedError = new Error('Request failed with status 401');
|
||||
const onUnauthorized = jest.fn().mockResolvedValue({ Authorization: 'Bearer new-token' });
|
||||
mockClient.connect
|
||||
.mockRejectedValueOnce(unauthorizedError)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
|
||||
expect(Transport).toHaveBeenCalledTimes(2);
|
||||
expect(Transport).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(URL),
|
||||
expect.objectContaining({
|
||||
requestInit: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer old-token',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(Transport).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(URL),
|
||||
expect.objectContaining({
|
||||
requestInit: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer new-token',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not retry on not 401', async () => {
|
||||
const error = new Error('Internal Server Error');
|
||||
mockClient.connect.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('connection');
|
||||
}
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(Transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not retry when onUnauthorized is not provided', async () => {
|
||||
const error = new Error('Request failed with status 401');
|
||||
mockClient.connect.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('auth');
|
||||
}
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(Transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not retry when onUnauthorized returns null', async () => {
|
||||
const error = new Error('Request failed with status 401');
|
||||
const onUnauthorized = jest.fn().mockResolvedValue(null);
|
||||
mockClient.connect.mockRejectedValueOnce(error);
|
||||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('auth');
|
||||
}
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(Transport).toHaveBeenCalledTimes(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
|
||||
});
|
||||
|
||||
it('should not retry more than once', async () => {
|
||||
const error = new Error('Request failed with status 401');
|
||||
mockClient.connect.mockRejectedValue(error);
|
||||
const onUnauthorized = jest.fn().mockResolvedValue({ Authorization: 'Bearer new-token' });
|
||||
|
||||
const result = await connectMcpClient({
|
||||
serverTransport: transport,
|
||||
endpointUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer old-token' },
|
||||
name: 'test-client',
|
||||
version: 1,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe('auth');
|
||||
}
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(2);
|
||||
expect(Transport).toHaveBeenCalledTimes(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledWith({ Authorization: 'Bearer old-token' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { IDisplayOptions, INodeCredentialDescription, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const transportSelect = ({
|
||||
defaultOption,
|
||||
displayOptions,
|
||||
}: {
|
||||
defaultOption: 'sse' | 'httpStreamable';
|
||||
displayOptions?: IDisplayOptions;
|
||||
}): INodeProperties => ({
|
||||
displayName: 'Server Transport',
|
||||
name: 'serverTransport',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'HTTP Streamable',
|
||||
value: 'httpStreamable',
|
||||
},
|
||||
{
|
||||
name: 'Server Sent Events (Deprecated)',
|
||||
value: 'sse',
|
||||
},
|
||||
],
|
||||
default: defaultOption,
|
||||
description: 'The transport used by your endpoint',
|
||||
displayOptions,
|
||||
});
|
||||
|
||||
export const credentials: INodeCredentialDescription[] = [
|
||||
{
|
||||
name: 'httpBearerAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['bearerAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'httpHeaderAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['headerAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mcpOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['mcpOAuth2Api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'httpMultipleHeadersAuth',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['multipleHeadersAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
|
||||
export type McpTool = { name: string; description?: string; inputSchema: JSONSchema7 };
|
||||
|
||||
export type McpServerTransport = 'sse' | 'httpStreamable';
|
||||
|
||||
export type McpAuthenticationOption =
|
||||
| 'none'
|
||||
| 'headerAuth'
|
||||
| 'bearerAuth'
|
||||
| 'mcpOAuth2Api'
|
||||
| 'multipleHeadersAuth';
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { ClientOAuth2TokenData } from '@n8n/client-oauth2';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INode,
|
||||
ISupplyDataFunctions,
|
||||
Result,
|
||||
} from 'n8n-workflow';
|
||||
import { createResultError, createResultOk, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { proxyFetch } from '@n8n/ai-utilities';
|
||||
|
||||
import type { McpAuthenticationOption, McpServerTransport, McpTool } from './types';
|
||||
|
||||
export async function getAllTools(client: Client, cursor?: string): Promise<McpTool[]> {
|
||||
const { tools, nextCursor } = await client.listTools({ cursor });
|
||||
|
||||
if (nextCursor) {
|
||||
return (tools as McpTool[]).concat(await getAllTools(client, nextCursor));
|
||||
}
|
||||
|
||||
return tools as McpTool[];
|
||||
}
|
||||
|
||||
function safeCreateUrl(url: string, baseUrl?: string | URL): Result<URL, Error> {
|
||||
try {
|
||||
return createResultOk(new URL(url, baseUrl));
|
||||
} catch (error) {
|
||||
return createResultError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAndValidateUrl(input: string): Result<URL, Error> {
|
||||
const withProtocol = !/^https?:\/\//i.test(input) ? `https://${input}` : input;
|
||||
const parsedUrl = safeCreateUrl(withProtocol);
|
||||
|
||||
if (!parsedUrl.ok) {
|
||||
return createResultError(parsedUrl.error);
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
function errorHasCode(error: unknown, code: number): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
(('code' in error && Number(error.code) === code) ||
|
||||
('message' in error &&
|
||||
typeof error.message === 'string' &&
|
||||
error.message.includes(code.toString())))
|
||||
);
|
||||
}
|
||||
|
||||
function isUnauthorizedError(error: unknown): boolean {
|
||||
return errorHasCode(error, 401);
|
||||
}
|
||||
|
||||
function isForbiddenError(error: unknown): boolean {
|
||||
return errorHasCode(error, 403);
|
||||
}
|
||||
|
||||
type OnUnauthorizedHandler = (
|
||||
headers?: Record<string, string>,
|
||||
) => Promise<Record<string, string> | null>;
|
||||
|
||||
type ConnectMcpClientError =
|
||||
| { type: 'invalid_url'; error: Error }
|
||||
| { type: 'connection'; error: Error }
|
||||
| { type: 'auth'; error: Error };
|
||||
|
||||
export function mapToNodeOperationError(
|
||||
node: INode,
|
||||
error: ConnectMcpClientError,
|
||||
): NodeOperationError {
|
||||
switch (error.type) {
|
||||
case 'invalid_url':
|
||||
return new NodeOperationError(node, error.error, {
|
||||
message: 'Could not connect to your MCP server. The provided URL is invalid.',
|
||||
});
|
||||
case 'auth':
|
||||
return new NodeOperationError(node, error.error, {
|
||||
message: 'Could not connect to your MCP server. Authentication failed.',
|
||||
description: error.error.message,
|
||||
});
|
||||
case 'connection':
|
||||
default:
|
||||
return new NodeOperationError(node, error.error, {
|
||||
message: 'Could not connect to your MCP server',
|
||||
description: error.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectMcpClient({
|
||||
headers,
|
||||
serverTransport,
|
||||
endpointUrl,
|
||||
name,
|
||||
version,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
serverTransport: McpServerTransport;
|
||||
endpointUrl: string;
|
||||
headers?: Record<string, string>;
|
||||
name: string;
|
||||
version: number;
|
||||
onUnauthorized?: OnUnauthorizedHandler;
|
||||
}): Promise<Result<Client, ConnectMcpClientError>> {
|
||||
const endpoint = normalizeAndValidateUrl(endpointUrl);
|
||||
|
||||
if (!endpoint.ok) {
|
||||
return createResultError({ type: 'invalid_url', error: endpoint.error });
|
||||
}
|
||||
|
||||
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
|
||||
|
||||
if (serverTransport === 'httpStreamable') {
|
||||
try {
|
||||
const transport = new StreamableHTTPClientTransport(endpoint.result, {
|
||||
requestInit: { headers },
|
||||
fetch: proxyFetch,
|
||||
});
|
||||
await client.connect(transport);
|
||||
return createResultOk(client);
|
||||
} catch (error) {
|
||||
if (onUnauthorized && isUnauthorizedError(error)) {
|
||||
const newHeaders = await onUnauthorized(headers);
|
||||
if (newHeaders) {
|
||||
// Don't pass `onUnauthorized` to avoid possible infinite recursion
|
||||
return await connectMcpClient({
|
||||
headers: newHeaders,
|
||||
serverTransport,
|
||||
endpointUrl,
|
||||
name,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(error) || isForbiddenError(error)) {
|
||||
return createResultError({ type: 'auth', error: error as Error });
|
||||
} else {
|
||||
return createResultError({ type: 'connection', error: error as Error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sseTransport = new SSEClientTransport(endpoint.result, {
|
||||
eventSourceInit: {
|
||||
fetch: async (url, init) =>
|
||||
await proxyFetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
}),
|
||||
},
|
||||
fetch: proxyFetch,
|
||||
requestInit: { headers },
|
||||
});
|
||||
await client.connect(sseTransport);
|
||||
return createResultOk(client);
|
||||
} catch (error) {
|
||||
if (onUnauthorized && isUnauthorizedError(error)) {
|
||||
const newHeaders = await onUnauthorized(headers);
|
||||
if (newHeaders) {
|
||||
// Don't pass `onUnauthorized` to avoid possible infinite recursion
|
||||
return await connectMcpClient({
|
||||
headers: newHeaders,
|
||||
serverTransport,
|
||||
endpointUrl,
|
||||
name,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(error) || isForbiddenError(error)) {
|
||||
return createResultError({ type: 'auth', error: error as Error });
|
||||
} else {
|
||||
return createResultError({ type: 'connection', error: error as Error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthHeaders(
|
||||
ctx: Pick<IExecuteFunctions, 'getCredentials'>,
|
||||
authentication: McpAuthenticationOption,
|
||||
): Promise<{ headers?: Record<string, string> }> {
|
||||
switch (authentication) {
|
||||
case 'headerAuth': {
|
||||
const header = await ctx
|
||||
.getCredentials<{ name: string; value: string }>('httpHeaderAuth')
|
||||
.catch(() => null);
|
||||
|
||||
if (!header) return {};
|
||||
|
||||
return { headers: { [header.name]: header.value } };
|
||||
}
|
||||
case 'bearerAuth': {
|
||||
const result = await ctx
|
||||
.getCredentials<{ token: string }>('httpBearerAuth')
|
||||
.catch(() => null);
|
||||
|
||||
if (!result) return {};
|
||||
|
||||
return { headers: { Authorization: `Bearer ${result.token}` } };
|
||||
}
|
||||
case 'mcpOAuth2Api': {
|
||||
const result = await ctx
|
||||
.getCredentials<{ oauthTokenData: { access_token: string } }>('mcpOAuth2Api')
|
||||
.catch(() => null);
|
||||
|
||||
if (!result) return {};
|
||||
|
||||
return { headers: { Authorization: `Bearer ${result.oauthTokenData.access_token}` } };
|
||||
}
|
||||
case 'multipleHeadersAuth': {
|
||||
const result = await ctx
|
||||
.getCredentials<{ headers: { values: Array<{ name: string; value: string }> } }>(
|
||||
'httpMultipleHeadersAuth',
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
if (!result) return {};
|
||||
|
||||
return {
|
||||
headers: result.headers.values.reduce(
|
||||
(acc, cur) => {
|
||||
acc[cur.name] = cur.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'none':
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to refresh the OAuth2 token, storing them in the database if successful
|
||||
* @param ctx - The execution context
|
||||
* @param authentication - The authentication method
|
||||
* @param headers - The headers to refresh
|
||||
* @returns The refreshed headers or null if the authentication method is not oAuth2Api or has failed
|
||||
*/
|
||||
export async function tryRefreshOAuth2Token(
|
||||
ctx: IExecuteFunctions | ISupplyDataFunctions | ILoadOptionsFunctions,
|
||||
authentication: McpAuthenticationOption,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
if (authentication !== 'mcpOAuth2Api') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let access_token: string | null = null;
|
||||
try {
|
||||
const result = (await ctx.helpers.refreshOAuth2Token.call(
|
||||
ctx,
|
||||
'mcpOAuth2Api',
|
||||
)) as ClientOAuth2TokenData;
|
||||
access_token = result?.access_token;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!headers) {
|
||||
return {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...headers,
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user