Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
5c6f5db
feat(editor): Connect workflows from MCP settings page
MiloradFilipovic Dec 9, 2025
af66e29
✨ Fetch eligible workflows in the modal
MiloradFilipovic Dec 9, 2025
e204832
⚡ Only list workflows that don't have access already
MiloradFilipovic Dec 9, 2025
565e46b
✨ Extract workflows search select
MiloradFilipovic Dec 9, 2025
a6b771e
⚡ Fix back-end query
MiloradFilipovic Dec 9, 2025
12966fc
⚡ Toggle workflow MCP access when saved
MiloradFilipovic Dec 9, 2025
c93f4d3
✨ Implement search
MiloradFilipovic Dec 9, 2025
ee47870
✨ Allow specifying scope when listing workflows
MiloradFilipovic Dec 9, 2025
39fe9d2
🔨 Extract workflow location and re-use it in table and select
MiloradFilipovic Dec 9, 2025
7b21a1c
👌 Update notice, reduce loading timeout
MiloradFilipovic Dec 10, 2025
dfc12f9
👌 Adjust loading state style
MiloradFilipovic Dec 10, 2025
9a119d6
👌 Fix responsiveness of workflow table
MiloradFilipovic Dec 10, 2025
551614b
📈 Add telemetry
MiloradFilipovic Dec 10, 2025
f9d184d
⚡ Handle modal dismiss on ESC or click outside
MiloradFilipovic Dec 10, 2025
07e2287
👌 Update notice copy
MiloradFilipovic Dec 10, 2025
6fd22a7
⚡ Handle errors, add missing props
MiloradFilipovic Dec 10, 2025
20e5092
👕 Update ref type
MiloradFilipovic Dec 10, 2025
b54da23
⚡ Handle empty state better
MiloradFilipovic Dec 10, 2025
383a9b3
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 11, 2025
355ba17
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 11, 2025
ed0ea29
🔥 Remove freaking build log
MiloradFilipovic Dec 11, 2025
a8c9c10
⏪ Revert temporary backend filtering logic
MiloradFilipovic Dec 11, 2025
3981c1b
⚡ Update middleware to work with mcp endpoints
MiloradFilipovic Dec 11, 2025
becde45
✨ Update trigger filter to support arrays
MiloradFilipovic Dec 11, 2025
f0174d9
👌 Update allowedTags in the notice
MiloradFilipovic Dec 11, 2025
f4edaae
✅ Update `mcp.settings.controller` tests
MiloradFilipovic Dec 11, 2025
7641da9
✔️ Add `workflow.service` tests for scope parameter
MiloradFilipovic Dec 11, 2025
fcfd5e0
✅ Update workflows table tests
MiloradFilipovic Dec 11, 2025
efa03fa
✅ Add more tests for the main mcp settings page
MiloradFilipovic Dec 11, 2025
d4d36a9
🔥 Remove unused emit
MiloradFilipovic Dec 11, 2025
044cf4a
✅ Add tests for `WorkflowLocation` component
MiloradFilipovic Dec 11, 2025
baf1f42
✔️ Fix tests
MiloradFilipovic Dec 11, 2025
b003370
✅ Add workflow select tests
MiloradFilipovic Dec 11, 2025
84b246c
🔨 Extracting reusable methods to test utils
MiloradFilipovic Dec 11, 2025
7452720
🔨 Extract more methods
MiloradFilipovic Dec 11, 2025
dc554c1
✅ Adding tests for the connection modal
MiloradFilipovic Dec 11, 2025
f19b8fc
✔️ Update notice tests
MiloradFilipovic Dec 11, 2025
e4eb6f3
🔥 Remove dead css class
MiloradFilipovic Dec 11, 2025
f37164b
👌 Update timeout handling in workflows select
MiloradFilipovic Dec 11, 2025
82986c8
👌 Update modal title
MiloradFilipovic Dec 11, 2025
c3d4125
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 11, 2025
aa26661
👕 Fix async function
MiloradFilipovic Dec 11, 2025
fe6964f
⚡ Wait for workflow select to be ready before focusing
MiloradFilipovic Dec 11, 2025
cec1e99
👕 Fix async method
MiloradFilipovic Dec 11, 2025
29e7dc2
⚡ Count `null` as `false` in mcp access filter
MiloradFilipovic Dec 11, 2025
3f65398
👌 Use `prefix` slot for search icon
MiloradFilipovic Dec 11, 2025
4207453
👌 Remove workflows table empty state description
MiloradFilipovic Dec 11, 2025
1359753
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 11, 2025
dce37e6
✔️ Update workflows table tests
MiloradFilipovic Dec 11, 2025
36114d9
Merge remote-tracking branch 'origin/ADO-4437-connect-workflows-from-…
MiloradFilipovic Dec 11, 2025
be78239
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
59c5b3a
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
f78bce3
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
f5f68bb
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
b148ec7
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
902eac9
📈 Added simple logging for MCP requests
MiloradFilipovic Dec 12, 2025
a493283
Merge remote-tracking branch 'origin/ADO-4437-connect-workflows-from-…
MiloradFilipovic Dec 12, 2025
d7075d9
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 12, 2025
791ba6a
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 15, 2025
c88867d
💄 Make description link inline
MiloradFilipovic Dec 15, 2025
71f1edc
Merge branch 'master' into ADO-4437-connect-workflows-from-mcp-page
MiloradFilipovic Dec 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,11 @@ describe('WorkflowRepository', () => {
});
});

describe('applyTriggerNodeTypeFilter', () => {
it('should left join activeVersion with addSelect and use COALESCE for PostgreSQL', async () => {
describe('applyTriggerNodeTypesFilter', () => {
it('should left join activeVersion with addSelect and use COALESCE for PostgreSQL with single trigger type', async () => {
const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeType: 'n8n-nodes-base.executeWorkflowTrigger' },
filter: { triggerNodeTypes: ['n8n-nodes-base.executeWorkflowTrigger'] },
};

await workflowRepository.getMany(workflowIds, options);
Expand All @@ -324,8 +324,35 @@ describe('WorkflowRepository', () => {
// Should use COALESCE to check activeVersion.nodes first, falling back to workflow.nodes
// PostgreSQL uses quoted identifiers
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType',
{ triggerNodeType: '%n8n-nodes-base.executeWorkflowTrigger%' },
'(COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType0)',
{ triggerNodeType0: '%n8n-nodes-base.executeWorkflowTrigger%' },
);
});

it('should filter by multiple trigger types with OR condition for PostgreSQL', async () => {
const workflowIds = ['workflow1'];
const options = {
filter: {
triggerNodeTypes: [
'n8n-nodes-base.executeWorkflowTrigger',
'n8n-nodes-base.webhook',
'n8n-nodes-base.scheduleTrigger',
],
},
};

await workflowRepository.getMany(workflowIds, options);

expect(queryBuilder.leftJoin).toHaveBeenCalledWith('workflow.activeVersion', 'activeVersion');
expect(queryBuilder.addSelect).toHaveBeenCalledWith('activeVersion.versionId');
// Should use OR conditions for multiple trigger types
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'(COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType0 OR COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType1 OR COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType2)',
{
triggerNodeType0: '%n8n-nodes-base.executeWorkflowTrigger%',
triggerNodeType1: '%n8n-nodes-base.webhook%',
triggerNodeType2: '%n8n-nodes-base.scheduleTrigger%',
},
);
});

Expand All @@ -343,7 +370,7 @@ describe('WorkflowRepository', () => {

const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeType: 'n8n-nodes-base.errorTrigger' },
filter: { triggerNodeTypes: ['n8n-nodes-base.errorTrigger'] },
};

await sqliteWorkflowRepository.getMany(workflowIds, options);
Expand All @@ -352,8 +379,38 @@ describe('WorkflowRepository', () => {
expect(queryBuilder.addSelect).toHaveBeenCalledWith('activeVersion.versionId');
// Should use COALESCE to check activeVersion.nodes first, falling back to workflow.nodes
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType',
{ triggerNodeType: '%n8n-nodes-base.errorTrigger%' },
'(COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType0)',
{ triggerNodeType0: '%n8n-nodes-base.errorTrigger%' },
);
});

it('should filter by multiple trigger types with OR condition for SQLite', async () => {
const sqliteConfig = mockInstance(GlobalConfig, {
database: { type: 'sqlite' },
});
const sqliteWorkflowRepository = new WorkflowRepository(
entityManager.connection,
sqliteConfig,
folderRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);

const workflowIds = ['workflow1'];
const options = {
filter: {
triggerNodeTypes: ['n8n-nodes-base.webhook', 'n8n-nodes-base.scheduleTrigger'],
},
};

await sqliteWorkflowRepository.getMany(workflowIds, options);

expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'(COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType0 OR COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType1)',
{
triggerNodeType0: '%n8n-nodes-base.webhook%',
triggerNodeType1: '%n8n-nodes-base.scheduleTrigger%',
},
);
});

Expand All @@ -371,7 +428,7 @@ describe('WorkflowRepository', () => {

const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeType: 'n8n-nodes-base.executeWorkflowTrigger' },
filter: { triggerNodeTypes: ['n8n-nodes-base.executeWorkflowTrigger'] },
};

await mysqlWorkflowRepository.getMany(workflowIds, options);
Expand All @@ -380,8 +437,8 @@ describe('WorkflowRepository', () => {
expect(queryBuilder.addSelect).toHaveBeenCalledWith('activeVersion.versionId');
// Should use COALESCE to check activeVersion.nodes first, falling back to workflow.nodes
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType',
{ triggerNodeType: '%n8n-nodes-base.executeWorkflowTrigger%' },
'(COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType0)',
{ triggerNodeType0: '%n8n-nodes-base.executeWorkflowTrigger%' },
);
});

Expand All @@ -396,7 +453,7 @@ describe('WorkflowRepository', () => {

const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeType: 'n8n-nodes-base.executeWorkflowTrigger' },
filter: { triggerNodeTypes: ['n8n-nodes-base.executeWorkflowTrigger'] },
};

await workflowRepository.getMany(workflowIds, options);
Expand All @@ -409,12 +466,12 @@ describe('WorkflowRepository', () => {

// But the filter should still be applied (with quoted identifiers for PostgreSQL)
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType',
{ triggerNodeType: '%n8n-nodes-base.executeWorkflowTrigger%' },
'(COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType0)',
{ triggerNodeType0: '%n8n-nodes-base.executeWorkflowTrigger%' },
);
});

it('should not apply filter when triggerNodeType is not provided', async () => {
it('should not apply filter when triggerNodeTypes is not provided', async () => {
const workflowIds = ['workflow1'];
const options = {
filter: { query: 'test' },
Expand All @@ -428,10 +485,24 @@ describe('WorkflowRepository', () => {
expect(triggerFilterCalls).toHaveLength(0);
});

it('should not apply filter when triggerNodeType is undefined', async () => {
it('should not apply filter when triggerNodeTypes is undefined', async () => {
const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeTypes: undefined },
};

await workflowRepository.getMany(workflowIds, options);

const triggerFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
call[0]?.includes?.('triggerNodeType'),
);
expect(triggerFilterCalls).toHaveLength(0);
});

it('should not apply filter when triggerNodeTypes is empty array', async () => {
const workflowIds = ['workflow1'];
const options = {
filter: { triggerNodeType: undefined },
filter: { triggerNodeTypes: [] },
};

await workflowRepository.getMany(workflowIds, options);
Expand All @@ -447,14 +518,14 @@ describe('WorkflowRepository', () => {
const options = {
filter: {
query: 'workflow',
triggerNodeType: 'n8n-nodes-base.executeWorkflowTrigger',
triggerNodeTypes: ['n8n-nodes-base.executeWorkflowTrigger'],
active: true,
},
};

await workflowRepository.getMany(workflowIds, options);

// Should have called andWhere for both name and triggerNodeType filters
// Should have called andWhere for both name and triggerNodeTypes filters
const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) =>
call[0]?.includes?.('workflow.name'),
);
Expand Down
89 changes: 60 additions & 29 deletions packages/@n8n/db/src/repositories/workflow.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
const qb = this.createBaseQuery(workflowIds);

this.applyFilters(qb, options.filter);
this.applyTriggerNodeTypeFilter(qb, options.filter?.triggerNodeType as string | undefined);
this.applyTriggerNodeTypesFilter(qb, options.filter?.triggerNodeTypes as string[] | undefined);
this.applySelect(qb, options.select);
this.applyRelations(qb, options.select);
this.applySorting(qb, options.sortBy);
Expand Down Expand Up @@ -484,50 +484,81 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
if (typeof filter?.availableInMCP === 'boolean') {
const dbType = this.globalConfig.database.type;

if (['postgresdb'].includes(dbType)) {
qb.andWhere("workflow.settings ->> 'availableInMCP' = :availableInMCP", {
availableInMCP: filter.availableInMCP.toString(),
});
} else if (['mysqldb', 'mariadb'].includes(dbType)) {
qb.andWhere("JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", {
availableInMCP: filter.availableInMCP,
});
} else if (dbType === 'sqlite') {
qb.andWhere("JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", {
availableInMCP: filter.availableInMCP ? 1 : 0, // SQLite stores booleans as 0/1
});
if (filter.availableInMCP) {
// When filtering for true, only match explicit true values
if (['postgresdb'].includes(dbType)) {
qb.andWhere("workflow.settings ->> 'availableInMCP' = :availableInMCP", {
availableInMCP: 'true',
});
} else if (['mysqldb', 'mariadb'].includes(dbType)) {
qb.andWhere("JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", {
availableInMCP: true,
});
} else if (dbType === 'sqlite') {
qb.andWhere("JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP", {
availableInMCP: 1, // SQLite stores booleans as 0/1
});
}
} else {
// When filtering for false, match explicit false OR null/undefined (field not set)
if (['postgresdb'].includes(dbType)) {
qb.andWhere(
"(workflow.settings ->> 'availableInMCP' = :availableInMCP OR workflow.settings ->> 'availableInMCP' IS NULL)",
{ availableInMCP: 'false' },
);
} else if (['mysqldb', 'mariadb'].includes(dbType)) {
qb.andWhere(
"(JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP OR JSON_EXTRACT(workflow.settings, '$.availableInMCP') IS NULL)",
{ availableInMCP: false },
);
} else if (dbType === 'sqlite') {
qb.andWhere(
"(JSON_EXTRACT(workflow.settings, '$.availableInMCP') = :availableInMCP OR JSON_EXTRACT(workflow.settings, '$.availableInMCP') IS NULL)",
{ availableInMCP: 0 }, // SQLite stores booleans as 0/1
);
}
}
}
}

private applyTriggerNodeTypeFilter(
private applyTriggerNodeTypesFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
triggerNodeType?: string,
triggerNodeTypes?: string[],
): void {
if (triggerNodeType) {
const dbType = this.globalConfig.database.type;
if (!triggerNodeTypes || triggerNodeTypes.length === 0) {
return;
}

// Left join the activeVersion relation if not already joined
// We must also addSelect to ensure TypeORM includes the join when using raw SQL in andWhere
if (!qb.expressionMap.aliases.find((alias) => alias.name === 'activeVersion')) {
qb.leftJoin('workflow.activeVersion', 'activeVersion').addSelect('activeVersion.versionId');
}
const dbType = this.globalConfig.database.type;

// Left join the activeVersion relation if not already joined
// We must also addSelect to ensure TypeORM includes the join when using raw SQL in andWhere
if (!qb.expressionMap.aliases.find((alias) => alias.name === 'activeVersion')) {
qb.leftJoin('workflow.activeVersion', 'activeVersion').addSelect('activeVersion.versionId');
}

// Build OR conditions for each trigger node type
const conditions: string[] = [];
const parameters: Record<string, string> = {};

triggerNodeTypes.forEach((triggerNodeType, index) => {
const paramName = `triggerNodeType${index}`;
parameters[paramName] = `%${triggerNodeType}%`;

// Use COALESCE to check activeVersion.nodes if exists (workflow is active),
// otherwise fall back to workflow.nodes (draft workflows)
// In PostgreSQL, cast JSON column to text for LIKE operator
if (['postgresdb'].includes(dbType)) {
qb.andWhere(
'COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :triggerNodeType',
{ triggerNodeType: `%${triggerNodeType}%` },
conditions.push(
`COALESCE("activeVersion"."nodes"::text, "workflow"."nodes"::text) LIKE :${paramName}`,
);
} else {
// SQLite and MySQL store nodes as text
qb.andWhere('COALESCE(activeVersion.nodes, workflow.nodes) LIKE :triggerNodeType', {
triggerNodeType: `%${triggerNodeType}%`,
});
conditions.push(`COALESCE(activeVersion.nodes, workflow.nodes) LIKE :${paramName}`);
}
}
});

qb.andWhere(`(${conditions.join(' OR ')})`, parameters);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ export class WorkflowFilter extends BaseFilter {
@Expose()
nodeTypes?: string[];

@IsString()
@IsArray()
@IsString({ each: true })
@IsOptional()
@Expose()
triggerNodeType?: string;
triggerNodeTypes?: string[];

static async fromString(rawFilter: string) {
return await this.toFilter(rawFilter, WorkflowFilter);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/middlewares/list-query/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const filterListQueryMiddleware = async (

let Filter;

if (req.baseUrl.endsWith('workflows')) {
if (req.baseUrl.endsWith('workflows') || req.path.endsWith('workflows')) {
Filter = WorkflowFilter;
} else if (req.baseUrl.endsWith('credentials')) {
Filter = CredentialsFilter;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/middlewares/list-query/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const selectListQueryMiddleware: RequestHandler = (req: ListQuery.Request

let Select;

if (req.baseUrl.endsWith('workflows')) {
if (req.baseUrl.endsWith('workflows') || req.path.endsWith('workflows')) {
Select = WorkflowSelect;
} else if (req.baseUrl.endsWith('credentials')) {
Select = CredentialsSelect;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/middlewares/list-query/sort-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const sortByQueryMiddleware: RequestHandler = (req: ListQuery.Request, re
let SortBy;

try {
if (req.baseUrl.endsWith('workflows')) {
if (req.baseUrl.endsWith('workflows') || req.path.endsWith('workflows')) {
SortBy = WorkflowSorting;
} else {
return next();
Expand Down
Loading
Loading