mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
fix(core): Connect Slack Agent channels through managed setup in MCP (#39776)
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fe1bfdfd29
commit
93b658c8db
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSlackManagerCredentialReady } from '../slack/manager-credential';
|
||||
|
||||
describe('isSlackManagerCredentialReady', () => {
|
||||
it.each([
|
||||
[{ connected: true, reconnectRequired: false }, true],
|
||||
[{ connected: true, reconnectRequired: true }, false],
|
||||
[{ connected: false, reconnectRequired: false }, false],
|
||||
])('returns the readiness of %o', (credential, expected) => {
|
||||
expect(isSlackManagerCredentialReady(credential)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './dto';
|
||||
export * from './manager-credential';
|
||||
export type * from './types';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SlackManagerCredentialSummary } from './types';
|
||||
|
||||
/** True when n8n can use this Slack manager credential to create and install Slack apps. */
|
||||
export function isSlackManagerCredentialReady(
|
||||
credential: Pick<SlackManagerCredentialSummary, 'connected' | 'reconnectRequired'>,
|
||||
): boolean {
|
||||
return credential.connected && !credential.reconnectRequired;
|
||||
}
|
||||
+128
@@ -127,6 +127,7 @@ describe('Slack setup services', () => {
|
||||
deleteManagedAppForCredential: (
|
||||
options: AgentIntegrationRemovalContext,
|
||||
) => ReturnType<SlackManagedSetupService['deleteAppForCredential']>;
|
||||
isAppConfiguredForAgent: (credentialId: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -216,6 +217,8 @@ describe('Slack setup services', () => {
|
||||
updateManagedAppSettings: async (options) => await managedService.updateAppSettings(options),
|
||||
deleteManagedAppForCredential: async (options) =>
|
||||
await managedService.deleteAppForCredential(options),
|
||||
isAppConfiguredForAgent: async (credentialId) =>
|
||||
await managedService.isAppConfiguredForAgent(credentialId, agent as never, user),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -422,6 +425,7 @@ describe('Slack setup services', () => {
|
||||
data: {
|
||||
accessToken: 'xoxb-installed-token',
|
||||
signatureSecret: 'signing-secret',
|
||||
agentId: 'agent-1',
|
||||
},
|
||||
projectId: 'project-1',
|
||||
},
|
||||
@@ -809,12 +813,16 @@ describe('Slack setup services', () => {
|
||||
managerCredentialId: 'manager',
|
||||
workspaceId: 'T123',
|
||||
user,
|
||||
modifiedBy: 'mcp',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'connected',
|
||||
appId: 'A123',
|
||||
credentialId: 'bot-credential',
|
||||
});
|
||||
expect(integrationManagementService.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modifiedBy: 'mcp' }),
|
||||
);
|
||||
|
||||
const manifestParams = fetchParams(requestMock, 0);
|
||||
const manifest = JSON.parse(manifestParams.get('manifest') ?? '') as {
|
||||
@@ -866,6 +874,7 @@ describe('Slack setup services', () => {
|
||||
data: expect.objectContaining({
|
||||
accessToken: 'xoxb-managed',
|
||||
signatureSecret: 'signing-secret',
|
||||
agentId: 'agent-1',
|
||||
managedAppId: 'A123',
|
||||
teamId: 'T123',
|
||||
managerCredentialId: 'manager',
|
||||
@@ -1126,6 +1135,7 @@ describe('Slack setup services', () => {
|
||||
data: {
|
||||
accessToken: 'xoxb-installed-token',
|
||||
signatureSecret: 'signing-secret',
|
||||
agentId: 'agent-1',
|
||||
managedAppId: 'A123',
|
||||
teamId: 'T456',
|
||||
managerCredentialId: 'manager',
|
||||
@@ -1548,4 +1558,122 @@ describe('Slack setup services', () => {
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential');
|
||||
});
|
||||
|
||||
describe('isAppConfiguredForAgent', () => {
|
||||
const botCredential = {
|
||||
id: 'bot-credential',
|
||||
name: 'Slack bot',
|
||||
type: 'slackApi',
|
||||
} as CredentialsEntity;
|
||||
const managerCredential = {
|
||||
id: 'manager',
|
||||
name: 'Slack manager',
|
||||
type: 'slackManagerOAuth2Api',
|
||||
} as CredentialsEntity;
|
||||
const legacyBotData = {
|
||||
accessToken: 'xoxb-token',
|
||||
managedAppId: 'A123',
|
||||
managerCredentialId: 'manager',
|
||||
};
|
||||
const agentRequestUrl =
|
||||
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack';
|
||||
|
||||
function mockManagerCredential() {
|
||||
credentialsOverwrites.usesManagedAuth.mockReturnValue(true);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
|
||||
{ id: 'manager', type: 'slackManagerOAuth2Api' },
|
||||
] as never);
|
||||
credentialsFinderService.findCredentialForUser
|
||||
.mockResolvedValueOnce(botCredential)
|
||||
.mockResolvedValueOnce(managerCredential);
|
||||
credentialsService.decrypt.mockResolvedValueOnce(legacyBotData).mockResolvedValueOnce({
|
||||
oauthTokenData: {
|
||||
authed_user: {
|
||||
access_token: 'xoxp-manager',
|
||||
scope: 'app_configurations:read app_configurations:write managed_apps:install',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function exportedManifest(requestUrl: string) {
|
||||
return slackResponse({
|
||||
ok: true,
|
||||
manifest: { settings: { event_subscriptions: { request_url: requestUrl } } },
|
||||
});
|
||||
}
|
||||
|
||||
it('returns true when n8n built the app for this Agent', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
accessToken: 'xoxb-token',
|
||||
agentId: 'agent-1',
|
||||
});
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(true);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when n8n built the app for another Agent', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
...legacyBotData,
|
||||
agentId: 'agent-deleted',
|
||||
});
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false for a credential that n8n did not build', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential);
|
||||
credentialsService.decrypt.mockResolvedValue({ accessToken: 'xoxb-token' });
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false for a missing credential or one of another type', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null);
|
||||
await expect(service.isAppConfiguredForAgent('missing')).resolves.toBe(false);
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValueOnce({
|
||||
...botCredential,
|
||||
type: 'telegramApi',
|
||||
} as CredentialsEntity);
|
||||
await expect(service.isAppConfiguredForAgent('other')).resolves.toBe(false);
|
||||
expect(credentialsService.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the request URL from Slack for a managed app without an Agent ID', async () => {
|
||||
mockManagerCredential();
|
||||
requestMock.mockResolvedValueOnce(exportedManifest(agentRequestUrl));
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(true);
|
||||
expect(requestMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
url: 'https://slack.com/api/apps.manifest.export',
|
||||
});
|
||||
expect(fetchParams(requestMock, 0).get('app_id')).toBe('A123');
|
||||
});
|
||||
|
||||
it('returns false when the managed app sends events to another Agent', async () => {
|
||||
mockManagerCredential();
|
||||
requestMock.mockResolvedValueOnce(
|
||||
exportedManifest(
|
||||
'https://hooks.example/rest/projects/project-1/agents/v2/agent-deleted/webhooks/slack',
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the manager credential cannot be used in this project', async () => {
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(botCredential);
|
||||
credentialsService.decrypt.mockResolvedValue(legacyBotData);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
|
||||
|
||||
await expect(service.isAppConfiguredForAgent('bot-credential')).resolves.toBe(false);
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('SlackMethodsService', () => {
|
||||
data: {
|
||||
accessToken: 'xoxb-token',
|
||||
signatureSecret: 'signing-secret',
|
||||
agentId: 'agent-1',
|
||||
},
|
||||
projectId: 'project-1',
|
||||
},
|
||||
|
||||
+55
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentActor,
|
||||
AgentIntegrationConfig,
|
||||
CreateSlackManagerCredentialResponse,
|
||||
InstallSlackManagedAppResponse,
|
||||
@@ -63,6 +64,7 @@ export interface GetManagedSetupStateOptions {
|
||||
export interface InstallManagedSlackAppOptions extends GetManagedSetupStateOptions {
|
||||
managerCredentialId: string;
|
||||
workspaceId: string;
|
||||
modifiedBy?: AgentActor;
|
||||
}
|
||||
|
||||
export interface FinalizeSlackManagerCredentialOptions extends GetManagedSetupStateOptions {
|
||||
@@ -364,6 +366,58 @@ export class SlackManagedSetupService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when n8n built the Slack app behind this bot credential for this
|
||||
* Agent, so the app sends its events to the Agent's request URL. A credential
|
||||
* that n8n built for another Agent, or a credential from any other Slack app,
|
||||
* returns false.
|
||||
*/
|
||||
async isAppConfiguredForAgent(credentialId: string, agent: Agent, user: User): Promise<boolean> {
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
credentialId,
|
||||
user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) return false;
|
||||
const data = await this.credentialsService.decrypt(credential, true);
|
||||
const agentId = stringProperty(data, 'agentId');
|
||||
if (agentId) return agentId === agent.id;
|
||||
return await this.managedAppSendsEventsTo(data, agent, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed credentials made before n8n recorded the Agent ID only know their
|
||||
* app, so read the app's request URL from Slack instead.
|
||||
*/
|
||||
private async managedAppSendsEventsTo(
|
||||
data: ICredentialDataDecryptedObject,
|
||||
agent: Agent,
|
||||
user: User,
|
||||
): Promise<boolean> {
|
||||
const managedAppId = stringProperty(data, 'managedAppId');
|
||||
const managerCredentialId = stringProperty(data, 'managerCredentialId');
|
||||
if (!managedAppId || !managerCredentialId) return false;
|
||||
try {
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
managerCredentialId,
|
||||
agent.projectId,
|
||||
user,
|
||||
);
|
||||
const manifest = await this.exportManagedAppManifest(manager, managedAppId);
|
||||
const settings = childRecord(manifest, 'settings');
|
||||
const eventSubscriptions = settings
|
||||
? childRecord(settings, 'event_subscriptions')
|
||||
: undefined;
|
||||
return (
|
||||
stringProperty(eventSubscriptions, 'request_url') ===
|
||||
this.methods.webhookUrl(agent.projectId, agent.id)
|
||||
);
|
||||
} catch {
|
||||
// The manager credential is gone or not usable here, so n8n cannot confirm the app.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAppForCredential(
|
||||
options: AgentIntegrationRemovalContext,
|
||||
): Promise<ManagedSlackAppDeletionWarning | undefined> {
|
||||
@@ -750,6 +804,7 @@ export class SlackManagedSetupService {
|
||||
options.user,
|
||||
botAccessToken,
|
||||
session,
|
||||
options.modifiedBy,
|
||||
);
|
||||
return { status: 'connected', appId: session.appId, credentialId };
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentActor,
|
||||
AgentIntegrationConfig,
|
||||
SlackAgentAppManifest,
|
||||
SlackApiErrorMeta,
|
||||
@@ -203,6 +204,7 @@ export class SlackMethodsService {
|
||||
user: User,
|
||||
accessToken: string,
|
||||
session: SlackAppSetupSession,
|
||||
modifiedBy?: AgentActor,
|
||||
): Promise<string> {
|
||||
const credentialData = {
|
||||
name: this.credentialName(session.teamName, agent.name),
|
||||
@@ -210,6 +212,7 @@ export class SlackMethodsService {
|
||||
data: {
|
||||
accessToken,
|
||||
signatureSecret: session.signingSecret,
|
||||
agentId: session.agentId,
|
||||
...(session.managerCredentialId
|
||||
? {
|
||||
managedAppId: session.appId,
|
||||
@@ -233,6 +236,7 @@ export class SlackMethodsService {
|
||||
agent,
|
||||
user,
|
||||
integration,
|
||||
...(modifiedBy ? { modifiedBy } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
await this.deleteUnreferencedCredential(agent.id, credential.id, user);
|
||||
@@ -294,7 +298,7 @@ export class SlackMethodsService {
|
||||
}
|
||||
}
|
||||
|
||||
private webhookUrl(projectId: string, agentId: string): string {
|
||||
webhookUrl(projectId: string, agentId: string): string {
|
||||
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic
|
||||
import { AgentsService } from '@/modules/agents/agents.service';
|
||||
import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service';
|
||||
import type { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service';
|
||||
import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service';
|
||||
import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service';
|
||||
import type { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository';
|
||||
import type { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
@@ -63,6 +65,7 @@ import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { AGENT_TOOLS, TOOLS_BY_SCOPE } from '../mcp-scopes';
|
||||
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
|
||||
import { McpAgentSlackSetup } from '../tools/agents/agent-slack-setup';
|
||||
import { McpAgentToolsService } from '../tools/agents/agent-tools.service';
|
||||
|
||||
const userHasScopesMock = userHasScopes as Mock;
|
||||
@@ -138,6 +141,15 @@ describe('McpAgentToolsService', () => {
|
||||
const outboundHttp = mockInstance(OutboundHttp);
|
||||
const urlService = mockInstance(UrlService);
|
||||
const projectScopeService = mockInstance(ProjectScopeService);
|
||||
const slackManagedSetup = mockInstance(SlackManagedSetupService);
|
||||
const slackManualSetup = mockInstance(SlackManualSetupService);
|
||||
const slackManifest = {
|
||||
settings: {
|
||||
event_subscriptions: {
|
||||
request_url: 'https://n8n.test/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const service = new McpAgentToolsService(
|
||||
telemetry,
|
||||
@@ -161,6 +173,7 @@ describe('McpAgentToolsService', () => {
|
||||
outboundHttp,
|
||||
urlService,
|
||||
projectScopeService,
|
||||
new McpAgentSlackSetup(slackManagedSetup, slackManualSetup, mockLogger()),
|
||||
);
|
||||
|
||||
let tools: Map<string, RegisteredTool>;
|
||||
@@ -173,6 +186,8 @@ describe('McpAgentToolsService', () => {
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([] as never);
|
||||
urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.test');
|
||||
projectScopeService.getProjectIds.mockResolvedValue(['project-1']);
|
||||
slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(false);
|
||||
slackManualSetup.getManifest.mockResolvedValue({ manifest: slackManifest } as never);
|
||||
|
||||
tools = new Map();
|
||||
registerResource = vi.fn();
|
||||
@@ -1628,6 +1643,22 @@ describe('McpAgentToolsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('tells the client to use managed setup for Slack in kind=integrations', async () => {
|
||||
integrationPersistenceService.listChatIntegrations.mockReturnValue([
|
||||
{ type: 'slack', label: 'Slack', icon: 'slack', credentialTypes: ['slackApi'] },
|
||||
{ type: 'linear', label: 'Linear', icon: 'linear', credentialTypes: ['linearApi'] },
|
||||
] as never);
|
||||
|
||||
const result = await callTool('discover_agent_assets', {
|
||||
projectId: 'project-1',
|
||||
kind: 'integrations',
|
||||
});
|
||||
|
||||
const [slack, linear] = result.structuredContent.data as Array<Record<string, unknown>>;
|
||||
expect(slack.setupGuidance).toEqual(expect.stringContaining('no credentialId'));
|
||||
expect(linear).not.toHaveProperty('setupGuidance');
|
||||
});
|
||||
|
||||
it('lists MCP registry servers for kind=mcpServers without a query', async () => {
|
||||
mcpRegistryService.list.mockResolvedValue([{ name: 'github' }] as never);
|
||||
|
||||
@@ -2046,6 +2077,441 @@ describe('McpAgentToolsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update_agent_integration Slack credential', () => {
|
||||
const input = {
|
||||
agentId: 'agent-1',
|
||||
action: 'connect',
|
||||
type: 'slack',
|
||||
credentialId: 'cred-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: 'v1' }));
|
||||
integrationManagementService.connect.mockResolvedValue({
|
||||
integration: { type: 'slack', credentialId: 'cred-1' },
|
||||
savedAgent: agentEntity({
|
||||
activeVersionId: 'v1',
|
||||
integrations: [{ type: 'slack', credentialId: 'cred-1' }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the request URL and a warning for a Slack app that n8n did not create', async () => {
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(slackManagedSetup.isAppConfiguredForAgent).toHaveBeenCalledWith(
|
||||
'cred-1',
|
||||
expect.objectContaining({ id: 'agent-1' }),
|
||||
user,
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
slackApp: {
|
||||
configuredForAgent: false,
|
||||
requestUrl: slackManifest.settings.event_subscriptions.request_url,
|
||||
manifest: slackManifest,
|
||||
},
|
||||
warning: expect.stringContaining('could not confirm'),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the app as unverified when the check fails after the connect is saved', async () => {
|
||||
slackManagedSetup.isAppConfiguredForAgent.mockRejectedValue(new Error('decrypt failed'));
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(integrationManagementService.connect).toHaveBeenCalled();
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
configured: true,
|
||||
slackApp: {
|
||||
configuredForAgent: false,
|
||||
requestUrl: slackManifest.settings.event_subscriptions.request_url,
|
||||
},
|
||||
warning: expect.stringContaining('could not confirm'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no warning for a Slack app that n8n created', async () => {
|
||||
slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(true);
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
slackApp: { configuredForAgent: true },
|
||||
});
|
||||
expect(result.structuredContent).not.toHaveProperty('warning');
|
||||
expect(slackManualSetup.getManifest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not describe a Slack app for other integration types', async () => {
|
||||
integrationManagementService.connect.mockResolvedValue({
|
||||
integration: { type: 'linear', credentialId: 'cred-1' },
|
||||
savedAgent: agentEntity({ activeVersionId: 'v1' }),
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', { ...input, type: 'linear' });
|
||||
|
||||
expect(result.structuredContent).not.toHaveProperty('slackApp');
|
||||
expect(slackManagedSetup.isAppConfiguredForAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update_agent_integration Slack managed setup', () => {
|
||||
const input = { agentId: 'agent-1', action: 'connect', type: 'slack' };
|
||||
|
||||
beforeEach(() => {
|
||||
// A fresh install stores this Agent's ID on the new bot credential.
|
||||
slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(true);
|
||||
});
|
||||
const managerCredential = {
|
||||
id: 'manager-1',
|
||||
name: 'Workspace credentials',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
workspaces: [{ id: 'T1', name: 'Acme', connected: false }],
|
||||
};
|
||||
|
||||
it('reports the setup steps when this instance cannot create Slack apps', async () => {
|
||||
slackManagedSetup.getSetupState.mockResolvedValue({
|
||||
managedSetupAvailable: false,
|
||||
managerCredentials: [],
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: false,
|
||||
code: 'slack_managed_setup_unavailable',
|
||||
configured: false,
|
||||
agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1',
|
||||
slackApp: { requestUrl: slackManifest.settings.event_subscriptions.request_url },
|
||||
});
|
||||
expect(integrationManagementService.connect).not.toHaveBeenCalled();
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('links to the Agent when no Slack workspace is connected', async () => {
|
||||
slackManagedSetup.getSetupState.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [],
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: false,
|
||||
code: 'slack_workspace_not_connected',
|
||||
agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1',
|
||||
});
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the workspace credentials to reconnect when none is ready to use', async () => {
|
||||
slackManagedSetup.getSetupState.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{ ...managerCredential, reconnectRequired: true },
|
||||
{ ...managerCredential, id: 'manager-2', connected: false },
|
||||
{ ...managerCredential, id: 'manager-3', workspaces: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: false,
|
||||
code: 'slack_manager_reconnect_required',
|
||||
agentUrl: 'https://n8n.test/projects/project-1/agents/agent-1',
|
||||
managerCredentials: [
|
||||
{ managerCredentialId: 'manager-1', name: 'Workspace credentials' },
|
||||
{ managerCredentialId: 'manager-2', name: 'Workspace credentials' },
|
||||
{ managerCredentialId: 'manager-3', name: 'Workspace credentials' },
|
||||
],
|
||||
});
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the workspaces to choose from without installing anything', async () => {
|
||||
slackManagedSetup.getSetupState.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
managerCredential,
|
||||
{ ...managerCredential, id: 'manager-2', reconnectRequired: true },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', input);
|
||||
|
||||
expect(slackManagedSetup.getSetupState).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
user,
|
||||
});
|
||||
expect(result.structuredContent).toEqual({
|
||||
ok: true,
|
||||
status: 'workspace_selection_required',
|
||||
agentId: 'agent-1',
|
||||
configured: false,
|
||||
managerCredentials: [
|
||||
{
|
||||
managerCredentialId: 'manager-1',
|
||||
name: 'Workspace credentials',
|
||||
workspaces: [{ workspaceId: 'T1', name: 'Acme', connected: false }],
|
||||
},
|
||||
],
|
||||
nextStep: expect.any(String),
|
||||
});
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
expect(integrationManagementService.connect).not.toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith(
|
||||
USER_CALLED_MCP_TOOL_EVENT,
|
||||
expect.objectContaining({
|
||||
tool_name: 'update_agent_integration',
|
||||
parameters: expect.objectContaining({ setup: 'managed_discovery' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('installs the Slack app in the chosen workspace and connects the channel', async () => {
|
||||
const saved = agentEntity({
|
||||
activeVersionId: 'v1',
|
||||
integrations: [{ type: 'slack', credentialId: 'bot-1' }],
|
||||
});
|
||||
agentsService.findByIdForUser
|
||||
.mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' }))
|
||||
.mockResolvedValueOnce(saved);
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A1',
|
||||
credentialId: 'bot-1',
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(userHasScopesMock).toHaveBeenCalledWith(user, ['credential:create'], false, {
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(slackManagedSetup.installApp).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
user,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
modifiedBy: 'mcp',
|
||||
});
|
||||
expect(result.structuredContent).toEqual({
|
||||
ok: true,
|
||||
agentId: 'agent-1',
|
||||
integration: { type: 'slack', credentialId: 'bot-1' },
|
||||
configured: true,
|
||||
connected: true,
|
||||
appId: 'A1',
|
||||
slackApp: { configuredForAgent: true },
|
||||
published: true,
|
||||
activeVersionId: 'v1',
|
||||
configHash: getAgentConfigHash({
|
||||
...baseConfig,
|
||||
integrations: [{ type: 'slack', credentialId: 'bot-1' }],
|
||||
}),
|
||||
});
|
||||
expect(integrationManagementService.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an install on an unpublished Agent as configured but not connected', async () => {
|
||||
agentsService.findByIdForUser.mockResolvedValue(
|
||||
agentEntity({ integrations: [{ type: 'slack', credentialId: 'bot-1' }] }),
|
||||
);
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A1',
|
||||
credentialId: 'bot-1',
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
configured: true,
|
||||
connected: false,
|
||||
published: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('still reports a saved install when MCP access is turned off meanwhile', async () => {
|
||||
agentsService.findByIdForUser
|
||||
.mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' }))
|
||||
.mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1', availableInMCP: false }));
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A1',
|
||||
credentialId: 'bot-1',
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
configured: true,
|
||||
published: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('still reports a saved install when the Agent is gone before the result is read', async () => {
|
||||
agentsService.findByIdForUser
|
||||
.mockResolvedValueOnce(agentEntity({ activeVersionId: 'v1' }))
|
||||
.mockResolvedValueOnce(null);
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A1',
|
||||
credentialId: 'bot-1',
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(result.structuredContent).toEqual({
|
||||
ok: true,
|
||||
agentId: 'agent-1',
|
||||
integration: { type: 'slack', credentialId: 'bot-1' },
|
||||
configured: true,
|
||||
appId: 'A1',
|
||||
slackApp: { configuredForAgent: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an existing bot credential whose Slack app belongs to another Agent', async () => {
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A0',
|
||||
credentialId: 'leftover-bot',
|
||||
});
|
||||
slackManagedSetup.isAppConfiguredForAgent.mockResolvedValue(false);
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(slackManagedSetup.isAppConfiguredForAgent).toHaveBeenCalledWith(
|
||||
'leftover-bot',
|
||||
expect.objectContaining({ id: 'agent-1' }),
|
||||
user,
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: false,
|
||||
code: 'slack_app_built_for_another_agent',
|
||||
configured: false,
|
||||
integration: { type: 'slack', credentialId: 'leftover-bot' },
|
||||
nextStep: expect.stringContaining('action=disconnect'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the install URL when Slack requires the user to approve the install', async () => {
|
||||
slackManagedSetup.installApp.mockResolvedValue({
|
||||
status: 'manual_install_required',
|
||||
appId: 'A1',
|
||||
installUrl: 'https://slack.com/oauth/v2/authorize?state=abc',
|
||||
});
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
ok: true,
|
||||
status: 'install_approval_required',
|
||||
configured: false,
|
||||
appId: 'A1',
|
||||
installUrl: 'https://slack.com/oauth/v2/authorize?state=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an install when the user cannot create credentials', async () => {
|
||||
userHasScopesMock.mockImplementation(
|
||||
async (_user: unknown, scopes: string[]) => !scopes.includes('credential:create'),
|
||||
);
|
||||
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
workspaceId: 'T1',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: 'You do not have permission to create credentials in this project.',
|
||||
});
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ managerCredentialId: 'manager-1' }, 'Pass managerCredentialId and workspaceId together'],
|
||||
[{ workspaceId: 'T1' }, 'Pass managerCredentialId and workspaceId together'],
|
||||
[
|
||||
{ managerCredentialId: 'manager-1', workspaceId: 'T1', credentialId: 'cred-1' },
|
||||
'Managed Slack setup creates its own credential',
|
||||
],
|
||||
[
|
||||
{ managerCredentialId: 'manager-1', workspaceId: 'T1', replacesCredentialId: 'cred-0' },
|
||||
'Managed Slack setup creates its own credential',
|
||||
],
|
||||
[
|
||||
{ managerCredentialId: 'manager-1', workspaceId: 'T1', settings: { a: 1 } },
|
||||
'Managed Slack setup creates its own credential',
|
||||
],
|
||||
[
|
||||
{ managerCredentialId: 'manager-1', workspaceId: 'T1', type: 'linear' },
|
||||
'managerCredentialId and workspaceId apply only to Slack',
|
||||
],
|
||||
])('rejects an invalid managed setup input %o', async (extra, message) => {
|
||||
const result = await callTool('update_agent_integration', { ...input, ...extra });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({ error: expect.stringContaining(message) });
|
||||
expect(slackManagedSetup.installApp).not.toHaveBeenCalled();
|
||||
expect(integrationManagementService.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a credential for other integration types', async () => {
|
||||
const result = await callTool('update_agent_integration', { ...input, type: 'linear' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: 'credentialId is required to connect this integration',
|
||||
});
|
||||
expect(slackManagedSetup.getSetupState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update_agent_integration disconnect', () => {
|
||||
const input = {
|
||||
projectId: 'project-1',
|
||||
@@ -2086,6 +2552,31 @@ describe('McpAgentToolsService', () => {
|
||||
expect(result.structuredContent).toMatchObject({ ok: true, connected: false });
|
||||
});
|
||||
|
||||
it('rejects managed setup inputs', async () => {
|
||||
const result = await callTool('update_agent_integration', {
|
||||
...input,
|
||||
managerCredentialId: 'manager-1',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: 'managerCredentialId and workspaceId apply only to connect',
|
||||
});
|
||||
expect(integrationManagementService.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a credential', async () => {
|
||||
const { credentialId: _credentialId, ...withoutCredential } = input;
|
||||
|
||||
const result = await callTool('update_agent_integration', withoutCredential);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
error: 'credentialId is required to disconnect',
|
||||
});
|
||||
expect(integrationManagementService.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a plain disconnect for an unknown integration type', async () => {
|
||||
agentsService.findByIdForUser.mockResolvedValue(agentEntity({ integrations: [] }));
|
||||
|
||||
|
||||
@@ -165,6 +165,34 @@ which validates the credential and persists the configuration without publishing
|
||||
channel stays inactive until publish_agent is called unless the Agent already has an active version;
|
||||
in that case, it connects immediately to the existing active snapshot.
|
||||
|
||||
### Slack
|
||||
|
||||
A Slack app sends events only to the request URL in its own configuration, so a Slack channel works
|
||||
only with a Slack app that points at this Agent. A slackApi credential made for another use, such as a
|
||||
Slack Trigger or another Agent, passes every credential check but the Agent receives nothing. Connect
|
||||
Slack in this order:
|
||||
|
||||
1. Call update_agent_integration with action=connect, type=slack, and no credentialId. Nothing changes.
|
||||
- status=workspace_selection_required lists managerCredentials with their workspaces. Ask the user
|
||||
which workspace to use and confirm that n8n may create a Slack app there.
|
||||
- code=slack_workspace_not_connected means no Slack workspace is connected to n8n yet. Give the
|
||||
user agentUrl and ask them to connect the workspace once through Add channel, then retry.
|
||||
- code=slack_manager_reconnect_required means the listed Slack workspace credentials must be
|
||||
reconnected. Give the user agentUrl and ask them to reconnect through Add channel, then retry.
|
||||
- code=slack_managed_setup_unavailable means this instance cannot create Slack apps. Relay
|
||||
slackApp.requestUrl and slackApp.manifest so the user can configure a Slack app, then connect
|
||||
with that app's slackApi credential.
|
||||
2. Call update_agent_integration again with the chosen managerCredentialId and workspaceId. n8n
|
||||
creates the Slack app, installs it, and connects the channel. If the result has
|
||||
status=install_approval_required, give the user installUrl; n8n connects the channel after they
|
||||
approve the install. Call get_agent to confirm. If the result has
|
||||
code=slack_app_built_for_another_agent, the Agent already uses a bot credential from another
|
||||
Slack app: disconnect that credential, then install again.
|
||||
|
||||
When you connect with an explicit slackApi credential, the result has slackApp.configuredForAgent. It
|
||||
is true only when n8n built the Slack app for this Agent. If it is false, relay the warning and
|
||||
slackApp.requestUrl to the user instead of reporting the channel as ready.
|
||||
|
||||
## MCP servers
|
||||
|
||||
The top-level \`mcpServers\` config array connects external MCP tool catalogs to the Agent. Discover
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isSlackManagerCredentialReady } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { SlackManagedSetupService } from '@/modules/agents/integrations/platforms/slack/slack-managed-setup.service';
|
||||
import { SlackManualSetupService } from '@/modules/agents/integrations/platforms/slack/slack-manual-setup.service';
|
||||
import type { Agent } from '@/modules/agents/entities/agent.entity';
|
||||
|
||||
export const SLACK_INTEGRATION_TYPE = 'slack';
|
||||
|
||||
const CONNECT_WORKSPACE_STEP =
|
||||
'Ask the user to open the Agent in n8n, choose Add channel, pick Slack, and connect their Slack workspace once. Then call update_agent_integration again without credentialId.';
|
||||
|
||||
const RECONNECT_WORKSPACE_STEP =
|
||||
'Ask the user to open the Agent in n8n, choose Add channel, pick Slack, and reconnect the listed Slack workspace credential. Then call update_agent_integration again without credentialId.';
|
||||
|
||||
const UNVERIFIED_APP_WARNING =
|
||||
'n8n could not confirm that it built the Slack app behind this credential for this Agent, so the Agent may receive no events. This happens for an app made for another use, such as a Slack Trigger, or for an app that n8n built for another Agent. Make sure the app sends Event Subscriptions and Interactivity to slackApp.requestUrl, with Socket Mode off and the bot events in slackApp.manifest. To let n8n build a correct app, disconnect this credential and call update_agent_integration without credentialId.';
|
||||
|
||||
/**
|
||||
* Slack setup steps for MCP clients.
|
||||
*
|
||||
* A Slack app sends events only to the request URL in its own configuration.
|
||||
* n8n sets that URL itself when it builds the app for an Agent. A bot
|
||||
* credential from any other Slack app, such as one made for a Slack Trigger
|
||||
* or for another Agent, passes every credential check but sends its events
|
||||
* elsewhere.
|
||||
*/
|
||||
@Service()
|
||||
export class McpAgentSlackSetup {
|
||||
constructor(
|
||||
private readonly managedSetup: SlackManagedSetupService,
|
||||
private readonly manualSetup: SlackManualSetupService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Report the next managed setup step without changing anything, so the
|
||||
* client must come back with an explicit workspace before n8n creates a
|
||||
* Slack app.
|
||||
*/
|
||||
async describeSetup(agent: Agent, user: User, agentUrl: string) {
|
||||
const state = await this.managedSetup.getSetupState({
|
||||
projectId: agent.projectId,
|
||||
agentId: agent.id,
|
||||
user,
|
||||
});
|
||||
if (!state.managedSetupAvailable) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'slack_managed_setup_unavailable',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
error:
|
||||
'This n8n instance cannot create Slack apps automatically. Connect Slack with a bot credential from a Slack app that sends its events to this Agent.',
|
||||
agentUrl,
|
||||
slackApp: await this.appRequirements(agent),
|
||||
nextStep:
|
||||
"Ask the user to create a Slack app from slackApp.manifest, or to set its Event Subscriptions and Interactivity request URLs to slackApp.requestUrl with Socket Mode off. Then call update_agent_integration with a slackApi credential that holds that app's Bot User OAuth Token and Signing Secret. The user can also do all of this from agentUrl with Add channel.",
|
||||
};
|
||||
}
|
||||
|
||||
const managerCredentials = state.managerCredentials
|
||||
.filter((manager) => isSlackManagerCredentialReady(manager) && manager.workspaces.length > 0)
|
||||
.map((manager) => ({
|
||||
managerCredentialId: manager.id,
|
||||
name: manager.name,
|
||||
workspaces: manager.workspaces.map((workspace) => ({
|
||||
workspaceId: workspace.id,
|
||||
name: workspace.name,
|
||||
connected: workspace.connected,
|
||||
...(workspace.botCredentialId ? { credentialId: workspace.botCredentialId } : {}),
|
||||
})),
|
||||
}));
|
||||
if (managerCredentials.length === 0 && state.managerCredentials.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'slack_manager_reconnect_required',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
error: 'The Slack workspace credentials in this project must be reconnected.',
|
||||
managerCredentials: state.managerCredentials.map((manager) => ({
|
||||
managerCredentialId: manager.id,
|
||||
name: manager.name,
|
||||
})),
|
||||
agentUrl,
|
||||
nextStep: RECONNECT_WORKSPACE_STEP,
|
||||
};
|
||||
}
|
||||
if (managerCredentials.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'slack_workspace_not_connected',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
error: 'No connected Slack workspace is available to this project.',
|
||||
agentUrl,
|
||||
nextStep: CONNECT_WORKSPACE_STEP,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 'workspace_selection_required',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
managerCredentials,
|
||||
nextStep:
|
||||
'Ask the user which workspace to install the Agent in and confirm that n8n may create a Slack app there. Then call update_agent_integration again with that managerCredentialId and workspaceId. A workspace with connected=true already has this Agent.',
|
||||
};
|
||||
}
|
||||
|
||||
async install(
|
||||
agent: Agent,
|
||||
user: User,
|
||||
target: { managerCredentialId: string; workspaceId: string },
|
||||
) {
|
||||
return await this.managedSetup.installApp({
|
||||
projectId: agent.projectId,
|
||||
agentId: agent.id,
|
||||
user,
|
||||
managerCredentialId: target.managerCredentialId,
|
||||
workspaceId: target.workspaceId,
|
||||
modifiedBy: 'mcp',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* n8n cannot read a Slack app's request URL with a bot token, so for a
|
||||
* credential that n8n did not build for this Agent, return what the app must
|
||||
* be set to.
|
||||
*/
|
||||
async describeBotCredential(agent: Agent, user: User, credentialId: string) {
|
||||
if (await this.isAppConfiguredForAgent(agent, user, credentialId)) {
|
||||
return { slackApp: { configuredForAgent: true } };
|
||||
}
|
||||
return {
|
||||
slackApp: { configuredForAgent: false, ...(await this.appRequirements(agent)) },
|
||||
warning: UNVERIFIED_APP_WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed check counts as unverified: the caller has already saved the
|
||||
* connect, or can only report the result.
|
||||
*/
|
||||
async isAppConfiguredForAgent(agent: Agent, user: User, credentialId: string) {
|
||||
try {
|
||||
return await this.managedSetup.isAppConfiguredForAgent(credentialId, agent, user);
|
||||
} catch (error) {
|
||||
this.logger.warn('[McpAgentSlackSetup] Could not check the Slack app of a credential', {
|
||||
agentId: agent.id,
|
||||
credentialId,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async appRequirements(agent: Agent) {
|
||||
const { manifest } = await this.manualSetup.getManifest({
|
||||
projectId: agent.projectId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
return { requestUrl: manifest.settings.event_subscriptions.request_url, manifest };
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { UrlService } from '@n8n/backend-services';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { createAiMcpFetch } from '@/utils/ai-proxy-fetch';
|
||||
|
||||
import { McpAgentSlackSetup, SLACK_INTEGRATION_TYPE } from './agent-slack-setup';
|
||||
import {
|
||||
AGENT_BUILDER_GUIDE,
|
||||
AGENT_BUILDER_REFERENCE,
|
||||
@@ -338,7 +339,27 @@ const updateIntegrationInput = {
|
||||
...agentIdentityShape,
|
||||
action: z.enum(['connect', 'disconnect']),
|
||||
type: z.string().min(1).describe('Integration type returned by discover_agent_assets'),
|
||||
credentialId: z.string().min(1).describe('Accessible credential for this integration'),
|
||||
credentialId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Accessible credential for this integration. Required for disconnect and for non-Slack connect operations. Omit it to connect Slack through managed setup',
|
||||
),
|
||||
managerCredentialId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Slack managed setup only: the managerCredentialId returned by a Slack connect call without credentialId',
|
||||
),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Slack managed setup only: the workspaceId returned by a Slack connect call without credentialId',
|
||||
),
|
||||
settings: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
@@ -382,6 +403,12 @@ type MutateAgentInput = z.infer<z.ZodObject<typeof mutateAgentInput>>;
|
||||
type DiscoverAssetsInput = z.infer<z.ZodObject<typeof discoverAssetsInput>>;
|
||||
type VerifyMcpServerInput = z.infer<z.ZodObject<typeof verifyMcpServerInput>>;
|
||||
type UpdateIntegrationInput = z.infer<z.ZodObject<typeof updateIntegrationInput>>;
|
||||
|
||||
/** Telemetry label for the connect flow that an update_agent_integration call takes. */
|
||||
function integrationSetupPath(input: UpdateIntegrationInput) {
|
||||
if (input.action === 'disconnect' || input.credentialId) return 'credential';
|
||||
return input.managerCredentialId || input.workspaceId ? 'managed_install' : 'managed_discovery';
|
||||
}
|
||||
type CallAgentInput = z.infer<z.ZodObject<typeof callAgentInput>>;
|
||||
|
||||
type MutationResource = {
|
||||
@@ -421,6 +448,7 @@ export class McpAgentToolsService {
|
||||
private readonly outboundHttp: OutboundHttp,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly projectScopeService: ProjectScopeService,
|
||||
private readonly slackSetup: McpAgentSlackSetup,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1025,7 +1053,7 @@ export class McpAgentToolsService {
|
||||
name: 'update_agent_integration',
|
||||
config: {
|
||||
description:
|
||||
"Configure or disconnect a Slack, Telegram, or Linear conversation integration. This is the only way to manage integrations; config.replace and config.patch can't change them. Configuration never publishes the Agent. If the Agent is already published, connecting starts the channel immediately. Otherwise, the channel stays inactive until publish_agent is called.",
|
||||
"Configure or disconnect a Slack, Telegram, or Linear conversation integration. This is the only way to manage integrations; config.replace and config.patch can't change them. Configuration never publishes the Agent. If the Agent is already published, connecting starts the channel immediately. Otherwise, the channel stays inactive until publish_agent is called. For Slack, connect without credentialId first: n8n returns the workspaces where it can create a Slack app for the Agent, or the steps the user must take in n8n. Then call again with managerCredentialId and workspaceId after the user confirms the workspace.",
|
||||
inputSchema: updateIntegrationInput,
|
||||
annotations: {
|
||||
title: 'Update Agent Integration',
|
||||
@@ -1043,6 +1071,7 @@ export class McpAgentToolsService {
|
||||
agentId: input.agentId,
|
||||
action: input.action,
|
||||
type: input.type,
|
||||
setup: integrationSetupPath(input),
|
||||
},
|
||||
async () => await this.updateIntegration(user, input),
|
||||
),
|
||||
@@ -1624,6 +1653,12 @@ export class McpAgentToolsService {
|
||||
return this.integrationPersistenceService.listChatIntegrations().map((integration) => ({
|
||||
...integration,
|
||||
settingsRequired: integration.type === 'telegram',
|
||||
...(integration.type === SLACK_INTEGRATION_TYPE
|
||||
? {
|
||||
setupGuidance:
|
||||
'Call update_agent_integration with action=connect and no credentialId. n8n returns the workspaces where it can create a Slack app for this Agent, or the steps the user must take in n8n. A slackApi credential from another Slack app, such as one made for a Slack Trigger, does not send events to the Agent.',
|
||||
}
|
||||
: {}),
|
||||
...(integration.type === 'telegram'
|
||||
? {
|
||||
settingsSchema: TELEGRAM_SETTINGS_JSON_SCHEMA,
|
||||
@@ -1697,17 +1732,22 @@ export class McpAgentToolsService {
|
||||
}
|
||||
|
||||
private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) {
|
||||
const { credentialId } = input;
|
||||
if (!credentialId) throw new UserError('credentialId is required to disconnect');
|
||||
if (input.managerCredentialId || input.workspaceId) {
|
||||
throw new UserError('managerCredentialId and workspaceId apply only to connect');
|
||||
}
|
||||
const { savedAgent: saved, warning } = await this.integrationManagementService.disconnect({
|
||||
agent,
|
||||
user,
|
||||
type: input.type,
|
||||
credentialId: input.credentialId,
|
||||
credentialId,
|
||||
modifiedBy: 'mcp',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
agentId: input.agentId,
|
||||
integration: { type: input.type, credentialId: input.credentialId },
|
||||
integration: { type: input.type, credentialId },
|
||||
connected: false,
|
||||
...(warning ? { warning } : {}),
|
||||
published: saved.activeVersionId !== null,
|
||||
@@ -1717,9 +1757,121 @@ export class McpAgentToolsService {
|
||||
}
|
||||
|
||||
private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) {
|
||||
const managedTarget = this.managedSlackTarget(input);
|
||||
if (managedTarget) return await this.installManagedSlackApp(user, agent, managedTarget);
|
||||
if (!input.credentialId) {
|
||||
if (input.type !== SLACK_INTEGRATION_TYPE) {
|
||||
throw new UserError('credentialId is required to connect this integration');
|
||||
}
|
||||
return await this.slackSetup.describeSetup(
|
||||
agent,
|
||||
user,
|
||||
this.getAgentUrl(agent.projectId, agent.id),
|
||||
);
|
||||
}
|
||||
|
||||
const connected = await this.connectWithCredential(user, input, agent, input.credentialId);
|
||||
if (input.type !== SLACK_INTEGRATION_TYPE) return connected;
|
||||
return {
|
||||
...connected,
|
||||
...(await this.slackSetup.describeBotCredential(agent, user, input.credentialId)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the managed setup target when the input names one, after it checks
|
||||
* that the input does not mix managed setup with a credential connect.
|
||||
*/
|
||||
private managedSlackTarget(input: UpdateIntegrationInput) {
|
||||
const { managerCredentialId, workspaceId } = input;
|
||||
if (!managerCredentialId && !workspaceId) return undefined;
|
||||
if (input.type !== SLACK_INTEGRATION_TYPE) {
|
||||
throw new UserError('managerCredentialId and workspaceId apply only to Slack');
|
||||
}
|
||||
if (!managerCredentialId || !workspaceId) {
|
||||
throw new UserError('Pass managerCredentialId and workspaceId together');
|
||||
}
|
||||
if (input.credentialId || input.replacesCredentialId || input.settings) {
|
||||
throw new UserError(
|
||||
'Managed Slack setup creates its own credential. Omit credentialId, replacesCredentialId, and settings',
|
||||
);
|
||||
}
|
||||
return { managerCredentialId, workspaceId };
|
||||
}
|
||||
|
||||
private async installManagedSlackApp(
|
||||
user: User,
|
||||
agent: Agent,
|
||||
target: { managerCredentialId: string; workspaceId: string },
|
||||
) {
|
||||
// Managed setup stores the new bot token as a credential in the project.
|
||||
if (
|
||||
!(await userHasScopes(user, ['credential:create'], false, { projectId: agent.projectId }))
|
||||
) {
|
||||
throw new ForbiddenError('You do not have permission to create credentials in this project.');
|
||||
}
|
||||
|
||||
const result = await this.slackSetup.install(agent, user, target);
|
||||
if (result.status === 'manual_install_required') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'install_approval_required',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
appId: result.appId,
|
||||
installUrl: result.installUrl,
|
||||
nextStep:
|
||||
'Give installUrl to the user. After they approve the install in Slack, n8n connects the channel. Call get_agent to confirm the Slack integration.',
|
||||
};
|
||||
}
|
||||
|
||||
// installApp returns a bot credential that this Agent already has instead of
|
||||
// building a new app. That credential can come from another Agent's app.
|
||||
if (!(await this.slackSetup.isAppConfiguredForAgent(agent, user, result.credentialId))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'slack_app_built_for_another_agent',
|
||||
agentId: agent.id,
|
||||
configured: false,
|
||||
integration: { type: SLACK_INTEGRATION_TYPE, credentialId: result.credentialId },
|
||||
error:
|
||||
'This Agent already uses a Slack bot credential that n8n did not build for this Agent.',
|
||||
nextStep:
|
||||
'Disconnect integration.credentialId from this Agent with update_agent_integration action=disconnect. Then call the install again, and n8n builds a new Slack app for this Agent.',
|
||||
};
|
||||
}
|
||||
|
||||
const installed = {
|
||||
ok: true,
|
||||
agentId: agent.id,
|
||||
integration: { type: SLACK_INTEGRATION_TYPE, credentialId: result.credentialId },
|
||||
configured: true,
|
||||
appId: result.appId,
|
||||
slackApp: { configuredForAgent: true },
|
||||
};
|
||||
// The install is already saved, so skip the MCP access guard of resolveAgent:
|
||||
// a concurrent change to that setting must not turn this result into an error.
|
||||
const saved = await this.agentsService.findByIdForUser(agent.id, user);
|
||||
if (!saved) return installed;
|
||||
const published = saved.activeVersionId !== null;
|
||||
return {
|
||||
...installed,
|
||||
connected: published,
|
||||
published,
|
||||
activeVersionId: saved.activeVersionId,
|
||||
configHash: getAgentConfigHash(this.configFromEntity(saved)),
|
||||
};
|
||||
}
|
||||
|
||||
private async connectWithCredential(
|
||||
user: User,
|
||||
input: UpdateIntegrationInput,
|
||||
agent: Agent,
|
||||
credentialId: string,
|
||||
) {
|
||||
const candidate = {
|
||||
type: input.type,
|
||||
credentialId: input.credentialId,
|
||||
credentialId,
|
||||
...(input.settings ? { settings: input.settings } : {}),
|
||||
};
|
||||
const { savedAgent: saved } = await this.integrationManagementService.connect({
|
||||
@@ -1734,7 +1886,7 @@ export class McpAgentToolsService {
|
||||
const result = {
|
||||
ok: true,
|
||||
agentId: input.agentId,
|
||||
integration: { type: input.type, credentialId: input.credentialId },
|
||||
integration: { type: input.type, credentialId },
|
||||
configured: true,
|
||||
published: saved.activeVersionId !== null,
|
||||
activeVersionId: saved.activeVersionId,
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SlackManagedSetupState } from '@n8n/api-types';
|
||||
import { isSlackManagerCredentialReady, type SlackManagedSetupState } from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nIconButton,
|
||||
@@ -66,7 +66,7 @@ const credentialOptions = computed<CredentialOption[]>(() =>
|
||||
})),
|
||||
);
|
||||
const managerConnected = computed(
|
||||
() => selectedCredential.value?.connected === true && !selectedCredential.value.reconnectRequired,
|
||||
() => !!selectedCredential.value && isSlackManagerCredentialReady(selectedCredential.value),
|
||||
);
|
||||
const workspaces = computed(() => selectedCredential.value?.workspaces ?? []);
|
||||
const hasManagerCredentials = computed(() => props.setup.managerCredentials.length > 0);
|
||||
|
||||
@@ -50,6 +50,13 @@ export class SlackApi implements ICredentialType {
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
// Set when n8n builds the Slack app for an Agent: the app sends its events to that Agent only
|
||||
displayName: 'Agent ID',
|
||||
name: 'agentId',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'We strongly recommend setting up a <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#verify-the-webhook" target="_blank">signing secret</a> to ensure the authenticity of requests.',
|
||||
|
||||
Reference in New Issue
Block a user