feat(core): Save, update and undo a preference over MCP (no-changelog) (#39436)

Co-authored-by: Roman Makowiak <roman.makowiak@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ricardo Espinoza 2026-09-24 09:35:56 +00:00 committed by GitHub
parent a2370042f4
commit b4106dcf63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2194 additions and 95 deletions

View File

@ -32,6 +32,7 @@ export const MCP_INSTANCE_SCOPES = [
'tag:read',
'communityPackage:install',
'aiPreference:read',
'aiPreference:write',
] as const;
export type McpScope = (typeof MCP_INSTANCE_SCOPES)[number];

View File

@ -19,6 +19,12 @@ describe('aiPreference scope', () => {
expect(RESOURCES.aiPreference).toContain('read');
});
// `aiPreference:write` is an OAuth consent scope, not an RBAC operation: like `workflow:write`
// it unlocks tools, and the service then applies these three operations to the row.
it('offers the create, update and delete operations the MCP write grant maps to', () => {
expect(RESOURCES.aiPreference).toEqual(expect.arrayContaining(['create', 'update', 'delete']));
});
it("is granted to the roles that may see other users' rows", () => {
expect(GLOBAL_OWNER_SCOPES).toContain('aiPreference:read');
expect(GLOBAL_ADMIN_SCOPES).toContain('aiPreference:read');

View File

@ -54,9 +54,18 @@ export const CONTEXT_TELEMETRY = defineTelemetryEvents({
source: z
.enum(['row', 'bulk', 'rejected'])
.describe(
'`row` is the per-row Delete button and `bulk` the selection toolbar on the settings page. `rejected` is the Undo on the chat card: the user did not accept a preference the assistant saved, a late refusal of one they first let stand',
'`row` is the per-row Delete button and `bulk` the selection toolbar on the settings page. `rejected` is the Undo on the chat card, Decline on the MCP review form, or the MCP undo tool: the user did not accept a preference the assistant saved, a late refusal of one they first let stand',
),
scope_types: z.array(scopeType).describe('Distinct scopes the deleted preferences covered'),
surface: assistantSurfaceSchema
.optional()
.describe('Set on a `rejected` delete, to tell the chat card from an MCP client'),
seconds_since_saved: z
.number()
.optional()
.describe(
'On a `rejected` delete, how long after the assistant write the user took it back. A short gap means the write itself was wrong, not a later change of mind',
),
}),
},
@ -108,7 +117,7 @@ export const CONTEXT_TELEMETRY = defineTelemetryEvents({
PREFERENCE_CONFIRMATION_SHOWN: {
name: 'Preference confirmation shown',
description:
'The assistant saved a preference and the card that lets the user edit or undo it was shown. Fires with the write, whatever the user does next.',
'The assistant saved a preference and the user was given the way to edit or undo it: the chat card, the MCP review form, or, on an MCP client without elicitation, the tool result that tells the client to relay the saved text and the settings link. Fires with the write, whatever the user does next.',
properties: z.object({
surface: assistantSurfaceSchema,
scope_type: scopeType.describe('Scope the assistant offered'),
@ -125,7 +134,7 @@ export const CONTEXT_TELEMETRY = defineTelemetryEvents({
outcome: z
.enum(['accepted', 'accepted_after_edit', 'rejected'])
.describe(
'`accepted` is implicit, fired with the write. `accepted_after_edit` means the user changed the text from the card. `rejected` is reserved for a surface that asks before it writes',
'`accepted` is implicit, fired with the write. `accepted_after_edit` means the user changed the text from the card or the MCP review form. `rejected` is reserved for a surface that asks before it writes',
),
scope_type: scopeType.describe(
'Scope the preference was saved with, or the offered scope on a rejection',

View File

@ -19,7 +19,7 @@ import {
INSTANCE_AI_SETUP_PANEL_ENABLED_VARIANT,
INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT,
} from '@n8n/api-types';
import type { AiGatewayConfigDto, AiPreferenceDto } from '@n8n/api-types';
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
@ -83,7 +83,6 @@ import type {
UpsertEvaluationConfigInput,
InstanceAiActivityService,
InstanceAiPreferenceService,
InstanceAiPreferenceWriteRejection,
InstanceAiMcpService,
InstanceAiExecuteNodeService,
ExecuteNodeResult as InstanceAiExecuteNodeResult,
@ -147,9 +146,7 @@ import { CollaborationService } from '@/collaboration/collaboration.service';
import { CredentialsOverwrites } from '@/credentials-overwrites';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { LockedError } from '@/errors/response-errors/locked.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EvaluationConfigService } from '@/evaluation.ee/evaluation-config.service';
@ -178,6 +175,7 @@ import { userHasScopes } from '@/permissions.ee/check-access';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PostHogClient } from '@/posthog';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { writeAssistantPreference } from '@/services/ai-preference-write';
import { AiPreferenceService } from '@/services/ai-preference.service';
import { FolderFinderService } from '@/services/folder-finder.service';
import { FolderService } from '@/services/folder.service';
@ -327,21 +325,6 @@ function toTelemetryReason(
}
}
/** The tool package cannot import cli error classes, so the boundary speaks in
* reasons. Only `scope: 'user'` reaches the service from here, so the one
* BadRequestError it can raise is the per-scope cap. The three mapped classes
* carry user-facing text, so their message passes through; anything else is
* an unexpected fault, so its message stays internal (the caller logs it). */
function toPreferenceWriteRejection(error: unknown): {
reason: InstanceAiPreferenceWriteRejection;
message: string;
} {
if (error instanceof ConflictError) return { reason: 'duplicate', message: error.message };
if (error instanceof BadRequestError) return { reason: 'scope_full', message: error.message };
if (error instanceof ForbiddenError) return { reason: 'not_permitted', message: error.message };
return { reason: 'failed', message: 'The preference could not be saved.' };
}
// Credential types are loaded once at boot, so the derived host index is
// process-global and safe to memoize across users.
let httpCredentialHostsCache: CredentialHostInfo[] | undefined;
@ -721,60 +704,19 @@ export class InstanceAiAdapterService {
return {
create: async ({ content, scope }) => {
const textLength = content.length;
// Only the write sits in the try: a telemetry fault after a committed row
// must not turn into `ok: false`, or the model reports a failed save and
// a retry runs into the duplicate check.
let dto: AiPreferenceDto;
try {
dto = await aiPreferenceService.create(user, { content, scope }, 'aia');
} catch (error) {
const rejection = toPreferenceWriteRejection(error);
// The three mapped classes are expected outcomes with their own
// user-facing text. Anything else is a real fault whose message stays
// internal, so it must not go unlogged.
if (rejection.reason === 'failed') {
this.logger.error('Saving an AI preference from the assistant failed', { error });
}
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED, {
surface: 'aia',
reason: rejection.reason,
scope_type: scope,
text_length: textLength,
});
return { ok: false, ...rejection };
}
try {
// Write-first: the card is the confirmation, shown after the write, and
// doing nothing is agreement, so shown and resolved(accepted) fire together.
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_SHOWN, {
surface: 'aia',
scope_type: scope,
text_length: textLength,
});
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED, {
surface: 'aia',
outcome: 'accepted',
scope_type: scope,
text_length: textLength,
});
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_SCOPE_ACCEPTED, {
surface: 'aia',
offered_scope: scope,
accepted_scope: scope,
scope_changed: false,
});
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE, {
surface: 'aia',
scope_type: scope,
text_length: textLength,
replaced_existing: false,
});
} catch (error) {
this.logger.warn('Preference telemetry failed after the row was saved', { error });
}
return { ok: true, preference: { id: dto.id, content: dto.content, scope } };
// The write, the refusal mapping and the events are shared with the MCP tool.
const result = await writeAssistantPreference({
aiPreferenceService,
telemetry: this.telemetry,
logger: this.logger,
user,
surface: 'aia',
content,
scope,
});
if (!result.ok) return result;
const { id, content: saved } = result.preference;
return { ok: true, preference: { id, content: saved, scope } };
},
recordRejection: (reason, textLength) => {
this.telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED, {

View File

@ -175,6 +175,15 @@ describe('McpProtectedResource', () => {
]);
});
it('advertises the preferences write scope with its three tools', async () => {
expect(resource.scopes).toContain('aiPreference:write');
expect((await resource.getScopeTools())['aiPreference:write']).toEqual([
'save_user_preference',
'update_user_preference',
'undo_user_preference',
]);
});
// The flag is per-user PostHog and unreachable from the descriptor, so the scope is
// offered to everyone; granting it yields no tool until the flag is on.
it('keeps advertising the preferences scope regardless of the builder', async () => {

View File

@ -115,6 +115,14 @@ describe('getAllowedToolNames', () => {
expect(getAllowedToolNames(['aiPreference:read'])).toEqual(new Set(['get_user_preferences']));
});
// The read tool does not ride along on the write grant: the consent screen shows them as
// two scopes, and a client that may write is expected to hold both.
it('resolves the preferences write scope to the save, update and undo tools only', () => {
expect(getAllowedToolNames(['aiPreference:write'])).toEqual(
new Set(['save_user_preference', 'update_user_preference', 'undo_user_preference']),
);
});
it('ignores unknown scopes', () => {
expect(getAllowedToolNames(['tool:listWorkflows', 'openid'])).toEqual(new Set());
});
@ -483,6 +491,59 @@ describe('McpService scope enforcement', () => {
});
});
describe('preference write tools registration', () => {
const WRITE_TOOLS = ['save_user_preference', 'update_user_preference', 'undo_user_preference'];
it('registers the three write tools when the preferences flag is on', async () => {
const server = await buildService().getServer(
user,
mcpFeatureFlags({ aiPreferencesEnabled: true }),
);
for (const name of WRITE_TOOLS) expect(getRegisteredToolNames(server)).toContain(name);
});
it('registers none of them when the preferences flag is off', async () => {
const server = await buildService().getServer(
user,
mcpFeatureFlags({ aiPreferencesEnabled: false }),
);
for (const name of WRITE_TOOLS) expect(getRegisteredToolNames(server)).not.toContain(name);
});
it('registers them with the builder disabled, like the read tool', async () => {
const server = await buildService({ builderEnabled: false }).getServer(
user,
mcpFeatureFlags({ aiPreferencesEnabled: true }),
);
for (const name of WRITE_TOOLS) expect(getRegisteredToolNames(server)).toContain(name);
});
// A granted scope set is fixed at authorization, so a client that consented before the
// write scope existed cannot write until the user consents again.
it('keeps them out of reach of a grant that holds only the read scope', async () => {
const server = await buildService().getServer(user, mcpFeatureFlags(), undefined, {
grantedScopes: ['aiPreference:read'],
});
const registered = getRegisteredToolNames(server);
expect(registered).toContain('get_user_preferences');
for (const name of WRITE_TOOLS) expect(registered).not.toContain(name);
});
it('registers them, and not the read tool, for a write-only grant', async () => {
const server = await buildService().getServer(user, mcpFeatureFlags(), undefined, {
grantedScopes: ['aiPreference:write'],
});
const registered = getRegisteredToolNames(server);
expect(registered).not.toContain('get_user_preferences');
for (const name of WRITE_TOOLS) expect(registered).toContain(name);
});
});
it('does not register folder tools when folders are not licensed', async () => {
const server = await buildService({ foldersLicensed: false }).getServer(
user,

View File

@ -0,0 +1,513 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import { AI_PREFERENCE_CONTENT_MAX_LENGTH, AI_PREFERENCE_MAX_PER_SCOPE } from '@n8n/api-types';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
import type { InputRequiredResult } from '@modelcontextprotocol/server';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { AiPreferenceService } from '@/services/ai-preference.service';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import type { ToolHandlerResult } from '../mcp.types';
import { createSaveUserPreferenceTool } from '../tools/save-user-preference.tool';
/**
* The ticket fixes the description as a requirement: there is no system prompt on the MCP side,
* so this text alone decides when a client saves and whether it reports the write back. It is
* asserted verbatim so an edit has to be deliberate.
*/
const DESCRIPTION = [
'Saves a preference about how the user likes to work with n8n, so the n8n assistant and every connected AI tool apply it from now on: node and credential choices, naming, how work is organised, patterns to avoid.',
'Call this only for a durable preference the user states about their way of working, such as "always add an error workflow" or "name nodes in English". Do not call it for an instruction that applies to the current task only, such as "make this one a POST request", and do not infer a preference the user did not state.',
'Read get_user_preferences first. If a saved preference already covers the same ground, call update_user_preference with its id instead of saving a near-duplicate; saving the exact same text again is refused.',
'The preference is saved at once, without a confirmation step. In the same turn, tell the user the exact text that was saved and give them the settings link from the result, so they can check it. If they want it changed, call update_user_preference; if they want it gone, call undo_user_preference.',
].join('\n\n');
const URL = 'https://n8n.example.com/settings/context/preferences';
const ID = '0f6c3b5a-4d2e-4c1b-9a8f-1e2d3c4b5a69';
const TEXT = 'Always add an error workflow.';
const user = Object.assign(new User(), {
id: 'user-1',
role: { slug: 'global:member', scopes: [] },
});
const dto = (overrides: Partial<AiPreferenceDto> = {}): AiPreferenceDto => ({
id: ID,
content: TEXT,
userId: 'user-1',
user: null,
projectId: null,
project: null,
source: 'mcp',
scopes: ['aiPreference:read', 'aiPreference:update', 'aiPreference:delete'],
createdAt: new Date(Date.now() - 30_000).toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
const createMocks = () => {
const aiPreferenceService = mockInstance(AiPreferenceService, {
create: vi.fn().mockResolvedValue(dto()),
updateContent: vi.fn(),
undoWrite: vi.fn(),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
const urlService = mockInstance(UrlService, {
getInstanceBaseUrl: vi.fn().mockReturnValue('https://n8n.example.com'),
});
const tool = createSaveUserPreferenceTool(
user,
aiPreferenceService,
telemetry,
urlService,
mockLogger(),
);
return { aiPreferenceService, telemetry, tool };
};
/** The slice of the SDK handler context the tool reads. `requestState: null` means none echoed. */
const ctx = ({
elicitation = false,
review,
requestState = ID,
}: {
elicitation?: boolean | Record<string, unknown>;
review?: { action: 'accept' | 'decline' | 'cancel'; content?: Record<string, unknown> };
requestState?: string | null;
} = {}) => ({
mcpReq: {
envelope: elicitation
? { [CLIENT_CAPABILITIES_META_KEY]: { elicitation: elicitation === true ? {} : elicitation } }
: {},
inputResponses: review ? { review } : undefined,
requestState: () => requestState ?? undefined,
},
});
const isInputRequired = (result: ToolHandlerResult): result is InputRequiredResult =>
'resultType' in result && result.resultType === 'input_required';
const structured = (result: ToolHandlerResult) => {
if (isInputRequired(result)) throw new Error('expected a tool result');
return result.structuredContent as Record<string, unknown>;
};
const text = (result: ToolHandlerResult) => {
if (isInputRequired(result)) throw new Error('expected a tool result');
const item = result.content[0];
return item.type === 'text' ? item.text : '';
};
describe('save_user_preference MCP tool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('tool definition', () => {
test('is named save_user_preference', () => {
expect(createMocks().tool.name).toBe('save_user_preference');
});
test('carries the agreed description verbatim', () => {
expect(createMocks().tool.config.description).toBe(DESCRIPTION);
});
test('carries both caps in its content field, so the model reads them before it writes', () => {
const { tool } = createMocks();
const content = tool.config.inputSchema!.content;
expect(Object.keys(tool.config.inputSchema!)).toEqual(['content']);
expect(content.safeParse('x'.repeat(AI_PREFERENCE_CONTENT_MAX_LENGTH)).success).toBe(true);
expect(content.safeParse('x'.repeat(AI_PREFERENCE_CONTENT_MAX_LENGTH + 1)).success).toBe(
false,
);
expect(content.safeParse(' ').success).toBe(false);
expect(content.description).toContain(String(AI_PREFERENCE_CONTENT_MAX_LENGTH));
expect(content.description).toContain(String(AI_PREFERENCE_MAX_PER_SCOPE));
});
test('is annotated as a non-destructive write', () => {
expect(createMocks().tool.config.annotations).toMatchObject({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
});
});
});
describe('the write', () => {
test('saves a personal preference at once, with the source fixed to mcp', async () => {
const { aiPreferenceService, tool } = createMocks();
await tool.handler({ content: TEXT }, ctx());
expect(aiPreferenceService.create).toHaveBeenCalledWith(
user,
{ content: TEXT, scope: 'user' },
'mcp',
);
});
test('names the saved text, the id, the scope and the settings link', async () => {
const { tool } = createMocks();
const result = await tool.handler({ content: TEXT }, ctx());
expect(structured(result)).toEqual({
saved: true,
preference: { id: ID, scope: 'user', text: TEXT, url: URL },
});
expect(text(result)).toContain(`"${TEXT}"`);
expect(text(result)).toContain(URL);
});
// The write goes through the shared assistant write, so the same four events fire here as
// for the chat: shown and accepted fire with the write, because silence keeps the row.
test('reports the write with the shared assistant events on the mcp surface', async () => {
const { telemetry, tool } = createMocks();
await tool.handler({ content: TEXT }, ctx());
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE,
{ surface: 'mcp', scope_type: 'user', text_length: TEXT.length, replaced_existing: false },
);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_SHOWN,
{ surface: 'mcp', scope_type: 'user', text_length: TEXT.length },
);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED,
{ surface: 'mcp', outcome: 'accepted', scope_type: 'user', text_length: TEXT.length },
);
});
test('records the length and the elicitation capability on the tool event, never the text', async () => {
const { telemetry, tool } = createMocks();
await tool.handler({ content: TEXT }, ctx());
expect(telemetry.track).toHaveBeenCalledWith(USER_CALLED_MCP_TOOL_EVENT, {
user_id: 'user-1',
tool_name: 'save_user_preference',
parameters: { text_length: TEXT.length, can_elicit: false },
results: { success: true, data: { saved: true, removed: false, review: 'none' } },
});
expect(JSON.stringify(vi.mocked(telemetry.track).mock.calls)).not.toContain(TEXT);
});
test('returns the plain saved result to a client that declared no elicitation', async () => {
const { tool } = createMocks();
const result = await tool.handler({ content: TEXT }, ctx({ elicitation: false }));
expect(isInputRequired(result)).toBe(false);
expect(structured(result)).toMatchObject({ saved: true });
});
// On this revision a client names its modes. One that offers only `url` has no form to show.
test('returns the plain saved result to a client that declared URL elicitation only', async () => {
const { tool } = createMocks();
const result = await tool.handler({ content: TEXT }, ctx({ elicitation: { url: {} } }));
expect(isInputRequired(result)).toBe(false);
expect(structured(result)).toMatchObject({ saved: true });
});
test('shows the form to a client that named the form mode', async () => {
const { tool } = createMocks();
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: { form: {}, url: {} } }),
);
expect(isInputRequired(result)).toBe(true);
});
test('serves a call with no handler context like a client without elicitation', async () => {
const { tool } = createMocks();
const result = await tool.handler({ content: TEXT });
expect(structured(result)).toMatchObject({ saved: true });
});
});
describe('refusals', () => {
test.each([
[new ConflictError('dup'), 'duplicate', 'dup'],
[new BadRequestError('cap'), 'scope_full', 'cap'],
[new ForbiddenError('no'), 'not_permitted', 'no'],
[new Error('db down'), 'failed', 'The preference could not be saved.'],
])(
'relays the refusal %s as a readable result with reason %s',
async (error, reason, message) => {
const { aiPreferenceService, telemetry, tool } = createMocks();
aiPreferenceService.create.mockRejectedValue(error);
const result = await tool.handler({ content: TEXT }, ctx());
expect(isInputRequired(result)).toBe(false);
if (isInputRequired(result)) return;
expect(result.isError).toBe(true);
expect(structured(result)).toEqual({ saved: false, error: message, reason });
expect(text(result)).toContain('Nothing was saved');
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED,
{ surface: 'mcp', reason, scope_type: 'user', text_length: TEXT.length },
);
},
);
test('leaves the duplicate rule to the service: the tool never reads existing preferences', async () => {
const { aiPreferenceService, tool } = createMocks();
await tool.handler({ content: TEXT }, ctx());
expect(aiPreferenceService.getApplicableAcrossProjects).not.toHaveBeenCalled();
});
});
describe('the review form on a client with elicitation', () => {
test('writes first, then asks for input with the saved text prefilled', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
const result = await tool.handler({ content: TEXT }, ctx({ elicitation: true }));
expect(aiPreferenceService.create).toHaveBeenCalledTimes(1);
expect(isInputRequired(result)).toBe(true);
if (!isInputRequired(result)) return;
expect(result.requestState).toBe(ID);
const request = result.inputRequests?.review;
expect(request).toMatchObject({
method: 'elicitation/create',
params: {
message: expect.stringContaining(`"${TEXT}"`),
requestedSchema: {
type: 'object',
properties: {
text: { type: 'string', default: TEXT, maxLength: AI_PREFERENCE_CONTENT_MAX_LENGTH },
},
},
},
});
// The person learns where to manage the preference, and which consent stops more saves.
const message = request?.method === 'elicitation/create' ? request.params.message : '';
expect(message).toMatch(/^Saved to your n8n preferences\. Accept to keep it, or Decline/);
expect(message).toContain('Settings > Context > Preferences');
expect(message).toContain('"Save, update and undo AI preferences" permission');
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_SHOWN,
{ surface: 'mcp', scope_type: 'user', text_length: TEXT.length },
);
});
// `accepted` fired with the write, so an unchanged accept adds no event of its own.
test('keeps the preference when the form is accepted unchanged, without a second event', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'accept', content: { text: TEXT } } }),
);
expect(aiPreferenceService.create).not.toHaveBeenCalled();
expect(aiPreferenceService.updateContent).not.toHaveBeenCalled();
expect(aiPreferenceService.undoWrite).not.toHaveBeenCalled();
expect(structured(result)).toEqual({
saved: true,
preference: { id: ID, scope: 'user', text: TEXT, url: URL },
});
expect(telemetry.track).toHaveBeenCalledTimes(1);
expect(telemetry.track).toHaveBeenCalledWith(USER_CALLED_MCP_TOOL_EVENT, expect.anything());
});
test('applies an edit to the same row and reports it as accepted after edit', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
const edited = 'Always add an error workflow that posts to Slack.';
aiPreferenceService.updateContent.mockResolvedValue(dto({ content: edited }));
const result = await tool.handler(
{ content: TEXT },
ctx({
elicitation: true,
review: { action: 'accept', content: { text: ` ${edited} ` } },
}),
);
expect(aiPreferenceService.updateContent).toHaveBeenCalledWith(user, ID, edited);
expect(aiPreferenceService.create).not.toHaveBeenCalled();
expect(structured(result)).toEqual({
saved: true,
preference: { id: ID, scope: 'user', text: edited, url: URL },
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED,
{
surface: 'mcp',
outcome: 'accepted_after_edit',
scope_type: 'user',
text_length: edited.length,
},
);
});
// Reported as the chat card reports its Undo: a deleted preference with source `rejected`.
test('reports a declined form as a rejected delete with the time since the write', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
aiPreferenceService.undoWrite.mockResolvedValue(dto());
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'decline', content: { text: TEXT } } }),
);
expect(aiPreferenceService.undoWrite).toHaveBeenCalledWith(user, ID, 'mcp');
expect(aiPreferenceService.updateContent).not.toHaveBeenCalled();
expect(structured(result)).toEqual({ saved: false, removed: true });
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.USER_DELETED_PREFERENCES,
{
count: 1,
source: 'rejected',
scope_types: ['user'],
surface: 'mcp',
seconds_since_saved: 30,
},
);
expect(telemetry.track).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED,
expect.anything(),
);
});
// Every client offers Decline, and a press after the write means "not this one".
test('removes the row when the form is declined without content', async () => {
const { aiPreferenceService, tool } = createMocks();
aiPreferenceService.undoWrite.mockResolvedValue(dto());
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'decline' } }),
);
expect(aiPreferenceService.undoWrite).toHaveBeenCalledWith(user, ID, 'mcp');
expect(aiPreferenceService.create).not.toHaveBeenCalled();
expect(structured(result)).toEqual({ saved: false, removed: true });
});
// A closed form is silence, and silence must not delete data.
test('keeps the row and resolves nothing when the form is cancelled', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'cancel' } }),
);
expect(aiPreferenceService.undoWrite).not.toHaveBeenCalled();
expect(aiPreferenceService.updateContent).not.toHaveBeenCalled();
expect(aiPreferenceService.create).not.toHaveBeenCalled();
expect(structured(result)).toEqual({
saved: true,
preference: { id: ID, scope: 'user', text: TEXT, url: URL },
});
expect(text(result)).toContain('stays as saved');
expect(telemetry.track).toHaveBeenCalledTimes(1);
expect(telemetry.track).toHaveBeenCalledWith(
USER_CALLED_MCP_TOOL_EVENT,
expect.objectContaining({
results: { success: true, data: { saved: true, removed: false, review: 'cancel' } },
}),
);
});
test('refuses an edit over the length cap without touching the row', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
const tooLong = 'x'.repeat(AI_PREFERENCE_CONTENT_MAX_LENGTH + 1);
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'accept', content: { text: tooLong } } }),
);
expect(aiPreferenceService.updateContent).not.toHaveBeenCalled();
expect(isInputRequired(result)).toBe(false);
if (isInputRequired(result)) return;
expect(result.isError).toBe(true);
expect(structured(result)).toMatchObject({
saved: true,
preference: { id: ID, text: TEXT },
reason: 'too_long',
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED,
{ surface: 'mcp', reason: 'too_long', scope_type: 'user', text_length: tooLong.length },
);
});
test('reports a refused edit and states that the original text stands', async () => {
const { aiPreferenceService, tool } = createMocks();
aiPreferenceService.updateContent.mockRejectedValue(new ConflictError('dup'));
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'accept', content: { text: 'Other.' } } }),
);
expect(isInputRequired(result)).toBe(false);
if (isInputRequired(result)) return;
expect(result.isError).toBe(true);
expect(structured(result)).toMatchObject({
saved: true,
preference: { id: ID, text: TEXT },
reason: 'duplicate',
});
expect(text(result)).toContain(`"${TEXT}"`);
});
test('reports a failed removal after Decline and states that the preference is still saved', async () => {
const { aiPreferenceService, tool } = createMocks();
aiPreferenceService.undoWrite.mockRejectedValue(new Error('db down'));
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'decline' } }),
);
expect(isInputRequired(result)).toBe(false);
if (isInputRequired(result)) return;
expect(result.isError).toBe(true);
expect(structured(result)).toMatchObject({ saved: true, reason: 'failed' });
expect(text(result)).toContain('still saved');
});
test.each([null, 'not-a-uuid'])(
'writes nothing twice when the retry carries no usable request state (%s)',
async (requestState) => {
const { aiPreferenceService, tool } = createMocks();
const result = await tool.handler(
{ content: TEXT },
ctx({ elicitation: true, review: { action: 'accept', content: {} }, requestState }),
);
expect(aiPreferenceService.create).not.toHaveBeenCalled();
expect(aiPreferenceService.updateContent).not.toHaveBeenCalled();
expect(aiPreferenceService.undoWrite).not.toHaveBeenCalled();
expect(structured(result)).toMatchObject({ saved: true });
expect(text(result)).toContain('get_user_preferences');
},
);
});
});

View File

@ -0,0 +1,120 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiPreferenceService } from '@/services/ai-preference.service';
import { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import { createUndoUserPreferenceTool } from '../tools/undo-user-preference.tool';
const DESCRIPTION = [
'Removes a preference that save_user_preference saved for this user. Call it when the user takes back a preference that was just saved, or asks to forget one that a connected AI tool saved earlier.',
'It only removes preferences saved through a connected AI tool for this user. A preference the user wrote in n8n settings, or one saved for a project or the whole instance, is refused; the user removes those in settings.',
'Tell the user in the same turn that the preference is gone.',
].join('\n\n');
const user = Object.assign(new User(), {
id: 'user-1',
role: { slug: 'global:member', scopes: [] },
});
/** Built when the mock answers, so `seconds_since_saved` is read against a fresh clock. */
const removedRow = (): AiPreferenceDto => ({
id: 'pref-1',
content: 'Keep replies short.',
userId: 'user-1',
user: null,
projectId: null,
project: null,
source: 'mcp',
scopes: ['aiPreference:read', 'aiPreference:update', 'aiPreference:delete'],
createdAt: new Date(Date.now() - 120_000).toISOString(),
updatedAt: new Date().toISOString(),
});
const createMocks = () => {
const aiPreferenceService = mockInstance(AiPreferenceService, {
undoWrite: vi.fn().mockImplementation(async () => removedRow()),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
const logger = mockLogger();
const tool = createUndoUserPreferenceTool(user, aiPreferenceService, telemetry, logger);
return { aiPreferenceService, telemetry, tool, logger };
};
describe('undo_user_preference MCP tool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('is named undo_user_preference, carries the agreed description and is marked destructive', () => {
const { tool } = createMocks();
expect(tool.name).toBe('undo_user_preference');
expect(tool.config.description).toBe(DESCRIPTION);
expect(tool.config.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false });
});
test('removes through the narrow undo, never through the general delete', async () => {
const { aiPreferenceService, tool } = createMocks();
const result = await tool.handler({ id: 'pref-1' });
expect(aiPreferenceService.undoWrite).toHaveBeenCalledWith(user, 'pref-1', 'mcp');
expect(aiPreferenceService.delete).not.toHaveBeenCalled();
expect(result.structuredContent).toEqual({ removed: true, id: 'pref-1' });
});
// The same event the chat card fires on Undo, so one number covers every surface.
test('reports the undo as a rejected delete with the time since the write', async () => {
const { telemetry, tool } = createMocks();
await tool.handler({ id: 'pref-1' });
expect(telemetry.track).toHaveBeenCalledWith(TELEMETRY_EVENT.CONTEXT.USER_DELETED_PREFERENCES, {
count: 1,
source: 'rejected',
scope_types: ['user'],
surface: 'mcp',
seconds_since_saved: 120,
});
expect(telemetry.track).toHaveBeenCalledWith(USER_CALLED_MCP_TOOL_EVENT, {
user_id: 'user-1',
tool_name: 'undo_user_preference',
parameters: {},
results: { success: true, data: { removed: true } },
});
});
test.each([
[new NotFoundError('not saved by mcp for you'), 'not_found', 'not saved by mcp for you'],
[new ForbiddenError('no'), 'not_permitted', 'no'],
[new Error('db down'), 'failed', 'The preference could not be removed.'],
])(
'relays what the service refuses (%s) as %s and removes nothing',
async (error, reason, message) => {
const { aiPreferenceService, telemetry, tool, logger } = createMocks();
aiPreferenceService.undoWrite.mockRejectedValue(error);
const result = await tool.handler({ id: 'pref-1' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
removed: false,
id: 'pref-1',
error: message,
reason,
});
// A fault keeps its text away from the client and goes to the log instead.
expect(logger.error).toHaveBeenCalledTimes(reason === 'failed' ? 1 : 0);
expect(telemetry.track).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.USER_DELETED_PREFERENCES,
expect.anything(),
);
},
);
});

View File

@ -0,0 +1,140 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import { AI_PREFERENCE_CONTENT_MAX_LENGTH } from '@n8n/api-types';
import { mockInstance, mockLogger } from '@n8n/backend-test-utils';
import { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiPreferenceService } from '@/services/ai-preference.service';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
import { USER_CALLED_MCP_TOOL_EVENT } from '../mcp.constants';
import { createUpdateUserPreferenceTool } from '../tools/update-user-preference.tool';
const DESCRIPTION = [
'Replaces the text of a saved preference. Use it when the user changes or refines a preference that is already saved, instead of saving a second one next to it.',
'Take the id from get_user_preferences or from the result of the save. The scope stays where it is; only the text changes.',
'Tell the user the new text in the same turn, so they see what changed.',
].join('\n\n');
const URL = 'https://n8n.example.com/settings/context/preferences';
const user = Object.assign(new User(), {
id: 'user-1',
role: { slug: 'global:member', scopes: [] },
});
const dto = (overrides: Partial<AiPreferenceDto> = {}): AiPreferenceDto => ({
id: 'pref-1',
content: 'New text.',
userId: 'user-1',
user: null,
projectId: null,
project: null,
source: 'ui',
scopes: ['aiPreference:read', 'aiPreference:update', 'aiPreference:delete'],
createdAt: '2026-09-21T10:00:00.000Z',
updatedAt: '2026-09-21T10:00:00.000Z',
...overrides,
});
const createMocks = () => {
const aiPreferenceService = mockInstance(AiPreferenceService, {
updateContent: vi.fn().mockResolvedValue(dto()),
});
const telemetry = mockInstance(Telemetry, { track: vi.fn() });
const urlService = mockInstance(UrlService, {
getInstanceBaseUrl: vi.fn().mockReturnValue('https://n8n.example.com'),
});
const logger = mockLogger();
const tool = createUpdateUserPreferenceTool(
user,
aiPreferenceService,
telemetry,
urlService,
logger,
);
return { aiPreferenceService, telemetry, tool, logger };
};
describe('update_user_preference MCP tool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('is named update_user_preference and carries the agreed description verbatim', () => {
const { tool } = createMocks();
expect(tool.name).toBe('update_user_preference');
expect(tool.config.description).toBe(DESCRIPTION);
});
test('takes an id and the shared content schema, so the caps travel with it', () => {
const { tool } = createMocks();
expect(Object.keys(tool.config.inputSchema!)).toEqual(['id', 'content']);
expect(tool.config.inputSchema!.id.safeParse('').success).toBe(false);
expect(
tool.config.inputSchema!.content.safeParse('x'.repeat(AI_PREFERENCE_CONTENT_MAX_LENGTH + 1))
.success,
).toBe(false);
});
test('replaces the text in place and reports the result with the settings link', async () => {
const { aiPreferenceService, tool } = createMocks();
const result = await tool.handler({ id: 'pref-1', content: 'New text.' });
expect(aiPreferenceService.updateContent).toHaveBeenCalledWith(user, 'pref-1', 'New text.');
expect(aiPreferenceService.update).not.toHaveBeenCalled();
expect(result.structuredContent).toEqual({
saved: true,
preference: { id: 'pref-1', scope: 'user', text: 'New text.', url: URL },
});
// The text is what the client's model relays, so it has to carry the new text and the link.
const [item] = result.content;
const spoken = item.type === 'text' ? item.text : '';
expect(spoken).toContain('"New text."');
expect(spoken).toContain(URL);
});
test('reports the edit as an assistant save that replaced an existing row, with the row scope', async () => {
const { aiPreferenceService, telemetry, tool } = createMocks();
aiPreferenceService.updateContent.mockResolvedValue(dto({ content: 'Rule text.' }));
const result = await tool.handler({ id: 'pref-1', content: 'Rule text.' });
expect(result.structuredContent).toMatchObject({ preference: { scope: 'user' } });
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE,
{ surface: 'mcp', scope_type: 'user', text_length: 10, replaced_existing: true },
);
expect(telemetry.track).toHaveBeenCalledWith(USER_CALLED_MCP_TOOL_EVENT, {
user_id: 'user-1',
tool_name: 'update_user_preference',
parameters: { text_length: 10 },
results: { success: true, data: { scope: 'user' } },
});
});
test.each([
[new NotFoundError('gone'), 'not_found', 'not_permitted', 'gone'],
[new ConflictError('dup'), 'duplicate', 'duplicate', 'dup'],
[new Error('boom'), 'failed', 'failed', 'The preference could not be saved.'],
])('relays the refusal %s with reason %s', async (error, reason, rejectedReason, message) => {
const { aiPreferenceService, telemetry, tool, logger } = createMocks();
aiPreferenceService.updateContent.mockRejectedValue(error);
const result = await tool.handler({ id: 'pref-1', content: 'New text.' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({ saved: false, error: message, reason });
// Only a fault is logged: the mapped refusals are expected answers with their own text.
expect(logger.error).toHaveBeenCalledTimes(reason === 'failed' ? 1 : 0);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED,
{ surface: 'mcp', reason: rejectedReason, text_length: 9 },
);
});
});

View File

@ -1,7 +1,12 @@
import type { McpScope } from '@n8n/api-types';
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
import { MCP_GET_USER_PREFERENCES_TOOL_NAME } from './mcp.constants';
import {
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
} from './mcp.constants';
/**
* Maps each grantable OAuth scope to the MCP tools it unlocks. A tool is
@ -107,6 +112,14 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
// than an MCP-only string. Not builder-gated: preferences apply to Agents, data tables and
// folders too, none of which need the builder.
'aiPreference:read': [MCP_GET_USER_PREFERENCES_TOOL_NAME],
// The read tool does not ride along: a client that may write is expected to read first, and
// the consent screen shows that as two scopes. Undo is here rather than on a delete scope
// because it only removes what this surface wrote.
'aiPreference:write': [
MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
],
};
/**

View File

@ -52,6 +52,9 @@ export const MISSING_PROTOCOL_VERSION_ERROR_MESSAGE =
*/
export const LIST_N8N_GATEWAY_SERVICES_TOOL_NAME = 'list_n8n_gateway_services';
export const MCP_GET_USER_PREFERENCES_TOOL_NAME = 'get_user_preferences';
export const MCP_SAVE_USER_PREFERENCE_TOOL_NAME = 'save_user_preference';
export const MCP_UPDATE_USER_PREFERENCE_TOOL_NAME = 'update_user_preference';
export const MCP_UNDO_USER_PREFERENCE_TOOL_NAME = 'undo_user_preference';
/**
* Installs a vetted community package so its nodes become usable. Not in

View File

@ -1,4 +1,4 @@
import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
import type { InputRequiredResult, McpServer } from '@modelcontextprotocol/server';
import {
CREDENTIAL_DESCRIPTIONS_FLAG,
MCP_APPS_FLAG,
@ -22,6 +22,7 @@ import { lazyImport } from '@n8n/utils/lazy-import';
import { createDeferredPromise, type IDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { InstanceSettings } from 'n8n-core';
import { ManualExecutionCancelledError, type FeatureFlags, type IRun } from 'n8n-workflow';
import type z from 'zod';
import { ActiveExecutions } from '@/active-executions';
import { CollaborationService } from '@/collaboration/collaboration.service';
@ -70,6 +71,7 @@ import type {
RegisterResourceFn,
RegisterToolFn,
ToolDefinition,
ToolHandlerResult,
} from './mcp.types';
import { shapeToStandardSchema } from './tool-schema.util';
import { createCreateFolderTool } from './tools/create-folder.tool';
@ -109,13 +111,16 @@ import { createListTagsTool } from './tools/list-tags.tool';
import { createMoveWorkflowsToFolderTool } from './tools/move-workflows-to-folder.tool';
import { createPrepareTestPinDataTool } from './tools/prepare-workflow-pin-data.tool';
import { createPublishWorkflowTool } from './tools/publish-workflow.tool';
import { createSaveUserPreferenceTool } from './tools/save-user-preference.tool';
import { createSearchExecutionsTool } from './tools/search-executions.tool';
import { createSearchFoldersTool } from './tools/search-folders.tool';
import { createSearchProjectsTool } from './tools/search-projects.tool';
import { createSearchWorkflowsTool } from './tools/search-workflows.tool';
import { createTestWorkflowTool } from './tools/test-workflow.tool';
import { createUndoUserPreferenceTool } from './tools/undo-user-preference.tool';
import { createUnpublishWorkflowTool } from './tools/unpublish-workflow.tool';
import { createUpdateFolderTool } from './tools/update-folder.tool';
import { createUpdateUserPreferenceTool } from './tools/update-user-preference.tool';
import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL } from './tools/workflow-builder/constants';
import { createCreateWorkflowFromCodeTool } from './tools/workflow-builder/create-workflow-from-code.tool';
import { createArchiveWorkflowTool } from './tools/workflow-builder/delete-workflow.tool';
@ -160,6 +165,11 @@ type McpAppTelemetryResolution = {
instanceOrigin?: string;
};
/** Mirrors the SDK's `isInputRequiredResult` without a value import of the SDK at boot. */
function isInputRequired(result: ToolHandlerResult | undefined): result is InputRequiredResult {
return result !== undefined && 'resultType' in result && result.resultType === 'input_required';
}
/**
* There is no standard failure contract across MCP tools: most set MCP's
* `isError` flag, but several catch their own errors and return a normal
@ -170,11 +180,14 @@ type McpAppTelemetryResolution = {
* on success, so it doubles as failure marker and message source, with the
* first text content item as fallback.
*/
function getToolCallOutcome(result: CallToolResult | undefined): {
function getToolCallOutcome(result: ToolHandlerResult | undefined): {
status: 'success' | 'error';
errorMessage?: string;
} {
if (!result) return { status: 'success' };
// A multi-round-trip handler asked the client for input; the write it reports on, if any,
// has already been recorded by the handler itself.
if (isInputRequired(result)) return { status: 'success' };
// v2 types structuredContent as an arbitrary JSON value; narrow to an
// object before reading the failure markers off it.
@ -345,10 +358,10 @@ export class McpService {
clientInfo?: McpClientInfo,
auth?: McpAuthContext,
) {
return (tool: ToolDefinition) => {
return (tool: ToolDefinition<z.ZodRawShape, ToolHandlerResult>) => {
// `ToolHandler` is a union of 1- and 2-arity signatures, so we invoke it
// through a generic callable and narrow the result back to a tool result.
const invoke = tool.handler as (...handlerArgs: unknown[]) => Promise<CallToolResult>;
const invoke = tool.handler as (...handlerArgs: unknown[]) => Promise<ToolHandlerResult>;
const instrumentedHandler = async (...handlerArgs: unknown[]) => {
const workflowId = getWorkflowId(handlerArgs[0]);
@ -359,7 +372,9 @@ export class McpService {
this.eventService.emit('mcp-tool-called', {
user,
toolName: tool.name,
workflowId: workflowId ?? getWorkflowId(result?.structuredContent),
workflowId:
workflowId ??
(isInputRequired(result) ? undefined : getWorkflowId(result?.structuredContent)),
status,
errorMessage,
...auth?.caller,
@ -755,6 +770,29 @@ export class McpService {
registerIfAllowed(
createGetUserPreferencesTool(user, this.aiPreferenceService, this.telemetry),
);
// The write path. Gated by `aiPreference:write` at registration; the service applies the
// same rules as the settings area, and `source` is fixed to `mcp` inside the tools.
registerIfAllowed(
createSaveUserPreferenceTool(
user,
this.aiPreferenceService,
this.telemetry,
this.urlService,
this.logger,
),
);
registerIfAllowed(
createUpdateUserPreferenceTool(
user,
this.aiPreferenceService,
this.telemetry,
this.urlService,
this.logger,
),
);
registerIfAllowed(
createUndoUserPreferenceTool(user, this.aiPreferenceService, this.telemetry, this.logger),
);
}
// Workflow builder tools (enabled via N8N_MCP_BUILDER_ENABLED)

View File

@ -1,4 +1,4 @@
import type { CallToolResult } from '@modelcontextprotocol/server';
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import type { WorkflowPublishBlockedReason } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import type { INode } from 'n8n-workflow';
@ -15,12 +15,25 @@ import type { WorkflowDetailsOutputSchema } from './tools/get-workflow-details.t
* Standard Schema interface the v2 SDK expects (see tool-schema.util.ts), so
* handlers keep receiving the zod-parsed args object.
*/
export type ToolHandler<InputArgs extends z.ZodRawShape = z.ZodRawShape> = (
/**
* A handler answers with a tool result or, on a multi-round-trip tool, with a request for more
* input from the client. Tools default to the plain result so their tests read `content` freely;
* a tool that elicits names the union explicitly.
*/
export type ToolHandlerResult = CallToolResult | InputRequiredResult;
export type ToolHandler<
InputArgs extends z.ZodRawShape = z.ZodRawShape,
Result extends ToolHandlerResult = CallToolResult,
> = (
args: z.objectOutputType<InputArgs, z.ZodTypeAny>,
extra?: unknown,
) => CallToolResult | Promise<CallToolResult>;
) => Result | Promise<Result>;
export type ToolDefinition<InputArgs extends z.ZodRawShape = z.ZodRawShape> = {
export type ToolDefinition<
InputArgs extends z.ZodRawShape = z.ZodRawShape,
Result extends ToolHandlerResult = CallToolResult,
> = {
name: string;
config: {
description?: string;
@ -36,12 +49,15 @@ export type ToolDefinition<InputArgs extends z.ZodRawShape = z.ZodRawShape> = {
/** Arbitrary tool metadata, e.g. the MCP App resource marker. */
_meta?: Record<string, unknown>;
};
handler: ToolHandler<InputArgs>;
handler: ToolHandler<InputArgs, Result>;
};
/** Registers a tool on the per-request server if the granted scopes cover it. */
export type RegisterToolFn = <InputArgs extends z.ZodRawShape>(
tool: ToolDefinition<InputArgs>,
export type RegisterToolFn = <
InputArgs extends z.ZodRawShape,
Result extends ToolHandlerResult = CallToolResult,
>(
tool: ToolDefinition<InputArgs, Result>,
) => void;
/** Read result for a static MCP resource (a single text document). */

View File

@ -0,0 +1,337 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import { AI_PREFERENCE_CONTENT_MAX_LENGTH, aiPreferenceContentSchema } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { isRecord } from '@n8n/utils/is-record';
import { lazyImport } from '@n8n/utils/lazy-import';
import z from 'zod';
import { writeAssistantPreference } from '@/services/ai-preference-write';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import {
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../mcp.constants';
import type {
ToolDefinition,
ToolHandlerResult,
UserCalledMCPToolEventPayload,
} from '../mcp.types';
import {
classifyPreferenceWriteError,
PREFERENCE_WRITE_REASONS,
preferencesSettingsUrl,
savedPreferenceSchema,
secondsSinceSaved,
toSavedPreferenceOutput,
} from './user-preference-tool.utils';
/**
* A requirement of CONTEXT-161, pinned verbatim by a test. There is no system prompt on this side,
* so the description is the only lever over when a client saves, and it has to carry the line
* between a durable preference and a one-off instruction on its own.
*/
const DESCRIPTION = [
'Saves a preference about how the user likes to work with n8n, so the n8n assistant and every connected AI tool apply it from now on: node and credential choices, naming, how work is organised, patterns to avoid.',
'Call this only for a durable preference the user states about their way of working, such as "always add an error workflow" or "name nodes in English". Do not call it for an instruction that applies to the current task only, such as "make this one a POST request", and do not infer a preference the user did not state.',
`Read ${MCP_GET_USER_PREFERENCES_TOOL_NAME} first. If a saved preference already covers the same ground, call ${MCP_UPDATE_USER_PREFERENCE_TOOL_NAME} with its id instead of saving a near-duplicate; saving the exact same text again is refused.`,
`The preference is saved at once, without a confirmation step. In the same turn, tell the user the exact text that was saved and give them the settings link from the result, so they can check it. If they want it changed, call ${MCP_UPDATE_USER_PREFERENCE_TOOL_NAME}; if they want it gone, call ${MCP_UNDO_USER_PREFERENCE_TOOL_NAME}.`,
].join('\n\n');
/** The key of the one input request, read back from the retry's `inputResponses`. */
const REVIEW_KEY = 'review';
const inputSchema = {
content: aiPreferenceContentSchema,
} satisfies z.ZodRawShape;
const outputSchema = {
saved: z
.boolean()
.describe('True when a preference is saved when this call returns. False when nothing is.'),
preference: savedPreferenceSchema
.optional()
.describe('The preference as saved. Absent when `saved` is false.'),
removed: z
.boolean()
.optional()
.describe('True when the user declined the review form that followed the write.'),
error: z.string().optional().describe('Set when the write or the review failed.'),
reason: z
.enum(PREFERENCE_WRITE_REASONS)
.optional()
.describe(
'Why the write was refused. `duplicate`: the same text is saved already, read the preferences and update that one. `scope_full`: the user has to remove a preference first. Do not retry the same call.',
),
} satisfies z.ZodRawShape;
type Output = z.objectOutputType<typeof outputSchema, z.ZodTypeAny>;
/** The slice of the SDK's handler context this tool reads. */
type HandlerContext = {
mcpReq?: {
envelope?: Record<string, unknown>;
inputResponses?: Record<string, unknown>;
requestState?: <T = unknown>() => T | undefined;
};
};
/** What the form asks: the saved text, editable. The three answers carry the rest. */
const reviewFormSchema = z.object({
text: z.string().optional(),
});
/**
* Whether the client can show a form. On this revision a client names its modes, and one that
* declares `url` alone cannot answer a form. A bare `elicitation: {}` still counts as form
* support: the rule from before modes existed, which the SDK keeps as well.
*/
function supportsFormElicitation(capabilities: unknown): boolean {
const elicitation = isRecord(capabilities) ? capabilities.elicitation : undefined;
if (!isRecord(elicitation)) return false;
return 'form' in elicitation || !('url' in elicitation);
}
/**
* Saves a personal preference with no confirmation gate, then, on a client that declares
* elicitation, follows the write with one form that offers edit and undo: Accept keeps the text
* as shown, Decline removes the row, and a cancelled form keeps it, because the write already
* happened and silence must not delete data.
*
* The form is the second round of one `tools/call` (multi-round-trip elicitation, protocol
* revision 2026-07-28): the client answers and retries the same call, and the row id travels in
* `requestState`. The retry re-authorizes through the service, so a forged id reaches nothing the
* caller could not already reach through the update and undo tools.
*/
export const createSaveUserPreferenceTool = (
user: User,
aiPreferenceService: AiPreferenceService,
telemetry: Telemetry,
urlService: UrlService,
logger: Logger,
): ToolDefinition<typeof inputSchema, ToolHandlerResult> => ({
name: MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
config: {
description: DESCRIPTION,
inputSchema,
outputSchema,
annotations: {
title: 'Save User Preference',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
handler: async ({ content }, extra) => {
const { inputRequired, inputResponse, CLIENT_CAPABILITIES_META_KEY } = await lazyImport<
typeof import('@modelcontextprotocol/server')
>(async () => await import('@modelcontextprotocol/server'));
const ctx = (extra ?? {}) as HandlerContext;
const canElicit = supportsFormElicitation(ctx.mcpReq?.envelope?.[CLIENT_CAPABILITIES_META_KEY]);
const review = inputResponse(ctx.mcpReq?.inputResponses, REVIEW_KEY);
const url = preferencesSettingsUrl(urlService);
// Count and capability, never the text, which is the person's own writing.
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
parameters: { text_length: content.length, can_elicit: canElicit },
};
const done = (output: Output, text: string, isError = false): ToolHandlerResult => {
telemetryPayload.results = isError
? { success: false, error: output.error, data: { reason: output.reason } }
: {
success: true,
data: {
saved: output.saved,
removed: output.removed ?? false,
review: review.kind === 'elicit' ? review.action : 'none',
},
};
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [{ type: 'text', text }],
structuredContent: output,
...(isError ? { isError: true } : {}),
};
};
const savedResult = (preference: AiPreferenceDto, lead: string) =>
done(
{ saved: true, preference: toSavedPreferenceOutput(preference, url) },
`${lead} Saved text: "${preference.content}". Tell the user this is what was saved and that they can review it at ${url}.`,
);
const applyReview = async (
id: string,
answer: Extract<ReturnType<typeof inputResponse>, { kind: 'elicit' }>,
): Promise<ToolHandlerResult> => {
const keep = `Tell the user the preference stays as saved and that they can review it at ${url}.`;
// Cancel means keep: the row is already there, and a dismissed form removes nothing.
if (answer.action === 'cancel') {
return done(
{ saved: true, preference: { id, scope: 'user', text: content, url } },
`The user closed the review without changing the preference. Saved text: "${content}". ${keep}`,
);
}
// Decline removes: every client offers that answer, and a press after the write means
// "not this one". Unlike cancel, it is only ever a deliberate act.
const form = reviewFormSchema.safeParse(answer.content ?? {});
const wantsRemoval = answer.action === 'decline';
const editedText = form.success ? form.data.text?.trim() : undefined;
if (wantsRemoval) {
try {
const removed = await aiPreferenceService.undoWrite(user, id, 'mcp');
// The same event the chat card fires on Undo: a late refusal of a write the
// user first let stand.
telemetry.track(TELEMETRY_EVENT.CONTEXT.USER_DELETED_PREFERENCES, {
count: 1,
source: 'rejected',
scope_types: ['user'],
surface: 'mcp',
seconds_since_saved: secondsSinceSaved(removed),
});
return done(
{ saved: false, removed: true },
'The user removed the preference in the review. Nothing is saved now. Do not save it again unless they ask.',
);
} catch (error) {
const { reason, message } = classifyPreferenceWriteError(error);
return done(
{ saved: true, error: message, reason },
`The user asked to remove the preference but the removal failed: ${message}. The preference is still saved. ${keep}`,
true,
);
}
}
if (editedText && editedText !== content) {
// The form's maxLength is advice to the client. The cap is applied here as the tool
// input applies it, so an edit cannot save what a fresh save would refuse.
const parsed = aiPreferenceContentSchema.safeParse(editedText);
if (!parsed.success) {
const message = `The edited text is longer than ${AI_PREFERENCE_CONTENT_MAX_LENGTH} characters.`;
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED, {
surface: 'mcp',
reason: 'too_long',
scope_type: 'user',
text_length: editedText.length,
});
return done(
{
saved: true,
preference: { id, scope: 'user', text: content, url },
error: message,
reason: 'too_long',
},
`The user edited the preference but the edit was refused: ${message} The original text is still saved: "${content}". ${keep}`,
true,
);
}
try {
const updated = await aiPreferenceService.updateContent(user, id, parsed.data);
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED, {
surface: 'mcp',
outcome: 'accepted_after_edit',
scope_type: 'user',
text_length: updated.content.length,
});
return savedResult(updated, 'The user edited the preference in the review.');
} catch (error) {
const { reason, message } = classifyPreferenceWriteError(error);
return done(
{
saved: true,
preference: { id, scope: 'user', text: content, url },
error: message,
reason,
},
`The user edited the preference but the edit was refused: ${message}. The original text is still saved: "${content}". ${keep}`,
true,
);
}
}
// `accepted` already fired with the write: silence is agreement, and so is this.
return done(
{ saved: true, preference: { id, scope: 'user', text: content, url } },
`The user kept the preference as saved. Saved text: "${content}". ${keep}`,
);
};
// Second round: the client answered the review form. The write is already on disk.
if (review.kind === 'elicit') {
const id = ctx.mcpReq?.requestState?.();
if (!(typeof id === 'string' && z.string().uuid().safeParse(id).success)) {
return done(
{
saved: true,
error: 'The review answer could not be matched to the saved preference.',
},
`The preference was saved, but the review answer could not be matched to it. Read ${MCP_GET_USER_PREFERENCES_TOOL_NAME} to find it, and tell the user it is saved at ${url}.`,
);
}
return await applyReview(id, review);
}
// First round: write at once, through the write the n8n Assistant tool also uses. It sets
// the source, maps a refusal to a reason and fires the shown/accepted/saved events: the
// review form below is the surface those events describe, and silence keeps the row.
const written = await writeAssistantPreference({
aiPreferenceService,
telemetry,
logger,
user,
surface: 'mcp',
content,
scope: 'user',
});
if (!written.ok) {
return done(
{ saved: false, error: written.message, reason: written.reason },
`Nothing was saved: ${written.message}. Do not call this tool again with the same text.`,
true,
);
}
const { preference } = written;
if (!canElicit) return savedResult(preference, 'Preference saved.');
return inputRequired({
inputRequests: {
[REVIEW_KEY]: inputRequired.elicit({
// Clients may show only the first lines of the message, so the state comes first:
// the row is already saved, and closing the form keeps it. The last lines name where
// the person manages it, and the consent permission that stops this tool saving more.
message: `Saved to your n8n preferences. Accept to keep it, or Decline to delete it.\n\n"${preference.content}"\n\nFrom now on, the n8n assistant and your connected AI tools follow this preference. To change it, edit the text, then accept. If you close this, the preference stays saved.\n\nYou can change or delete it anytime in n8n under Settings > Context > Preferences. To stop this tool from saving preferences, connect it again without the "Save, update and undo AI preferences" permission.`,
requestedSchema: {
type: 'object',
properties: {
text: {
type: 'string',
title: 'Preference',
description: "Edit the text to change what's saved.",
default: preference.content,
maxLength: AI_PREFERENCE_CONTENT_MAX_LENGTH,
},
},
},
}),
},
requestState: preference.id,
});
},
});

View File

@ -0,0 +1,123 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import z from 'zod';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { Telemetry } from '@/telemetry';
import {
MCP_SAVE_USER_PREFERENCE_TOOL_NAME,
MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import {
classifyPreferenceWriteError,
preferenceScopeOf,
secondsSinceSaved,
} from './user-preference-tool.utils';
/** Pinned verbatim by a test, like the other preference tools. */
const DESCRIPTION = [
`Removes a preference that ${MCP_SAVE_USER_PREFERENCE_TOOL_NAME} saved for this user. Call it when the user takes back a preference that was just saved, or asks to forget one that a connected AI tool saved earlier.`,
'It only removes preferences saved through a connected AI tool for this user. A preference the user wrote in n8n settings, or one saved for a project or the whole instance, is refused; the user removes those in settings.',
'Tell the user in the same turn that the preference is gone.',
].join('\n\n');
const inputSchema = {
id: z.string().min(1).describe('Id of the preference to remove, from the save result.'),
} satisfies z.ZodRawShape;
const outputSchema = {
removed: z.boolean().describe('True when the preference is gone.'),
id: z.string().describe('The id that was asked for.'),
error: z.string().optional().describe('Set when the removal was refused or failed.'),
reason: z
.enum(['not_found', 'not_permitted', 'failed'])
.optional()
.describe(
'`not_found`: no preference with this id was saved through a connected AI tool for this user. Do not retry.',
),
} satisfies z.ZodRawShape;
export const createUndoUserPreferenceTool = (
user: User,
aiPreferenceService: AiPreferenceService,
telemetry: Telemetry,
logger: Logger,
): ToolDefinition<typeof inputSchema> => ({
name: MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
config: {
description: DESCRIPTION,
inputSchema,
outputSchema,
annotations: {
title: 'Undo Saved Preference',
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async ({ id }) => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: MCP_UNDO_USER_PREFERENCE_TOOL_NAME,
parameters: {},
};
let removed: AiPreferenceDto;
try {
removed = await aiPreferenceService.undoWrite(user, id, 'mcp');
} catch (error) {
const classified = classifyPreferenceWriteError(error);
// The undo has no scope rule to break, so the write reasons fold to these three.
const reason =
classified.reason === 'not_found' || classified.reason === 'not_permitted'
? classified.reason
: 'failed';
// The two refusals carry text meant for the user. A fault keeps its message internal.
if (reason === 'failed') {
logger.error('Removing an AI preference over MCP failed', { error });
}
const message =
reason === 'failed' ? 'The preference could not be removed.' : classified.message;
telemetryPayload.results = { success: false, error: message, data: { reason } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [
{
type: 'text',
text: `The preference was not removed: ${message}. Do not call this tool again with the same id; the user can remove preferences in n8n settings.`,
},
],
structuredContent: { removed: false, id, error: message, reason },
isError: true,
};
}
// The same event the chat card fires on Undo and the review form fires on remove, so one
// number covers every way a user takes back an assistant write.
telemetry.track(TELEMETRY_EVENT.CONTEXT.USER_DELETED_PREFERENCES, {
count: 1,
source: 'rejected',
scope_types: [preferenceScopeOf(removed)],
surface: 'mcp',
seconds_since_saved: secondsSinceSaved(removed),
});
telemetryPayload.results = { success: true, data: { removed: true } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [
{
type: 'text',
text: 'The preference was removed. Nothing is saved for it now; tell the user so.',
},
],
structuredContent: { removed: true, id },
};
},
});

View File

@ -0,0 +1,129 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import { aiPreferenceContentSchema } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import z from 'zod';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import {
MCP_GET_USER_PREFERENCES_TOOL_NAME,
MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
USER_CALLED_MCP_TOOL_EVENT,
} from '../mcp.constants';
import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../mcp.types';
import {
classifyPreferenceWriteError,
PREFERENCE_WRITE_REASONS,
preferenceScopeOf,
preferencesSettingsUrl,
savedPreferenceSchema,
toRejectedReason,
toSavedPreferenceOutput,
} from './user-preference-tool.utils';
/** Pinned verbatim by a test, like the other preference tools. */
const DESCRIPTION = [
'Replaces the text of a saved preference. Use it when the user changes or refines a preference that is already saved, instead of saving a second one next to it.',
`Take the id from ${MCP_GET_USER_PREFERENCES_TOOL_NAME} or from the result of the save. The scope stays where it is; only the text changes.`,
'Tell the user the new text in the same turn, so they see what changed.',
].join('\n\n');
const inputSchema = {
id: z.string().min(1).describe('Id of the preference to change.'),
content: aiPreferenceContentSchema,
} satisfies z.ZodRawShape;
const outputSchema = {
saved: z.boolean().describe('True when the preference now carries the new text.'),
preference: savedPreferenceSchema.optional().describe('The preference after the edit.'),
error: z.string().optional().describe('Set when the edit was refused or failed.'),
reason: z
.enum(PREFERENCE_WRITE_REASONS)
.optional()
.describe(
'Why the edit was refused. `not_found`: no preference with this id is visible to the user; read the preferences again. `duplicate`: another preference in the same scope already has this text. Do not retry the same call.',
),
} satisfies z.ZodRawShape;
export const createUpdateUserPreferenceTool = (
user: User,
aiPreferenceService: AiPreferenceService,
telemetry: Telemetry,
urlService: UrlService,
logger: Logger,
): ToolDefinition<typeof inputSchema> => ({
name: MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
config: {
description: DESCRIPTION,
inputSchema,
outputSchema,
annotations: {
title: 'Update User Preference',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
handler: async ({ id, content }) => {
const telemetryPayload: UserCalledMCPToolEventPayload = {
user_id: user.id,
tool_name: MCP_UPDATE_USER_PREFERENCE_TOOL_NAME,
parameters: { text_length: content.length },
};
let preference: AiPreferenceDto;
try {
preference = await aiPreferenceService.updateContent(user, id, content);
} catch (error) {
const { reason, message } = classifyPreferenceWriteError(error);
// The mapped refusals carry their own text. Anything else is a fault whose message stays
// internal, so it goes to the log or it is lost.
if (reason === 'failed') {
logger.error('Updating an AI preference over MCP failed', { error });
}
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED, {
surface: 'mcp',
reason: toRejectedReason(reason),
text_length: content.length,
});
telemetryPayload.results = { success: false, error: message, data: { reason } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
return {
content: [
{
type: 'text',
text: `The preference was not changed: ${message}. Do not call this tool again with the same input.`,
},
],
structuredContent: { saved: false, error: message, reason },
isError: true,
};
}
const scope = preferenceScopeOf(preference);
telemetry.track(TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE, {
surface: 'mcp',
scope_type: scope,
text_length: preference.content.length,
replaced_existing: true,
});
telemetryPayload.results = { success: true, data: { scope } };
telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload);
const url = preferencesSettingsUrl(urlService);
return {
content: [
{
type: 'text',
text: `Preference updated. New text: "${preference.content}". Tell the user this is what is saved now and that they can review it at ${url}.`,
},
],
structuredContent: { saved: true, preference: toSavedPreferenceOutput(preference, url) },
};
},
});

View File

@ -0,0 +1,81 @@
import type { AiPreferenceDto, AiPreferenceScope } from '@n8n/api-types';
import { aiPreferenceScopeOf } from '@n8n/api-types';
import type { InferTelemetryProps, TELEMETRY_EVENT } from '@n8n/telemetry';
import z from 'zod';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AiPreferenceWriteRejection } from '@/services/ai-preference-write';
import { toAiPreferenceWriteRejection } from '@/services/ai-preference-write';
import type { UrlService } from '@/services/url.service';
/**
* Why a write by id did not land. The shared rejections plus `not_found`, which only a tool that
* takes an id can hit: the service answers a hidden row and a missing row alike.
*/
export type PreferenceWriteReason = AiPreferenceWriteRejection | 'not_found';
export const PREFERENCE_WRITE_REASONS = [
'too_long',
'scope_full',
'duplicate',
'not_permitted',
'blocked_by_admin',
'failed',
'not_found',
] as const satisfies readonly PreferenceWriteReason[];
/** The refusal of a write by id, from the class the service threw. */
export function classifyPreferenceWriteError(error: unknown): {
reason: PreferenceWriteReason;
message: string;
} {
if (error instanceof NotFoundError) return { reason: 'not_found', message: error.message };
return toAiPreferenceWriteRejection(error);
}
type RejectedReason = InferTelemetryProps<
typeof TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED
>['reason'];
/** The shared event knows no `not_found`; a row the caller cannot reach is a permission answer. */
export function toRejectedReason(reason: PreferenceWriteReason): RejectedReason {
return reason === 'not_found' ? 'not_permitted' : reason;
}
/** What a tool result says about one saved preference. */
export const savedPreferenceSchema = z.object({
id: z.string().describe('Stable id. Pass it to update_user_preference or undo_user_preference.'),
scope: z
.enum(['user', 'project', 'instance'])
.describe('Who the preference applies to. Preferences saved over MCP are `user` scoped.'),
text: z.string().describe('The preference as it is now saved.'),
url: z.string().describe('Where the user can review, edit or delete it in n8n settings.'),
});
export type SavedPreferenceOutput = z.infer<typeof savedPreferenceSchema>;
/** One page for every preference: the list is paginated, so a row cannot be addressed by URL. */
export function preferencesSettingsUrl(urlService: UrlService): string {
return `${urlService.getInstanceBaseUrl()}/settings/context/preferences`;
}
export function toSavedPreferenceOutput(
preference: AiPreferenceDto,
url: string,
): SavedPreferenceOutput {
return {
id: preference.id,
scope: aiPreferenceScopeOf(preference),
text: preference.content,
url,
};
}
export function preferenceScopeOf(preference: AiPreferenceDto): AiPreferenceScope {
return aiPreferenceScopeOf(preference);
}
/** Whole seconds between the write and now, for the undo-rate reading. */
export function secondsSinceSaved(preference: AiPreferenceDto, now = Date.now()): number {
return Math.max(0, Math.round((now - new Date(preference.createdAt).getTime()) / 1000));
}

View File

@ -0,0 +1,131 @@
import type { AiPreferenceDto } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { mock } from 'vitest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import {
toAiPreferenceWriteRejection,
writeAssistantPreference,
} from '@/services/ai-preference-write';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { Telemetry } from '@/telemetry';
const user = mock<User>({ id: 'user-1' });
const saved = { id: 'pref-1', content: 'Keep replies short.' } as AiPreferenceDto;
const build = () => {
const aiPreferenceService = mock<AiPreferenceService>();
const telemetry = mock<Telemetry>();
const logger = mock<Logger>();
const write = async (surface: 'aia' | 'mcp' = 'mcp') =>
await writeAssistantPreference({
aiPreferenceService,
telemetry,
logger,
user,
surface,
content: 'Keep replies short.',
scope: 'user',
});
return { aiPreferenceService, telemetry, logger, write };
};
/** One write for both assistant surfaces, so the events and the refusals cannot drift. */
describe('writeAssistantPreference', () => {
it.each(['aia', 'mcp'] as const)('writes with the surface as source (%s)', async (surface) => {
const { aiPreferenceService, write } = build();
aiPreferenceService.create.mockResolvedValue(saved);
const result = await write(surface);
expect(aiPreferenceService.create).toHaveBeenCalledWith(
user,
{ content: 'Keep replies short.', scope: 'user' },
surface,
);
expect(result).toEqual({ ok: true, preference: saved });
});
it('fires shown, resolved(accepted), scope_accepted and saved with the write', async () => {
const { aiPreferenceService, telemetry, write } = build();
aiPreferenceService.create.mockResolvedValue(saved);
await write('mcp');
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_SHOWN,
{ surface: 'mcp', scope_type: 'user', text_length: 19 },
);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED,
{ surface: 'mcp', outcome: 'accepted', scope_type: 'user', text_length: 19 },
);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_SCOPE_ACCEPTED,
{
surface: 'mcp',
offered_scope: 'user',
accepted_scope: 'user',
scope_changed: false,
},
);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE,
{ surface: 'mcp', scope_type: 'user', text_length: 19, replaced_existing: false },
);
});
it.each([
[new ConflictError('dup'), 'duplicate', 'dup'],
[new BadRequestError('cap'), 'scope_full', 'cap'],
[new ForbiddenError('no'), 'not_permitted', 'no'],
[new Error('boom'), 'failed', 'The preference could not be saved.'],
])('maps %s to reason %s and fires write_rejected only', async (error, reason, message) => {
const { aiPreferenceService, telemetry, logger, write } = build();
aiPreferenceService.create.mockRejectedValue(error);
const result = await write('mcp');
expect(result).toEqual({ ok: false, reason, message });
expect(telemetry.track).toHaveBeenCalledTimes(1);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED,
{
surface: 'mcp',
reason,
scope_type: 'user',
text_length: 19,
},
);
expect(logger.error).toHaveBeenCalledTimes(reason === 'failed' ? 1 : 0);
});
// The row is committed before any event fires, so a telemetry fault must not turn a saved
// preference into a failed one that the model retries into a duplicate.
it('still reports the write as saved when telemetry throws afterwards', async () => {
const { aiPreferenceService, telemetry, logger, write } = build();
aiPreferenceService.create.mockResolvedValue(saved);
telemetry.track.mockImplementation(() => {
throw new Error('telemetry down');
});
const result = await write('aia');
expect(result).toEqual({ ok: true, preference: saved });
expect(logger.warn).toHaveBeenCalledTimes(1);
});
});
describe('toAiPreferenceWriteRejection', () => {
it('reads a not-found as a generic failure: the shared write never addresses a row', () => {
expect(toAiPreferenceWriteRejection(new NotFoundError('gone'))).toEqual({
reason: 'failed',
message: 'The preference could not be saved.',
});
});
});

View File

@ -563,6 +563,171 @@ describe('AiPreferenceService', () => {
expect(aiPreferenceRepository.existsForTargetWithContent).not.toHaveBeenCalled();
});
});
/** For callers that hold an id and no scope, such as an MCP client. */
describe('updateContent', () => {
const member = mock<User>({ id: 'user-1', role: GLOBAL_MEMBER_ROLE });
beforeEach(() => {
aiPreferenceRepository.save.mockImplementation(
async (row) => ({ ...row, createdAt: new Date(), updatedAt: new Date() }) as AiPreference,
);
aiPreferenceRepository.existsForTargetWithContent.mockResolvedValue(false);
});
it('replaces the text and leaves the scope and the source where they are', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: 'user-1', source: 'ui' }),
);
const updated = await service.updateContent(member, 'pref-1', 'A better rule.');
expect(updated).toMatchObject({ content: 'A better rule.', userId: 'user-1', source: 'ui' });
expect(aiPreferenceRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ content: 'A better rule.', userId: 'user-1', source: 'ui' }),
);
// An edit in place never moves the row, so the cap is not the edit's business.
expect(aiPreferenceRepository.countForTarget).not.toHaveBeenCalled();
});
it('answers not found for a row the caller cannot see, like the other reads', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: 'user-2' }),
);
await expect(service.updateContent(member, 'pref-1', 'Mine now.')).rejects.toBeInstanceOf(
NotFoundError,
);
expect(aiPreferenceRepository.save).not.toHaveBeenCalled();
});
it('refuses a row the caller may see but not edit, such as an instance rule for a member', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: null, projectId: null }),
);
await expect(service.updateContent(member, 'pref-1', 'Mine now.')).rejects.toBeInstanceOf(
NotFoundError,
);
expect(aiPreferenceRepository.save).not.toHaveBeenCalled();
});
// The owner may edit all three in settings. Over this path they are shared rules or somebody
// else's, so they answer like a missing row.
it.each([
['an instance rule', { userId: null, projectId: null }],
['a team project rule', { userId: null, projectId: 'p-1' }],
["another user's personal rule", { userId: 'user-2', projectId: null }],
])('refuses %s even for an owner', async (_label, overrides) => {
const owner = mock<User>({ id: 'user-1', role: GLOBAL_OWNER_ROLE });
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', ...overrides }),
);
await expect(service.updateContent(owner, 'pref-1', 'Mine now.')).rejects.toBeInstanceOf(
NotFoundError,
);
expect(aiPreferenceRepository.save).not.toHaveBeenCalled();
});
it('refuses a text another row in the scope already holds, through the shared rule', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: 'user-1' }),
);
aiPreferenceRepository.existsForTargetWithContent.mockResolvedValue(true);
await expect(service.updateContent(member, 'pref-1', 'Other.')).rejects.toThrow(
'This user already has a preference with the same text',
);
expect(aiPreferenceRepository.existsForTargetWithContent).toHaveBeenCalledWith(
{ scope: 'user', userId: 'user-1' },
'Other.',
'pref-1',
);
});
it('skips the duplicate check when the text did not change', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: 'user-1' }),
);
await service.updateContent(member, 'pref-1', 'Rule.');
expect(aiPreferenceRepository.existsForTargetWithContent).not.toHaveBeenCalled();
});
});
/**
* The undo of an assistant write. Narrower than delete on purpose: a client takes back what it
* saved, and nothing the person wrote by hand.
*/
describe('undoWrite', () => {
const member = mock<User>({ id: 'user-1', role: GLOBAL_MEMBER_ROLE });
it('removes a row the named surface created for the caller', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({
id: 'pref-1',
content: 'Rule.',
userId: 'user-1',
source: 'mcp',
createdById: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
}),
);
const removed = await service.undoWrite(member, 'pref-1', 'mcp');
expect(aiPreferenceRepository.delete).toHaveBeenCalledWith({ id: 'pref-1' });
expect(removed).toMatchObject({ id: 'pref-1', content: 'Rule.', source: 'mcp' });
});
it.each([
['a row the person wrote in settings', { source: 'ui' as const, createdById: 'user-1' }],
['a row another surface wrote', { source: 'aia' as const, createdById: 'user-1' }],
[
'a row the same surface wrote for somebody else',
{ source: 'mcp' as const, createdById: 'owner-1' },
],
])('refuses %s and answers like a missing row', async (_label, overrides) => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: 'user-1', ...overrides }),
);
await expect(service.undoWrite(member, 'pref-1', 'mcp')).rejects.toBeInstanceOf(
NotFoundError,
);
expect(aiPreferenceRepository.delete).not.toHaveBeenCalled();
});
it('refuses a row the person has since moved to the instance, even for an owner', async () => {
const owner = mock<User>({ id: 'user-1', role: GLOBAL_OWNER_ROLE });
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({ id: 'pref-1', content: 'Rule.', userId: null, source: 'mcp', createdById: 'user-1' }),
);
await expect(service.undoWrite(owner, 'pref-1', 'mcp')).rejects.toBeInstanceOf(NotFoundError);
expect(aiPreferenceRepository.delete).not.toHaveBeenCalled();
});
it('refuses a row the caller cannot see, before it reads the source', async () => {
aiPreferenceRepository.findByIdWithRelations.mockResolvedValue(
row({
id: 'pref-1',
content: 'Rule.',
userId: 'user-2',
source: 'mcp',
createdById: 'user-1',
}),
);
await expect(service.undoWrite(member, 'pref-1', 'mcp')).rejects.toBeInstanceOf(
NotFoundError,
);
expect(aiPreferenceRepository.delete).not.toHaveBeenCalled();
});
});
});
describe('groupAiPreferences', () => {

View File

@ -0,0 +1,124 @@
import type { AiPreferenceDto, AiPreferenceScope, AiPreferenceSource } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { Telemetry } from '@/telemetry';
/**
* Why an assistant write did not land, in words a model can relay. The same six values as the
* `Preference write rejected` event, so a reason maps onto telemetry without translation.
*/
export type AiPreferenceWriteRejection =
| 'too_long'
| 'scope_full'
| 'duplicate'
| 'not_permitted'
| 'blocked_by_admin'
| 'failed';
export type AiPreferenceWriteResult =
| { ok: true; preference: AiPreferenceDto }
| { ok: false; reason: AiPreferenceWriteRejection; message: string };
/** The surfaces that write on the user's behalf. The settings area is a person writing. */
export type AssistantSurface = Exclude<AiPreferenceSource, 'ui'>;
/**
* The service refuses with HTTP error classes; an assistant surface speaks in reasons. Only
* `scope: 'user'` reaches the service from an assistant, so the one BadRequestError it can raise
* is the per-scope cap. The three mapped classes carry user-facing text, so their message passes
* through; anything else is an unexpected fault, so its message stays internal.
*/
export function toAiPreferenceWriteRejection(error: unknown): {
reason: AiPreferenceWriteRejection;
message: string;
} {
if (error instanceof ConflictError) return { reason: 'duplicate', message: error.message };
if (error instanceof BadRequestError) return { reason: 'scope_full', message: error.message };
if (error instanceof ForbiddenError) return { reason: 'not_permitted', message: error.message };
return { reason: 'failed', message: 'The preference could not be saved.' };
}
export type AssistantPreferenceWrite = {
aiPreferenceService: AiPreferenceService;
telemetry: Telemetry;
logger: Logger;
user: User;
surface: AssistantSurface;
content: string;
scope: AiPreferenceScope;
};
/**
* One write for every assistant surface: the n8n Assistant tool and the MCP tool both call this,
* so the source, the refusal mapping and the events cannot drift between them.
*
* Write first: the surface shows the result afterwards and doing nothing is agreement, so
* `shown` and `resolved(accepted)` fire together with the write. Only the write sits in the try;
* a telemetry fault after a committed row must not turn into `ok: false`, or the model reports a
* failed save and a retry runs into the duplicate check.
*/
export async function writeAssistantPreference({
aiPreferenceService,
telemetry,
logger,
user,
surface,
content,
scope,
}: AssistantPreferenceWrite): Promise<AiPreferenceWriteResult> {
const textLength = content.length;
let preference: AiPreferenceDto;
try {
preference = await aiPreferenceService.create(user, { content, scope }, surface);
} catch (error) {
const rejection = toAiPreferenceWriteRejection(error);
// The mapped classes are expected outcomes with their own user-facing text. Anything else
// is a real fault whose message stays internal, so it must not go unlogged.
if (rejection.reason === 'failed') {
logger.error('Saving an AI preference from the assistant failed', { error });
}
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_WRITE_REJECTED, {
surface,
reason: rejection.reason,
scope_type: scope,
text_length: textLength,
});
return { ok: false, ...rejection };
}
try {
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_SHOWN, {
surface,
scope_type: scope,
text_length: textLength,
});
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_CONFIRMATION_RESOLVED, {
surface,
outcome: 'accepted',
scope_type: scope,
text_length: textLength,
});
telemetry.track(TELEMETRY_EVENT.CONTEXT.PREFERENCE_SCOPE_ACCEPTED, {
surface,
offered_scope: scope,
accepted_scope: scope,
scope_changed: false,
});
telemetry.track(TELEMETRY_EVENT.CONTEXT.ASSISTANT_SAVED_PREFERENCE, {
surface,
scope_type: scope,
text_length: textLength,
replaced_existing: false,
});
} catch (error) {
logger.warn('Preference telemetry failed after the row was saved', { error });
}
return { ok: true, preference };
}

View File

@ -35,6 +35,49 @@ every write, so a new write path that forgets to name one fails instead of recor
silent `ui`. The telemetry and the settings list use this column to tell an assistant
write from a person's own write.
## Writes without a scope in hand
`updateContent(user, id, content)` replaces the text and keeps the scope where it is. It
serves callers that hold an id and no scope, such as an MCP client editing a row that
`get_user_preferences` returned; `update()` needs the full request and would read a missing
scope as a move. It reaches only the caller's own personal rows. A project row, an instance
row or another user's row answers like a missing row, whatever the role: those rules apply to
other people, and the settings area owns them.
`undoWrite(user, id, source)` removes a row only when the named surface created it for the
caller and it is still the caller's personal row: `source`, `createdById` and `userId` must
all match. A row the person has since moved to a project or the instance is out of reach,
whatever their role. It is the undo of an assistant write,
narrower than `delete()` on purpose, so a client can take back what it saved and nothing the
person wrote by hand. A row that fails the check answers like one that does not exist.
## The assistant write
Every assistant surface writes through `writeAssistantPreference` in
[`ai-preference-write.ts`](./ai-preference-write.ts): the n8n Assistant tool with
`surface: 'aia'` and the MCP tool with `surface: 'mcp'`. It sets `source`, maps a refusal
to one of six reasons (`too_long`, `scope_full`, `duplicate`, `not_permitted`,
`blocked_by_admin`, `failed`) and fires the events, so the two surfaces cannot drift.
## The MCP write tools
Three tools in `packages/cli/src/modules/mcp/tools/`, all behind the `aiPreference:write`
OAuth scope and the `CONTEXT_PREFERENCES_FLAG`:
| Tool | Does |
| --- | --- |
| `save_user_preference` | Creates a personal preference with `source` set to `mcp`, at once, with no confirmation gate. The result names the saved text, the id and the settings page. |
| `update_user_preference` | Replaces the text of a row by id, through `updateContent`. |
| `undo_user_preference` | Removes a row through `undoWrite`, so only what MCP saved for this user. |
On a client that declares the elicitation capability, the save follows the write with one
form: the saved text, prefilled and editable. It is the second round of the same
`tools/call` (multi-round-trip elicitation, revision 2026-07-28); the row id travels in
`requestState`, and the retry re-authorizes through the service. Accept keeps the text as
shown, edited or not. Decline removes the row: every client offers that answer, and a press
after the write means "not this one". A cancelled form keeps the row, because a client with
no way to show the form answers cancel on its own, and silence must not delete data.
## Who may read and write
`AiPreferenceService` holds the rules for every surface, and the REST controller adds
@ -138,6 +181,14 @@ write, the confirmation shown and answered, the scope accepted against the scope
and a refused write with its reason. The `get_user_preferences` MCP tool reports the count
and the scopes it returned on the existing tool event.
The MCP write tools fire the same events with `surface` set to `mcp`, through the shared
write. A removal from the review form, or through the undo tool, is `User deleted
preferences` with `source` set to `rejected`, as the chat card reports its Undo, and carries
`seconds_since_saved`; a declined form counts as a removal. An edit from the form is
`accepted_after_edit`. A cancelled form fires
no event of its own; the tool event records it, along with whether the client declared
elicitation at all.
Run `pnpm --filter @n8n/telemetry catalog` to read the registered events and their
properties. No event carries preference text. The events report lengths, counts and
scopes.

View File

@ -254,6 +254,29 @@ export class AiPreferenceService {
return this.toDto(saved, await this.scopesFor(user, saved, access));
}
/**
* Replaces the text and leaves the scope where it is. For a caller that holds an id but no
* scope, such as an MCP client editing what `get_user_preferences` returned: `update()` needs
* the full request and would read a missing scope as a move.
*
* Only the caller's own personal rows. A project or instance row is a shared rule that applies
* to other people, and the settings area owns it, as in `undoWrite()`. A row that fails the
* check answers like a row that does not exist.
*/
async updateContent(user: User, id: string, content: string): Promise<AiPreferenceDto> {
const access = this.projectAccess(user);
const row = await this.requireVisible(user, id, access);
if (row.userId !== user.id) {
throw new NotFoundError(`Preference with id ${id} is not one of your personal preferences`);
}
await this.assertCanWrite(user, row, 'update', access);
if (row.content !== content) await this.assertNotDuplicate(row, content, row.id);
row.content = content;
const saved = await this.aiPreferenceRepository.save(row);
return this.toDto(saved, await this.scopesFor(user, saved, access));
}
async delete(user: User, id: string): Promise<void> {
const access = this.projectAccess(user);
const row = await this.requireVisible(user, id, access);
@ -262,6 +285,26 @@ export class AiPreferenceService {
await this.aiPreferenceRepository.delete({ id: row.id });
}
/**
* The undo of an assistant write: removes a row only when the named surface wrote it for this
* caller. Narrower than `delete()` on purpose, so a client can take back what it saved and
* nothing the person wrote by hand in settings. A row that fails the check answers like a row
* that does not exist.
*/
async undoWrite(user: User, id: string, source: AiPreferenceSource): Promise<AiPreferenceDto> {
const access = this.projectAccess(user);
const row = await this.requireVisible(user, id, access);
// Only a personal row the surface saved for the caller. A row the person has since moved to
// a project or the instance is a shared rule now, and the settings area owns it.
if (row.source !== source || row.createdById !== user.id || row.userId !== user.id) {
throw new NotFoundError(`Preference with id ${id} was not saved by ${source} for you`);
}
await this.assertCanWrite(user, row, 'delete', access);
await this.aiPreferenceRepository.delete({ id: row.id });
return this.toDto(row, await this.scopesFor(user, row, access));
}
private projectAccess(user: User): ProjectAccess {
return new ProjectAccess(user, this.projectRelationRepository);
}

View File

@ -3331,6 +3331,10 @@
"settings.context.preferences.back": "Back to Context",
"settings.context.preferences.columns.preference": "Preference",
"settings.context.preferences.columns.scope": "Scope",
"settings.context.preferences.columns.source": "Source",
"settings.context.preferences.source.ui": "Settings",
"settings.context.preferences.source.aia": "n8n assistant",
"settings.context.preferences.source.mcp": "Connected AI tool",
"settings.context.preferences.scope.user": "Just you · All projects",
"settings.context.preferences.scope.personalProject": "Just you · Personal project",
"settings.context.preferences.scope.otherUser": "{name} · All projects",
@ -3607,6 +3611,7 @@
"settings.mcp.oAuthClients.scope.tag.read": "List tags",
"settings.mcp.oAuthClients.scope.communityPackage.install": "Install verified community nodes",
"settings.mcp.oAuthClients.scope.aiPreference.read": "Read AI preferences",
"settings.mcp.oAuthClients.scope.aiPreference.write": "Save, update and undo AI preferences",
"settings.mcp.oAuthClients.scope.more": "+{count}",
"settings.mcp.oAuthClients.access.full": "Full access",
"settings.mcp.oAuthClients.access.none": "No access",

View File

@ -112,6 +112,20 @@ describe('PreferencesTable', () => {
expect(badges[2]).toHaveTextContent('Just you · Personal project');
});
// Undo rests on the person recognising what a connected tool wrote on their behalf.
it('names the surface that wrote each row', () => {
const { getAllByTestId } = render([
preference({ id: 'a', source: 'ui' }),
preference({ id: 'b', source: 'aia' }),
preference({ id: 'c', source: 'mcp' }),
]);
const sources = getAllByTestId('preference-source');
expect(sources[0]).toHaveTextContent('Settings');
expect(sources[1]).toHaveTextContent('n8n assistant');
expect(sources[2]).toHaveTextContent('Connected AI tool');
});
it('enables both actions when the row carries write scopes', () => {
const { getByTestId } = render([preference({ scopes: WRITABLE })]);

View File

@ -1,14 +1,14 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nButton, N8nDataTableServer, N8nTooltip } from '@n8n/design-system';
import { N8nButton, N8nDataTableServer, N8nText, N8nTooltip } from '@n8n/design-system';
import type { TableHeader, TableOptions } from '@n8n/design-system';
import PreferenceContentCell from './PreferenceContentCell.vue';
import PreferenceScopeBadge from './PreferenceScopeBadge.vue';
import { PREFERENCES_PAGE_SIZES } from '../context.constants';
import type { Preference } from '../context.types';
import { preferenceScope, toPreferencePermissions } from '../context.utils';
import { preferenceScope, preferenceSourceLabel, toPreferencePermissions } from '../context.utils';
const props = defineProps<{
preferences: Preference[];
@ -40,12 +40,13 @@ function isSelectable(preference: Preference) {
const readOnlyHint = computed(() => i18n.baseText('settings.context.preferences.readOnly.tooltip'));
// Sorting is off until the endpoints support ORDER BY. The widths act as ratios.
// Sorting is off until the endpoints support ORDER BY. The widths are pixel sizes that sum to
// the table's minimum width, so a new column takes its room from the others, not from the page.
const headers = computed<Array<TableHeader<Preference>>>(() => [
{
title: i18n.baseText('settings.context.preferences.columns.preference'),
key: 'content',
width: 560,
width: 480,
disableSort: true,
resize: false,
},
@ -54,7 +55,16 @@ const headers = computed<Array<TableHeader<Preference>>>(() => [
// Not a column on the row: derived from the userId/projectId tri-state.
key: 'scope',
value: (row) => preferenceScope(row),
width: 400,
width: 300,
disableSort: true,
resize: false,
},
{
// Names the surface that wrote the row, so a person can recognise what a connected AI
// tool saved on their behalf and take it back.
title: i18n.baseText('settings.context.preferences.columns.source'),
key: 'source',
width: 180,
disableSort: true,
resize: false,
},
@ -98,6 +108,12 @@ const headers = computed<Array<TableHeader<Preference>>>(() => [
<PreferenceScopeBadge :preference="item" />
</template>
<template #[`item.source`]="{ item }">
<N8nText size="small" color="text-base" data-test-id="preference-source">
{{ preferenceSourceLabel(i18n, item.source) }}
</N8nText>
</template>
<template #[`item.actions`]="{ item }">
<div :class="$style.actions" @click.stop>
<N8nTooltip :disabled="permissions(item).update" :content="readOnlyHint">

View File

@ -1,9 +1,10 @@
import type { AiPreferenceScope, AiPreferenceUserDto } from '@n8n/api-types';
import type { AiPreferenceScope, AiPreferenceSource, AiPreferenceUserDto } from '@n8n/api-types';
import {
aiPreferenceScopeOf,
CONTEXT_PREFERENCES_ENABLED_VARIANT,
CONTEXT_PREFERENCES_FLAG,
} from '@n8n/api-types';
import type { BaseTextKey } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { usePostHog } from '@/app/stores/posthog.store';
@ -76,6 +77,14 @@ export function preferenceAudience(
}
}
/** The surface that wrote the row, as the settings list names it. */
export function preferenceSourceLabel(
i18n: { baseText: (key: BaseTextKey) => string },
source: AiPreferenceSource,
): string {
return i18n.baseText(`settings.context.preferences.source.${source}`);
}
export function preferenceUserName(user: AiPreferenceUserDto | null): string {
if (!user) return '';
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ');