test(core): Support credential seeding in MCP workflow evals (no-changelog) (#35782)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
Svetoslav Dekov
2026-08-10 19:56:03 +00:00
committed by GitHub
co-authored by Claude Fable 5 Ricardo Espinoza
parent c04825d4c4
commit 9205eb3f19
12 changed files with 543 additions and 103 deletions
@@ -436,8 +436,19 @@ How it differs from the manifest flow:
place, so every iteration gets a genuinely fresh build (clean `pass@k`/`pass^k`
variance) instead of rotating a fixed list of prebuilt IDs.
- **Multi-lane.** Unlike `--prebuilt-workflows` (single instance), `--build-via-mcp`
accepts a comma-separated `--base-url`. Each lane enables MCP, mints its own API
key, and stages its own `claude` MCP config — the CLI does this setup for you.
accepts a comma-separated `--base-url`. Each lane enables MCP; the CLI does this
setup for you.
- **Per-case build users + credential seeding.** Every build runs `claude` as its
own freshly-invited member user (invited in batches through the lane owner,
accepted lazily per build), with that user's MCP API key staged into a per-build
`claude` MCP config. MCP credential/workflow visibility is user-scoped, so each
build sees an isolated view: exactly the case's declared `credentials` (created
in the member's personal project — the MCP analog of the orchestrator's
per-thread credential pinning), and nothing from concurrent builds. Requires the
lanes to have no SMTP configured (otherwise invites are emailed and the CLI
can't obtain the accept token — eval instances never have SMTP). Build users are
deleted with the built workflows after the run; under `--keep-workflows` they
stay, since deleting a user deletes their remaining data.
- **Throwaway cleanup.** Built workflows are deleted after the run unless you pass
`--keep-workflows`. Known limitation: cleanup keys off the `WORKFLOW_ID` trailer
`claude` prints, so a build that times out or never emits the trailer can leave
@@ -1,10 +1,20 @@
import type { EvaluationConfigDto, InstanceAiAgentNode, InstanceAiMessage } from '@n8n/api-types';
import type { WorkflowResponse } from '../clients/n8n-client';
import type { EvalLogger } from '../harness/logger';
import type { WorkflowTestCase } from '../types';
/** Shared fixture builders for artifact-handler and outcome tests — see individual test files for usage. */
export const silentLogger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: () => {},
error: () => {},
isVerbose: false,
};
export function agentNode(overrides: Partial<InstanceAiAgentNode> = {}): InstanceAiAgentNode {
return {
agentId: 'agent-1',
@@ -0,0 +1,186 @@
import { silentLogger } from './fixtures';
import type { N8nClient } from '../clients/n8n-client';
import { cleanupLaneUsers, LaneUserPool, provisionCaseBuildUser } from '../run/lane-users';
import type { TestCaseCredential } from '../types';
type InvitedUser = { id: string; email: string; acceptToken: string };
/** Owner-client stand-in that fulfills every invite and records the batches. */
function fakeOwnerClient() {
const inviteBatches: string[][] = [];
let nextId = 0;
const inviteMembers = vi.fn(async (emails: string[]): Promise<InvitedUser[]> => {
inviteBatches.push(emails);
return await Promise.resolve(
emails.map((email) => {
nextId += 1;
return { id: `u${String(nextId)}`, email, acceptToken: `token-${email}` };
}),
);
});
return { client: { inviteMembers } as unknown as N8nClient, inviteMembers, inviteBatches };
}
describe('LaneUserPool', () => {
it('invites a chunk on first claim and serves later claims from the pool', async () => {
const { client, inviteMembers } = fakeOwnerClient();
const pool = new LaneUserPool(client, 4);
const first = await pool.claim();
const second = await pool.claim();
expect(inviteMembers).toHaveBeenCalledTimes(1);
expect(first.email).not.toBe(second.email);
expect(pool.createdUserIds).toHaveLength(4);
});
it('shares one in-flight refill across concurrent claims on a drained pool', async () => {
const { client, inviteMembers } = fakeOwnerClient();
const pool = new LaneUserPool(client, 4);
const users = await Promise.all([pool.claim(), pool.claim(), pool.claim(), pool.claim()]);
expect(inviteMembers).toHaveBeenCalledTimes(1);
expect(new Set(users.map((u) => u.email)).size).toBe(4);
});
it('refills again when concurrent claims outnumber one chunk', async () => {
const { client, inviteMembers } = fakeOwnerClient();
const pool = new LaneUserPool(client, 2);
const users = await Promise.all(Array.from({ length: 5 }, async () => await pool.claim()));
expect(new Set(users.map((u) => u.email)).size).toBe(5);
expect(inviteMembers.mock.calls.length).toBeGreaterThanOrEqual(3);
expect(pool.createdUserIds.length).toBe(inviteMembers.mock.calls.length * 2);
});
it('mints unique emails across refills', async () => {
const { client, inviteBatches } = fakeOwnerClient();
const pool = new LaneUserPool(client, 2);
for (let i = 0; i < 5; i++) await pool.claim();
const emails = inviteBatches.flat();
expect(new Set(emails).size).toBe(emails.length);
});
it('records shells for cleanup and explains itself when a batch yields no accept token', async () => {
// Mirrors an SMTP-configured lane: the shells exist server-side, but the
// accept URL (and so the token) is withheld.
const inviteMembers = vi.fn().mockResolvedValue([
{ id: 'u1', email: 'a@b.invalid' },
{ id: 'u2', email: 'c@d.invalid' },
]);
const pool = new LaneUserPool({ inviteMembers } as unknown as N8nClient, 2);
await expect(pool.claim()).rejects.toThrow('none returned an accept token');
expect(pool.createdUserIds).toEqual(['u1', 'u2']);
});
it('serves the token-bearing invites when only some of a batch carry one', async () => {
const inviteMembers = vi.fn().mockResolvedValue([
{ id: 'u1', email: 'a@b.invalid', error: 'already invited' },
{ id: 'u2', email: 'c@d.invalid', acceptToken: 't2' },
]);
const pool = new LaneUserPool({ inviteMembers } as unknown as N8nClient, 2);
await expect(pool.claim()).resolves.toMatchObject({ id: 'u2' });
expect(pool.createdUserIds).toEqual(['u1', 'u2']);
});
it('rejects waiting claims when the invite fails, then recovers', async () => {
const inviteMembers = vi
.fn()
.mockRejectedValueOnce(new Error('invite exploded'))
.mockResolvedValue([{ id: 'u1', email: 'a@b.invalid', acceptToken: 't' }]);
const pool = new LaneUserPool({ inviteMembers } as unknown as N8nClient, 1);
await expect(pool.claim()).rejects.toThrow('invite exploded');
await expect(pool.claim()).resolves.toMatchObject({ id: 'u1' });
});
});
describe('provisionCaseBuildUser', () => {
function fakeMemberClient() {
const created: Array<{ name: string; type: string }> = [];
const member = {
acceptInvitation: vi.fn().mockResolvedValue(undefined),
rotateMcpApiKey: vi.fn().mockResolvedValue('mcp-key-1'),
createCredential: vi.fn(async (name: string, type: string) => {
created.push({ name, type });
return await Promise.resolve({ id: `cred-${String(created.length)}` });
}),
};
return { member, client: member as unknown as N8nClient, created };
}
it("accepts the pooled invite as the member and returns that user's MCP key", async () => {
const { client: ownerClient } = fakeOwnerClient();
const pool = new LaneUserPool(ownerClient, 1);
const { member, client } = fakeMemberClient();
const mcpApiKey = await provisionCaseBuildUser({
pool,
memberClient: client,
onCredentialCreated: () => {},
});
expect(member.acceptInvitation).toHaveBeenCalledTimes(1);
const acceptArgs = member.acceptInvitation.mock.calls[0][0] as {
token: string;
password: string;
};
expect(acceptArgs.password).toBe(pool.password);
expect(acceptArgs.token).toContain('token-');
expect(mcpApiKey).toBe('mcp-key-1');
expect(member.createCredential).not.toHaveBeenCalled();
});
it('seeds declared credentials via the member client and reports each id', async () => {
const { client: ownerClient } = fakeOwnerClient();
const pool = new LaneUserPool(ownerClient, 1);
const { client, created } = fakeMemberClient();
const credentials: TestCaseCredential[] = [{ type: 'slackApi' }, { type: 'notionApi' }];
const reported: string[] = [];
await provisionCaseBuildUser({
pool,
memberClient: client,
credentials,
onCredentialCreated: (id) => reported.push(id),
});
expect(created.map((c) => c.type)).toEqual(['slackApi', 'notionApi']);
expect(reported).toEqual(['cred-1', 'cred-2']);
});
it('propagates credential-seeding failures so the build fails instead of skewing', async () => {
const { client: ownerClient } = fakeOwnerClient();
const pool = new LaneUserPool(ownerClient, 1);
const { member, client } = fakeMemberClient();
member.createCredential.mockRejectedValueOnce(new Error('credential POST failed'));
await expect(
provisionCaseBuildUser({
pool,
memberClient: client,
credentials: [{ type: 'slackApi' }],
onCredentialCreated: () => {},
}),
).rejects.toThrow('credential POST failed');
});
});
describe('cleanupLaneUsers', () => {
it('deletes every invited user best-effort, surviving individual failures', async () => {
const { client: ownerClient } = fakeOwnerClient();
const pool = new LaneUserPool(ownerClient, 3);
await pool.claim();
const deleteUser = vi.fn().mockRejectedValueOnce(new Error('409')).mockResolvedValue(undefined);
await cleanupLaneUsers({ deleteUser } as unknown as N8nClient, pool, silentLogger);
expect(deleteUser).toHaveBeenCalledTimes(3);
});
});
@@ -129,6 +129,12 @@ describe('unsupportedMcpBuildSetupFields', () => {
expect(unsupportedMcpBuildSetupFields(testCase({ credentials: [] }))).toEqual([]);
});
it('does not flag declared credentials (the fused path seeds them per-case)', () => {
expect(
unsupportedMcpBuildSetupFields(testCase({ credentials: [{ type: 'slackApi' }] })),
).toEqual([]);
});
it('does not flag messageBudget (inapplicable to a single-shot claude build)', () => {
expect(unsupportedMcpBuildSetupFields(testCase({ messageBudget: 6 }))).toEqual([]);
});
@@ -136,7 +142,6 @@ describe('unsupportedMcpBuildSetupFields', () => {
// One `seed` entry covers every mode: the classification keys off the slot, not
// the mode, so a new arm needs no edit here.
it.each<[string, string, Partial<WorkflowTestCase>]>([
['credentials', 'credentials', { credentials: [{ type: 'slackApi' }] }],
[
'an inline seed',
'seed',
@@ -163,7 +168,7 @@ describe('unsupportedMcpBuildSetupFields', () => {
expect(unsupportedMcpBuildSetupFields(testCase(overrides))).toEqual([field]);
});
it('flags multiple declared fields together', () => {
it('flags only the seed when credentials and a seed are declared together', () => {
expect(
unsupportedMcpBuildSetupFields(
testCase({
@@ -171,7 +176,7 @@ describe('unsupportedMcpBuildSetupFields', () => {
seed: { mode: 'replay', threadId: 't1' },
}),
),
).toEqual(['credentials', 'seed']);
).toEqual(['seed']);
});
});
@@ -5,7 +5,7 @@
// loader uses, so shape regressions surface here rather than at eval time.
import { execSync } from 'child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { basename, join, resolve } from 'path';
import { z } from 'zod';
@@ -523,14 +523,8 @@ async function main(): Promise<void> {
repoRoot,
repoRoot ? undefined : process.cwd(),
]);
// Removed on process exit by the staging module itself.
const mcpConfigPath = stageMcpConfigFromClaudeJson(args.mcpServerName, projectScopes);
process.on('exit', () => {
try {
unlinkSync(mcpConfigPath);
} catch {
// best-effort
}
});
const tasks: Array<{ slug: string; iteration: number }> = [];
for (const slug of args.slugs) {
@@ -42,7 +42,7 @@ async function main(): Promise<void> {
// One lane per base URL; the drivers dispatch builds across them via the
// work-stealing allocator inside the shared eval session.
const { lanes, cleanupStagedMcpConfigs } = await setupLanes(args, logger);
const lanes = await setupLanes(args, logger);
const startTime = Date.now();
// Delete workflows after the run when they're throwaway: prebuilt opt-in
@@ -131,7 +131,6 @@ async function main(): Promise<void> {
});
} finally {
await cleanupLanes(lanes, cleanupBuiltWorkflows, logger);
cleanupStagedMcpConfigs();
}
}
@@ -15,7 +15,7 @@
// ---------------------------------------------------------------------------
import { spawn } from 'child_process';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
import { setTimeout as delay } from 'node:timers/promises';
import { homedir, tmpdir } from 'os';
import { join } from 'path';
@@ -61,6 +61,24 @@ export function uniqueProjectScopes(scopes: Array<string | undefined>): string[]
return uniqueDefined(scopes);
}
// Every staged config embeds a bearer token, so exit cleanup is intrinsic to
// staging: writeMcpConfig tracks each path and the exit hook unlinks whatever
// is left. Callers that finish normally unlink eagerly via unlinkStagedMcpConfig.
const stagedConfigPaths = new Set<string>();
process.on('exit', () => {
for (const staged of stagedConfigPaths) unlinkStagedMcpConfig(staged);
});
/** Eagerly remove a staged MCP config (and drop it from exit cleanup). */
export function unlinkStagedMcpConfig(path: string): void {
stagedConfigPaths.delete(path);
try {
unlinkSync(path);
} catch {
// best-effort
}
}
function writeMcpConfig(serverName: string, block: unknown, filePrefix: string): string {
// A random suffix (not just pid+time) keeps concurrent lane stages from
// colliding when several are written in the same millisecond.
@@ -69,6 +87,7 @@ function writeMcpConfig(serverName: string, block: unknown, filePrefix: string):
`${filePrefix}-${String(process.pid)}-${String(Date.now())}-${Math.random().toString(36).slice(2, 8)}.json`,
);
writeFileSync(tmpPath, JSON.stringify({ mcpServers: { [serverName]: block } }), { mode: 0o600 });
stagedConfigPaths.add(tmpPath);
return tmpPath;
}
@@ -102,10 +121,11 @@ export function stageMcpConfigFromClaudeJson(
}
/**
* Stage an MCP config for one eval lane directly from its URL + API key, with no
* Stage an MCP config for one build directly from a lane URL + API key, with no
* dependency on `~/.claude.json`. Used by the fused `--build-via-mcp` path where
* the eval CLI mints an MCP key per lane and points `claude` at that lane's MCP
* server. Returns the temp config path; the caller owns cleanup.
* the eval CLI mints an MCP key per build user and points `claude` at that
* lane's MCP server. Returns the temp config path; removed on process exit, or
* earlier via unlinkStagedMcpConfig.
*/
export function stageLaneMcpConfig(opts: {
serverName: string;
@@ -134,12 +154,13 @@ export function buildAllowedTools(serverName: string): readonly string[] {
type McpBuildKeySupport = 'supported' | 'orchestrator-only';
/**
* Classification of EVERY test-case schema key for the `claude -p` MCP build
* path. `orchestrator-only` keys are build-side setup the orchestrator seeds
* before driving the in-product agent (credential creation, conversation/thread
* seeding), while `claude` receives only the flattened conversation prompt — a
* case relying on them would build without its prerequisites and fail
* misleadingly, so callers skip cases that declare them.
* Classification of EVERY test-case schema key for the fused `--build-via-mcp`
* build path (the standalone manifest builder does not consult this filter).
* `orchestrator-only` keys are build-side setup the orchestrator seeds
* before driving the in-product agent (conversation/thread seeding), while
* `claude` receives only the flattened conversation prompt — a case relying on
* them would build without its prerequisites and fail misleadingly, so callers
* skip cases that declare them.
*
* Deliberately exhaustive rather than a blocklist: the type covers the
* WorkflowTestCase interface (plus the schema's forbidden legacy key), and a
@@ -167,7 +188,10 @@ export const MCP_BUILD_KEY_SUPPORT: Record<
// Forbidden legacy key — the schema rejects it at load, so it can never
// reach this check; classified only to keep the map schema-complete.
buildExpectations: 'supported',
credentials: 'orchestrator-only',
// The fused --build-via-mcp path seeds these into the per-build member
// user's personal project (the standalone manifest builder cannot — it has
// no n8n session).
credentials: 'supported',
seed: 'orchestrator-only',
datasets: 'supported',
};
@@ -189,7 +213,7 @@ export function unsupportedMcpBuildSetupFields(testCase: WorkflowTestCase): stri
return ORCHESTRATOR_ONLY_KEYS.filter((key) => {
const value = values[key];
if (value === undefined || value === null || value === '') return false;
// An empty array (e.g. credentials: []) declares nothing to seed.
// An empty array declares nothing to seed.
return !Array.isArray(value) || value.length > 0;
});
}
@@ -47,6 +47,21 @@ function serverBudgetFor(timeoutMs: number): number {
return Math.max(timeoutMs - CLIENT_ABORT_MARGIN_MS, 30_000);
}
// -- Invitation response shapes ------------------------------------------------
const InvitedUsersEnvelope = z.object({
data: z.array(
z.object({
user: z.object({
id: z.string(),
email: z.string(),
inviteAcceptUrl: z.string().optional(),
}),
error: z.string().optional(),
}),
),
});
// -- Conversation seeding response shapes -------------------------------------
const RestoreThreadEnvelope = z.object({
@@ -660,6 +675,61 @@ export class N8nClient {
await this.fetch(`/rest/credentials/${id}`, { method: 'DELETE' });
}
/**
* Invite member users in one batched request. Requires an owner session.
* Returns one row per invitee, reporting rather than throwing on failure:
* n8n creates the user shells before it reports per-invite errors, so the
* caller needs every id back to clean up. `acceptToken` is present only when
* the invite was not emailed (`inviteAcceptUrl` is the token's only carrier,
* and it is withheld when SMTP is configured or N8N_INVITE_LINKS_EMAIL_ONLY
* is set).
* POST /rest/invitations body: [{ email, role: 'global:member' }, ...]
*/
async inviteMembers(
emails: string[],
): Promise<Array<{ id: string; email: string; acceptToken?: string; error?: string }>> {
if (emails.length === 0) return [];
const response = InvitedUsersEnvelope.parse(
await this.fetch('/rest/invitations', {
method: 'POST',
body: emails.map((email) => ({ email, role: 'global:member' })),
}),
);
return response.data.map(({ user, error }) => ({
id: user.id,
email: user.email,
acceptToken: user.inviteAcceptUrl
? (new URL(user.inviteAcceptUrl).searchParams.get('token') ?? undefined)
: undefined,
error: error === '' ? undefined : error,
}));
}
/**
* Accept an invitation. The response issues the new user's session cookie,
* so on a fresh N8nClient this doubles as their login.
* POST /rest/invitations/accept
*/
async acceptInvitation(opts: {
token: string;
firstName: string;
lastName: string;
password: string;
}): Promise<void> {
await this.fetch('/rest/invitations/accept', { method: 'POST', body: opts });
if (!this.sessionCookie) {
throw new Error('Invitation accepted but no session cookie received');
}
}
/**
* Delete a user, including the data remaining in their personal project.
* DELETE /rest/users/:id
*/
async deleteUser(id: string): Promise<void> {
await this.fetch(`/rest/users/${id}`, { method: 'DELETE' });
}
/**
* Pin a build thread's credential view to exactly these IDs (empty array =
* the thread sees no credentials).
@@ -12,11 +12,18 @@ import type { InstanceAiRunDebugResponse } from '@n8n/api-types';
import { sleep } from '@n8n/utils/sleep';
import type { LaneAllocator } from './lane-allocator';
import { provisionCaseBuildUser, type LaneUserPool } from './lane-users';
import { selectAuthorExpectations } from '../build-expectations/select';
import { allFailVerdicts, verifyBuildExpectations } from '../build-expectations/verifier';
import type { CliArgs } from '../cli/args';
import { buildWorkflowViaMcp, type McpBuildSettings } from '../cli/mcp-builder';
import type { N8nClient } from '../clients/n8n-client';
import {
buildWorkflowViaMcp,
stageLaneMcpConfig,
unlinkStagedMcpConfig,
type McpBuildResult,
type McpBuildSettings,
} from '../cli/mcp-builder';
import { N8nClient } from '../clients/n8n-client';
import {
fetchAgentScenarioContext,
findAgentArtifactRef,
@@ -36,6 +43,7 @@ import {
import type { executeScenario } from '../harness/scenario-execution';
import type { ScenarioSeedContext } from '../harness/seed-tables';
import {
extractErrorMessage,
findProviderOutage,
isRequestAbort,
isTransientNetworkError,
@@ -45,6 +53,7 @@ import {
import type {
BuildExpectationResult,
ExecutionScenario,
TestCaseCredential,
TranscriptTurn,
WorkflowTestCase,
} from '../types';
@@ -68,9 +77,9 @@ export interface Lane {
* per-lane because prebuilt/MCP-built workflows only exist on their own lane —
* deleting them via another lane's client would 404. */
workflowIdsToDelete: Set<string>;
/** Staged `claude` MCP config for this lane (`--build-via-mcp` only). Points
* `claude -p` at this lane's own MCP server + minted API key. Cleaned up on exit. */
mcpConfigPath?: string;
/** Pool of invited member users for `--build-via-mcp` — each build claims one
* so its MCP credential/workflow view is isolated to that user. */
mcpUserPool?: LaneUserPool;
}
/** One `claude` build's Anthropic spend (`--build-via-mcp` only). Mirrors
@@ -136,10 +145,15 @@ function mcpBuildSettingsFromArgs(args: CliArgs): McpBuildSettings {
* verify path is identical to `--prebuilt-workflows`. Never throws: a failed
* build resolves to an unsuccessful BuildResult. The workflow lives on `lane`,
* so the caller must verify it on that same lane.
*
* Each build runs as its own member user with the declared credentials seeded
* into that user's personal project (see lane-users.ts); the lane owner's
* global scopes cover the fetch-back and verification.
*/
async function buildWorkflowViaMcpOnLane(config: {
lane: Lane;
conversation: WorkflowTestCase['conversation'];
credentials?: TestCaseCredential[];
slug: string;
iteration: number;
args: CliArgs;
@@ -148,27 +162,53 @@ async function buildWorkflowViaMcpOnLane(config: {
/** Run-wide spend collector; every attempt is recorded, success or not. */
buildSpend: McpBuildSpend[];
}): Promise<BuildResult> {
const { lane, conversation, slug, iteration, args, logDir, logger, buildSpend } = config;
if (!lane.mcpConfigPath) {
return {
success: false,
error: `Lane ${lane.baseUrl} has no staged MCP config — cannot build via MCP`,
workflowJsons: [],
createdWorkflowIds: [],
createdDataTableIds: [],
};
const { lane, conversation, credentials, slug, iteration, args, logDir, logger, buildSpend } =
config;
const failure = (error: string): BuildResult => ({
success: false,
error,
workflowJsons: [],
createdWorkflowIds: [],
createdDataTableIds: [],
});
if (!lane.mcpUserPool) {
return failure(`Lane ${lane.baseUrl} has no MCP build user pool — cannot build via MCP`);
}
const result = await buildWorkflowViaMcp({
conversation: conversation ?? [],
slug,
iteration,
mcpConfigPath: lane.mcpConfigPath,
settings: mcpBuildSettingsFromArgs(args),
logDir,
log: (message) => logger.info(message),
let mcpApiKey: string;
try {
mcpApiKey = await provisionCaseBuildUser({
pool: lane.mcpUserPool,
memberClient: new N8nClient(lane.baseUrl),
credentials,
onCredentialCreated: (id) => lane.createdCredentialIds.add(id),
logger,
});
} catch (error) {
return failure(`MCP build user/credential setup failed: ${extractErrorMessage(error)}`);
}
const mcpConfigPath = stageLaneMcpConfig({
serverName: args.mcpServerName,
url: `${lane.baseUrl}/mcp-server/http`,
apiKey: mcpApiKey,
});
let result: McpBuildResult;
try {
result = await buildWorkflowViaMcp({
conversation: conversation ?? [],
slug,
iteration,
mcpConfigPath,
settings: mcpBuildSettingsFromArgs(args),
logDir,
log: (message) => logger.info(message),
});
} finally {
unlinkStagedMcpConfig(mcpConfigPath);
}
// Record spend whether or not the build produced a workflow — failed builds
// cost money too, and this is the run's spend record.
buildSpend.push({ costUsd: result.cost, turns: result.turns });
@@ -183,13 +223,7 @@ async function buildWorkflowViaMcpOnLane(config: {
}
if (!result.workflowId) {
return {
success: false,
error: `MCP build produced no workflow (${result.failureReason ?? 'unknown'})`,
workflowJsons: [],
createdWorkflowIds: [],
createdDataTableIds: [],
};
return failure(`MCP build produced no workflow (${result.failureReason ?? 'unknown'})`);
}
return await fetchPrebuiltBuild(lane.client, result.workflowId, logger);
@@ -416,6 +450,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
build = await buildWorkflowViaMcpOnLane({
lane: lane.runner,
conversation: entry.conversation,
credentials: entry.credentials,
slug: fileSlug,
iteration,
args,
@@ -64,9 +64,10 @@ export function selectCases(
}
// `claude -p` builds get only the flattened conversation — build-side setup
// (credentials, seeds) is orchestrator-only. Skip cases that declare it
// rather than building them without prerequisites and reporting misleading
// failures (mirrors the prebuilt-coverage partition above).
// the fused path can't provide (conversation/thread seeds) is
// orchestrator-only. Skip cases that declare it rather than building them
// without prerequisites and reporting misleading failures (mirrors the
// prebuilt-coverage partition above).
if (args.buildViaMcp) {
const skippedMcp: string[] = [];
testCasesWithFiles = testCasesWithFiles.filter(({ testCase, fileSlug }) => {
@@ -1,14 +1,12 @@
// ---------------------------------------------------------------------------
// Lane setup — one authenticated n8n lane per --base-url (TRUST-261):
// login, MCP-registry seed, optional `claude` MCP config staging, and the
// pre-run workflow snapshot. Plus the end-of-run per-lane artifact cleanup.
// login, MCP-registry seed, optional MCP build-user pool, and the pre-run
// workflow snapshot. Plus the end-of-run per-lane artifact cleanup.
// ---------------------------------------------------------------------------
import { unlinkSync } from 'fs';
import type { Lane } from './build-orchestrator';
import { cleanupLaneUsers, LaneUserPool } from './lane-users';
import type { CliArgs } from '../cli/args';
import { stageLaneMcpConfig } from '../cli/mcp-builder';
import { N8nClient } from '../clients/n8n-client';
import { cleanupCredentials } from '../credentials/seeder';
import type { EvalLogger } from '../harness/logger';
@@ -16,30 +14,7 @@ import { cleanupPrebuiltWorkflows } from '../harness/prebuilt-workflows';
import { seedMcpRegistry } from '../mcp-registry/seeder';
import { snapshotDataTableIds, snapshotWorkflowIds } from '../outcome/workflow-discovery';
export interface LaneSetup {
lanes: Lane[];
/** Removes staged `claude` MCP configs (they embed lane bearer tokens). Also
* registered on process exit; calling it twice is safe. */
cleanupStagedMcpConfigs: () => void;
}
export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<LaneSetup> {
// Remove staged MCP configs (they embed the lane's bearer token) on exit,
// belt-and-suspenders alongside the explicit finally cleanup below.
// Registered before lane init and populated as configs are staged, so a
// lane failing setup can't strand another lane's already-staged token file.
const stagedMcpConfigPaths: string[] = [];
const cleanupStagedMcpConfigs = () => {
for (const path of stagedMcpConfigPaths) {
try {
unlinkSync(path);
} catch {
// best-effort
}
}
};
if (args.buildViaMcp) process.on('exit', cleanupStagedMcpConfigs);
export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lane[]> {
// One lane per base URL. The LangSmith path then uses a work-stealing
// allocator (lane-allocator.ts) to dispatch builds across lanes; the direct
// path partitions test cases statically per lane.
@@ -62,21 +37,13 @@ export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lan
logger.info(`Skipped MCP registry seed (test endpoint unavailable)${tag}`);
}
// --build-via-mcp: enable MCP, mint this lane's own API key, and stage a
// `claude` MCP config pointing at this lane's MCP server. Each lane is a
// self-contained build+verify target — a workflow built here is verified
// here, so N lanes parallelize the whole pipeline within one process.
let mcpConfigPath: string | undefined;
// --build-via-mcp: enable MCP and set up the lane's build-user pool.
// Each lane is a self-contained build+verify target — a workflow built
// here is verified here, so N lanes parallelize the whole pipeline.
let mcpUserPool: LaneUserPool | undefined;
if (args.buildViaMcp) {
await client.enableMcpAccess();
const apiKey = await client.rotateMcpApiKey();
mcpConfigPath = stageLaneMcpConfig({
serverName: args.mcpServerName,
url: `${baseUrl}/mcp-server/http`,
apiKey,
});
stagedMcpConfigPaths.push(mcpConfigPath);
logger.info(`Staged MCP build config${tag}`);
mcpUserPool = new LaneUserPool(client);
}
const preRunWorkflowIds = await snapshotWorkflowIds(client);
@@ -92,12 +59,12 @@ export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lan
claimedWorkflowIds,
createdCredentialIds,
workflowIdsToDelete,
mcpConfigPath,
mcpUserPool,
};
}),
);
return { lanes, cleanupStagedMcpConfigs };
return lanes;
}
/** Per-lane cleanup: each lane only holds the workflows built/fetched on it,
@@ -114,6 +81,11 @@ export async function cleanupLanes(
await cleanupPrebuiltWorkflows(lane.client, lane.workflowIdsToDelete, logger);
}
await cleanupCredentials(lane.client, [...lane.createdCredentialIds]).catch(() => {});
// Deleting a user deletes their remaining data, so keep the build
// users when workflows are kept.
if (cleanupBuiltWorkflows && lane.mcpUserPool) {
await cleanupLaneUsers(lane.client, lane.mcpUserPool, logger);
}
}),
);
}
@@ -0,0 +1,133 @@
// ---------------------------------------------------------------------------
// Per-case build users for `--build-via-mcp`.
//
// MCP credential/workflow visibility is user-scoped, so running each build as
// its own freshly-invited member gives it an isolated credential view holding
// exactly the case's declared credentials — the MCP analog of the
// orchestrator's per-thread credential pinning.
// ---------------------------------------------------------------------------
import { randomBytes } from 'crypto';
import type { N8nClient } from '../clients/n8n-client';
import { createDeclaredCredentials } from '../credentials/seeder';
import type { EvalLogger } from '../harness/logger';
import type { TestCaseCredential } from '../types';
export interface InvitedCaseUser {
id: string;
email: string;
acceptToken: string;
}
const INVITE_CHUNK_SIZE = 8;
export class LaneUserPool {
private readonly available: InvitedCaseUser[] = [];
private refill?: Promise<void>;
/** Every user this pool invited (accepted or not), for post-run deletion. */
readonly createdUserIds: string[] = [];
private seq = 0;
private readonly nonce = randomBytes(4).toString('hex');
/** Shared by this pool's throwaway members; random per run. */
readonly password = `Eval1!${randomBytes(12).toString('hex')}`;
constructor(
private readonly ownerClient: N8nClient,
private readonly chunkSize = INVITE_CHUNK_SIZE,
) {}
/** Claim an invited-but-unaccepted user, inviting a fresh chunk when the
* pool runs dry. Concurrent claims share one in-flight refill. */
async claim(): Promise<InvitedCaseUser> {
let user = this.available.pop();
while (!user) {
this.refill ??= this.doRefill().finally(() => (this.refill = undefined));
await this.refill;
user = this.available.pop();
}
return user;
}
private async doRefill(): Promise<void> {
// One batched POST per chunk — /rest/invitations is IP-rate-limited in production.
const emails = Array.from({ length: this.chunkSize }, () => {
this.seq += 1;
return `eval-mcp-${this.nonce}-${String(this.seq)}@n8n-evals.invalid`;
});
const invited = await this.ownerClient.inviteMembers(emails);
// Record every shell before judging usability: n8n creates them all up
// front, so even an unusable batch has to be deleted after the run.
for (const user of invited) this.createdUserIds.push(user.id);
const usable = invited.filter(
(user): user is InvitedCaseUser => user.acceptToken !== undefined,
);
this.available.push(...usable);
if (usable.length === 0) {
const reason = invited.map((user) => user.error).find(Boolean);
throw new Error(
`Invited ${String(invited.length)} user(s) but none returned an accept token${reason ? ` (${reason})` : ''} — the lane must run without SMTP and without N8N_INVITE_LINKS_EMAIL_ONLY for the eval build-user flow`,
);
}
}
}
/**
* Accept a pooled invitation (which logs the fresh client in as the member),
* mint and return the member's MCP API key, and create the case's declared
* credentials in their personal project. Throws on any step so a partial setup
* fails the build instead of skewing it.
*/
export async function provisionCaseBuildUser(opts: {
pool: LaneUserPool;
/** A fresh, logged-out client for the lane — accepting the invite logs it in. */
memberClient: N8nClient;
credentials?: TestCaseCredential[];
onCredentialCreated: (id: string) => void;
logger?: EvalLogger;
}): Promise<string> {
const { pool, memberClient } = opts;
const user = await pool.claim();
await memberClient.acceptInvitation({
token: user.acceptToken,
firstName: 'Eval',
lastName: 'Builder',
password: pool.password,
});
const mcpApiKey = await memberClient.rotateMcpApiKey();
await createDeclaredCredentials(memberClient, opts.credentials ?? [], {
onCreated: opts.onCredentialCreated,
logger: opts.logger,
});
return mcpApiKey;
}
/** Best-effort deletion of the pool's users. Deleting a user also deletes
* what's left in their personal project, so only call this when built
* workflows are throwaway. */
export async function cleanupLaneUsers(
ownerClient: N8nClient,
pool: LaneUserPool,
logger: EvalLogger,
): Promise<void> {
const ids = pool.createdUserIds;
if (ids.length === 0) return;
let deleted = 0;
// Sequential, like cleanupCredentials: each delete cascades server-side and
// this runs after the last verdict, so nothing waits on it.
for (const id of ids) {
try {
await ownerClient.deleteUser(id);
deleted++;
} catch {
// best-effort
}
}
logger.verbose(`Deleted ${String(deleted)}/${String(ids.length)} MCP build user(s)`);
}