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,45 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4303_1230)">
|
||||
<g clip-path="url(#clip1_4303_1230)">
|
||||
<path d="M48.6273 23.9918C48.4433 16.9565 48.3514 13.4388 49.3514 10.6312C50.9152 6.24117 54.3152 2.75106 58.6629 1.07311C61.4434 0 64.9623 0 72 0C79.1102 0 85.2633 12.5109 89.4089 29.9356C91.3095 37.9241 92.2597 41.9184 89.8575 44.9592C87.4553 48 83.095 48 74.3746 48H65C55.6524 48 51.1922 50.0237 49.6213 62.0074L48.6273 23.9918Z" fill="url(#paint0_linear_4303_1230)"/>
|
||||
<path d="M48.6273 23.9918C48.4433 16.9565 48.3514 13.4388 49.3514 10.6312C50.9152 6.24117 54.3152 2.75106 58.6629 1.07311C61.4434 0 64.9623 0 72 0C79.1102 0 85.2633 12.5109 89.4089 29.9356C91.3095 37.9241 92.2597 41.9184 89.8575 44.9592C87.4553 48 83.095 48 74.3746 48H65C55.6524 48 51.1922 50.0237 49.6213 62.0074L48.6273 23.9918Z" fill="url(#paint1_radial_4303_1230)"/>
|
||||
<path d="M56 76C56 64.9543 64.9543 56 76 56C87.0457 56 96 64.9543 96 76C96 87.0457 87.0457 96 76 96C64.9543 96 56 87.0457 56 76Z" fill="url(#paint2_linear_4303_1230)"/>
|
||||
<path d="M56 76C56 64.9543 64.9543 56 76 56C87.0457 56 96 64.9543 96 76C96 87.0457 87.0457 96 76 96C64.9543 96 56 87.0457 56 76Z" fill="url(#paint3_radial_4303_1230)"/>
|
||||
<path d="M48 0C25.3343 0 6.89691 31.7119 1.56146 72.064C0.172668 82.5675 -0.52173 87.8192 3.06341 91.9096C6.64855 96 12.4324 96 24 96C35.1353 96 40.703 96 44.1613 92.7703C47.6197 89.5406 48.0198 83.6982 48.8198 72.0133C51.4763 33.214 60.3542 0 72 0H48Z" fill="url(#paint4_linear_4303_1230)"/>
|
||||
<path d="M48 0C25.3343 0 6.89691 31.7119 1.56146 72.064C0.172668 82.5675 -0.52173 87.8192 3.06341 91.9096C6.64855 96 12.4324 96 24 96C35.1353 96 40.703 96 44.1613 92.7703C47.6197 89.5406 48.0198 83.6982 48.8198 72.0133C51.4763 33.214 60.3542 0 72 0H48Z" fill="url(#paint5_linear_4303_1230)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4303_1230" x1="62" y1="5.5" x2="87" y2="64.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B44B1"/>
|
||||
<stop offset="1" stop-color="#2764E7"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint1_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(80.5 71) rotate(-92.0166) scale(71.0441 31.9304)">
|
||||
<stop offset="0.539446" stop-color="#2052CB" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#163697"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint2_linear_4303_1230" x1="96.6015" y1="96" x2="96.7128" y2="56.0003" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#367AF2"/>
|
||||
<stop offset="1" stop-color="#BABAFF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint3_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71 93.7778) rotate(-74.268) scale(40.9795 35.3954)">
|
||||
<stop offset="0.513697" stop-color="#BABAFF" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#FECBE6"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint4_linear_4303_1230" x1="57" y1="0" x2="57" y2="96" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#BABAFF"/>
|
||||
<stop offset="0.513233" stop-color="#4894FE"/>
|
||||
<stop offset="1" stop-color="#2764E7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_4303_1230" x1="51" y1="33.5" x2="51" y2="-2.21398e-06" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#BABAFF" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#FECBE6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Vendored
+373
@@ -0,0 +1,373 @@
|
||||
import type { INodeType, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { MicrosoftAgent365Trigger } from './MicrosoftAgent365Trigger.node';
|
||||
import {
|
||||
createMicrosoftAgentApplication,
|
||||
configureAdapterProcessCallback,
|
||||
type MicrosoftAgent365Credentials,
|
||||
type ActivityCapture,
|
||||
} from './microsoft-utils';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('./microsoft-utils', () => ({
|
||||
createMicrosoftAgentApplication: jest.fn(),
|
||||
configureAdapterProcessCallback: jest.fn(),
|
||||
microsoftMcpServers: [
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock('../../agents/Agent/V2/utils', () => ({
|
||||
getInputs: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MicrosoftAgent365Trigger', () => {
|
||||
let microsoftAgent365Trigger: INodeType;
|
||||
let mockWebhookFunctions: IWebhookFunctions;
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let mockAdapter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
microsoftAgent365Trigger = new MicrosoftAgent365Trigger();
|
||||
|
||||
// Create mock request
|
||||
mockRequest = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
headers: {},
|
||||
};
|
||||
|
||||
// Create mock response
|
||||
mockResponse = {
|
||||
end: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// Create mock adapter
|
||||
mockAdapter = {
|
||||
process: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Create mock webhook functions using jest-mock-extended
|
||||
mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
mockWebhookFunctions.getRequestObject = jest.fn().mockReturnValue(mockRequest);
|
||||
mockWebhookFunctions.getResponseObject = jest.fn().mockReturnValue(mockResponse);
|
||||
mockWebhookFunctions.getCredentials = jest.fn() as any;
|
||||
mockWebhookFunctions.getNode = jest.fn().mockReturnValue({
|
||||
name: 'Microsoft Agent 365',
|
||||
type: 'microsoftAgent365Trigger',
|
||||
});
|
||||
mockWebhookFunctions.helpers = {
|
||||
returnJsonArray: jest.fn((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => ({ json: item }));
|
||||
}
|
||||
return [{ json: data }];
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Node Description', () => {
|
||||
test('should have correct basic properties', () => {
|
||||
expect(microsoftAgent365Trigger.description.displayName).toBe('Microsoft Agent 365 Trigger');
|
||||
expect(microsoftAgent365Trigger.description.name).toBe('microsoftAgent365Trigger');
|
||||
expect(microsoftAgent365Trigger.description.group).toEqual(['trigger']);
|
||||
});
|
||||
|
||||
test('should have webhook configuration', () => {
|
||||
expect(microsoftAgent365Trigger.description.webhooks).toBeDefined();
|
||||
expect(microsoftAgent365Trigger.description.webhooks).toHaveLength(2);
|
||||
|
||||
// Check POST webhook
|
||||
const postWebhook = microsoftAgent365Trigger.description.webhooks![0];
|
||||
expect(postWebhook.httpMethod).toBe('POST');
|
||||
expect(postWebhook.path).toBe('webhook');
|
||||
expect(postWebhook.responseMode).toBe('onReceived');
|
||||
|
||||
// Check HEAD webhook
|
||||
const headWebhook = microsoftAgent365Trigger.description.webhooks![1];
|
||||
expect(headWebhook.httpMethod).toBe('HEAD');
|
||||
expect(headWebhook.path).toBe('webhook');
|
||||
});
|
||||
|
||||
test('should require microsoftAgent365Api credentials', () => {
|
||||
expect(microsoftAgent365Trigger.description.credentials).toBeDefined();
|
||||
expect(microsoftAgent365Trigger.description.credentials).toHaveLength(1);
|
||||
expect(microsoftAgent365Trigger.description.credentials![0].name).toBe(
|
||||
'microsoftAgent365Api',
|
||||
);
|
||||
expect(microsoftAgent365Trigger.description.credentials![0].required).toBe(true);
|
||||
});
|
||||
|
||||
test('should have system prompt property', () => {
|
||||
const properties = microsoftAgent365Trigger.description.properties;
|
||||
const systemPromptProp = properties.find((p) => p.name === 'systemPrompt');
|
||||
|
||||
expect(systemPromptProp).toBeDefined();
|
||||
expect(systemPromptProp?.type).toBe('string');
|
||||
expect(systemPromptProp?.displayName).toBe('System Prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook method', () => {
|
||||
describe('HEAD request handling', () => {
|
||||
test('should handle HEAD request and return immediately', async () => {
|
||||
mockRequest.method = 'HEAD';
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockResponse.end).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
expect(mockWebhookFunctions.getCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST request handling', () => {
|
||||
let mockCredentials: MicrosoftAgent365Credentials;
|
||||
let mockAgent: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
mockAgent = {
|
||||
adapter: mockAdapter,
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
});
|
||||
|
||||
test('should process POST request successfully', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
// Verify credentials were retrieved
|
||||
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('microsoftAgent365Api');
|
||||
|
||||
// Verify agent application was created
|
||||
expect(createMicrosoftAgentApplication).toHaveBeenCalledWith(mockCredentials);
|
||||
|
||||
// Verify callback was configured
|
||||
expect(configureAdapterProcessCallback).toHaveBeenCalledWith(
|
||||
mockWebhookFunctions,
|
||||
mockAgent,
|
||||
mockCredentials,
|
||||
expect.objectContaining({
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify request user was set
|
||||
expect(mockRequest.user).toEqual({
|
||||
aud: mockCredentials.clientId,
|
||||
appid: mockCredentials.clientId,
|
||||
azp: mockCredentials.clientId,
|
||||
});
|
||||
|
||||
// Verify adapter process was called
|
||||
expect(mockAdapter.process).toHaveBeenCalledWith(mockRequest, mockResponse, mockCallback);
|
||||
|
||||
// Verify result structure
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
test('should capture activity data in workflowData', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
const result = (await microsoftAgent365Trigger.webhook!.call(
|
||||
mockWebhookFunctions,
|
||||
)) as IWebhookResponseData;
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(Array.isArray(result.workflowData)).toBe(true);
|
||||
expect(result.workflowData).toHaveLength(1);
|
||||
|
||||
// Verify returnJsonArray was called with activity capture
|
||||
expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should set request user properties correctly', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockRequest.user).toBeDefined();
|
||||
expect(mockRequest.user.aud).toBe(mockCredentials.clientId);
|
||||
expect(mockRequest.user.appid).toBe(mockCredentials.clientId);
|
||||
expect(mockRequest.user.azp).toBe(mockCredentials.clientId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
test('should throw NodeOperationError when credentials retrieval fails', async () => {
|
||||
const error = new Error('Credentials not found');
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle error with response data containing error object', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid client credentials',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(errorResponse);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
'Error: invalid_client',
|
||||
);
|
||||
});
|
||||
|
||||
test('should include error description in NodeOperationError', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'unauthorized',
|
||||
error_description: 'The provided credentials are invalid',
|
||||
},
|
||||
},
|
||||
message: 'Authentication failed',
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(errorResponse);
|
||||
|
||||
try {
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NodeOperationError);
|
||||
expect((error as NodeOperationError).description).toBe(
|
||||
'The provided credentials are invalid',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError with message when no error object in response', async () => {
|
||||
const error = new Error('Network error');
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NodeOperationError);
|
||||
expect((err as NodeOperationError).message).toContain('Network error');
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when agent creation fails', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Failed to create agent application');
|
||||
});
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when adapter process fails', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
const mockAgent = {
|
||||
adapter: {
|
||||
process: jest.fn().mockRejectedValue(new Error('Adapter processing failed')),
|
||||
},
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
test('should handle complete webhook flow with activity capture', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
const mockAgent = {
|
||||
adapter: mockAdapter,
|
||||
};
|
||||
|
||||
let capturedActivityCapture: ActivityCapture | undefined;
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
(configureAdapterProcessCallback as jest.Mock).mockImplementation(
|
||||
(_ctx, _agent, _creds, activityCapture) => {
|
||||
capturedActivityCapture = activityCapture;
|
||||
return mockCallback;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
// Verify activity capture was initialized
|
||||
expect(capturedActivityCapture).toBeDefined();
|
||||
expect(capturedActivityCapture?.input).toBe('');
|
||||
expect(capturedActivityCapture?.output).toEqual([]);
|
||||
expect(capturedActivityCapture?.activity).toEqual({});
|
||||
|
||||
// Verify the full flow completed
|
||||
expect(createMicrosoftAgentApplication).toHaveBeenCalled();
|
||||
expect(configureAdapterProcessCallback).toHaveBeenCalled();
|
||||
expect(mockAdapter.process).toHaveBeenCalled();
|
||||
expect(result.noWebhookResponse).toBe(true);
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getInputs } from '../../agents/Agent/V2/utils';
|
||||
|
||||
import {
|
||||
type ActivityCapture,
|
||||
configureAdapterProcessCallback,
|
||||
createMicrosoftAgentApplication,
|
||||
type MicrosoftAgent365Credentials,
|
||||
microsoftMcpServers,
|
||||
} from './microsoft-utils';
|
||||
|
||||
export class MicrosoftAgent365Trigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Microsoft Agent 365 Trigger',
|
||||
name: 'microsoftAgent365Trigger',
|
||||
icon: 'file:Agent365.svg',
|
||||
group: ['trigger'],
|
||||
description: 'Trigger for Microsoft Agent 365',
|
||||
codex: {
|
||||
categories: ['Core Nodes'],
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.microsoftAgent365Trigger/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
version: [1],
|
||||
defaults: {
|
||||
name: 'Microsoft Agent 365',
|
||||
},
|
||||
inputs: `={{
|
||||
((hasOutputParser, needsFallback) => {
|
||||
${getInputs.toString()};
|
||||
return getInputs(false, hasOutputParser, needsFallback);
|
||||
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
|
||||
}}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
triggerPanel: false,
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
ndvHideMethod: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'HEAD',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
ndvHideUrl: true,
|
||||
ndvHideMethod: true,
|
||||
},
|
||||
],
|
||||
credentials: [
|
||||
{
|
||||
name: 'microsoftAgent365Api',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
'This is an early preview for building Agents with Microsoft Agent 365 and n8n. You need to be part of the <a href="https://adoption.microsoft.com/copilot/frontier-program/" target="_blank">Frontier preview program</a> to get early access to Microsoft Agent 365. <a href="https://github.com/microsoft/Agent365-Samples/tree/main/nodejs/n8n/sample-agent" target="_blank">Learn more</a>',
|
||||
name: 'previewNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'System Prompt',
|
||||
name: 'systemPrompt',
|
||||
type: 'string',
|
||||
placeholder:
|
||||
'e.g. You are a friendly assistant that helps people find a weather forecast for a given time and place.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
hasOutputParser: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Fallback Model',
|
||||
name: 'needsFallback',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
|
||||
name: 'fallbackNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
needsFallback: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Microsoft MCP Tools',
|
||||
name: 'useMcpTools',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to allow the agent to use Microsoft MCP tools like Calendar, Email, and OneDrive to assist in completing tasks. Requires appropriate permissions in your Microsoft account.',
|
||||
},
|
||||
{
|
||||
displayName: 'Tools to Include',
|
||||
name: 'include',
|
||||
type: 'options',
|
||||
default: 'all',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useMcpTools: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
name: 'Selected',
|
||||
value: 'selected',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Tools to Include',
|
||||
name: 'includeTools',
|
||||
type: 'multiOptions',
|
||||
default: [],
|
||||
noDataExpression: true,
|
||||
options: microsoftMcpServers,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useMcpTools: [true],
|
||||
include: ['selected'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Require Specific Output Format',
|
||||
name: 'hasOutputParser',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Max Iterations',
|
||||
name: 'maxIterations',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
description: 'The maximum number of iterations the agent will run before stopping',
|
||||
},
|
||||
{
|
||||
displayName: 'Welcome Message',
|
||||
name: 'welcomeMessage',
|
||||
type: 'string',
|
||||
placeholder: "e.g. Hello! I'm here to help you!",
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const req = this.getRequestObject();
|
||||
const res = this.getResponseObject();
|
||||
|
||||
const method = req.method;
|
||||
if (method === 'HEAD') {
|
||||
res.end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = (await this.getCredentials(
|
||||
'microsoftAgent365Api',
|
||||
)) as MicrosoftAgent365Credentials;
|
||||
|
||||
const agent = createMicrosoftAgentApplication(credentials);
|
||||
|
||||
const activityCapture: ActivityCapture = {
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
};
|
||||
|
||||
const callback = configureAdapterProcessCallback(this, agent, credentials, activityCapture);
|
||||
|
||||
(req as any).user = {
|
||||
aud: credentials.clientId,
|
||||
appid: credentials.clientId,
|
||||
azp: credentials.clientId,
|
||||
};
|
||||
|
||||
await agent.adapter.process(req, res, callback);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
workflowData: [this.helpers.returnJsonArray({ ...activityCapture })],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data;
|
||||
if (typeof errorData === 'object' && 'error' in errorData) {
|
||||
const message = 'Error: ' + String(errorData.error);
|
||||
const description = (errorData.error_description as string) ?? error.message;
|
||||
|
||||
throw new NodeOperationError(this.getNode(), message, { description });
|
||||
}
|
||||
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { invokeAgent } from '../langchain-utils';
|
||||
|
||||
import {
|
||||
getChatModel,
|
||||
getOptionalMemory,
|
||||
getTools,
|
||||
preparePrompt,
|
||||
} from '../../../agents/Agent/agents/ToolsAgent/common';
|
||||
import { createAgentExecutor } from '../../../agents/Agent/agents/ToolsAgent/V2/execute';
|
||||
import { getOptionalOutputParser } from '../../../../utils/output_parsers/N8nOutputParser';
|
||||
|
||||
jest.mock('../../../agents/Agent/agents/ToolsAgent/common', () => ({
|
||||
getChatModel: jest.fn(),
|
||||
getOptionalMemory: jest.fn(),
|
||||
getTools: jest.fn(),
|
||||
preparePrompt: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../agents/Agent/agents/ToolsAgent/V2/execute', () => ({
|
||||
createAgentExecutor: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('langchain-utils', () => {
|
||||
describe('invokeAgent', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw error if no model is connected', async () => {
|
||||
(getChatModel as jest.Mock).mockResolvedValue(null);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(null);
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(
|
||||
'Please connect a model to the Chat Model input',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError if fallback is needed but fallback model is not connected', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
(getChatModel as jest.Mock).mockResolvedValueOnce(mockModel).mockResolvedValueOnce(null);
|
||||
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(null);
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return true;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
});
|
||||
|
||||
test('should invoke agent with microsoftMcpTools when provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const microsoftMcpTools = [{ name: 'mcp-tool1' }, { name: 'mcp-tool2' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(
|
||||
nodeContext,
|
||||
'test input',
|
||||
undefined,
|
||||
{},
|
||||
microsoftMcpTools as any,
|
||||
);
|
||||
|
||||
expect(result).toBe('test response');
|
||||
|
||||
expect(getTools).toHaveBeenCalled();
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
[...mockTools, ...microsoftMcpTools],
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should invoke agent without microsoftMcpTools when not provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle custom systemMessage parameter', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const customSystemMessage = 'Custom system message';
|
||||
const result = await invokeAgent(nodeContext, 'test input', customSystemMessage);
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: 'test input',
|
||||
system_message: customSystemMessage,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle fallback model when needsFallback is true', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockFallbackModel = { name: 'fallback-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock)
|
||||
.mockResolvedValueOnce(mockModel)
|
||||
.mockResolvedValueOnce(mockFallbackModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return true;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
mockFallbackModel,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle custom maxIterations option', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return { maxIterations: 20 };
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 20 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when executor returns rejected status', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockError = new Error('Execution failed');
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ status: 'rejected', reason: mockError }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
test('should parse JSON output when memory and outputParser are present', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockOutputParser = { name: 'outputParser' };
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ output: '{"output": {"result": "parsed response"}}' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(mockOutputParser);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toEqual({ result: 'parsed response' });
|
||||
});
|
||||
|
||||
test('should return raw output when memory is present but outputParser is not', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'raw output response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('raw output response');
|
||||
});
|
||||
|
||||
test('should concatenate microsoftMcpTools with regular tools', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }, { name: 'tool2' }];
|
||||
const microsoftMcpTools = [{ name: 'mcp-tool1' }, { name: 'mcp-tool2' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await invokeAgent(nodeContext, 'test input', undefined, {}, microsoftMcpTools as any);
|
||||
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
[...mockTools, ...microsoftMcpTools],
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should use empty array for microsoftMcpTools when not provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
// Verify that only regular tools were used (no microsoftMcpTools)
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass custom invokeOptions to executor', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const customInvokeOptions = { timeout: 5000, tags: ['test'] };
|
||||
await invokeAgent(nodeContext, 'test input', undefined, customInvokeOptions);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: 'test input',
|
||||
}),
|
||||
customInvokeOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+987
@@ -0,0 +1,987 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createMicrosoftAgentApplication,
|
||||
configureAdapterProcessCallback,
|
||||
getMicrosoftMcpTools,
|
||||
configureActivityCallback,
|
||||
microsoftMcpServers,
|
||||
extractActivityInfo,
|
||||
type MicrosoftAgent365Credentials,
|
||||
type ActivityCapture,
|
||||
type ActivityInfo,
|
||||
} from '../microsoft-utils';
|
||||
|
||||
jest.mock('@microsoft/agents-hosting', () => ({
|
||||
MemoryStorage: jest.fn().mockImplementation(() => ({})),
|
||||
AgentApplication: jest.fn().mockImplementation(function (this: any, config: any) {
|
||||
this.adapter = config.adapter;
|
||||
this.storage = config.storage;
|
||||
this.authorization = config.authorization;
|
||||
this.onConversationUpdate = jest.fn();
|
||||
this.onActivity = jest.fn();
|
||||
this.run = jest.fn();
|
||||
return this;
|
||||
}),
|
||||
CloudAdapter: jest.fn().mockImplementation((config: any) => ({ config })),
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-observability', () => ({
|
||||
ExecutionType: {
|
||||
HumanToAgent: 'HumanToAgent',
|
||||
},
|
||||
InvokeAgentScope: {
|
||||
start: jest.fn().mockReturnValue({
|
||||
withActiveSpanAsync: jest.fn().mockImplementation((fn: any) => fn()),
|
||||
recordInputMessages: jest.fn(),
|
||||
recordOutputMessages: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
}),
|
||||
},
|
||||
BaggageBuilder: jest.fn().mockImplementation(() => ({
|
||||
tenantId: jest.fn().mockReturnThis(),
|
||||
agentId: jest.fn().mockReturnThis(),
|
||||
correlationId: jest.fn().mockReturnThis(),
|
||||
agentName: jest.fn().mockReturnThis(),
|
||||
conversationId: jest.fn().mockReturnThis(),
|
||||
build: jest.fn().mockReturnValue({
|
||||
run: jest.fn().mockImplementation((fn: any) => fn()),
|
||||
}),
|
||||
})),
|
||||
ObservabilityManager: {
|
||||
configure: jest.fn().mockReturnValue({
|
||||
start: jest.fn(),
|
||||
shutdown: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-runtime', () => ({
|
||||
getMcpPlatformAuthenticationScope: jest.fn().mockReturnValue('mcp-scope'),
|
||||
getObservabilityAuthenticationScope: jest.fn().mockReturnValue('observability-scope'),
|
||||
Utility: {
|
||||
ResolveAgentIdentity: jest.fn().mockReturnValue('agent-identity'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-tooling', () => ({
|
||||
McpToolServerConfigurationService: jest.fn().mockImplementation(() => ({
|
||||
listToolServers: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
Utility: {
|
||||
ValidateAuthToken: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../langchain-utils', () => ({
|
||||
invokeAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../mcp/shared/utils', () => ({
|
||||
connectMcpClient: jest.fn(),
|
||||
getAllTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../mcp/McpClientTool/utils', () => ({
|
||||
createCallTool: jest.fn(),
|
||||
mcpToolToDynamicTool: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'test-uuid'),
|
||||
}));
|
||||
|
||||
import { MemoryStorage, AgentApplication, CloudAdapter } from '@microsoft/agents-hosting';
|
||||
import { invokeAgent } from '../langchain-utils';
|
||||
import { connectMcpClient, getAllTools } from '../../../mcp/shared/utils';
|
||||
import { createCallTool, mcpToolToDynamicTool } from '../../../mcp/McpClientTool/utils';
|
||||
|
||||
describe('microsoft-utils', () => {
|
||||
describe('createMicrosoftAgentApplication', () => {
|
||||
const mockCredentials: MicrosoftAgent365Credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
test('should create CloudAdapter with correct auth configuration', () => {
|
||||
createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(CloudAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientId: mockCredentials.clientId,
|
||||
clientSecret: mockCredentials.clientSecret,
|
||||
tenantId: mockCredentials.tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: expect.arrayContaining([
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${mockCredentials.tenantId}/`,
|
||||
`https://login.microsoftonline.com/${mockCredentials.tenantId}/v2.0`,
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should create MemoryStorage', () => {
|
||||
createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(MemoryStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should create AgentApplication with correct configuration', () => {
|
||||
const result = createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(AgentApplication).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adapter: expect.any(Object),
|
||||
storage: expect.any(Object),
|
||||
authorization: {
|
||||
agentic: {
|
||||
type: 'agentic',
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(AgentApplication);
|
||||
});
|
||||
|
||||
test('should return AgentApplication instance', () => {
|
||||
const result = createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('adapter');
|
||||
expect(result).toHaveProperty('storage');
|
||||
expect(result).toHaveProperty('authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureAdapterProcessCallback', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
let agent: any;
|
||||
let credentials: MicrosoftAgent365Credentials;
|
||||
let activityCapture: ActivityCapture;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
|
||||
agent = {
|
||||
authorization: {
|
||||
exchangeToken: jest.fn().mockResolvedValue({ token: 'mock-token' }),
|
||||
},
|
||||
onConversationUpdate: jest.fn(),
|
||||
onActivity: jest.fn(),
|
||||
run: jest.fn(),
|
||||
};
|
||||
|
||||
credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
activityCapture = {
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
};
|
||||
});
|
||||
|
||||
test('should configure agent with welcome message', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'conversationUpdate',
|
||||
text: 'Hello',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome to the agent!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.onConversationUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should set up activity callback that invokes agent', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input message',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test agent response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.onActivity).toHaveBeenCalled();
|
||||
expect(activityCapture.input).toBe('Test input message');
|
||||
});
|
||||
|
||||
test('should handle agent.run errors and throw NodeOperationError', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockError = new Error('Agent run failed');
|
||||
agent.run = jest.fn().mockRejectedValue(mockError);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await expect(callback(mockTurnContext as any)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
test('should call agent.run with turnContext', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.run).toHaveBeenCalledWith(mockTurnContext);
|
||||
});
|
||||
|
||||
test('should exchange tokens for observability and MCP', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.authorization.exchangeToken).toHaveBeenCalledWith(
|
||||
mockTurnContext,
|
||||
'observability-scope',
|
||||
'agentic',
|
||||
);
|
||||
|
||||
expect(agent.authorization.exchangeToken).toHaveBeenCalledWith(
|
||||
mockTurnContext,
|
||||
'agentic',
|
||||
expect.objectContaining({
|
||||
scopes: ['mcp-scope'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle MCP token exchange failure gracefully', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
agent.authorization.exchangeToken = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ token: 'observability-token' })
|
||||
.mockRejectedValueOnce(new Error('Token exchange failed'));
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should capture activity input and output', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'User input message',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockImplementation(async (_activityOrText: string) => {
|
||||
return {};
|
||||
}),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Agent response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(activityCapture.input).toBe('User input message');
|
||||
});
|
||||
|
||||
test('should handle observability when enabled', async () => {
|
||||
const originalEnv = process.env;
|
||||
process.env.ENABLE_OBSERVABILITY = 'true';
|
||||
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true';
|
||||
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
process.env = originalEnv;
|
||||
|
||||
expect(agent.run).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMicrosoftMcpTools', () => {
|
||||
let mockTurnContext: any;
|
||||
let mockConfigService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockTurnContext = {
|
||||
activity: {
|
||||
recipient: { tenantId: 'test-tenant-id' },
|
||||
channelData: { tenant: { id: 'test-tenant-id' } },
|
||||
},
|
||||
};
|
||||
|
||||
// Reset the mock implementation for each test
|
||||
const { McpToolServerConfigurationService } = jest.requireMock(
|
||||
'@microsoft/agents-a365-tooling',
|
||||
);
|
||||
mockConfigService = {
|
||||
listToolServers: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
(McpToolServerConfigurationService as jest.Mock).mockImplementation(() => mockConfigService);
|
||||
});
|
||||
|
||||
test('should return undefined when no servers are configured', async () => {
|
||||
mockConfigService.listToolServers.mockResolvedValue([]);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should filter servers when selectedTools is provided', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
{ mcpServerName: 'mcp_TeamsServer', url: 'http://teams-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
const mockTool = { name: 'test-tool', description: 'Test tool' };
|
||||
(getAllTools as jest.Mock).mockResolvedValue([mockTool]);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool = { name: 'test-tool' };
|
||||
(mcpToolToDynamicTool as jest.Mock).mockReturnValue(mockDynamicTool);
|
||||
|
||||
const selectedTools = ['mcp_CalendarTools', 'mcp_TeamsServer'];
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', selectedTools);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(connectMcpClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should connect to MCP servers with correct headers', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(connectMcpClient).toHaveBeenCalledWith({
|
||||
serverTransport: 'httpStreamable',
|
||||
endpointUrl: 'http://calendar-server',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token',
|
||||
'x-ms-tenant-id': 'test-tenant-id',
|
||||
},
|
||||
name: 'Microsoft-Agent-365',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle connection errors gracefully', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
(connectMcpClient as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'Connection failed',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { close: jest.fn() },
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
try {
|
||||
await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to connect to MCP server mcp_CalendarTools:',
|
||||
'Connection failed',
|
||||
);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('should create dynamic tools from MCP tools', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
const mockTools = [
|
||||
{ name: 'create_event', description: 'Create calendar event' },
|
||||
{ name: 'list_events', description: 'List calendar events' },
|
||||
];
|
||||
(getAllTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool1 = { name: 'create_event' };
|
||||
const mockDynamicTool2 = { name: 'list_events' };
|
||||
(mcpToolToDynamicTool as jest.Mock)
|
||||
.mockReturnValueOnce(mockDynamicTool1)
|
||||
.mockReturnValueOnce(mockDynamicTool2);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.tools).toHaveLength(2);
|
||||
expect(createCallTool).toHaveBeenCalledTimes(2);
|
||||
expect(mcpToolToDynamicTool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should return undefined when no tools are available', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should provide close method that closes all clients', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient1 = { close: jest.fn() };
|
||||
const mockClient2 = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock)
|
||||
.mockResolvedValueOnce({ ok: true, result: mockClient1 })
|
||||
.mockResolvedValueOnce({ ok: true, result: mockClient2 });
|
||||
|
||||
const mockTool = { name: 'test-tool', description: 'Test tool' };
|
||||
(getAllTools as jest.Mock).mockResolvedValue([mockTool]);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool = { name: 'test-tool' };
|
||||
(mcpToolToDynamicTool as jest.Mock).mockReturnValue(mockDynamicTool);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
await result?.client.close();
|
||||
|
||||
expect(mockClient1.close).toHaveBeenCalled();
|
||||
expect(mockClient2.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should use tenant id from channelData if recipient tenantId is not available', async () => {
|
||||
const contextWithChannelData = {
|
||||
activity: {
|
||||
recipient: {},
|
||||
channelData: { tenant: { id: 'channel-tenant-id' } },
|
||||
},
|
||||
};
|
||||
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await getMicrosoftMcpTools(contextWithChannelData as any, 'test-token', undefined);
|
||||
|
||||
expect(connectMcpClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'x-ms-tenant-id': 'channel-tenant-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureActivityCallback', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
let credentials: MicrosoftAgent365Credentials;
|
||||
let mcpTokenRef: { token: string | undefined };
|
||||
let mockTurnContext: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
|
||||
credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
mcpTokenRef = { token: 'test-mcp-token' };
|
||||
|
||||
mockTurnContext = {
|
||||
activity: {
|
||||
text: 'Test message',
|
||||
recipient: {
|
||||
agenticAppId: 'agent-id',
|
||||
name: 'Test Agent',
|
||||
tenantId: 'tenant-id',
|
||||
},
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
test('should invoke agent with input text and system prompt', async () => {
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'You are a helpful assistant';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Agent response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'Test message',
|
||||
'You are a helpful assistant',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle empty input text', async () => {
|
||||
const contextWithEmptyText = {
|
||||
...mockTurnContext,
|
||||
activity: {
|
||||
...mockTurnContext.activity,
|
||||
text: '',
|
||||
},
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(contextWithEmptyText);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'',
|
||||
'Test prompt',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not use MCP tools when token is not available', async () => {
|
||||
const noTokenRef = { token: undefined };
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, noTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'Test message',
|
||||
'Test prompt',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should send agent response to turn context', async () => {
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test agent response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(mockTurnContext.sendActivity).toHaveBeenCalledWith('Test agent response');
|
||||
});
|
||||
|
||||
test('should use default values when recipient data is missing', async () => {
|
||||
const contextWithoutRecipient = {
|
||||
activity: {
|
||||
text: 'Test message',
|
||||
conversation: { id: 'conversation-id' },
|
||||
recipient: {},
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(contextWithoutRecipient as any);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('microsoftMcpServers', () => {
|
||||
test('should export correct server options', () => {
|
||||
expect(microsoftMcpServers).toEqual([
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
{ name: 'Me', value: 'mcp_MeServer' },
|
||||
{ name: 'OneDrive & SharePoint', value: 'mcp_ODSPRemoteServer' },
|
||||
{ name: 'SharePoint Lists', value: 'mcp_SharePointListsTools' },
|
||||
{ name: 'Teams', value: 'mcp_TeamsServer' },
|
||||
{ name: 'Teams Canary', value: 'mcp_TeamsCanaryServer' },
|
||||
{ name: 'Word', value: 'mcp_WordServer' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('should have correct number of server options', () => {
|
||||
expect(microsoftMcpServers).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractActivityInfo', () => {
|
||||
test('should extract all fields from a complete activity', () => {
|
||||
const activity = {
|
||||
id: 'activity-123',
|
||||
type: 'message',
|
||||
channelId: 'msteams',
|
||||
conversation: { id: 'conv-456' },
|
||||
from: { id: 'user-789', name: 'John Doe' },
|
||||
recipient: { id: 'bot-abc', name: 'Test Bot' },
|
||||
timestamp: new Date('2024-01-15T10:30:00Z'),
|
||||
locale: 'en-US',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'activity-123',
|
||||
type: 'message',
|
||||
channelId: 'msteams',
|
||||
conversationId: 'conv-456',
|
||||
from: { id: 'user-789', name: 'John Doe' },
|
||||
recipient: { id: 'bot-abc', name: 'Test Bot' },
|
||||
timestamp: '2024-01-15T10:30:00.000Z',
|
||||
locale: 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle activity with missing optional fields', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
type: 'message',
|
||||
channelId: undefined,
|
||||
conversationId: undefined,
|
||||
from: undefined,
|
||||
recipient: undefined,
|
||||
timestamp: undefined,
|
||||
locale: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle activity with string timestamp', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.timestamp).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
test('should handle activity with partial from/recipient data', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
from: { id: 'user-123' },
|
||||
recipient: { name: 'Bot Name' },
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.from).toEqual({ id: 'user-123', name: undefined });
|
||||
expect(result.recipient).toEqual({ id: undefined, name: 'Bot Name' });
|
||||
});
|
||||
|
||||
test('should handle activity with null conversation', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
conversation: null,
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should extract conversationId from conversation object', () => {
|
||||
const activity = {
|
||||
type: 'conversationUpdate',
|
||||
conversation: { id: 'conversation-id-123', name: 'Test Conversation' },
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.conversationId).toBe('conversation-id-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NodeOperationError, type IWebhookFunctions, assert, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getChatModel,
|
||||
getOptionalMemory,
|
||||
getTools,
|
||||
preparePrompt,
|
||||
} from '../../agents/Agent/agents/ToolsAgent/common';
|
||||
|
||||
import { SYSTEM_MESSAGE } from '../../agents/Agent/agents/ConversationalAgent/prompt';
|
||||
import { createAgentExecutor } from '../../agents/Agent/agents/ToolsAgent/V2/execute';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
|
||||
import { getOptionalOutputParser } from '../../../utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import type { ChatPromptTemplate, BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
|
||||
import { type N8nOutputParser } from '../../../utils/output_parsers/N8nOutputParser';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { ToolInputSchemaBase } from '@langchain/core/dist/tools/types';
|
||||
|
||||
export async function invokeAgent(
|
||||
nodeContext: IWebhookFunctions,
|
||||
input: string,
|
||||
systemMessage?: string,
|
||||
invokeOptions: RunnableConfig = {},
|
||||
microsoftMcpTools: Array<DynamicStructuredTool<ToolInputSchemaBase, any, any, any>> = [],
|
||||
): Promise<string> {
|
||||
const needsFallback = nodeContext.getNodeParameter('needsFallback', false) as boolean;
|
||||
const memory = await getOptionalMemory(nodeContext);
|
||||
const model = await getChatModel(nodeContext, 0);
|
||||
|
||||
assert(model, 'Please connect a model to the Chat Model input');
|
||||
|
||||
const fallbackModel = needsFallback ? await getChatModel(nodeContext, 1) : null;
|
||||
|
||||
if (needsFallback && !fallbackModel) {
|
||||
throw new NodeOperationError(
|
||||
nodeContext.getNode(),
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
}
|
||||
|
||||
const outputParser = await getOptionalOutputParser(nodeContext, 0);
|
||||
let tools = await getTools(nodeContext, outputParser);
|
||||
|
||||
if (microsoftMcpTools?.length) {
|
||||
tools = tools.concat(microsoftMcpTools);
|
||||
}
|
||||
|
||||
const options = nodeContext.getNodeParameter('options', {}) as {
|
||||
systemMessage?: string;
|
||||
maxIterations?: number;
|
||||
};
|
||||
|
||||
if (systemMessage) {
|
||||
options.systemMessage = systemMessage;
|
||||
}
|
||||
|
||||
if (options.maxIterations === undefined) {
|
||||
options.maxIterations = 10;
|
||||
}
|
||||
|
||||
const messages = await prepareMessages({
|
||||
systemMessage: options.systemMessage,
|
||||
outputParser,
|
||||
});
|
||||
const prompt: ChatPromptTemplate = preparePrompt(messages);
|
||||
|
||||
const executor = createAgentExecutor(
|
||||
model,
|
||||
tools,
|
||||
prompt,
|
||||
options,
|
||||
outputParser,
|
||||
memory,
|
||||
fallbackModel,
|
||||
);
|
||||
|
||||
const system_message = options.systemMessage ?? SYSTEM_MESSAGE;
|
||||
|
||||
const invokeParams = {
|
||||
input,
|
||||
system_message,
|
||||
formatting_instructions:
|
||||
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
|
||||
};
|
||||
|
||||
const result = await executor.invoke(invokeParams, invokeOptions);
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
const error = result.reason as Error;
|
||||
|
||||
throw new NodeOperationError(nodeContext.getNode(), error);
|
||||
}
|
||||
const response = result;
|
||||
|
||||
if (memory && outputParser) {
|
||||
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(response.output as string);
|
||||
response.output = parsedOutput?.output ?? parsedOutput;
|
||||
}
|
||||
|
||||
return response.output;
|
||||
}
|
||||
|
||||
async function prepareMessages(options: {
|
||||
systemMessage?: string;
|
||||
outputParser?: N8nOutputParser;
|
||||
}): Promise<BaseMessagePromptTemplateLike[]> {
|
||||
const messages: BaseMessagePromptTemplateLike[] = [];
|
||||
|
||||
if (options.systemMessage) {
|
||||
messages.push([
|
||||
'system',
|
||||
`{system_message}${options.outputParser ? '\n\n{formatting_instructions}' : ''}`,
|
||||
]);
|
||||
} else if (options.outputParser) {
|
||||
messages.push(['system', '{formatting_instructions}']);
|
||||
}
|
||||
|
||||
messages.push([
|
||||
'system',
|
||||
`{system_message}${options.outputParser ? '\n\n{formatting_instructions}' : ''}`,
|
||||
]);
|
||||
|
||||
messages.push(['placeholder', '{chat_history}'], ['human', '{input}']);
|
||||
|
||||
messages.push(['placeholder', '{agent_scratchpad}']);
|
||||
return messages;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
AuthConfiguration,
|
||||
DefaultConversationState,
|
||||
DefaultUserState,
|
||||
TurnContext,
|
||||
TurnState,
|
||||
} from '@microsoft/agents-hosting';
|
||||
|
||||
import { MemoryStorage, AgentApplication, CloudAdapter } from '@microsoft/agents-hosting';
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IWebhookFunctions,
|
||||
type INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
ExecutionType,
|
||||
type InvokeAgentDetails,
|
||||
InvokeAgentScope,
|
||||
type TenantDetails,
|
||||
BaggageBuilder,
|
||||
ObservabilityManager,
|
||||
type Builder,
|
||||
} from '@microsoft/agents-a365-observability';
|
||||
import {
|
||||
getMcpPlatformAuthenticationScope,
|
||||
getObservabilityAuthenticationScope,
|
||||
Utility as RuntimeUtility,
|
||||
} from '@microsoft/agents-a365-runtime';
|
||||
import { type Activity, ActivityTypes } from '@microsoft/agents-activity';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { invokeAgent } from './langchain-utils';
|
||||
|
||||
import { McpToolServerConfigurationService, Utility } from '@microsoft/agents-a365-tooling';
|
||||
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { connectMcpClient, getAllTools } from '../../mcp/shared/utils';
|
||||
import { createCallTool, mcpToolToDynamicTool } from '../../mcp/McpClientTool/utils';
|
||||
|
||||
export type MicrosoftAgent365Credentials = {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type ActivityInfo = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
channelId?: string;
|
||||
conversationId?: string;
|
||||
from?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
recipient?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
timestamp?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type ActivityCapture = {
|
||||
input: string;
|
||||
output: string[];
|
||||
activity: ActivityInfo;
|
||||
};
|
||||
|
||||
export function extractActivityInfo(activity: Activity): ActivityInfo {
|
||||
return {
|
||||
id: activity.id,
|
||||
type: activity.type,
|
||||
channelId: activity.channelId,
|
||||
conversationId: activity.conversation?.id,
|
||||
from: activity.from
|
||||
? {
|
||||
id: activity.from.id,
|
||||
name: activity.from.name,
|
||||
}
|
||||
: undefined,
|
||||
recipient: activity.recipient
|
||||
? {
|
||||
id: activity.recipient.id,
|
||||
name: activity.recipient.name,
|
||||
}
|
||||
: undefined,
|
||||
timestamp:
|
||||
activity.timestamp instanceof Date ? activity.timestamp.toISOString() : activity.timestamp,
|
||||
locale: activity.locale,
|
||||
};
|
||||
}
|
||||
|
||||
export const microsoftMcpServers: INodePropertyOptions[] = [
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
{ name: 'Me', value: 'mcp_MeServer' },
|
||||
{ name: 'OneDrive & SharePoint', value: 'mcp_ODSPRemoteServer' },
|
||||
{ name: 'SharePoint Lists', value: 'mcp_SharePointListsTools' },
|
||||
{ name: 'Teams', value: 'mcp_TeamsServer' },
|
||||
{ name: 'Teams Canary', value: 'mcp_TeamsCanaryServer' },
|
||||
{ name: 'Word', value: 'mcp_WordServer' },
|
||||
];
|
||||
|
||||
const MS_TENANT_ID_HEADER = 'x-ms-tenant-id';
|
||||
|
||||
function isMicrosoftObservabilityEnabled(): boolean {
|
||||
return (
|
||||
process.env.ENABLE_OBSERVABILITY === 'true' &&
|
||||
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
export function createMicrosoftAgentApplication(credentials: MicrosoftAgent365Credentials) {
|
||||
const authConfig: AuthConfiguration = createAuthConfig(credentials);
|
||||
|
||||
const adapter = new CloudAdapter(authConfig);
|
||||
const storage = new MemoryStorage();
|
||||
|
||||
const agent: AgentApplication<TurnState> = new AgentApplication<TurnState>({
|
||||
adapter,
|
||||
storage,
|
||||
authorization: {
|
||||
agentic: {
|
||||
type: 'agentic',
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function getMicrosoftMcpTools(
|
||||
turnContext: TurnContext,
|
||||
mcpAuthToken: string,
|
||||
selectedTools: string[] | undefined,
|
||||
) {
|
||||
const configService: McpToolServerConfigurationService = new McpToolServerConfigurationService();
|
||||
|
||||
Utility.ValidateAuthToken(mcpAuthToken);
|
||||
|
||||
const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, mcpAuthToken);
|
||||
let servers = await configService.listToolServers(agenticAppId, mcpAuthToken);
|
||||
|
||||
if (servers.length === 0) return undefined;
|
||||
|
||||
if (selectedTools?.length) {
|
||||
servers = servers.filter((server) => selectedTools.includes(server.mcpServerName));
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
turnContext.activity.recipient?.tenantId || turnContext.activity?.channelData?.tenant?.id;
|
||||
|
||||
const tools: DynamicStructuredTool[] = [];
|
||||
const clients: Client[] = [];
|
||||
const timeout = 60000;
|
||||
|
||||
for (const server of servers) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (mcpAuthToken) {
|
||||
headers['Authorization'] = `Bearer ${mcpAuthToken}`;
|
||||
}
|
||||
if (tenantId) {
|
||||
headers[MS_TENANT_ID_HEADER] = tenantId;
|
||||
}
|
||||
|
||||
const clientResult = await connectMcpClient({
|
||||
serverTransport: 'httpStreamable', // Microsoft servers use HTTP
|
||||
endpointUrl: server.url,
|
||||
headers,
|
||||
name: 'Microsoft-Agent-365',
|
||||
version: 1,
|
||||
});
|
||||
|
||||
if (!clientResult.ok) {
|
||||
console.error(`Failed to connect to MCP server ${server.mcpServerName}:`, clientResult.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = clientResult.result;
|
||||
clients.push(client);
|
||||
|
||||
const mcpTools = await getAllTools(client);
|
||||
|
||||
for (const tool of mcpTools) {
|
||||
const callToolFunc = createCallTool(tool.name, client, timeout, (errorMessage) => {
|
||||
console.error(`Tool "${tool.name}" execution error:`, errorMessage);
|
||||
});
|
||||
|
||||
const dynamicTool = mcpToolToDynamicTool(tool, callToolFunc);
|
||||
tools.push(dynamicTool);
|
||||
}
|
||||
}
|
||||
|
||||
if (tools.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
tools,
|
||||
client: {
|
||||
async close() {
|
||||
await Promise.all(clients.map(async (c) => await c.close()));
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const configureActivityCallback = (
|
||||
nodeContext: IWebhookFunctions,
|
||||
credentials: MicrosoftAgent365Credentials,
|
||||
mcpTokenRef: { token: string | undefined },
|
||||
) => {
|
||||
const systemPrompt = nodeContext.getNodeParameter('systemPrompt') as string;
|
||||
const { clientId, tenantId } = credentials;
|
||||
|
||||
return async (turnContext: TurnContext) => {
|
||||
const agentId = turnContext.activity.recipient?.agenticAppId ?? clientId;
|
||||
const agentName = turnContext.activity.recipient?.name ?? 'Microsoft Agent 365';
|
||||
const tenantDetails: TenantDetails = {
|
||||
tenantId: turnContext.activity.recipient?.tenantId ?? tenantId ?? '',
|
||||
};
|
||||
const conversationId = turnContext.activity.conversation?.id;
|
||||
const inputText = turnContext.activity.text || '';
|
||||
|
||||
const baggageScope = new BaggageBuilder()
|
||||
.tenantId(tenantDetails.tenantId)
|
||||
.agentId(agentId)
|
||||
.correlationId(uuid())
|
||||
.agentName(agentName)
|
||||
.conversationId(conversationId)
|
||||
.build();
|
||||
|
||||
await baggageScope.run(async () => {
|
||||
const invokeAgentDetails: InvokeAgentDetails = {
|
||||
agentId,
|
||||
agentName,
|
||||
conversationId,
|
||||
request: {
|
||||
content: inputText || 'Unknown text',
|
||||
executionType: ExecutionType.HumanToAgent,
|
||||
sessionId: conversationId,
|
||||
},
|
||||
};
|
||||
|
||||
const invokeAgentScope = InvokeAgentScope.start(invokeAgentDetails, tenantDetails);
|
||||
|
||||
await invokeAgentScope.withActiveSpanAsync(async () => {
|
||||
invokeAgentScope.recordInputMessages([inputText || 'Unknown text']);
|
||||
|
||||
let microsoftMcpTools = undefined;
|
||||
let mcpClient = undefined;
|
||||
if (mcpTokenRef.token) {
|
||||
try {
|
||||
const useMcpTools = nodeContext.getNodeParameter('useMcpTools', false) as boolean;
|
||||
|
||||
if (useMcpTools) {
|
||||
let selectedTools: string[] | undefined = undefined;
|
||||
const include = nodeContext.getNodeParameter('include', 'all') as 'all' | 'selected';
|
||||
|
||||
if (include === 'selected') {
|
||||
const selected = nodeContext.getNodeParameter('includeTools', []) as string[];
|
||||
selectedTools = microsoftMcpServers
|
||||
.filter((server) => selected.includes(server.value as string))
|
||||
.map((server) => server.value as string);
|
||||
}
|
||||
|
||||
const result = await getMicrosoftMcpTools(
|
||||
turnContext,
|
||||
mcpTokenRef.token,
|
||||
selectedTools,
|
||||
);
|
||||
|
||||
mcpClient = result?.client;
|
||||
microsoftMcpTools = result?.tools;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error retrieving MCP tools');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await invokeAgent(
|
||||
nodeContext,
|
||||
inputText,
|
||||
systemPrompt,
|
||||
{
|
||||
configurable: { thread_id: turnContext.activity.conversation!.id },
|
||||
},
|
||||
microsoftMcpTools,
|
||||
);
|
||||
|
||||
invokeAgentScope.recordOutputMessages([`n8n Agent Response: ${response}`]);
|
||||
|
||||
await turnContext.sendActivity(response);
|
||||
} finally {
|
||||
if (mcpClient) await mcpClient.close();
|
||||
invokeAgentScope.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function configureAdapterProcessCallback(
|
||||
nodeContext: IWebhookFunctions,
|
||||
agent: AgentApplication<TurnState<DefaultConversationState, DefaultUserState>>,
|
||||
credentials: MicrosoftAgent365Credentials,
|
||||
activityCapture: ActivityCapture,
|
||||
) {
|
||||
return async (turnContext: TurnContext) => {
|
||||
const { token: aauToken } = await agent.authorization.exchangeToken(
|
||||
turnContext,
|
||||
getObservabilityAuthenticationScope(),
|
||||
'agentic',
|
||||
);
|
||||
|
||||
let observability: ReturnType<typeof ObservabilityManager.configure> | undefined;
|
||||
|
||||
if (isMicrosoftObservabilityEnabled()) {
|
||||
observability = ObservabilityManager.configure((builder: Builder) =>
|
||||
builder
|
||||
.withService('TypeScript Sample Agent', '1.0.0')
|
||||
.withTokenResolver((_agentId: string, _tenantId: string) => aauToken || ''),
|
||||
);
|
||||
|
||||
observability.start();
|
||||
}
|
||||
|
||||
const mcpTokenRef = { token: undefined as string | undefined };
|
||||
|
||||
try {
|
||||
turnContext.turnState.set('AgenticAuthorization/agentic', undefined);
|
||||
const tokenResult = await agent.authorization.exchangeToken(turnContext, 'agentic', {
|
||||
scopes: [getMcpPlatformAuthenticationScope()],
|
||||
});
|
||||
mcpTokenRef.token = tokenResult.token;
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP token');
|
||||
}
|
||||
|
||||
try {
|
||||
const originalSendActivity = turnContext.sendActivity.bind(turnContext);
|
||||
activityCapture.input = turnContext.activity.text || '';
|
||||
activityCapture.activity = extractActivityInfo(turnContext.activity);
|
||||
|
||||
const sendActivityWrapper = async (activityOrText: string | Activity) => {
|
||||
if (typeof activityOrText === 'string') {
|
||||
activityCapture.output.push(activityOrText);
|
||||
} else if (activityOrText.text) {
|
||||
activityCapture.output.push(activityOrText.text);
|
||||
}
|
||||
return await originalSendActivity(activityOrText);
|
||||
};
|
||||
|
||||
turnContext.sendActivity = sendActivityWrapper;
|
||||
|
||||
const welcomeMessage = nodeContext.getNodeParameter(
|
||||
'options.welcomeMessage',
|
||||
"Hello! I'm here to help you!",
|
||||
) as string;
|
||||
|
||||
agent.onConversationUpdate('membersAdded', async (context) => {
|
||||
await context.sendActivity(welcomeMessage);
|
||||
});
|
||||
|
||||
const onActivity = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
agent.onActivity(ActivityTypes.Message, onActivity, ['agentic']);
|
||||
|
||||
await agent.run(turnContext);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(nodeContext.getNode(), error);
|
||||
} finally {
|
||||
if (observability) {
|
||||
await observability.shutdown();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createAuthConfig = (credentials: MicrosoftAgent365Credentials) => {
|
||||
const { clientId, tenantId, clientSecret } = credentials;
|
||||
const connections: Map<string, AuthConfiguration> = new Map();
|
||||
connections.set('serviceConnection', {
|
||||
clientId,
|
||||
clientSecret,
|
||||
tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: [
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${tenantId}/`,
|
||||
`https://login.microsoftonline.com/${tenantId}/v2.0`,
|
||||
],
|
||||
});
|
||||
|
||||
const config = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: [
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${tenantId}/`,
|
||||
`https://login.microsoftonline.com/${tenantId}/v2.0`,
|
||||
],
|
||||
connections,
|
||||
connectionsMap: [
|
||||
{
|
||||
connection: 'serviceConnection',
|
||||
serviceUrl: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
Reference in New Issue
Block a user