mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Give MCP clients the opening instance context (no-changelog) (#38292)
This commit is contained in:
parent
cd623fd6ca
commit
ba16e4f08d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
174
packages/cli/src/modules/mcp/tools/get-instance-context.tool.ts
Normal file
174
packages/cli/src/modules/mcp/tools/get-instance-context.tool.ts
Normal 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.';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 : '',
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user