Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.77.0] - 2026-08-31

### Added

- **A rejected workflow write now names the keys it sent** ([#1047](https://github.com/czlonkowski/n8n-mcp/issues/1047)). n8n answers an unknown property in a workflow write with `must NOT have additional properties` and never says which property broke it — diagnosing an instance of this class meant diffing n8n source (#248, #466, #1043). When the rejection hits `request/body` or `request/body/settings`, the error now appends the top-level or settings key names that were actually sent, flags settings keys missing from n8n-mcp's known-settings table, and surfaces the property name when n8n's validator params carry one. Key names only, never values.

### Fixed

- **Multi-tenant: rotated credentials no longer keep serving from frozen sessions** ([#1045](https://github.com/czlonkowski/n8n-mcp/issues/1045)). Two defects in the `instance` session strategy. The session's `configHash` — its config identity — covered only the URL and instance ID, so rotating the n8n API key or the instance-level MCP access token produced a byte-identical hash, so a routing layer comparing config identities had no way to tell that a live session was still bound to the pre-rotation secrets. The hash input now includes both credentials, so a rotation changes the session's config identity and the stale session can be detected and re-initialized (an initialize always binds the fresh credentials); only the first 8 characters of the digest — keyed with the server's auth token, so it cannot be used to verify credential guesses offline — ever appear in session IDs and logs, never the values. The server also refreshes a live `instance`-strategy session itself when a request arrives carrying the complete tenant identity (API key plus the same instance ID and same URL the session is bound to) with changed credentials. Fields a request omits stay as stored, and a partial context, a different instance ID, or a different URL never overwrites a session's credentials. Separately, `exportSessionState()`/`restoreSessionState()` rebuilt the context field by field and silently dropped `n8nMcpAccessToken` (and the timeout/retry tuning) for every session persisted across a restart, so `n8n_manage_agents` reported `NOT_CONFIGURED` after every deploy for a token that was still correctly stored. `SessionState['context']` is now derived from `InstanceContext` and export/restore copy the whole context, so every field — current and future — survives the round-trip.

## [2.76.1] - 2026-08-31

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.76.1",
"version": "2.77.0",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion package.runtime.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp-runtime",
"version": "2.76.1",
"version": "2.77.0",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {
Expand Down
75 changes: 57 additions & 18 deletions src/http-server-single-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import dotenv from 'dotenv';
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
import { PROJECT_VERSION } from './utils/version';
import { v4 as uuidv4 } from 'uuid';
import { createHash } from 'crypto';
import { createHmac } from 'crypto';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import {
negotiateProtocolVersion,
logProtocolNegotiation,
STANDARD_PROTOCOL_VERSION
} from './utils/protocol-version';
import { InstanceContext, validateInstanceContext } from './types/instance-context';
import { InstanceContext, pickInstanceContextFields, validateInstanceContext } from './types/instance-context';
import { SessionState } from './types/session-state';
import type { AdditionalTool } from './types/additional-tools';
import { closeSharedDatabase } from './database/shared-database';
Expand Down Expand Up @@ -537,7 +537,7 @@ export class SingleSessionHTTPServer {

// Only switch if the context has actually changed
if (JSON.stringify(existingContext) !== JSON.stringify(newContext)) {
logger.info('Multi-tenant shared mode: Updating instance context for session', {
logger.info('Multi-tenant mode: Updating instance context for session', {
sessionId,
oldInstanceId: existingContext?.instanceId,
newInstanceId: newContext.instanceId
Expand Down Expand Up @@ -782,11 +782,22 @@ export class SingleSessionHTTPServer {
if (isMultiTenantEnabled && sessionStrategy === 'instance' && instanceContext?.instanceId) {
// In multi-tenant mode with instance strategy, create session per instance
// This ensures each tenant gets isolated sessions
// Include configuration hash to prevent collisions with different configs
const configHash = createHash('sha256')
// Include configuration hash to prevent collisions with different configs.
// The credentials are part of the config identity (#1045): rotating the n8n
// API key or the instance-level MCP access token must change the hash, so a
// routing layer comparing hashes stops matching sessions bound to the old
// secrets. The secrets only feed the digest — the session ID and logs carry
// just its first 8 hex chars, never the values — and the digest is keyed
// with the server's auth token, so the truncated fingerprint is not an
// offline confirmation oracle for credential guesses (CodeQL
// js/insufficient-password-hash). Any legitimate hash-comparing consumer
// already holds AUTH_TOKEN, so cross-process comparability is preserved.
const configHash = createHmac('sha256', this.authToken ?? '')
.update(JSON.stringify({
url: instanceContext.n8nApiUrl,
instanceId: instanceContext.instanceId
instanceId: instanceContext.instanceId,
n8nApiKey: instanceContext.n8nApiKey,
n8nMcpAccessToken: instanceContext.n8nMcpAccessToken
}))
.digest('hex')
.substring(0, 8);
Expand Down Expand Up @@ -907,6 +918,30 @@ export class SingleSessionHTTPServer {
if (isMultiTenantEnabled && sessionStrategy === 'shared' && instanceContext) {
// Update the context for this session with locking to prevent race conditions
await this.switchSessionContext(sessionId, instanceContext);
} else if (isMultiTenantEnabled && sessionStrategy === 'instance' && instanceContext) {
// #1045: in instance strategy the context used to be frozen at creation, so a
// rotated n8n API key or MCP access token kept being served until the session
// idled out or the client re-initialized. Refresh it — but only from a request
// that carries the COMPLETE tenant identity for the SAME instance AND the SAME
// n8n URL this session is bound to. A partial context (GHSA-2cf7-hpwf-47h9,
// #844) or a different instanceId must never overwrite a session's
// credentials, and a changed URL is a different config identity that has to go
// through initialize, not mutate an existing session. Fields the request omits
// mean "unchanged" — merging over the stored context keeps a request without
// e.g. the MCP access token header from clearing a configured token.
const storedContext = this.sessionContexts[sessionId];
if (
instanceContext.n8nApiUrl &&
instanceContext.n8nApiKey &&
instanceContext.instanceId &&
storedContext?.instanceId === instanceContext.instanceId &&
storedContext?.n8nApiUrl === instanceContext.n8nApiUrl
) {
await this.switchSessionContext(sessionId, {
...storedContext,
...pickInstanceContextFields(instanceContext)
});
}
}

// Update session access time
Expand Down Expand Up @@ -1830,7 +1865,7 @@ export class SingleSessionHTTPServer {

// Skip sessions without context - these can't be restored meaningfully
// (Context is required to reconnect to the correct n8n instance)
if (!context || !context.n8nApiUrl || !context.n8nApiKey) {
if (!context?.n8nApiUrl || !context?.n8nApiKey) {
logger.debug(`Skipping session ${sessionId} - missing required context`);
continue;
}
Expand All @@ -1842,12 +1877,16 @@ export class SingleSessionHTTPServer {
createdAt: metadata.createdAt.toISOString(),
lastAccess: metadata.lastAccess.toISOString()
},
// Copy every declared InstanceContext field instead of re-listing them here:
// a hand-maintained list silently dropped n8nMcpAccessToken (and the
// timeout/retry tuning) when those fields were added to InstanceContext
// (#1045). The pick keeps embedder-supplied extra properties out of the
// persisted plaintext; its key list is compile-time checked for completeness.
context: {
...pickInstanceContextFields(context),
n8nApiUrl: context.n8nApiUrl,
n8nApiKey: context.n8nApiKey,
instanceId: context.instanceId || sessionId, // Use sessionId as fallback
sessionId: context.sessionId,
metadata: context.metadata
instanceId: context.instanceId || sessionId // Use sessionId as fallback
}
});
}
Expand Down Expand Up @@ -1973,14 +2012,14 @@ export class SingleSessionHTTPServer {
lastAccess
};

// Restore session context
this.sessionContexts[sessionState.sessionId] = {
n8nApiUrl: sessionState.context.n8nApiUrl,
n8nApiKey: sessionState.context.n8nApiKey,
instanceId: sessionState.context.instanceId,
sessionId: sessionState.context.sessionId,
metadata: sessionState.context.metadata
};
// Restore session context. Copy every declared InstanceContext field — a
// hand-maintained field list silently dropped n8nMcpAccessToken for every
// restored session (#1045) — while keeping unknown keys from the persisted
// JSON out of the live context. The context has already passed
// validateInstanceContext plus the credential-completeness guard above.
this.sessionContexts[sessionState.sessionId] = pickInstanceContextFields(
sessionState.context
);

logger.debug(`Restored session ${sessionState.sessionId}`);
logSecurityEvent('session_restore', {
Expand Down
52 changes: 47 additions & 5 deletions src/services/n8n-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
ProjectSummary,
Project,
} from '../types/n8n-api';
import { handleN8nApiError, logN8nError, N8nApiError, N8nValidationError } from '../utils/n8n-errors';
import { enrichUnknownPropertyError, handleN8nApiError, logN8nError, N8nApiError, N8nValidationError } from '../utils/n8n-errors';
import { encodeApiPathSegment } from '../utils/validation-schemas';
import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';
import {
Expand Down Expand Up @@ -560,6 +560,48 @@ export class N8nApiClient {
}
}

/**
* Send a workflow write. This is the one seam where the outgoing body is still in scope when
* n8n rejects it, so an unlocatable "must NOT have additional properties" 400 is enriched here
* with the keys that were sent (#1047) before it propagates to the handlers.
*
* The group ladder can retry with a different body than the caller's payload (groups dropped,
* or the field omitted when the instance is known not to support it), so each failed attempt
* records the body it actually sent and the enrichment reports that one — never a key the
* failing request did not carry. handleN8nApiError returns an existing N8nApiError unchanged,
* so the ladder's own mapping rethrows the same tracked instance.
*/
private async sendWorkflowWrite(
payload: Record<string, unknown>,
send: (body: Record<string, unknown>) => Promise<Workflow>,
options: WorkflowWriteOptions
): Promise<Workflow> {
const sentBodies = new WeakMap<N8nApiError, Record<string, unknown>>();
const trackedSend = async (body: Record<string, unknown>): Promise<Workflow> => {
try {
return await send(body);
} catch (error) {
const apiError = handleN8nApiError(error);
sentBodies.set(apiError, body);
throw apiError;
}
};
try {
return await this.sendWorkflowWriteWithGroupFallback(payload, trackedSend, options);
} catch (error) {
const apiError = handleN8nApiError(error);
const enriched = enrichUnknownPropertyError(apiError, sentBodies.get(apiError) ?? payload);
if (enriched !== apiError) {
// The axios interceptor has already logged n8n's generic message; without this
// line the key list reaches only the MCP caller, never the server logs (#1047).
logger.warn('n8n rejected a workflow write with an unnamed additional property', {
message: enriched.message
});
}
throw enriched;
}
}

/**
* Send a workflow write, degrading `nodeGroups` only as far as the instance forces.
*
Expand All @@ -575,7 +617,7 @@ export class N8nApiClient {
* Omitting the field is not a fix for case 3: n8n backfills the stored groups when the field is
* absent, so the same rejection returns. Each attempt must make progress or the loop stops.
*/
private async sendWorkflowWrite(
private async sendWorkflowWriteWithGroupFallback(
payload: Record<string, unknown>,
send: (body: Record<string, unknown>) => Promise<Workflow>,
options: WorkflowWriteOptions
Expand Down Expand Up @@ -644,8 +686,8 @@ export class N8nApiClient {
}

/**
* Decide how to retry after n8n rejected a write, per the ladder in sendWorkflowWrite: the groups
* to send next, `omit-field` to send no groups at all, or `give-up` to surface n8n's error.
* Decide how to retry after n8n rejected a write, per the ladder in sendWorkflowWriteWithGroupFallback:
* the groups to send next, `omit-field` to send no groups at all, or `give-up` to surface n8n's error.
*/
private degradeGroupsAfterRejection(
classification: GroupErrorClassification,
Expand All @@ -662,7 +704,7 @@ export class N8nApiClient {
}

// Deliberately does not latch groupSupport or warn: whether the field really is the problem is
// only known once the retry without it succeeds. sendWorkflowWrite records it there.
// only known once the retry without it succeeds. sendWorkflowWriteWithGroupFallback records it there.
if (classification.kind === 'schema-field') return 'omit-field';

if (classification.kind !== 'semantic') return 'give-up';
Expand Down
38 changes: 38 additions & 0 deletions src/types/instance-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,44 @@ export interface InstanceContext {
metadata?: Record<string, any>;
}

/**
* Every InstanceContext field, as a value-level list. `satisfies` keeps each entry a real
* key, and the exhaustiveness assertion below turns a field added to InstanceContext but
* not listed here into a compile error — the silent-field-drop class of #1045 cannot recur.
*/
const INSTANCE_CONTEXT_KEYS = [
'n8nApiUrl',
'n8nApiKey',
'n8nApiTimeout',
'n8nApiMaxRetries',
'n8nMcpAccessToken',
'instanceId',
'sessionId',
'metadata'
] as const satisfies readonly (keyof InstanceContext)[];

type MissingInstanceContextKeys = Exclude<keyof InstanceContext, (typeof INSTANCE_CONTEXT_KEYS)[number]>;
const _instanceContextKeysExhaustive: MissingInstanceContextKeys extends never ? true : never = true;
void _instanceContextKeysExhaustive;

/**
* Copy exactly the declared InstanceContext fields from a context-shaped object.
*
* Structural typing lets embedders hand over a larger record (a tenant row, a config
* object), and restore reads persisted JSON — a plain spread would carry every extra
* enumerable property across the session-persistence boundary. Undefined fields are
* omitted rather than written as explicit `undefined`.
*/
export function pickInstanceContextFields(source: InstanceContext): InstanceContext {
const picked: Record<string, unknown> = {};
for (const key of INSTANCE_CONTEXT_KEYS) {
if (source[key] !== undefined) {
picked[key] = source[key];
}
}
return picked as InstanceContext;
}

/**
* Validate URL format with enhanced checks
*/
Expand Down
30 changes: 9 additions & 21 deletions src/types/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,16 @@ export interface SessionState {
* Contains the n8n API credentials and instance-specific settings.
* This is the critical data needed to reconnect to the correct n8n instance.
*
* Note: API keys are stored in plaintext. The downstream application
* MUST encrypt this data before persisting to disk.
* Derived from {@link InstanceContext} so every field the live context carries —
* including `n8nMcpAccessToken` and any field added later — is part of the
* persistence contract instead of being silently dropped on export/restore
* (#1045). Only `n8nApiUrl` and `n8nApiKey` are narrowed to required: a session
* without both cannot be restored (GHSA-2cf7-hpwf-47h9 hardening, #844).
*
* Note: API keys and access tokens are stored in plaintext. The downstream
* application MUST encrypt this data before persisting to disk.
*/
context: {
context: Omit<InstanceContext, 'n8nApiUrl' | 'n8nApiKey'> & {
/**
* n8n instance API URL
* Example: "https://n8n.example.com"
Expand All @@ -70,23 +76,5 @@ export interface SessionState {
* Example: "n8n_api_1234567890abcdef"
*/
n8nApiKey: string;

/**
* Instance identifier (optional)
* Custom identifier for tracking which n8n instance this session belongs to
*/
instanceId?: string;

/**
* Session-specific ID (optional)
* May differ from top-level sessionId in some proxy configurations
*/
sessionId?: string;

/**
* Additional metadata (optional)
* Extensible field for custom application data
*/
metadata?: Record<string, any>;
};
}
Loading
Loading