feat(core): Give MCP clients the opening instance context (no-changelog) (#38292)

This commit is contained in:
Arvin A 2026-09-17 13:16:10 +00:00 committed by GitHub
parent cd623fd6ca
commit ba16e4f08d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1438 additions and 121 deletions

View File

@ -207,8 +207,10 @@ export class EndpointsConfig {
mcpAppsEnabled: boolean = false;
/**
* Force-enable the instance-context read surface on the MCP server: the
* activity tools and the node-usage tool.
* Force-enable the instance-context read surface on the MCP server. That is four tools —
* `get_instance_context`, `get_instance_activity`, `expand_instance_activity` and
* `get_node_usage` — plus the `n8n://instance/context` resource and the one sentence in the
* server instructions that points a client at them.
*
* Acts as an operator-level override of the PostHog rollout flag. Cannot
* force-disable: setting this to `false` falls back to PostHog.

View File

@ -52,6 +52,7 @@ import {
SharedWorkflow,
WorkflowEntity,
} from '../entities';
import type { ActivityProjectScope } from './activity-event.repository';
import { BaseRepository } from './base-repository';
import { SharedWorkflowRepository } from './shared-workflow.repository';
import type {
@ -775,7 +776,7 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
* outcome, and is the row a reader most needs.
*/
async summariseRunsForProjects(query: {
projectIds: string[];
projectIds: ActivityProjectScope;
/**
* Start of the window, inclusive. Half-open with `stoppedBefore` — `[after, before)` — so
* consecutive windows tile the timeline with neither a gap nor an overlap: a caller passing
@ -787,6 +788,12 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
stoppedBefore: Date;
/** How many workflows may contribute, so schedules cannot crowd out everything else. */
workflowLimit: number;
/**
* Count only workflows this instance exposes to MCP. Pushed into the query rather than
* applied to the result, because these are aggregates: dropping rows afterwards would leave
* "ran 43 times" standing for runs the caller may not see.
*/
mcpVisibleOnly?: boolean;
}): Promise<
Array<{
workflowId: string;
@ -797,14 +804,14 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
lastFailedExecutionId: string | null;
}>
> {
if (query.projectIds.length === 0) return [];
if (query.projectIds !== 'all-projects' && query.projectIds.length === 0) return [];
if (!Number.isInteger(query.workflowLimit) || query.workflowLimit <= 0) return [];
// `crashed` and `error` are the failure half of `CompletedExecutionStatus`. `canceled` is
// somebody stopping a run on purpose, which is not a fault to report.
const failureStatuses: ExecutionStatus[] = ['error', 'crashed'];
const rows = await this.createQueryBuilder('execution')
const qb = this.createQueryBuilder('execution')
.select('execution.workflowId', 'workflowId')
.addSelect('MAX(workflow.name)', 'workflowName')
// A workflow can be shared into several projects, so the join multiplies rows when more
@ -822,16 +829,30 @@ export class ExecutionRepository extends BaseRepository<ExecutionEntity> {
'lastFailedExecutionId',
)
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = execution.workflowId')
.innerJoin(SharedWorkflow, 'sw', 'sw.workflowId = workflow.id')
.where('sw.projectId IN (:...projectIds)', { projectIds: query.projectIds })
.andWhere('execution.deletedAt IS NULL')
.where('execution.deletedAt IS NULL')
// An evaluation suite is machine-paced and would bury everything a person did. The
// activity feed used to keep eval runs in their own category for the same reason.
.andWhere('execution.mode != :evaluationMode', { evaluationMode: 'evaluation' })
.andWhere('execution.stoppedAt IS NOT NULL')
.andWhere('execution.stoppedAt >= :stoppedAfter', { stoppedAfter: query.stoppedAfter })
.andWhere('execution.stoppedAt < :stoppedBefore', { stoppedBefore: query.stoppedBefore })
.setParameter('failureStatuses', failureStatuses)
.setParameter('failureStatuses', failureStatuses);
// A whole-instance reader needs no project predicate, and skipping the join also removes the
// row multiplication a workflow shared into several projects would otherwise cause.
if (query.projectIds !== 'all-projects') {
qb.innerJoin(SharedWorkflow, 'sw', 'sw.workflowId = workflow.id').andWhere(
'sw.projectId IN (:...projectIds)',
{ projectIds: query.projectIds },
);
}
if (query.mcpVisibleOnly) {
qb.andWhere('workflow.isArchived = :notArchived', { notArchived: false });
applyWorkflowBooleanSettingFilter(qb, this.globalConfig, 'availableInMCP', true);
}
const rows = await qb
.groupBy('execution.workflowId')
.orderBy('MAX(execution.stoppedAt)', 'DESC')
.limit(query.workflowLimit)

View File

@ -15,6 +15,7 @@ import type {
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { PROJECT_ROOT, UnexpectedError, UserError } from 'n8n-workflow';
import type { ActivityProjectScope } from './activity-event.repository';
import { BaseRepository } from './base-repository';
import { FolderRepository } from './folder.repository';
import { SharedWorkflowRepository } from './shared-workflow.repository';
@ -115,19 +116,39 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
* being exactly the work somebody might want picked up.
*/
async findRecentForProjects(
projectIds: string[],
projectIds: ActivityProjectScope,
limit: number,
options: { mcpVisibleOnly?: boolean } = {},
): Promise<{ total: number; workflows: Array<{ id: string; name: string; active: boolean }> }> {
if (projectIds.length === 0) return { total: 0, workflows: [] };
if (projectIds !== 'all-projects' && projectIds.length === 0) {
return { total: 0, workflows: [] };
}
if (!Number.isInteger(limit) || limit <= 0) return { total: 0, workflows: [] };
// A workflow can be shared into several projects, so the join multiplies rows when more than
// one of them is in scope. Both the count and the page are made distinct on the workflow.
const base = () =>
this.createQueryBuilder('workflow')
.innerJoin(SharedWorkflow, 'shared', 'shared.workflowId = workflow.id')
.where('shared.projectId IN (:...projectIds)', { projectIds })
.andWhere('workflow.isArchived = :archived', { archived: false });
// A whole-instance reader needs no project predicate, so it skips the join altogether.
const base = () => {
const qb = this.createQueryBuilder('workflow').where('workflow.isArchived = :archived', {
archived: false,
});
if (projectIds !== 'all-projects') {
qb.innerJoin(SharedWorkflow, 'shared', 'shared.workflowId = workflow.id').andWhere(
'shared.projectId IN (:...projectIds)',
{ projectIds },
);
}
// Pushed into the query rather than applied to the rows, because the caller reads a
// count as well as a page: filtering after the aggregate would report a total that
// includes workflows the caller may not see.
if (options.mcpVisibleOnly) {
applyWorkflowBooleanSettingFilter(qb, this.globalConfig, 'availableInMCP', true);
}
return qb;
};
const totalRow = await base()
.select('COUNT(DISTINCT workflow.id)', 'total')

View File

@ -43,6 +43,7 @@ const MCP_BOUND: InstanceContextScope = {
surface: 'mcp',
projectId: PROJECT_ID,
credentialGranted: true,
executionGranted: true,
};
function entry(overrides: Partial<ActivityEvent> = {}): ActivityEvent {
@ -114,7 +115,9 @@ describe('InstanceContextService', () => {
it('builds nothing with the flag off, and reads nothing either', async () => {
const service = serviceWith(false);
expect(await service.buildBlock({ user: USER, cursor: null, now: NOW })).toBeNull();
expect(
await service.buildBlock({ user: USER, scope: BOUND, cursor: null, now: NOW }),
).toBeNull();
expect(activityEventRepository.findFeed).not.toHaveBeenCalled();
expect(executionRepository.summariseRunsForProjects).not.toHaveBeenCalled();
});
@ -128,7 +131,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
isMachineFollowUp: true,
now: NOW,
@ -158,7 +161,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -183,7 +186,17 @@ describe('InstanceContextService', () => {
it('builds nothing, and reads nothing, when the conversation is bound to no project', async () => {
const service = serviceWith();
expect(await service.buildBlock({ user: USER, cursor: null, now: NOW })).toBeNull();
// A conversation with no project, which is the point of this case — not `BOUND`.
const unboundConversation: InstanceContextScope = { surface: 'conversation' };
expect(
await service.buildBlock({
user: USER,
scope: unboundConversation,
cursor: null,
now: NOW,
}),
).toBeNull();
expect(activityEventRepository.findFeed).not.toHaveBeenCalled();
expect(executionRepository.summariseRunsForProjects).not.toHaveBeenCalled();
expect(workflowRepository.findRecentForProjects).not.toHaveBeenCalled();
@ -195,7 +208,7 @@ describe('InstanceContextService', () => {
expect(
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
}),
@ -212,7 +225,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -231,7 +244,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -248,7 +261,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -265,7 +278,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -281,7 +294,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -298,7 +311,7 @@ describe('InstanceContextService', () => {
expect(
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
}),
@ -314,7 +327,7 @@ describe('InstanceContextService', () => {
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -325,7 +338,9 @@ describe('InstanceContextService', () => {
expect(executionRepository.summariseRunsForProjects).toHaveBeenCalledWith(
expect.objectContaining({ projectIds: [PROJECT_ID] }),
);
expect(workflowRepository.findRecentForProjects).toHaveBeenCalledWith([PROJECT_ID], 8);
expect(workflowRepository.findRecentForProjects).toHaveBeenCalledWith([PROJECT_ID], 8, {
mcpVisibleOnly: false,
});
});
describe('deltas', () => {
@ -341,7 +356,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
});
@ -367,7 +382,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
});
@ -402,7 +417,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
});
@ -418,7 +433,7 @@ describe('InstanceContextService', () => {
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
});
@ -438,7 +453,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
});
@ -454,7 +469,7 @@ describe('InstanceContextService', () => {
expect(
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor,
now: NOW,
}),
@ -469,7 +484,7 @@ describe('InstanceContextService', () => {
expect(
await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
}),
@ -493,7 +508,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -512,7 +527,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -531,7 +546,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -548,7 +563,7 @@ describe('InstanceContextService', () => {
});
const built = await service.buildBlock({
user: USER,
projectId: PROJECT_ID,
scope: BOUND,
cursor: null,
now: NOW,
});
@ -622,6 +637,7 @@ describe('InstanceContextService', () => {
const unbound = (credentialGranted = true): InstanceContextScope => ({
surface: 'mcp',
credentialGranted,
executionGranted: true,
});
it('reads every project the caller can see, plus their personal one', async () => {
@ -661,10 +677,12 @@ describe('InstanceContextService', () => {
it('reads the whole instance without enumerating projects for a global reader', async () => {
const service = serviceWith();
await service.list({ user: GLOBAL_READER, scope: unbound(), limit: 5 });
await service.list({ user: GLOBAL_READER, scope: unbound(false), limit: 5 });
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
expect(projectRepository.getPersonalProjectForUser).not.toHaveBeenCalled();
// Never enumerated for workflows — that is the branch this exists to avoid.
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalledWith(GLOBAL_READER, [
'workflow:read',
]);
expect(activityEventRepository.findFeed).toHaveBeenLastCalledWith(
expect.objectContaining({ projectIds: 'all-projects' }),
);
@ -703,6 +721,87 @@ describe('InstanceContextService', () => {
expect(entries.map((e) => e.id)).toEqual([2]);
});
describe('the opening block', () => {
it('filters the aggregate legs inside the query, not after it', async () => {
const service = serviceWith();
workflowRepository.findRecentForProjects.mockResolvedValue({
total: 1,
workflows: [{ id: 'wf-1', name: 'Lead enrichment', active: false }],
});
await service.buildBlock({ user: USER, scope: MCP_BOUND, cursor: null, now: NOW });
// A count filtered after the fact would report workflows the caller cannot see.
expect(workflowRepository.findRecentForProjects).toHaveBeenCalledWith(
[PROJECT_ID],
expect.any(Number),
{ mcpVisibleOnly: true },
);
expect(executionRepository.summariseRunsForProjects).toHaveBeenCalledWith(
expect.objectContaining({ mcpVisibleOnly: true }),
);
});
/** An MCP client is handed the text directly and has nothing to strip a tag out of. */
it('carries no instance-context tags and names MCP tools, not Instance AI ones', async () => {
const service = serviceWith();
workflowRepository.findRecentForProjects.mockResolvedValue({
total: 3,
workflows: [{ id: 'wf-1', name: 'Lead enrichment', active: false }],
});
const built = await service.buildBlock({
user: USER,
scope: MCP_BOUND,
cursor: null,
now: NOW,
});
expect(built?.block).not.toContain('<instance-context>');
expect(built?.block).toContain('get_instance_activity');
expect(built?.block).toContain('search_workflows');
expect(built?.block).not.toContain('activity(action=');
expect(built?.block).not.toContain('workflows(action=');
});
it('still tags the block and names Instance AI tools on a conversation', async () => {
const service = serviceWith();
workflowRepository.findRecentForProjects.mockResolvedValue({
total: 3,
workflows: [{ id: 'wf-1', name: 'Lead enrichment', active: false }],
});
const built = await service.buildBlock({
user: USER,
scope: BOUND,
cursor: null,
now: NOW,
});
expect(built?.block).toContain('<instance-context>');
expect(built?.block).toContain('activity(action="list")');
expect(built?.block).not.toContain('get_instance_activity');
});
/** The MCP surface answers to its own flag, checked where its tools are registered. */
it('builds over MCP even with the Instance AI read flag off', async () => {
const service = serviceWith(false);
workflowRepository.findRecentForProjects.mockResolvedValue({
total: 1,
workflows: [{ id: 'wf-1', name: 'Lead enrichment', active: false }],
});
const built = await service.buildBlock({
user: USER,
scope: MCP_BOUND,
cursor: null,
now: NOW,
});
expect(built?.block).toBeTruthy();
});
});
/**
* The case the cursor exists for: the read filled, every row was withheld, so the page shows
* nothing and still has to be pageable. A cursor drawn from the visible rows would be absent
@ -726,6 +825,69 @@ describe('InstanceContextService', () => {
expect(page.nextBeforeId).toBe(13);
});
/**
* The over-fetch can hold visible rows below the ones shown, so resuming from the lowest
* row *read* steps over them. Ids 20, 19, 18, 17 with a limit of 2 must resume at 19.
*/
it('resumes at the last row shown, not the lowest row read', async () => {
const service = serviceWith();
activityEventRepository.findFeed.mockResolvedValue(
[20, 19, 18, 17].map((id) => entry({ id, resourceId: 'wf-1' })),
);
workflowRepository.findMcpAvailabilityByIds.mockResolvedValue(new Map([['wf-1', true]]));
const page = await service.listPage({ user: USER, scope: MCP_BOUND, limit: 2 });
expect(page.entries.map((e) => e.id)).toEqual([20, 19]);
expect(page.hasMore).toBe(true);
// 17 would lose 18.
expect(page.nextBeforeId).toBe(19);
});
/** A resource narrowed to a withheld workflow must answer as a pruned one does. */
it('answers a withheld resource filter exactly as it answers an unknown one', async () => {
const service = serviceWith();
activityEventRepository.findFeed.mockResolvedValue(
Array.from({ length: 8 }, (_, i) => entry({ id: 40 - i, resourceId: 'wf-hidden' })),
);
workflowRepository.findMcpAvailabilityByIds.mockResolvedValue(
new Map([['wf-hidden', false]]),
);
const withheld = await service.listPage({
user: USER,
scope: MCP_BOUND,
limit: 1,
resourceId: 'wf-hidden',
});
activityEventRepository.findFeed.mockResolvedValue([]);
const unknown = await service.listPage({
user: USER,
scope: MCP_BOUND,
limit: 1,
resourceId: 'wf-never-existed',
});
expect(withheld).toEqual(unknown);
});
/** Reading every workflow does not imply reading every credential, nor none of them. */
it('keeps project-level credential access for a global workflow reader', async () => {
const service = serviceWith();
projectService.getProjectIdsWithScope.mockImplementation(async (_user, scopes) =>
scopes.includes('credential:read') ? ['team-a'] : [],
);
activityEventRepository.findFeed.mockResolvedValue([
entry({ id: 2, category: 'credential', resourceType: 'credential', projectId: 'team-a' }),
entry({ id: 1, category: 'credential', resourceType: 'credential', projectId: 'team-b' }),
]);
const entries = await service.list({ user: GLOBAL_READER, scope: unbound(true), limit: 5 });
expect(entries.map((e) => e.id)).toEqual([2]);
});
it('reports no more below when the read did not fill', async () => {
const service = serviceWith();
activityEventRepository.findFeed.mockResolvedValue([entry({ id: 3 })]);
@ -827,6 +989,17 @@ describe('InstanceContextService', () => {
expect(entries.map((e) => e.id)).toEqual([3]);
});
it('still shows them on the conversation surface, which has no MCP visibility rule', async () => {
const service = serviceWith();
activityEventRepository.findFeed.mockResolvedValue([
entry({ id: 3, action: 'deleted', resourceId: 'wf-gone' }),
]);
const entries = await service.list({ user: USER, scope: BOUND, limit: 5 });
expect(entries.map((e) => e.id)).toEqual([3]);
});
it('answers a withheld workflow the same way it answers a pruned id', async () => {
const service = serviceWith();
activityEventRepository.findEntry.mockResolvedValue(entry({ id: 7, resourceId: 'wf-1' }));

View File

@ -4063,7 +4063,10 @@ export class InstanceAiService {
// block would be paid for unread.
const instanceContext = await this.instanceContext.buildBlock({
user,
...(context.projectId !== undefined ? { projectId: context.projectId } : {}),
scope: {
surface: 'conversation',
...(context.projectId !== undefined ? { projectId: context.projectId } : {}),
},
cursor: readInstanceContextCursor(thread?.metadata),
isMachineFollowUp:
checkpoint?.isCheckpointFollowUp === true ||

View File

@ -51,6 +51,17 @@ const withheldFetchMultiplier = 4;
/** Older than this is history, not context — and the agent has tools for history. */
const maxAgeMs = 7 * Time.days.toMilliseconds;
/**
* How far back the run leg looks on the MCP surface.
*
* Shorter than `maxAgeMs` on purpose. Instance AI pays the full window once per thread and every
* later turn passes a cursor, so its window shrinks to the gap between turns. An MCP read always
* passes `cursor: null` — the server is stateless — so it would pay seven days of executions on
* every call, and a whole-instance reader has no project predicate to narrow it either. The block
* names at most `runWorkflowCap` workflows, so a shorter window costs almost nothing in content.
*/
const mcpRunWindowMs = 24 * Time.hours.toMilliseconds;
/**
* Distinct workflows whose runs may appear. Runs are already folded per workflow, so this caps
* breadth, not repetition: without it a busy instance's schedules crowd out every edit the user
@ -117,7 +128,19 @@ export type InstanceContextScope =
* of the credential gate — the other half is the caller's actual `credential:read` on the
* projects being read, which this service resolves rather than trusts.
*/
| { surface: 'mcp'; projectId?: string; credentialGranted: boolean };
| {
surface: 'mcp';
projectId?: string;
credentialGranted: boolean;
/**
* Whether the caller's token covers reading executions. The run leg ships run counts,
* failure counts and the id of the last failure, and every other execution read on the
* MCP server sits behind `execution:read` — so a grant that cannot fetch an execution
* is not handed one. Same shape as `credentialGranted`: a content gate, because the
* tool itself rides on `workflow:read` for the legs that are not execution data.
*/
executionGranted: boolean;
};
/**
* A scope after the caller's access has actually been resolved. Separate from the requested scope
@ -130,6 +153,8 @@ type ResolvedScope =
projectIds: ActivityProjectScope;
/** Of those, the ones whose credential entries the caller may read. */
credentialProjectIds: ActivityProjectScope;
/** Whether the run leg may be read at all. */
runsVisible: boolean;
};
export type ActivityPage = {
@ -235,7 +260,11 @@ export class InstanceContextService {
*/
async buildBlock(input: {
user: User;
projectId?: string;
/**
* Instance AI passes its thread's own project; an MCP client passes the MCP scope, which
* resolves the caller's own access instead. Both legs below are filtered accordingly.
*/
scope: InstanceContextScope;
cursor: InstanceContextCursor | null;
/**
* The agent continuing its own task — a checkpoint or a planned build — rather than a
@ -245,31 +274,38 @@ export class InstanceContextService {
isMachineFollowUp?: boolean;
now?: Date;
}): Promise<InstanceContextBlock | null> {
if (!this.enabled || input.isMachineFollowUp) return null;
// The flag gates Instance AI's own block. The MCP surface has its own flag, checked where
// its tools are registered, so it does not answer to this one.
if (input.scope.surface === 'conversation' && !this.enabled) return null;
if (input.isMachineFollowUp) return null;
try {
const now = input.now ?? new Date();
const isUpdate = input.cursor !== null;
// Conversation-bound: the per-turn block is Instance AI's, and its run and inventory legs
// have no MCP-visibility filter yet. The MCP surface reads through `list` and `expand`.
const resolved = await this.resolveScope(input.user, {
surface: 'conversation',
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
});
const resolved = await this.resolveScope(input.user, input.scope);
// Every leg is project-scoped, and a run has no acting user, so project is the only
// boundary available. Nothing in scope means nothing to show, never something wider.
// A conversation always resolves to a list, never the whole instance.
if (resolved?.surface !== 'conversation' || resolved.projectIds.length === 0) return null;
if (resolved === null) return null;
const projectIds = resolved.projectIds;
// Withheld and archived workflows are excluded inside each query, not after it. The
// inventory and run legs are aggregates, so filtering their rows afterwards would leave
// a total counting workflows the caller cannot see.
const mcpVisibleOnly = resolved.surface === 'mcp';
const [entries, runs, inventory] = await Promise.all([
this.readEntries({ projectIds, cursor: input.cursor, now }),
this.readRuns({ projectIds, cursor: input.cursor, now }),
this.readEntries({ projectIds, cursor: input.cursor, now, scope: resolved }),
resolved.surface === 'mcp' && !resolved.runsVisible
? Promise.resolve([])
: this.readRuns({ projectIds, cursor: input.cursor, now, mcpVisibleOnly }),
// Only on the opening block. A delta skips it: the estate has not changed in a way
// the earlier block failed to cover.
isUpdate
? Promise.resolve(undefined)
: this.workflowRepository.findRecentForProjects(projectIds, inventorySize),
: this.workflowRepository.findRecentForProjects(projectIds, inventorySize, {
mcpVisibleOnly,
}),
]);
// An instance can hold plenty of work and have had nothing happen to it lately — a fresh
@ -279,6 +315,7 @@ export class InstanceContextService {
return {
block: renderBlock({
surface: resolved.surface,
entries: entries.rows.map((row) => toFeedEntry(row, input.user.id, now)),
entriesTruncated: entries.truncated,
runs,
@ -293,8 +330,13 @@ export class InstanceContextService {
},
};
} catch (error) {
// Context is an enhancement; failing to build it must not fail the user's turn.
this.logger.warn('Failed to build the instance-context block', { error });
// A chat turn has no error channel, and the block is an enhancement, so a failed read
// must not fail the turn. A tool call does have one, and an MCP caller is told to read
// an empty answer as "nothing exists here yet" — so returning null on failure would
// send it off to rebuild work that is already there.
if (input.scope.surface === 'mcp') throw error;
return null;
}
}
@ -360,6 +402,14 @@ export class InstanceContextService {
const visible = filtersWithheld ? await this.withoutWithheldWorkflows(rows, resolved) : rows;
// A read narrowed to one resource answers about that resource, so a withheld one has to
// answer exactly as a pruned one does. Reporting `hasMore` off rows that were then
// withheld would say "this resource has history you cannot see", which is the probe the
// `notFound` contract exists to prevent.
if (input.resourceId !== undefined && rows.length > 0 && visible.length === 0) {
return empty;
}
// More is below either because the filter cut this page short of a full read, or because a
// full page came back and the read itself was capped. Both mean "page again".
const hasMore = visible.length > input.limit || rows.length === fetchLimit;
@ -443,15 +493,23 @@ export class InstanceContextService {
* shown: they have been accounted for, and re-reading them next turn would only cost tokens.
*/
private async readEntries(input: {
projectIds: string[];
projectIds: ActivityProjectScope;
cursor: InstanceContextCursor | null;
now: Date;
scope: ResolvedScope;
}): Promise<{ rows: ActivityEvent[]; mark: number; seen: number[]; truncated: boolean }> {
const cursor = input.cursor;
// Withheld workflows are dropped after the read on this surface, so the read has to start
// wider or a window whose newest rows are all withheld comes back empty while visible
// entries sit just below it. On an instance that predates `availableInMCP` that is the
// common case, not the corner one.
const fetchLimit =
input.scope.surface === 'mcp' ? entryFetchLimit * withheldFetchMultiplier : entryFetchLimit;
// Newest first, and on a delta only what arrived above the mark.
const arrivals = await this.activityEventRepository.findFeed({
limit: entryFetchLimit,
limit: fetchLimit,
projectIds: input.projectIds,
...(cursor ? { afterId: cursor.activityMark } : {}),
});
@ -470,7 +528,13 @@ export class InstanceContextService {
: [];
// Both are newest-first and every arrival outranks every band row, so this stays ordered.
const rows = [...arrivals, ...band];
const read = [...arrivals, ...band];
// Entries are individual rows rather than an aggregate, so the same post-read filter the
// `list` tool uses applies here. The mark below still advances past what was filtered:
// those rows have been accounted for and will not become visible on a later turn.
const rows =
input.scope.surface === 'mcp' ? await this.withoutWithheldWorkflows(read, input.scope) : read;
const alreadyShown = new Set(cursor?.activitySeen ?? []);
const fresh = rows.filter(
@ -479,7 +543,14 @@ export class InstanceContextService {
);
const shown = fresh.slice(0, windowSize);
const mark = rows.reduce(
// Over everything read, including rows the visibility filter dropped, so a window full of
// withheld rows still advances rather than being re-read every turn.
//
// The cost is real but unreachable today: `availableInMCP` is toggled at runtime, so a row
// dropped now could become visible later, and a delta past this mark would miss it. Only a
// cursor-bearing caller could hit that, and the MCP surface passes `cursor: null` on every
// read. Revisit this the day one carries a cursor.
const mark = read.reduce(
(highest, row) => Math.max(highest, row.id),
cursor?.activityMark ?? 0,
);
@ -497,14 +568,15 @@ export class InstanceContextService {
seen,
// Said out loud rather than left to inference. A cut list that does not say it is cut
// reads as the whole story, and the agent would draw conclusions from it.
truncated: fresh.length > windowSize || arrivals.length === entryFetchLimit,
truncated: fresh.length > windowSize || arrivals.length === fetchLimit,
};
}
private async readRuns(input: {
projectIds: string[];
projectIds: ActivityProjectScope;
cursor: InstanceContextCursor | null;
now: Date;
mcpVisibleOnly: boolean;
}): Promise<RunSummary[]> {
// Consecutive windows tile the timeline: half-open `[after, before)`, so a delta starts
// exactly where the last one ended and a run is summarised once. Re-reading a lag window
@ -517,15 +589,17 @@ export class InstanceContextService {
// The cost is stated rather than hidden: a run whose row commits after this read but whose
// `stoppedAt` precedes it is never summarised. Runs have no gap-tolerant cursor the way
// entries do, because the aggregate returns counts rather than the ids to de-duplicate on.
const windowMs = input.mcpVisibleOnly ? mcpRunWindowMs : maxAgeMs;
const stoppedAfter = input.cursor
? new Date(Date.parse(input.cursor.runsThrough))
: new Date(input.now.getTime() - maxAgeMs);
: new Date(input.now.getTime() - windowMs);
return await this.executionRepository.summariseRunsForProjects({
projectIds: input.projectIds,
stoppedAfter,
stoppedBefore: input.now,
workflowLimit: runWorkflowCap,
...(input.mcpVisibleOnly ? { mcpVisibleOnly: true } : {}),
});
}
@ -574,7 +648,12 @@ export class InstanceContextService {
(await this.projectRepository.getPersonalProjectForUser(user.id))?.id,
)
: [];
return { surface: 'mcp', projectIds: [projectId], credentialProjectIds };
return {
surface: 'mcp',
projectIds: [projectId],
credentialProjectIds,
runsVisible: scope.executionGranted,
};
}
if (scope.surface === 'conversation') return null;
@ -582,11 +661,20 @@ export class InstanceContextService {
// A global reader needs no project predicate at all, which is both correct and the only
// bounded option: `getProjectIdsWithScope` would hand back every project on the instance.
if (hasGlobalScope(user, ['workflow:read'], { mode: 'allOf' })) {
const credentialProjectIds: ActivityProjectScope =
scope.credentialGranted && hasGlobalScope(user, ['credential:read'], { mode: 'allOf' })
// Reading every workflow does not imply reading every credential, and the two are
// resolved apart: global `credential:read` opens all of them, otherwise the caller may
// still hold it on individual projects and should see those.
const credentialProjectIds: ActivityProjectScope = !scope.credentialGranted
? []
: hasGlobalScope(user, ['credential:read'], { mode: 'allOf' })
? 'all-projects'
: [];
return { surface: 'mcp', projectIds: 'all-projects', credentialProjectIds };
: await this.credentialReadableProjectIds(user, null, undefined);
return {
surface: 'mcp',
projectIds: 'all-projects',
credentialProjectIds,
runsVisible: scope.executionGranted,
};
}
const [scopedIds, personalProject] = await Promise.all([
@ -603,7 +691,12 @@ export class InstanceContextService {
? await this.credentialReadableProjectIds(user, projectIds, personalProject?.id)
: [];
return { surface: 'mcp', projectIds, credentialProjectIds };
return {
surface: 'mcp',
projectIds,
credentialProjectIds,
runsVisible: scope.executionGranted,
};
}
/**
@ -617,25 +710,48 @@ export class InstanceContextService {
*/
private async credentialReadableProjectIds(
user: User,
projectIds: string[],
/**
* Narrow to these, or `null` for a whole-instance reader, which has no list to narrow to.
* Passing the list keeps the query bounded to what the caller can already see; asking for
* every project on the instance and intersecting afterwards would read strictly more.
*/
projectIds: string[] | null,
/** Already read by the caller; passed in so this does not read it a second time. */
personalProjectId: string | undefined,
): Promise<string[]> {
if (projectIds.length === 0) return [];
if (projectIds !== null && projectIds.length === 0) return [];
// Bounded to what the caller can already see. Asking for every project on the instance
// where they may read credentials and then intersecting would read strictly more.
const readable = await this.projectService.getProjectIdsWithScope(
user,
['credential:read'],
projectIds,
projectIds ?? undefined,
);
const readableSet = new Set(readable);
if (personalProjectId) readableSet.add(personalProjectId);
if (projectIds === null) return [...readableSet];
return projectIds.filter((projectId) => readableSet.has(projectId));
}
/**
* Whether anything exists in scope that this surface is withholding, asked only when the block
* came back empty.
*
* `availableInMCP` defaults to withheld, so an instance that predates the setting exposes
* nothing: every leg comes back empty and the honest answer is "nothing is exposed", not
* "nothing has been built". Telling a client the latter sends it off to rebuild an estate it
* simply cannot see, which is the opposite of what this surface is for.
*/
async hasWithheldWorkflows(user: User, scope: InstanceContextScope): Promise<boolean> {
const resolved = await this.resolveScope(user, scope);
if (resolved === null) return false;
const { total } = await this.workflowRepository.findRecentForProjects(resolved.projectIds, 1, {
mcpVisibleOnly: false,
});
return total > 0;
}
/**
* Drops entries about workflows the instance withholds from MCP.
*
@ -716,15 +832,32 @@ function isCredentialVisible(row: ActivityEvent, scope: ResolvedScope): boolean
return scope.credentialProjectIds.includes(row.projectId);
}
const initialPreamble = [
/**
* How to reach the tools named in the block. The two surfaces do not share tool names, so a reader
* told to call `activity(action="list")` over MCP is told to call something that is not there.
*/
const toolNames = {
conversation: {
expand: '`activity(action="expand", id=N)`',
list: '`activity(action="list")`',
workflows: '`workflows(action="list")`',
},
mcp: {
expand: '`expand_instance_activity(id=N)`',
list: '`get_instance_activity`',
workflows: '`search_workflows`',
},
} as const;
const initialPreamble = (tools: SurfaceToolNames) => [
'What is going on in this instance. This is work that already exists and that you can pick up:',
'when the user is vague ("fix it", "carry on", "what should I look at"), the answer is usually',
'the most recent thing here, and often the most recent failure. Name what you think they mean',
'and act on it rather than asking them to choose from a list they can already see.',
'Do not narrate this back to them — unless they asked what has been happening, let it change',
'what you do rather than what you say.',
'Call `activity(action="expand", id=N)` on a bracketed id to see that entry in full along with',
'everything else that happened to the same resource, or `activity(action="list")` to look',
`Call ${tools.expand} on a bracketed id to see that entry in full along with`,
`everything else that happened to the same resource, or ${tools.list} to look`,
'further back than this window. An entry may name a resource that no longer exists.',
];
@ -732,16 +865,28 @@ const initialPreamble = [
* An update says so explicitly. Without that, a two-entry delta reads as though nothing else ever
* happened, and the agent would draw conclusions from a list it was never given in full.
*/
const updatePreamble = [
const updatePreamble = (tools: SurfaceToolNames) => [
'What has happened since the list earlier in this conversation. Those earlier entries still',
'stand — these are additions, not a replacement. Read them the same way: context on what the',
'user has been doing, not a task list or something to comment on unprompted.',
'`activity(action="expand", id=N)` and `activity(action="list")` work on these ids too.',
`${tools.expand} and ${tools.list} work on these ids too.`,
];
/** Named so the agent can act on one without a lookup: the id is what every tool takes. */
function renderInventory(inventory: Inventory): string[] {
if (inventory.total === 0) return ['Nothing has been built here yet.', ''];
function renderInventory(
inventory: Inventory,
tools: SurfaceToolNames,
surface: ResolvedScope['surface'],
): string[] {
// An empty inventory means two different things. On MCP the count is filtered, so zero
// usually means "all withheld" on an instance that predates `availableInMCP` — and a block
// can still render off the other legs, putting this line in front of activity that proves
// the estate exists. Saying "nothing has been built" there is simply false.
if (inventory.total === 0) {
return surface === 'mcp'
? ['No workflows here are exposed to MCP, so none can be named.', '']
: ['Nothing has been built here yet.', ''];
}
const named = inventory.workflows.map(
(workflow) =>
@ -754,7 +899,7 @@ function renderInventory(inventory: Inventory): string[] {
return [
`Workflows that already exist here: ${inventory.total}. Most recently worked on:`,
...named,
...(more > 0 ? [` ... and ${more} more — \`workflows(action="list")\` for the rest.`] : []),
...(more > 0 ? [` ... and ${more} more — ${tools.workflows} for the rest.`] : []),
'',
];
}
@ -797,26 +942,36 @@ function renderBlock(input: {
isUpdate: boolean;
inventory?: Inventory;
now: Date;
surface: ResolvedScope['surface'];
}): string {
const tools = toolNames[input.surface];
const prose = [
...(input.isUpdate ? updatePreamble : initialPreamble),
...(input.isUpdate ? updatePreamble(tools) : initialPreamble(tools)),
'',
...(input.inventory ? renderInventory(input.inventory) : []),
...(input.inventory ? renderInventory(input.inventory, tools, input.surface) : []),
...renderRuns(input.runs, input.isUpdate, input.now),
...(input.entries.length > 0
? [
input.isUpdate ? 'Changes since then:' : 'What changed recently:',
...input.entries,
...(input.entriesTruncated
? [' ... and more than these — `activity(action="list")` for the rest.']
? [` ... and more than these — ${tools.list} for the rest.`]
: []),
]
: []),
].join('\n');
return `${INSTANCE_CONTEXT_OPEN_TAG}\n${prose}\n${INSTANCE_CONTEXT_CLOSE_TAG}`;
// The tags are Instance AI's transport: `cleanStoredUserMessage` strips them back out of the
// stored message. An MCP client is handed the text directly and has nothing to strip, so it
// would only be reading a stray tag.
return input.surface === 'mcp'
? prose
: `${INSTANCE_CONTEXT_OPEN_TAG}\n${prose}\n${INSTANCE_CONTEXT_CLOSE_TAG}`;
}
type SurfaceToolNames = (typeof toolNames)[keyof typeof toolNames];
const knownCategories = new Set<string>(activityEventCategories);
function isKnownCategory(category: string | undefined): category is ActivityEventCategory {

View File

@ -0,0 +1,146 @@
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { InstanceContextService } from '@/modules/instance-ai/instance-context.service';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import {
createGetInstanceContextTool,
EMPTY_INSTANCE_CONTEXT_TEXT,
NOTHING_EXPOSED_TEXT,
} from '../tools/get-instance-context.tool';
const user = mock<User>({ id: 'user-1' });
function harness() {
const instanceContext = mock<InstanceContextService>();
const telemetry = mock<Telemetry>();
instanceContext.buildBlock.mockResolvedValue(null);
instanceContext.hasWithheldWorkflows.mockResolvedValue(false);
return {
instanceContext,
telemetry,
tool: createGetInstanceContextTool(user, instanceContext, telemetry, {
executionGranted: true,
}),
};
}
const payloadOf = (result: unknown) =>
(result as { structuredContent: Record<string, unknown> }).structuredContent;
const textOf = (result: unknown) =>
(result as { content: Array<{ text: string }> }).content[0].text;
describe('get_instance_context', () => {
it('reads under the MCP surface with credentials withheld', async () => {
const { tool, instanceContext } = harness();
await tool.handler({}, mock());
expect(instanceContext.buildBlock).toHaveBeenCalledWith(
expect.objectContaining({
scope: { surface: 'mcp', credentialGranted: false, executionGranted: true },
// Stateless server, no thread to track against, so every read is a full snapshot.
cursor: null,
}),
);
});
it('narrows to one project when the caller names it', async () => {
const { tool, instanceContext } = harness();
await tool.handler({ projectId: 'project-1' }, mock());
expect(instanceContext.buildBlock).toHaveBeenCalledWith(
expect.objectContaining({
scope: {
surface: 'mcp',
credentialGranted: false,
executionGranted: true,
projectId: 'project-1',
},
}),
);
});
it('returns the block as prose', async () => {
const { tool, instanceContext } = harness();
instanceContext.buildBlock.mockResolvedValue({
block: 'Workflows that already exist here: 3',
cursor: { activityMark: 9, activitySeen: [], runsThrough: '2026-09-10T00:00:00.000Z' },
});
const result = await tool.handler({}, mock());
expect(payloadOf(result)).toEqual({ context: 'Workflows that already exist here: 3' });
expect(textOf(result)).toBe('Workflows that already exist here: 3');
});
it('reports a genuinely empty instance as empty', async () => {
const { tool, instanceContext } = harness();
instanceContext.buildBlock.mockResolvedValue(null);
instanceContext.hasWithheldWorkflows.mockResolvedValue(false);
const result = await tool.handler({}, mock());
expect(payloadOf(result)).toEqual({ empty: true, nothingExposed: false });
expect(textOf(result)).toBe(EMPTY_INSTANCE_CONTEXT_TEXT);
});
/**
* `availableInMCP` defaults to withheld, so this is what every instance predating the setting
* answers. Calling it an empty instance would send a client off to rebuild an estate it simply
* cannot see.
*/
it('says nothing is exposed, not that the instance is empty, when the estate is withheld', async () => {
const { tool, instanceContext } = harness();
instanceContext.buildBlock.mockResolvedValue(null);
instanceContext.hasWithheldWorkflows.mockResolvedValue(true);
const result = await tool.handler({}, mock());
expect(payloadOf(result)).toEqual({ empty: true, nothingExposed: true });
expect(textOf(result)).toBe(NOTHING_EXPOSED_TEXT);
expect(textOf(result)).not.toBe(EMPTY_INSTANCE_CONTEXT_TEXT);
});
/**
* The instructions tell an agent an empty instance means "start from a blank page", so a read
* failure reported that way would send it off to rebuild work that already exists.
*/
it('fails loudly rather than reporting a failed read as an empty instance', async () => {
const { tool, telemetry, instanceContext } = harness();
instanceContext.buildBlock.mockRejectedValue(new Error('db is down'));
await expect(tool.handler({}, mock())).rejects.toThrow('db is down');
expect(telemetry.track).toHaveBeenCalledWith(
USER_CALLED_MCP_TOOL_EVENT,
expect.objectContaining({
tool_name: 'get_instance_context',
results: { success: false, error: 'db is down' },
}),
);
});
it('tracks a successful call', async () => {
const { tool, telemetry, instanceContext } = harness();
instanceContext.buildBlock.mockResolvedValue({
block: 'Workflows that already exist here: 3',
cursor: { activityMark: 9, activitySeen: [], runsThrough: '2026-09-10T00:00:00.000Z' },
});
await tool.handler({}, mock());
expect(telemetry.track).toHaveBeenCalledWith(
USER_CALLED_MCP_TOOL_EVENT,
expect.objectContaining({
tool_name: 'get_instance_context',
results: { success: true, data: { empty: false, outcome: 'context' } },
}),
);
});
});

View File

@ -33,9 +33,13 @@ function harness(credentialGranted = true) {
return {
instanceContext,
telemetry,
list: createGetInstanceActivityTool(user, instanceContext, telemetry, { credentialGranted }),
list: createGetInstanceActivityTool(user, instanceContext, telemetry, {
credentialGranted,
executionGranted: true,
}),
expand: createExpandInstanceActivityTool(user, instanceContext, telemetry, {
credentialGranted,
executionGranted: true,
}),
};
}
@ -52,7 +56,7 @@ describe('get_instance_activity', () => {
expect(instanceContext.listPage).toHaveBeenCalledWith(
expect.objectContaining({
scope: { surface: 'mcp', credentialGranted: true },
scope: { surface: 'mcp', credentialGranted: true, executionGranted: true },
}),
);
});
@ -76,7 +80,12 @@ describe('get_instance_activity', () => {
expect(instanceContext.listPage).toHaveBeenCalledWith(
expect.objectContaining({
scope: { surface: 'mcp', credentialGranted: true, projectId: 'project-1' },
scope: {
surface: 'mcp',
credentialGranted: true,
executionGranted: true,
projectId: 'project-1',
},
}),
);
});
@ -193,7 +202,7 @@ describe('expand_instance_activity', () => {
expect(instanceContext.expand).toHaveBeenCalledWith(
expect.objectContaining({
id: 7,
scope: { surface: 'mcp', credentialGranted: false },
scope: { surface: 'mcp', credentialGranted: false, executionGranted: true },
}),
);
});

View File

@ -120,3 +120,42 @@ describe('getMcpInstructions', () => {
});
});
});
describe('instance context', () => {
it('names the resource and the tool when the surface is on', () => {
const instructions = getMcpInstructions({
isBuilderEnabled: false,
isInstanceContextEnabled: true,
});
expect(instructions).toContain('n8n://instance/context');
expect(instructions).toContain('get_instance_context');
});
/** Every word here is paid for on every session, so an off surface costs nothing. */
it('says nothing about it when the surface is off', () => {
const instructions = getMcpInstructions({ isBuilderEnabled: false });
expect(instructions).not.toContain('n8n://instance/context');
expect(instructions).not.toContain('get_instance_context');
});
/**
* A client may keep only the opening of these instructions — Claude Code truncates at 2048
* characters — so a pointer below the cut never arrives and the surface goes undiscovered.
* The full text is well past the budget, which is what makes the position matter.
*/
it('keeps the pointer inside the 2048 characters a client may truncate to', () => {
const instructions = getMcpInstructions({
isBuilderEnabled: true,
isInstanceContextEnabled: true,
});
const start = instructions.indexOf('Start with the instance');
expect(start).toBeGreaterThan(-1);
const end = instructions.indexOf('\n\n', start);
expect(instructions.length).toBeGreaterThan(2048);
expect(end).toBeLessThan(2048);
});
});

View File

@ -6,6 +6,7 @@ import type { McpConfig } from '../mcp.config';
import type { McpSettingsService } from '../mcp.settings.service';
import type { UrlService } from '@/services/url.service';
import { INSTANCE_CONTEXT_TOOLS } from '../mcp-scopes';
import { McpProtectedResource } from '../mcp-protected-resource';
const makeGlobalConfig = ({
@ -54,6 +55,28 @@ describe('McpProtectedResource', () => {
});
/** Consent must not advertise a tool that `tools/list` will not carry. */
it('advertises the instance-context tools while the module is active', () => {
moduleRegistry.isActive.mockReturnValue(true);
const scopeTools = resource.getScopeTools();
for (const tool of INSTANCE_CONTEXT_TOOLS) {
expect(scopeTools['workflow:read']).toContain(tool);
}
});
it('withholds them from consent when the module is inactive', () => {
moduleRegistry.isActive.mockImplementation((name) => name !== 'instance-ai');
const scopeTools = resource.getScopeTools();
for (const tool of INSTANCE_CONTEXT_TOOLS) {
expect(scopeTools['workflow:read']).not.toContain(tool);
}
// Unrelated entries under the same scope are untouched.
expect(scopeTools['workflow:read']).toContain('search_workflows');
});
it('withholds the activity tools from consent when nothing writes the log', () => {
moduleRegistry.isActive.mockReturnValue(true);
const resourceWithoutLog = new McpProtectedResource(

View File

@ -31,6 +31,11 @@ import { ExecutionService } from '@/executions/execution.service';
import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks/subworkflow-policy-checker';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { InstanceContextService } from '@/modules/instance-ai/instance-context.service';
import {
EMPTY_INSTANCE_CONTEXT_TEXT,
INSTANCE_CONTEXT_RESOURCE_URI,
NOTHING_EXPOSED_TEXT,
} from '../tools/get-instance-context.tool';
import { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
@ -68,6 +73,22 @@ const mcpFeatureFlags = (overrides: Partial<McpFeatureFlags> = {}): McpFeatureFl
...overrides,
});
/** Reaches the resource's own read callback, which registration assertions never touch. */
const readResourceText = async (server: unknown, uri: string): Promise<string> => {
const registered = (
server as {
_registeredResources: Record<string, { readCallback: () => Promise<unknown> }>;
}
)._registeredResources[uri];
const result = (await registered.readCallback()) as { contents: Array<{ text: string }> };
return result.contents[0].text;
};
const getRegisteredResourceUris = (server: unknown): Set<string> =>
new Set(
Object.keys((server as { _registeredResources: Record<string, unknown> })._registeredResources),
);
const getRegisteredToolNames = (server: unknown): Set<string> =>
new Set(Object.keys((server as { _registeredTools: Record<string, unknown> })._registeredTools));
@ -249,9 +270,12 @@ describe('McpService scope enforcement', () => {
);
const registered = getRegisteredToolNames(server);
// Driven off the set so a fifth tool cannot be added without this case noticing.
const moduleBound = [...INSTANCE_CONTEXT_TOOLS].filter((name) => name !== 'get_node_usage');
expect(registered).toContain('get_node_usage');
expect(registered).not.toContain('get_instance_activity');
expect(registered).not.toContain('expand_instance_activity');
for (const name of moduleBound) expect(registered).not.toContain(name);
expect(getRegisteredResourceUris(server)).not.toContain(INSTANCE_CONTEXT_RESOURCE_URI);
});
/** They ride on `workflow:read`, so a grant without it must not reach them. */
@ -270,6 +294,82 @@ describe('McpService scope enforcement', () => {
for (const name of INSTANCE_CONTEXT_TOOLS) expect(registered).not.toContain(name);
});
/** The resource is how a client that reads resources gets the opening context without asking. */
it('registers the instance-context resource alongside the tool', async () => {
mockInstance(InstanceContextService);
mockInstance(WorkflowDependencyQueryService);
const server = await buildService({ instanceAiActive: true }).getServer(
user,
mcpFeatureFlags({ instanceContextEnabled: true }),
);
expect(getRegisteredResourceUris(server)).toContain(INSTANCE_CONTEXT_RESOURCE_URI);
expect(getRegisteredToolNames(server)).toContain('get_instance_context');
});
it('registers no instance-context resource with the flag off', async () => {
const server = await buildService({ instanceAiActive: true }).getServer(
user,
mcpFeatureFlags({ instanceContextEnabled: false }),
);
expect(getRegisteredResourceUris(server)).not.toContain(INSTANCE_CONTEXT_RESOURCE_URI);
});
/**
* The read callback, not just the registration. It is not a pass-through — it maps an empty
* read to its own text and emits its own telemetry — so the two paths can drift silently.
*/
it('serves the block through the resource, and the withheld text when nothing is exposed', async () => {
const instanceContext = mockInstance(InstanceContextService);
mockInstance(WorkflowDependencyQueryService);
const server = await buildService({ instanceAiActive: true }).getServer(
user,
mcpFeatureFlags({ instanceContextEnabled: true }),
);
instanceContext.buildBlock.mockResolvedValue({
block: 'Workflows that already exist here: 2',
cursor: { activityMark: 1, activitySeen: [], runsThrough: '2026-09-16T00:00:00.000Z' },
});
expect(await readResourceText(server, INSTANCE_CONTEXT_RESOURCE_URI)).toBe(
'Workflows that already exist here: 2',
);
// Empty read plus an estate that exists: the client must not be told the instance is empty.
instanceContext.buildBlock.mockResolvedValue(null);
instanceContext.hasWithheldWorkflows.mockResolvedValue(true);
expect(await readResourceText(server, INSTANCE_CONTEXT_RESOURCE_URI)).toBe(
NOTHING_EXPOSED_TEXT,
);
instanceContext.hasWithheldWorkflows.mockResolvedValue(false);
expect(await readResourceText(server, INSTANCE_CONTEXT_RESOURCE_URI)).toBe(
EMPTY_INSTANCE_CONTEXT_TEXT,
);
});
/**
* The resource carries the same instance data as the tool, so a grant that cannot call the
* tool must not be able to read it instead. `registerResource` does no filtering of its own.
*/
it('withholds the resource from a grant that does not cover the tool', async () => {
mockInstance(InstanceContextService);
mockInstance(WorkflowDependencyQueryService);
const server = await buildService({ instanceAiActive: true }).getServer(
user,
mcpFeatureFlags({ instanceContextEnabled: true }),
undefined,
{ grantedScopes: ['execution:read'] },
);
expect(getRegisteredToolNames(server)).not.toContain('get_instance_context');
expect(getRegisteredResourceUris(server)).not.toContain(INSTANCE_CONTEXT_RESOURCE_URI);
});
/**
* The log is off by default, and a tool answering from a store nothing writes to reports an
* empty feed — which an agent reads as "nothing has happened here".

View File

@ -28,6 +28,7 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
'validate_node_config',
// Instance-context reads. Both describe workflows, so `workflow:read` is the bar; the
// activity tools drop credential entries for a grant that lacks `credential:read`.
'get_instance_context',
'get_instance_activity',
'expand_instance_activity',
'get_node_usage',
@ -155,6 +156,7 @@ export const ACTIVITY_LOG_TOOLS: ReadonlySet<string> = new Set([
]);
export const INSTANCE_CONTEXT_TOOLS: ReadonlySet<string> = new Set([
'get_instance_context',
'get_instance_activity',
'expand_instance_activity',
'get_node_usage',

View File

@ -56,6 +56,7 @@ import {
MCP_CREATE_AGENT_TOOL_NAME,
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_PREVIEW_RENDER_REQUESTED_EVENT,
USER_CALLED_MCP_TOOL_EVENT,
} from './mcp.constants';
import { getAllowedToolNames } from './mcp-scopes';
import { areAgentToolsAvailable } from './mcp-tool-availability';
@ -81,6 +82,14 @@ import {
} from './tools/data-table';
import { createExecuteWorkflowTool } from './tools/execute-workflow.tool';
import { createGetExecutionTool } from './tools/get-execution.tool';
import {
createGetInstanceContextTool,
GET_INSTANCE_CONTEXT_TOOL_NAME,
instanceContextText,
INSTANCE_CONTEXT_RESOURCE_DESCRIPTION,
INSTANCE_CONTEXT_RESOURCE_URI,
readInstanceContext,
} from './tools/get-instance-context.tool';
import { createGetNodeUsageTool } from './tools/get-node-usage.tool';
import { createGetUserPreferencesTool } from './tools/get-user-preferences.tool';
import { createWorkflowDetailsTool } from './tools/get-workflow-details.tool';
@ -136,7 +145,10 @@ export type McpAppsResolution = {
/** Per-user resolution of every PostHog-gated MCP feature. */
export type McpFeatureFlags = {
mcpApps: McpAppsResolution;
/** The instance-context read surface: the activity tools and node-usage. */
/**
* The instance-context read surface: the four tools, the `n8n://instance/context` resource,
* and the sentence in the instructions that points a client at them.
*/
instanceContextEnabled: boolean;
/** The `get_user_preferences` tool. */
aiPreferencesEnabled: boolean;
@ -434,9 +446,11 @@ export class McpService {
// the agent tools gets no agent build walkthrough.
const agentInstructionsEnabled =
agentsEnabled && (allowedToolNames?.has(MCP_CREATE_AGENT_TOOL_NAME) ?? true);
// Same rationale again: never point a caller at a tool it cannot see. Gates the sentence
// only; registration below goes through `registerIfAllowed` like every other tool. No
// RBAC check: a user's own preferences need no scope.
// Same rationale again: a grant that cannot reach the instance-context tools is not told
// to start by calling them, and does not get the resource that carries the same data.
const contextToolsAllowed = allowedToolNames?.has(GET_INSTANCE_CONTEXT_TOOL_NAME) ?? true;
// Gates the sentence only; registration below goes through `registerIfAllowed` like every
// other tool. No RBAC check: a user's own preferences need no scope.
const userPreferencesInstructionsEnabled =
featureFlags.aiPreferencesEnabled &&
(allowedToolNames?.has(MCP_GET_USER_PREFERENCES_TOOL_NAME) ?? true);
@ -447,6 +461,10 @@ export class McpService {
},
{
instructions: getMcpInstructions({
isInstanceContextEnabled:
featureFlags.instanceContextEnabled &&
this.moduleRegistry.isActive('instance-ai') &&
contextToolsAllowed,
isBuilderEnabled: builderInstructionsEnabled,
isN8nConnectAvailable: n8nConnectAvailable,
isAgentsEnabled: agentInstructionsEnabled,
@ -607,6 +625,9 @@ export class McpService {
// permission, so the reader treats it as one half of the credential gate and resolves the
// caller's real access for the other half.
const credentialGranted = allowedToolNames?.has('list_credentials') ?? true;
// Same shape, for the run leg: it ships run counts and the id of the last failure, and
// every other execution read here sits behind `execution:read`.
const executionGranted = allowedToolNames?.has('get_workflow_execution') ?? true;
// The two activity tools also need the log to be *written*. `N8N_ACTIVITY_LOG_ENABLED`
// is off by default, and a tool that answers from a store nothing writes to reports an
@ -626,11 +647,65 @@ export class McpService {
registerIfAllowed(
createGetInstanceActivityTool(user, instanceContext, this.telemetry, {
credentialGranted,
executionGranted,
}),
);
registerIfAllowed(
createExpandInstanceActivityTool(user, instanceContext, this.telemetry, {
credentialGranted,
executionGranted,
}),
);
// The opening context, offered three ways because an MCP client has no turn to have
// it injected into: a resource for clients that read resources, a tool for the rest,
// and a line in the server instructions naming both.
//
// The resource carries the same instance data as the tool, so it follows the same
// scope gate — `registerResource` does no filtering of its own, and the resources
// that predate this one are static documents that needed none. Mirrors
// `McpAgentToolsService`, which gates its reference resource on its reference tool.
if (contextToolsAllowed) {
registerResource({
name: 'instance-context',
uri: INSTANCE_CONTEXT_RESOURCE_URI,
config: {
description: INSTANCE_CONTEXT_RESOURCE_DESCRIPTION,
mimeType: 'text/plain',
// Per-user data: never cache it across callers. The SDK defaults to this
// today, so this states the requirement rather than changing behaviour.
cacheHint: { ttlMs: 0, cacheScope: 'private' },
},
read: async () => {
// The tool beside this emits `USER_CALLED_MCP_TOOL_EVENT`; without the same
// event here the resource is invisible, and it is the path the instructions
// send resource-reading clients to first. Same event with the surface on it
// beats a second event name.
const read = await readInstanceContext(user, instanceContext, {
executionGranted,
});
this.telemetry.track(USER_CALLED_MCP_TOOL_EVENT, {
user_id: user.id,
tool_name: GET_INSTANCE_CONTEXT_TOOL_NAME,
parameters: { surface: 'resource' },
results: { success: true, data: { outcome: read.kind } },
});
return {
contents: [
{
uri: INSTANCE_CONTEXT_RESOURCE_URI,
mimeType: 'text/plain',
text: instanceContextText(read),
},
],
};
},
});
}
registerIfAllowed(
createGetInstanceContextTool(user, instanceContext, this.telemetry, {
executionGranted,
}),
);
}

View File

@ -0,0 +1,174 @@
/**
* The opening view of an instance: what exists here, what changed lately, and what has run.
*
* Instance AI is handed this on every turn. An MCP client has no turn to attach it to, so it is
* offered three ways at once — as a resource, as this tool, and as a line in the server
* instructions naming both. The SDK reference beside it settled that shape already: resources
* reach the clients that read them, the tool reaches the rest, and the instructions are what makes
* either get called.
*/
import type { User } from '@n8n/db';
import z from 'zod';
import type {
InstanceContextScope,
InstanceContextService,
} from '@/modules/instance-ai/instance-context.service';
import type { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
export const GET_INSTANCE_CONTEXT_TOOL_NAME = 'get_instance_context';
export const INSTANCE_CONTEXT_RESOURCE_URI = 'n8n://instance/context';
/** Said only when the read succeeded and the instance is genuinely empty — never on failure. */
export const EMPTY_INSTANCE_CONTEXT_TEXT = 'Nothing has been built on this instance yet.';
/**
* Said when the estate exists but none of it is exposed to MCP. `availableInMCP` defaults to
* withheld, so this is what every instance that predates the setting reports — and it must not be
* confused with an empty instance, which would send a client off to rebuild what it cannot see.
*/
export const NOTHING_EXPOSED_TEXT =
'No workflows on this instance are exposed to MCP, so there is nothing to report here. ' +
'Ask the user to expose one in Settings, under MCP.';
const DESCRIPTION =
'Read the opening picture of this n8n instance: which workflows exist, what has recently been ' +
'created, changed or deleted, and what has run and failed. Call it once at the start of a ' +
'session, before asking the user what they want to do — when they are vague ("fix it", ' +
'"carry on"), the answer is usually the most recent thing here. Returns prose, not records: ' +
'pass the workflow ids it names to search_workflows or get_workflow_details, and a bracketed ' +
'activity id to expand_instance_activity. get_instance_activity pages further back.';
const inputSchema = {
projectId: z
.string()
.min(1)
.optional()
.describe(
'Read one project instead of every project you can see. Obtain it from search_projects.',
),
} satisfies z.ZodRawShape;
const outputSchema = {
context: z
.string()
.optional()
.describe('The instance context, as prose. Absent when there is nothing to report.'),
empty: z
.boolean()
.optional()
.describe('Set when there is nothing to report. Read `nothingExposed` for the reason.'),
nothingExposed: z
.boolean()
.optional()
.describe(
'Present when the answer is empty. True means workflows exist here but none are exposed to MCP, so the estate is real and simply out of reach — do not treat it as a fresh instance. False means the instance genuinely holds nothing yet.',
),
} satisfies z.ZodRawShape;
/**
* Every read is a fresh snapshot. The delta machinery behind the per-turn block tracks what a
* thread has already been shown; the MCP server is stateless and has no thread, so there is
* nothing to track against and nothing to carry forward.
*/
export type InstanceContextRead =
| { kind: 'context'; text: string }
/** Nothing exists in scope at all. */
| { kind: 'empty' }
/** Things exist, but none of them are exposed to this surface. */
| { kind: 'withheld' };
export async function readInstanceContext(
user: User,
instanceContext: InstanceContextService,
options: { executionGranted: boolean },
projectId?: string,
): Promise<InstanceContextRead> {
const scope = buildScope(options.executionGranted, projectId);
const built = await instanceContext.buildBlock({ user, scope, cursor: null });
if (built) return { kind: 'context', text: built.block };
// An empty block has two very different causes, and the client acts on them differently.
return (await instanceContext.hasWithheldWorkflows(user, scope))
? { kind: 'withheld' }
: { kind: 'empty' };
}
/** The text a client sees for each outcome. */
export function instanceContextText(read: InstanceContextRead): string {
if (read.kind === 'context') return read.text;
return read.kind === 'withheld' ? NOTHING_EXPOSED_TEXT : EMPTY_INSTANCE_CONTEXT_TEXT;
}
const buildScope = (executionGranted: boolean, projectId?: string): InstanceContextScope => ({
surface: 'mcp',
executionGranted,
// Deliberately withheld, not a no-op: the feed does carry credential entries, and the
// conversation block renders them. No per-call grant reaches this read, so it takes the
// conservative floor and the block never names a credential.
credentialGranted: false,
...(projectId !== undefined ? { projectId } : {}),
});
export const createGetInstanceContextTool = (
user: User,
instanceContext: InstanceContextService,
telemetry: Telemetry,
options: { executionGranted: boolean },
): ToolDefinition<typeof inputSchema> => ({
name: GET_INSTANCE_CONTEXT_TOOL_NAME,
config: {
description: DESCRIPTION,
inputSchema,
outputSchema,
annotations: {
title: 'Get Instance Context',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async ({ projectId }: { projectId?: string }) => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: GET_INSTANCE_CONTEXT_TOOL_NAME,
parameters: { projectId },
};
try {
// A failed read throws rather than answering `empty`. The instructions tell an agent to
// treat an empty instance as "start from a blank page", so reporting a database failure
// that way would send it off to rebuild work that already exists.
const read = await readInstanceContext(user, instanceContext, options, projectId);
const text = instanceContextText(read);
const payload =
read.kind === 'context'
? { context: text }
: { empty: true, nothingExposed: read.kind === 'withheld' };
telemetryPayload.results = {
success: true,
data: { empty: read.kind !== 'context', outcome: read.kind },
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return { content: [{ type: 'text', text }], structuredContent: payload };
} catch (error) {
telemetryPayload.results = {
success: false,
error: error instanceof Error ? error.message : String(error),
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
throw error;
}
},
});
export const INSTANCE_CONTEXT_RESOURCE_DESCRIPTION =
'What exists on this n8n instance, what changed recently, and what has run. Read this at the ' +
'start of a session to pick up work already in progress.';

View File

@ -129,10 +129,11 @@ type ExpandParams = { id: number; projectId?: string };
*/
const buildScope = (
projectId: string | undefined,
credentialGranted: boolean,
options: { credentialGranted: boolean; executionGranted: boolean },
): InstanceContextScope => ({
surface: 'mcp',
credentialGranted,
credentialGranted: options.credentialGranted,
executionGranted: options.executionGranted,
...(projectId !== undefined ? { projectId } : {}),
});
@ -140,7 +141,7 @@ export const createGetInstanceActivityTool = (
user: User,
instanceContext: InstanceContextService,
telemetry: Telemetry,
options: { credentialGranted: boolean },
options: { credentialGranted: boolean; executionGranted: boolean },
): ToolDefinition<typeof listInputSchema> => ({
name: GET_INSTANCE_ACTIVITY_TOOL_NAME,
config: {
@ -174,7 +175,7 @@ export const createGetInstanceActivityTool = (
// this figure sizes a database read, so it should not depend on validation upstream.
const { entries, hasMore, nextBeforeId } = await instanceContext.listPage({
user,
scope: buildScope(projectId, options.credentialGranted),
scope: buildScope(projectId, options),
limit: Math.min(Math.max(1, limit ?? DEFAULT_LIMIT), MAX_RESULTS),
...(category !== undefined ? { category } : {}),
...(resourceId !== undefined ? { resourceId } : {}),
@ -210,7 +211,7 @@ export const createExpandInstanceActivityTool = (
user: User,
instanceContext: InstanceContextService,
telemetry: Telemetry,
options: { credentialGranted: boolean },
options: { credentialGranted: boolean; executionGranted: boolean },
): ToolDefinition<typeof expandInputSchema> => ({
name: EXPAND_INSTANCE_ACTIVITY_TOOL_NAME,
config: {
@ -239,7 +240,7 @@ export const createExpandInstanceActivityTool = (
const expansion = await instanceContext.expand({
id,
user,
scope: buildScope(projectId, options.credentialGranted),
scope: buildScope(projectId, options),
});
// A pruned id and one the caller may not see answer the same way, so the tool cannot be

View File

@ -6,6 +6,12 @@
* It provides a condensed orchestration guide for the tool calling sequence.
*/
import {
LIST_N8N_GATEWAY_SERVICES_TOOL_NAME,
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_USER_PREFERENCES_TRIGGER_CLAUSE,
} from '../../mcp.constants';
import { GET_INSTANCE_CONTEXT_TOOL_NAME } from '../get-instance-context.tool';
import {
MCP_CREATE_WORKFLOW_FROM_CODE_TOOL,
MCP_UPDATE_WORKFLOW_TOOL,
@ -18,11 +24,6 @@ import {
CODE_BUILDER_VALIDATE_TOOL,
CODE_BUILDER_VALIDATE_NODE_TOOL,
} from './constants';
import {
LIST_N8N_GATEWAY_SERVICES_TOOL_NAME,
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_USER_PREFERENCES_TRIGGER_CLAUSE,
} from '../../mcp.constants';
export type McpInstructionsOptions = {
/**
@ -43,6 +44,12 @@ export type McpInstructionsOptions = {
*/
isAgentsEnabled?: boolean;
/**
* Whether the instance-context surface is on. When it is, the instructions name the opening
* read — the one thing here a client would otherwise never think to ask for.
*/
isInstanceContextEnabled?: boolean;
/**
* Whether the `get_user_preferences` tool is registered for this caller. If true, one
* sentence points the client at it: clients that load tool descriptions on demand never
@ -55,6 +62,7 @@ export function getMcpInstructions(options: McpInstructionsOptions): string {
isBuilderEnabled,
isN8nConnectAvailable = false,
isAgentsEnabled = false,
isInstanceContextEnabled = false,
isUserPreferencesEnabled = false,
} = options;
const INTRO = 'This is the official MCP server for n8n, a workflow automation platform.';
@ -65,6 +73,18 @@ export function getMcpInstructions(options: McpInstructionsOptions): string {
? `Before ${MCP_USER_PREFERENCES_TRIGGER_CLAUSE} call ${MCP_GET_USER_PREFERENCES_TOOL_NAME} first and apply what it returns for the remainder of the task.`
: '';
// Its only job is to get the opening read called. Measured: with this sentence the read
// happens on every run; with the tools registered and nothing pointing at them, the client
// does not reach for them at all. What the read contains, and how to treat it, belong in the
// block and the tool description, which are not paid for at every handshake.
//
// Placement is load-bearing, and so is length. A client may keep only the opening of these
// instructions — Claude Code truncates at 2048 characters, and the full text is several times
// that — so anything below the cut never arrives. A test pins this inside the budget.
const INSTANCE_CONTEXT_HINT = isInstanceContextEnabled
? `Start with the instance, not a blank page. Read the n8n://instance/context resource, or call ${GET_INSTANCE_CONTEXT_TOOL_NAME} if you do not read resources, before your first substantive answer.`
: '';
const GROUPS_HINT = `
Node groups: when a workflow has several distinct stages, organise it into named groups so it is readable on the canvas. Before creating groups, call ${MCP_GET_SDK_REFERENCE_TOOL.toolName} with section "groups" for the rules, and ${MCP_GET_WORKFLOW_BEST_PRACTICES_TOOL.toolName} (technique "list") for when to group. The save never fails because of groups, so read its result: when it reports TOP_LEVEL_ITEMS_OVER_CEILING, skippedGroups or removedGroups, repair the groups with ${MCP_UPDATE_WORKFLOW_TOOL.toolName} before you tell the user the workflow is done. A warning marked [pre-existing] describes a canvas that was already like that before your update; you do not need to repair it before you report done.`;
@ -127,6 +147,7 @@ Agent conversations and runs are not workflow executions: get_workflow_execution
return [
INTRO,
USER_PREFERENCES_HINT,
INSTANCE_CONTEXT_HINT,
isBuilderEnabled && isAgentsEnabled ? ARTIFACT_ROUTING_INSTRUCTIONS : '',
isAgentsEnabled ? AGENT_INSTRUCTIONS : '',
isBuilderEnabled ? BUILDER_INSTRUCTIONS : '',

View File

@ -271,4 +271,76 @@ describe('ExecutionRepository.summariseRunsForProjects', () => {
}),
).toEqual([]);
});
const summarise = async (overrides: Record<string, unknown> = {}) =>
await repository.summariseRunsForProjects({
projectIds: 'all-projects',
stoppedAfter: windowStart(),
stoppedBefore: readTime(),
workflowLimit: 10,
...overrides,
});
/**
* `'all-projects'` drops the shared join, and it is the branch every instance owner takes.
* `mcpVisibleOnly` is the only thing narrowing it there, so both need their own cases.
*/
describe("the 'all-projects' scope", () => {
it('summarises a workflow whose project the list scope never mentions', async () => {
const workflow = await createWorkflow({ name: 'Elsewhere' }, otherProject);
await createExecution({ status: 'success', stoppedAt: recently() }, workflow);
const scoped = await repository.summariseRunsForProjects({
projectIds: [project.id],
stoppedAfter: windowStart(),
stoppedBefore: readTime(),
workflowLimit: 10,
});
expect(scoped).toEqual([]);
expect(await summarise()).toHaveLength(1);
});
it('counts a run once for a workflow shared into two projects', async () => {
const workflow = await createWorkflow({ name: 'Shared' }, project);
await shareWorkflowWithProjects(workflow, [{ project: otherProject }]);
await createExecution({ status: 'success', stoppedAt: recently() }, workflow);
const [summary] = await summarise();
expect(summary).toMatchObject({ total: 1 });
});
it('counts only workflows exposed to MCP when asked to', async () => {
const exposed = await createWorkflow(
{ name: 'Exposed', settings: { availableInMCP: true } },
project,
);
const withheld = await createWorkflow({ name: 'Withheld' }, project);
await createExecution({ status: 'success', stoppedAt: recently() }, exposed);
await createExecution({ status: 'error', stoppedAt: recently() }, withheld);
expect(await summarise()).toHaveLength(2);
const visible = await summarise({ mcpVisibleOnly: true });
expect(visible).toHaveLength(1);
expect(visible[0].workflowName).toBe('Exposed');
});
/**
* `isArchived` is inside the `mcpVisibleOnly` branch on purpose: the conversation surface
* has no MCP visibility rule and must keep seeing archived workflows' runs. Moving the
* predicate out of that branch would change the conversation surface silently.
*/
it('excludes an archived workflow only under mcpVisibleOnly', async () => {
const workflow = await createWorkflow(
{ name: 'Archived but flagged', isArchived: true, settings: { availableInMCP: true } },
project,
);
await createExecution({ status: 'error', stoppedAt: recently() }, workflow);
expect(await summarise()).toHaveLength(1);
expect(await summarise({ mcpVisibleOnly: true })).toEqual([]);
});
});
});

View File

@ -61,7 +61,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: null,
});
@ -77,7 +77,7 @@ describe('InstanceContextService', () => {
config.instanceAi.instanceContextEnabled = false;
try {
expect(await service.buildBlock({ user, projectId: project.id, cursor: null })).toBeNull();
expect(await service.buildBlock({ user, scope: bound(project.id), cursor: null })).toBeNull();
} finally {
config.instanceAi.instanceContextEnabled = true;
}
@ -101,7 +101,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: null,
});
@ -156,7 +156,7 @@ describe('InstanceContextService', () => {
const delta = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: {
activityMark: newest.id,
activitySeen: [newest.id],
@ -183,7 +183,7 @@ describe('InstanceContextService', () => {
const first = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: null,
});
await record({
@ -198,7 +198,7 @@ describe('InstanceContextService', () => {
const delta = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: first!.cursor,
});
@ -212,14 +212,14 @@ describe('InstanceContextService', () => {
const first = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: null,
});
expect(
await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: first!.cursor,
}),
).toBeNull();
@ -236,7 +236,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user,
projectId: otherProject.id,
scope: bound(otherProject.id),
cursor: null,
});
@ -249,7 +249,7 @@ describe('InstanceContextService', () => {
const built = await service.buildBlock({
user,
projectId: project.id,
scope: bound(project.id),
cursor: null,
});
@ -286,7 +286,9 @@ describe('InstanceContextService', () => {
resourceId: 'wf-1',
});
expect(await service.buildBlock({ user, cursor: null })).toBeNull();
expect(
await service.buildBlock({ user, scope: { surface: 'conversation' }, cursor: null }),
).toBeNull();
});
/**
@ -299,6 +301,228 @@ describe('InstanceContextService', () => {
const mcp = (credentialGranted = true): InstanceContextScope => ({
surface: 'mcp',
credentialGranted,
executionGranted: true,
});
it('reads the projects the caller can open and not the ones they cannot', async () => {
const mine = await createWorkflow(
{ name: 'Mine', settings: { availableInMCP: true } },
project,
);
const theirs = await createWorkflow(
{ name: 'Theirs', settings: { availableInMCP: true } },
otherProject,
);
await record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId: mine.id,
resourceName: mine.name,
});
await record({
category: 'workflow',
action: 'saved',
projectId: otherProject.id,
resourceType: 'workflow',
resourceId: theirs.id,
resourceName: theirs.name,
});
const entries = await service.list({ user, scope: mcp(), limit: 20 });
expect(entries.map((entry) => entry.resourceId)).toEqual([mine.id]);
});
it('does not report a workflow the instance withholds from MCP', async () => {
const visible = await createWorkflow(
{ name: 'Visible', settings: { availableInMCP: true } },
project,
);
// Withheld is the default: no `availableInMCP` at all reads as not available.
const withheld = await createWorkflow({ name: 'Withheld' }, project);
for (const workflow of [visible, withheld]) {
await record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId: workflow.id,
resourceName: workflow.name,
});
}
const entries = await service.list({ user, scope: mcp(), limit: 20 });
expect(entries.map((entry) => entry.resourceId)).toEqual([visible.id]);
});
/**
* The inventory and run legs are aggregates. Filtering their rows after the query would
* leave a total that still counts what the caller cannot see, so the filter has to be in
* the SQL — which only a real database can prove.
*/
it('counts only visible workflows in the opening block, not just lists them', async () => {
const visible = await createWorkflow(
{ name: 'Visible', settings: { availableInMCP: true } },
project,
);
await createWorkflow({ name: 'Withheld one' }, project);
await createWorkflow({ name: 'Withheld two' }, project);
await createExecution({ status: 'error', stoppedAt: recently() }, visible);
const built = await service.buildBlock({ user, scope: mcp(), cursor: null });
expect(built?.block).toContain('Workflows that already exist here: 1');
expect(built?.block).toContain('Visible');
expect(built?.block).not.toContain('Withheld');
});
it('leaves a withheld workflow out of the run counts', async () => {
const withheld = await createWorkflow({ name: 'Nightly sync' }, project);
await createExecution({ status: 'error', stoppedAt: recently() }, withheld);
const built = await service.buildBlock({ user, scope: mcp(), cursor: null });
expect(built?.block ?? '').not.toContain('Nightly sync');
});
/**
* A chat turn has no error channel so it degrades to no block, but an MCP caller reads an
* empty answer as "nothing exists here yet" — the two must not look the same.
*/
it('throws on a failed read instead of answering as though the instance were empty', async () => {
const spy = vi
.spyOn(activity, 'findFeed')
.mockRejectedValueOnce(new Error('db is down'))
.mockRejectedValueOnce(new Error('db is down'));
try {
await expect(service.buildBlock({ user, scope: mcp(), cursor: null })).rejects.toThrow(
'db is down',
);
await expect(
service.buildBlock({ user, scope: bound(project.id), cursor: null }),
).resolves.toBeNull();
} finally {
spy.mockRestore();
}
});
/**
* Every other MCP read refuses an archived workflow before it looks at the setting, so the
* feed must not be the one door that reports its history.
*/
it('treats an archived workflow as withheld even when it is marked available', async () => {
const archived = await createWorkflow(
{ name: 'Archived', isArchived: true, settings: { availableInMCP: true } },
project,
);
await record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId: archived.id,
resourceName: archived.name,
});
expect(await service.list({ user, scope: mcp(), limit: 20 })).toEqual([]);
});
/**
* A deleted workflow keeps its deletion and loses the rest: the setting that withheld it
* is gone with the row, so releasing its earlier history would undo that setting after
* the fact.
*/
it('keeps only the deletion for a workflow that no longer exists', async () => {
for (const action of ['created', 'saved', 'deleted']) {
await record({
category: 'workflow',
action,
projectId: project.id,
resourceType: 'workflow',
resourceId: 'wf-long-gone',
resourceName: 'Nightly sync',
});
}
const viaMcp = await service.list({ user, scope: mcp(), limit: 20 });
expect(viaMcp.map((entry) => entry.action)).toEqual(['deleted']);
// The conversation surface has no per-workflow visibility rule, so it sees them all.
const viaChat = await service.list({ user, scope: bound(project.id), limit: 20 });
expect(viaChat).toHaveLength(3);
});
it('answers a withheld id exactly as it answers a pruned one', async () => {
const withheld = await createWorkflow({ name: 'Withheld' }, project);
await record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId: withheld.id,
resourceName: withheld.name,
});
const [entry] = await activity.findFeed({ projectIds: [project.id], limit: 1 });
expect(await service.expand({ id: entry.id, user, scope: mcp() })).toBeNull();
expect(await service.expand({ id: entry.id + 5_000, user, scope: mcp() })).toBeNull();
});
/** The two surfaces do not share tool names, so the hint must be named for its caller. */
it('names the live record in MCP tool vocabulary, not Instance AI vocabulary', async () => {
const workflow = await createWorkflow(
{ name: 'Lead enrichment', settings: { availableInMCP: true } },
project,
);
await record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId: workflow.id,
resourceName: workflow.name,
});
const [entry] = await activity.findFeed({ projectIds: [project.id], limit: 1 });
const viaMcp = await service.expand({ id: entry.id, user, scope: mcp() });
const viaChat = await service.expand({ id: entry.id, user, scope: bound(project.id) });
expect(viaMcp?.liveRecordHint).toBe(`get_workflow_details(workflowId="${workflow.id}")`);
expect(viaChat?.liveRecordHint).toBe(`workflows(action="get", workflowId="${workflow.id}")`);
});
it('reads no credential history for a caller without the credential grant', async () => {
await record({
category: 'credential',
action: 'created',
projectId: project.id,
resourceType: 'credential',
resourceId: 'cred-1',
resourceName: 'Slack account',
});
expect(await service.list({ user, scope: mcp(false), limit: 20 })).toEqual([]);
expect(await service.list({ user, scope: mcp(), limit: 20 })).toHaveLength(1);
});
});
/**
* The MCP surface reads without a conversation to bind to, and under the per-workflow
* visibility rule the rest of that surface already enforces. Both are read from real rows:
* `availableInMCP` lives inside the workflow `settings` JSON column, and a mocked repository
* would report the filter working whatever that column actually holds.
*/
describe('the MCP surface', () => {
const mcp = (credentialGranted = true, executionGranted = true): InstanceContextScope => ({
surface: 'mcp',
credentialGranted,
executionGranted,
});
it('reads the projects the caller can open and not the ones they cannot', async () => {

View File

@ -107,4 +107,60 @@ describe('WorkflowRepository.findRecentForProjects', () => {
expect(await repository.findRecentForProjects([], 10)).toEqual({ total: 0, workflows: [] });
});
/**
* A whole-instance reader gets `'all-projects'` rather than a list, which drops the shared
* join entirely. That is the branch every instance owner takes, so it needs its own cases.
*/
describe("the 'all-projects' scope", () => {
it('names a workflow whose project the list scope never mentions', async () => {
await createWorkflow({ name: 'Elsewhere' }, otherProject);
const scoped = await repository.findRecentForProjects([project.id], 10);
const all = await repository.findRecentForProjects('all-projects', 10);
expect(scoped.total).toBe(0);
expect(all.total).toBe(1);
expect(all.workflows.map((w) => w.name)).toEqual(['Elsewhere']);
});
/** Without the join there is no row multiplication left to guard against — prove it. */
it('counts a workflow shared into two projects once', async () => {
const workflow = await createWorkflow({ name: 'Shared' }, project);
await shareWorkflowWithProjects(workflow, [{ project: otherProject }]);
const all = await repository.findRecentForProjects('all-projects', 10);
expect(all.total).toBe(1);
expect(all.workflows).toHaveLength(1);
});
it('counts only what carries the MCP setting when asked to', async () => {
await createWorkflow({ name: 'Exposed', settings: { availableInMCP: true } }, project);
await createWorkflow({ name: 'Withheld' }, otherProject);
const unfiltered = await repository.findRecentForProjects('all-projects', 10);
const visible = await repository.findRecentForProjects('all-projects', 10, {
mcpVisibleOnly: true,
});
expect(unfiltered.total).toBe(2);
expect(visible.total).toBe(1);
expect(visible.workflows.map((w) => w.name)).toEqual(['Exposed']);
});
/** Archived counts as withheld, matching every other MCP read. */
it('excludes an archived workflow even when it carries the setting', async () => {
await createWorkflow(
{ name: 'Archived but flagged', isArchived: true, settings: { availableInMCP: true } },
project,
);
const visible = await repository.findRecentForProjects('all-projects', 10, {
mcpVisibleOnly: true,
});
expect(visible).toEqual({ total: 0, workflows: [] });
});
});
});