mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
Merge ca5f1dd498 into 0ce820de73
This commit is contained in:
commit
21f0a853c4
|
|
@ -341,6 +341,7 @@ export {
|
|||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
InstanceAiGatewayCapabilitiesDto,
|
||||
InstanceAiGatewayCreateCredentialDto,
|
||||
InstanceAiFilesystemResponseDto,
|
||||
applyBranchReadOnlyOverrides,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
|
|
|||
|
|
@ -539,6 +539,13 @@ export class InstanceAiGatewayCapabilitiesDto extends Z.class({
|
|||
}) {}
|
||||
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
|
||||
|
||||
export class InstanceAiGatewayCreateCredentialDto extends Z.class({
|
||||
name: z.string().min(1).max(128),
|
||||
type: z.string().min(1).max(128),
|
||||
data: z.record(z.unknown()),
|
||||
projectId: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem bridge payloads (browser ↔ server round-trip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ function makeSession(overrides: Partial<GatewaySession> = {}): jest.Mocked<Gatew
|
|||
setDir: jest.fn(),
|
||||
getGroupMode: jest.fn().mockReturnValue('allow'),
|
||||
allowForSession: jest.fn(),
|
||||
clearSessionRules: jest.fn(),
|
||||
clearSession: jest.fn(),
|
||||
alwaysAllow: jest.fn(),
|
||||
alwaysDeny: jest.fn(),
|
||||
flush: jest.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
type AffectedResource,
|
||||
type CallToolResult,
|
||||
type ConfirmResourceAccess,
|
||||
type CreateCredentialPayload,
|
||||
type McpTool,
|
||||
type ResourceDecision,
|
||||
type ToolDefinition,
|
||||
|
|
@ -148,7 +149,7 @@ export class GatewayClient {
|
|||
if (this.disconnected) return;
|
||||
this.disconnected = true;
|
||||
this.shouldReconnect = false;
|
||||
this.options.session.clearSessionRules();
|
||||
this.options.session.clearSession();
|
||||
|
||||
const notifyServer = options.notifyServer ?? true;
|
||||
if (notifyServer) {
|
||||
|
|
@ -453,7 +454,34 @@ export class GatewayClient {
|
|||
typeof _confirmation === 'string' ? (_confirmation as ResourceDecision) : undefined;
|
||||
|
||||
const typedArgs: unknown = def.inputSchema.parse(cleanArgs);
|
||||
const context = { dir: this.dir };
|
||||
const session = this.options.session;
|
||||
const instanceUrl = this.options.url;
|
||||
const gatewayKey = this.apiKey;
|
||||
const context = {
|
||||
dir: this.dir,
|
||||
secretsBuffer: {
|
||||
capture: (k: string, f: string, v: string) => session.captureSecret(k, f, v),
|
||||
getFields: (k: string) => session.getSecretFields(k),
|
||||
clear: (k: string) => session.clearSecrets(k),
|
||||
},
|
||||
createCredential: async (payload: CreateCredentialPayload) => {
|
||||
const url = `${instanceUrl}/rest/instance-ai/gateway/credentials`;
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('X-Gateway-Key', gatewayKey);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Credential creation failed: ${res.status} ${text}`);
|
||||
}
|
||||
const body = (await res.json()) as { data: { credentialId: string } };
|
||||
return { credentialId: body.data.credentialId };
|
||||
},
|
||||
};
|
||||
|
||||
const resources = await def.getAffectedResources(typedArgs, context);
|
||||
await this.checkPermissions(resources, decision);
|
||||
|
|
|
|||
|
|
@ -281,8 +281,8 @@ describe('GatewaySession', () => {
|
|||
// Session-level allow rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('allowForSession / clearSessionRules', () => {
|
||||
it('allow for session is cleared after clearSessionRules', () => {
|
||||
describe('allowForSession / clearSession', () => {
|
||||
it('allow for session is cleared after clearSession', () => {
|
||||
const store = makeStore();
|
||||
const session = new GatewaySession(
|
||||
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
|
||||
|
|
@ -290,7 +290,7 @@ describe('GatewaySession', () => {
|
|||
);
|
||||
session.allowForSession('shell', 'npm');
|
||||
expect(session.check('shell', 'npm')).toBe('allow');
|
||||
session.clearSessionRules();
|
||||
session.clearSession();
|
||||
expect(session.check('shell', 'npm')).toBe('ask');
|
||||
});
|
||||
|
||||
|
|
@ -340,4 +340,54 @@ describe('GatewaySession', () => {
|
|||
expect(store.flush).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secrets buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('captureSecret / getSecretFields / clearSecrets', () => {
|
||||
function makeSession() {
|
||||
return new GatewaySession(
|
||||
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
|
||||
makeStore() as unknown as SettingsStore,
|
||||
);
|
||||
}
|
||||
|
||||
it('stores a captured field and retrieves it', () => {
|
||||
const session = makeSession();
|
||||
session.captureSecret('k1', 'apiKey', 'secret');
|
||||
const fields = session.getSecretFields('k1');
|
||||
expect(fields?.get('apiKey')).toBe('secret');
|
||||
});
|
||||
|
||||
it('accumulates multiple fields under the same key', () => {
|
||||
const session = makeSession();
|
||||
session.captureSecret('k1', 'clientId', 'id-value');
|
||||
session.captureSecret('k1', 'clientSecret', 'secret-value');
|
||||
const fields = session.getSecretFields('k1');
|
||||
expect(fields?.get('clientId')).toBe('id-value');
|
||||
expect(fields?.get('clientSecret')).toBe('secret-value');
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown key', () => {
|
||||
const session = makeSession();
|
||||
expect(session.getSecretFields('no-such-key')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears only the specified key', () => {
|
||||
const session = makeSession();
|
||||
session.captureSecret('k1', 'field', 'value');
|
||||
session.captureSecret('k2', 'other', 'other-value');
|
||||
session.clearSecrets('k1');
|
||||
expect(session.getSecretFields('k1')).toBeUndefined();
|
||||
expect(session.getSecretFields('k2')?.get('other')).toBe('other-value');
|
||||
});
|
||||
|
||||
it('clearSession also wipes the secrets buffer', () => {
|
||||
const session = makeSession();
|
||||
session.captureSecret('k1', 'field', 'value');
|
||||
session.clearSession();
|
||||
expect(session.getSecretFields('k1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export class GatewaySession {
|
|||
private _dir: string;
|
||||
private _permissions: Record<ToolGroup, PermissionMode>;
|
||||
private readonly sessionAllows: Map<ToolGroup, Set<string>> = new Map();
|
||||
private readonly _secretsBuffer: Map<string, Map<string, string>> = new Map();
|
||||
|
||||
constructor(
|
||||
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
|
||||
|
|
@ -100,8 +101,30 @@ export class GatewaySession {
|
|||
set.add(resource);
|
||||
}
|
||||
|
||||
clearSessionRules(): void {
|
||||
clearSession(): void {
|
||||
this.sessionAllows.clear();
|
||||
this._secretsBuffer.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secrets buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
captureSecret(credentialsKey: string, field: string, value: string): void {
|
||||
let fields = this._secretsBuffer.get(credentialsKey);
|
||||
if (!fields) {
|
||||
fields = new Map();
|
||||
this._secretsBuffer.set(credentialsKey, fields);
|
||||
}
|
||||
fields.set(field, value);
|
||||
}
|
||||
|
||||
getSecretFields(credentialsKey: string): Map<string, string> | undefined {
|
||||
return this._secretsBuffer.get(credentialsKey);
|
||||
}
|
||||
|
||||
clearSecrets(credentialsKey: string): void {
|
||||
this._secretsBuffer.delete(credentialsKey);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { CreateCredentialPayload, SecretsBuffer } from '@n8n/mcp-browser';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { ToolGroup } from '../config';
|
||||
|
||||
export type { CallToolResult };
|
||||
export type { CallToolResult, CreateCredentialPayload, SecretsBuffer };
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
|
|
@ -19,6 +20,8 @@ export interface McpTool {
|
|||
export interface ToolContext {
|
||||
/** Base filesystem directory (used by filesystem tools) */
|
||||
dir: string;
|
||||
secretsBuffer?: SecretsBuffer;
|
||||
createCredential?: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
|
||||
}
|
||||
|
||||
export interface ToolAnnotations {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"langsmith": "catalog:",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/mcp-browser": "workspace:*",
|
||||
"@n8n/sandbox-client": "0.0.1",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@n8n/workflow-sdk": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type GatewayConfirmationRequiredPayload,
|
||||
type McpToolCallResult,
|
||||
} from '@n8n/api-types';
|
||||
import { browserCreateCredentialSchema } from '@n8n/mcp-browser/dist/tools/credential';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3';
|
||||
|
|
@ -186,11 +187,20 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer, logger?: L
|
|||
|
||||
let inputSchema: z.ZodTypeAny;
|
||||
try {
|
||||
// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
|
||||
// McpTool.inputSchema properties are typed as Record<string, unknown> to
|
||||
// accommodate arbitrary JSON Schema values; the cast is safe here because
|
||||
// the daemon always sends valid JSON Schema objects.
|
||||
inputSchema = convertJsonSchemaToZod(mcpTool.inputSchema as JSONSchema);
|
||||
if (toolName === 'browser_create_credential') {
|
||||
// when converting json schema the `inputSchema` has the correct shape and parsed to correct output
|
||||
// but during execution all unspecified key from `data` and `resolveData` are stripped.
|
||||
// somewhere in mastra core the inputSchema is converted multiple times back and forth and
|
||||
// gets transformed to jsonSchema with `additionalProperties=false`
|
||||
// this does not happen when passing the schema directly
|
||||
inputSchema = browserCreateCredentialSchema;
|
||||
} else {
|
||||
// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
|
||||
// McpTool.inputSchema properties are typed as Record<string, unknown> to
|
||||
// accommodate arbitrary JSON Schema values; the cast is safe here because
|
||||
// the daemon always sends valid JSON Schema objects.
|
||||
inputSchema = convertJsonSchemaToZod(mcpTool.inputSchema as JSONSchema);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: accept any object if conversion fails
|
||||
inputSchema = z.record(z.unknown());
|
||||
|
|
|
|||
|
|
@ -611,4 +611,14 @@ export class AgentBrowserAdapter implements Adapter {
|
|||
getPageUrl(pageId: string): string | undefined {
|
||||
return this.urlCache.get(pageId);
|
||||
}
|
||||
|
||||
async getElementValue(pageId: string, target: ElementTarget): Promise<string> {
|
||||
await this.switchToTab(pageId);
|
||||
const ref = this.resolveTarget(target);
|
||||
const valueResp = await this.run(['get', 'value', ref]);
|
||||
const value = (valueResp.data as { value?: string } | undefined)?.value ?? '';
|
||||
if (value !== '') return value;
|
||||
const textResp = await this.run(['get', 'text', ref]);
|
||||
return (textResp.data as { text?: string } | undefined)?.text ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,10 @@ export class PlaywrightAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
getElementValue(_pageId: string, _target: ElementTarget): never {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
private async resolveLocator(pageId: string, target: ElementTarget): Promise<Locator> {
|
||||
if ('ref' in target) {
|
||||
return (await this.resolveRef(pageId, target.ref)) as Locator;
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ export type {
|
|||
ConnectResult,
|
||||
ConnectionState,
|
||||
Cookie,
|
||||
CreateCredentialPayload,
|
||||
ElementTarget,
|
||||
PageInfo,
|
||||
ResolvedConfig,
|
||||
SecretsBuffer,
|
||||
ToolContext,
|
||||
ToolDefinition,
|
||||
CallToolResult,
|
||||
|
|
|
|||
277
packages/@n8n/mcp-browser/src/tools/credential.test.ts
Normal file
277
packages/@n8n/mcp-browser/src/tools/credential.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import type { SecretsBuffer, ToolContext } from '../types';
|
||||
import { createCredentialTools } from './credential';
|
||||
import { createMockConnection, findTool, structuredOf } from './test-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBuffer(): jest.Mocked<SecretsBuffer> & { _store: Map<string, Map<string, string>> } {
|
||||
const store = new Map<string, Map<string, string>>();
|
||||
return {
|
||||
_store: store,
|
||||
capture: jest.fn((key: string, field: string, value: string) => {
|
||||
if (!store.has(key)) store.set(key, new Map());
|
||||
store.get(key)!.set(field, value);
|
||||
}),
|
||||
getFields: jest.fn((key: string) => store.get(key)),
|
||||
clear: jest.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<Pick<ToolContext, 'secretsBuffer' | 'createCredential'>> = {},
|
||||
): ToolContext {
|
||||
return { dir: '/test', ...overrides };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// browser_capture_secret
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('browser_capture_secret', () => {
|
||||
let mockConn: ReturnType<typeof createMockConnection>;
|
||||
let buffer: ReturnType<typeof makeBuffer>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConn = createMockConnection();
|
||||
buffer = makeBuffer();
|
||||
mockConn.adapter.getElementValue.mockResolvedValue('secret-value');
|
||||
});
|
||||
|
||||
const getTool = () =>
|
||||
findTool(createCredentialTools(mockConn.connection), 'browser_capture_secret');
|
||||
|
||||
it('captures element value into the buffer', async () => {
|
||||
await getTool().execute(
|
||||
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
|
||||
makeContext({ secretsBuffer: buffer }),
|
||||
);
|
||||
|
||||
expect(buffer.capture).toHaveBeenCalledWith('k1', 'apiKey', 'secret-value');
|
||||
});
|
||||
|
||||
it('does NOT include the secret value in the response', async () => {
|
||||
const result = await getTool().execute(
|
||||
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
|
||||
makeContext({ secretsBuffer: buffer }),
|
||||
);
|
||||
|
||||
const text = JSON.stringify(result);
|
||||
expect(text).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('returns ok:true with fieldsCaptured', async () => {
|
||||
const result = await getTool().execute(
|
||||
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
|
||||
makeContext({ secretsBuffer: buffer }),
|
||||
);
|
||||
|
||||
expect(structuredOf(result)).toMatchObject({ ok: true, fieldsCaptured: ['apiKey'] });
|
||||
});
|
||||
|
||||
it('passes sourceRef as the element ref to getElementValue', async () => {
|
||||
await getTool().execute(
|
||||
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e99' },
|
||||
makeContext({ secretsBuffer: buffer }),
|
||||
);
|
||||
|
||||
expect(mockConn.adapter.getElementValue).toHaveBeenCalledWith('page1', { ref: 'e99' });
|
||||
});
|
||||
|
||||
it('returns an error result when secretsBuffer is missing from context', async () => {
|
||||
const result = await getTool().execute(
|
||||
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e1' },
|
||||
makeContext(),
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// browser_create_credential
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('browser_create_credential', () => {
|
||||
let mockConn: ReturnType<typeof createMockConnection>;
|
||||
let buffer: ReturnType<typeof makeBuffer>;
|
||||
let createCredential: jest.MockedFunction<
|
||||
(p: {
|
||||
name: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
projectId?: string;
|
||||
}) => Promise<{ credentialId: string }>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConn = createMockConnection();
|
||||
buffer = makeBuffer();
|
||||
// Pre-populate buffer with some captured secrets
|
||||
buffer.capture('k1', 'clientId', 'client-id-value');
|
||||
buffer.capture('k1', 'clientSecret', 'client-secret-value');
|
||||
createCredential = jest.fn().mockResolvedValue({ credentialId: 'cred-123' });
|
||||
});
|
||||
|
||||
const getTool = () =>
|
||||
findTool(createCredentialTools(mockConn.connection), 'browser_create_credential');
|
||||
|
||||
const ctx = () => makeContext({ secretsBuffer: buffer, createCredential });
|
||||
|
||||
describe('resolveData', () => {
|
||||
it('resolves flat leaf values to captured secrets', async () => {
|
||||
await getTool().execute(
|
||||
{
|
||||
credentialsKey: 'k1',
|
||||
type: 'googleApi',
|
||||
name: 'My Cred',
|
||||
resolveData: { clientId: 'clientId', clientSecret: 'clientSecret' },
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
expect(createCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { clientId: 'client-id-value', clientSecret: 'client-secret-value' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves nested resolveData', async () => {
|
||||
buffer.capture('k1', 'token', 'tok-value');
|
||||
await getTool().execute(
|
||||
{
|
||||
credentialsKey: 'k1',
|
||||
type: 'googleApi',
|
||||
name: 'My Cred',
|
||||
resolveData: { oauth: { token: 'token' } },
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
expect(createCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { oauth: { token: 'tok-value' } } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a resolveData field name is not in the buffer', async () => {
|
||||
await expect(
|
||||
getTool().execute(
|
||||
{
|
||||
credentialsKey: 'k1',
|
||||
type: 'googleApi',
|
||||
name: 'My Cred',
|
||||
resolveData: { missing: 'noSuchField' },
|
||||
},
|
||||
ctx(),
|
||||
),
|
||||
).rejects.toThrow(/noSuchField/);
|
||||
});
|
||||
|
||||
it('deep-merges data and resolveData (resolved wins on collision)', async () => {
|
||||
await getTool().execute(
|
||||
{
|
||||
credentialsKey: 'k1',
|
||||
type: 'googleApi',
|
||||
name: 'My Cred',
|
||||
data: { scopes: 'email', clientId: 'literal-id' },
|
||||
resolveData: { clientId: 'clientId' },
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
|
||||
expect(createCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { scopes: 'email', clientId: 'client-id-value' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential creation', () => {
|
||||
it('returns ok:true with the new credentialId', async () => {
|
||||
const result = await getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
|
||||
ctx(),
|
||||
);
|
||||
expect(structuredOf(result)).toMatchObject({ ok: true, credentialId: 'cred-123' });
|
||||
});
|
||||
|
||||
it('does not include captured secret values in the result', async () => {
|
||||
const result = await getTool().execute(
|
||||
{
|
||||
credentialsKey: 'k1',
|
||||
type: 'googleApi',
|
||||
name: 'My Cred',
|
||||
resolveData: { clientId: 'clientId' },
|
||||
},
|
||||
ctx(),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain('client-id-value');
|
||||
});
|
||||
|
||||
it('passes name, type, and projectId to createCredential', async () => {
|
||||
await getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', projectId: 'proj-1' },
|
||||
ctx(),
|
||||
);
|
||||
expect(createCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'My Cred', type: 'googleApi', projectId: 'proj-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer clearing', () => {
|
||||
it('clears the buffer on success by default', async () => {
|
||||
await getTool().execute({ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' }, ctx());
|
||||
expect(buffer.clear).toHaveBeenCalledWith('k1');
|
||||
});
|
||||
|
||||
it('clears the buffer when clear is explicitly true', async () => {
|
||||
await getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', clear: true },
|
||||
ctx(),
|
||||
);
|
||||
expect(buffer.clear).toHaveBeenCalledWith('k1');
|
||||
});
|
||||
|
||||
it('retains the buffer when clear is false', async () => {
|
||||
await getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', clear: false },
|
||||
ctx(),
|
||||
);
|
||||
expect(buffer.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing context', () => {
|
||||
it('throws when secretsBuffer is absent', async () => {
|
||||
await expect(
|
||||
getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
|
||||
makeContext({ createCredential }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when createCredential is absent', async () => {
|
||||
await expect(
|
||||
getTool().execute(
|
||||
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
|
||||
makeContext({ secretsBuffer: buffer }),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when credentialsKey has no captured fields', async () => {
|
||||
await expect(
|
||||
getTool().execute(
|
||||
{ credentialsKey: 'no-such-key', type: 'googleApi', name: 'My Cred' },
|
||||
ctx(),
|
||||
),
|
||||
).rejects.toThrow(/no-such-key/);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
packages/@n8n/mcp-browser/src/tools/credential.ts
Normal file
203
packages/@n8n/mcp-browser/src/tools/credential.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { BrowserConnection } from '../connection';
|
||||
import type {
|
||||
AffectedResource,
|
||||
CreateCredentialPayload,
|
||||
SecretsBuffer,
|
||||
ToolContext,
|
||||
ToolDefinition,
|
||||
} from '../types';
|
||||
import { formatCallToolResult } from '../utils';
|
||||
import { createConnectedTool, pageIdField } from './helpers';
|
||||
|
||||
export function createCredentialTools(connection: BrowserConnection): ToolDefinition[] {
|
||||
return [browserCaptureSecret(connection), browserCreateCredential(connection)];
|
||||
}
|
||||
|
||||
const CREATE_CREDENTIAL_RESOURCE: AffectedResource = {
|
||||
toolGroup: 'browser',
|
||||
resource: 'credentials',
|
||||
description: 'Browser: credentials',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// browser_capture_secret
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const browserCaptureSecretSchema = z
|
||||
.object({
|
||||
credentialsKey: z
|
||||
.string()
|
||||
.describe('Key grouping related fields for one credential (e.g. "gcp-setup")'),
|
||||
field: z.string().describe('Field name to store the captured value under (e.g. "clientId")'),
|
||||
sourceRef: z
|
||||
.string()
|
||||
.describe('Element ref from browser_snapshot whose value will be captured'),
|
||||
pageId: pageIdField,
|
||||
})
|
||||
.describe('Capture a secret value from a DOM element into the session buffer');
|
||||
|
||||
function browserCaptureSecret(
|
||||
connection: BrowserConnection,
|
||||
): ToolDefinition<typeof browserCaptureSecretSchema> {
|
||||
return createConnectedTool(
|
||||
connection,
|
||||
'browser_capture_secret',
|
||||
'Read a secret value from a DOM element (identified by a snapshot ref) and store it in the session buffer. The value is never returned to the LLM. Use browser_create_credential to assemble buffered secrets into a credential.',
|
||||
browserCaptureSecretSchema,
|
||||
async (state, args, pageId, context) => {
|
||||
requireSecretsBuffer(context);
|
||||
const value = await state.adapter.getElementValue(pageId, { ref: args.sourceRef });
|
||||
context.secretsBuffer.capture(args.credentialsKey, args.field, value);
|
||||
return formatCallToolResult({ ok: true, fieldsCaptured: [args.field] });
|
||||
},
|
||||
undefined,
|
||||
{ skipEnrichment: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// browser_create_credential
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const browserCreateCredentialSchema = z
|
||||
.object({
|
||||
credentialsKey: z
|
||||
.string()
|
||||
.describe('Key identifying the buffered secrets group to use (e.g. "gcp-setup")'),
|
||||
type: z.string().describe('n8n credential type (e.g. "anthropicApi", "googleApi")'),
|
||||
name: z.string().describe('Display name for the new credential'),
|
||||
data: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe('Literal (non-secret) credential fields, may be nested'),
|
||||
resolveData: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
'Same nested shape as data, but leaf string values are field names from the captured buffer. All leaves must resolve.',
|
||||
),
|
||||
projectId: z.string().optional().describe('Project to create the credential in'),
|
||||
clear: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Clear the session buffer for credentialsKey after success (default: true)'),
|
||||
})
|
||||
.describe('Assemble buffered secrets into an n8n credential');
|
||||
|
||||
function browserCreateCredential(
|
||||
_connection: BrowserConnection,
|
||||
): ToolDefinition<typeof browserCreateCredentialSchema> {
|
||||
return {
|
||||
name: 'browser_create_credential',
|
||||
description:
|
||||
'Assemble secrets captured with browser_capture_secret into a new n8n credential. Literal fields go in `data`; fields that must come from the buffer go in `resolveData` (leaf values are buffer field names). The buffer is cleared after success unless clear=false.',
|
||||
inputSchema: browserCreateCredentialSchema,
|
||||
async execute(args, context: ToolContext) {
|
||||
requireSecretsBuffer(context);
|
||||
requireCreateCredential(context);
|
||||
|
||||
const captured = context.secretsBuffer.getFields(args.credentialsKey);
|
||||
if (!captured) {
|
||||
throw new Error(
|
||||
`No captured fields found for credentialsKey "${args.credentialsKey}". Call browser_capture_secret first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSecrets = args.resolveData ? resolveSecrets(args.resolveData, captured) : {};
|
||||
const mergedData = deepMerge(args.data ?? {}, resolvedSecrets);
|
||||
|
||||
const credential = await context.createCredential({
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
data: mergedData,
|
||||
projectId: args.projectId,
|
||||
} satisfies CreateCredentialPayload);
|
||||
|
||||
if (args.clear !== false) {
|
||||
context.secretsBuffer.clear(args.credentialsKey);
|
||||
}
|
||||
|
||||
return formatCallToolResult({ ok: true, credentialId: credential.credentialId });
|
||||
},
|
||||
getAffectedResources() {
|
||||
return [CREATE_CREDENTIAL_RESOURCE];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireSecretsBuffer(
|
||||
context: ToolContext,
|
||||
): asserts context is ToolContext & { secretsBuffer: SecretsBuffer } {
|
||||
if (!context.secretsBuffer) {
|
||||
throw new Error('This tool is only available when running inside the n8n gateway context.');
|
||||
}
|
||||
}
|
||||
|
||||
function requireCreateCredential(context: ToolContext): asserts context is ToolContext & {
|
||||
createCredential: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
|
||||
} {
|
||||
if (!context.createCredential) {
|
||||
throw new Error('This tool is only available when running inside the n8n gateway context.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk `resolveData`. Every leaf string value is a field name to
|
||||
* look up in `captured`. Throws if a field name is not found.
|
||||
*/
|
||||
function resolveSecrets(
|
||||
resolveData: Record<string, unknown>,
|
||||
captured: Map<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(resolveData)) {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
result[key] = resolveSecrets(value as Record<string, unknown>, captured);
|
||||
} else if (typeof value === 'string') {
|
||||
if (!captured.has(value)) {
|
||||
throw new Error(
|
||||
`resolveData references field "${value}" which was not captured. Call browser_capture_secret with field="${value}" first.`,
|
||||
);
|
||||
}
|
||||
result[key] = captured.get(value);
|
||||
} else {
|
||||
throw new Error(
|
||||
`resolveData leaf values must be strings (field names). Got ${typeof value} for key "${key}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Deep merge two plain objects. Values from `override` win on collision. */
|
||||
function deepMerge(
|
||||
base: Record<string, unknown>,
|
||||
override: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const [key, overrideVal] of Object.entries(override)) {
|
||||
const baseVal = result[key];
|
||||
if (
|
||||
overrideVal !== null &&
|
||||
typeof overrideVal === 'object' &&
|
||||
!Array.isArray(overrideVal) &&
|
||||
baseVal !== null &&
|
||||
typeof baseVal === 'object' &&
|
||||
!Array.isArray(baseVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
baseVal as Record<string, unknown>,
|
||||
overrideVal as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
result[key] = overrideVal;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -74,7 +74,12 @@ export function createConnectedTool<
|
|||
name: string,
|
||||
description: string,
|
||||
inputSchema: TSchema,
|
||||
fn: (state: ConnectionState, input: z.infer<TSchema>, pageId: string) => Promise<CallToolResult>,
|
||||
fn: (
|
||||
state: ConnectionState,
|
||||
input: z.infer<TSchema>,
|
||||
pageId: string,
|
||||
context: ToolContext,
|
||||
) => Promise<CallToolResult>,
|
||||
outputSchema?: z.ZodObject<z.ZodRawShape>,
|
||||
options?: ConnectedToolOptions,
|
||||
getResourceFromArgs?: (args: z.infer<TSchema>) => string,
|
||||
|
|
@ -84,7 +89,7 @@ export function createConnectedTool<
|
|||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
async execute(args: z.infer<TSchema>, _context: ToolContext) {
|
||||
async execute(args: z.infer<TSchema>, context: ToolContext) {
|
||||
try {
|
||||
const { state, pageId } = resolvePageContext(connection, args);
|
||||
|
||||
|
|
@ -96,8 +101,11 @@ export function createConnectedTool<
|
|||
}
|
||||
|
||||
const result = options?.waitForCompletion
|
||||
? await state.adapter.waitForCompletion(pageId, async () => await fn(state, args, pageId))
|
||||
: await fn(state, args, pageId);
|
||||
? await state.adapter.waitForCompletion(
|
||||
pageId,
|
||||
async () => await fn(state, args, pageId, context),
|
||||
)
|
||||
: await fn(state, args, pageId, context);
|
||||
|
||||
if (!options?.skipEnrichment) {
|
||||
// Re-resolve: tab-creating actions (tab_open) update activePageId
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserConnection } from '../connection';
|
||||
import type { BrowserToolkit, Config, ToolDefinition } from '../types';
|
||||
import { createCredentialTools } from './credential';
|
||||
import { createInspectionTools } from './inspection';
|
||||
import { createInteractionTools } from './interaction';
|
||||
import { createNavigationTools } from './navigation';
|
||||
|
|
@ -19,6 +20,7 @@ export function createBrowserTools(config?: Partial<Config>): BrowserToolkit {
|
|||
...createInspectionTools(connection),
|
||||
...createWaitTools(connection),
|
||||
...createStateTools(connection),
|
||||
...createCredentialTools(connection),
|
||||
];
|
||||
|
||||
return { tools, connection };
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ export function createMockAdapter() {
|
|||
setStorage: jest.fn().mockResolvedValue(undefined),
|
||||
clearStorage: jest.fn().mockResolvedValue(undefined),
|
||||
|
||||
// Credential helpers
|
||||
getElementValue: jest.fn().mockResolvedValue(''),
|
||||
|
||||
// URL lookup
|
||||
getPageUrl: jest.fn().mockReturnValue('http://test.com'),
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ export interface Adapter {
|
|||
clearStorage(pageId: string, kind: 'local' | 'session'): Promise<void>;
|
||||
// Sync helpers used by tool helpers
|
||||
getPageUrl(pageId: string): string | undefined;
|
||||
// Credential capture
|
||||
getElementValue(pageId: string, target: ElementTarget): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
|
|
@ -237,9 +239,24 @@ export interface WaitOptions {
|
|||
|
||||
export type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface SecretsBuffer {
|
||||
capture(credentialsKey: string, field: string, value: string): void;
|
||||
getFields(credentialsKey: string): Map<string, string> | undefined;
|
||||
clear(credentialsKey: string): void;
|
||||
}
|
||||
|
||||
export interface CreateCredentialPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
/** Base filesystem directory (used by filesystem tools) */
|
||||
dir: string;
|
||||
secretsBuffer?: SecretsBuffer;
|
||||
createCredential?: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
|
||||
}
|
||||
|
||||
export interface ToolDefinition<TSchema extends z.ZodType = z.ZodType> {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ import type {
|
|||
} from '@n8n/api-types';
|
||||
import type { ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
|
||||
import { ControllerRegistryMetadata } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
|
@ -58,6 +58,7 @@ import { mock } from 'jest-mock-extended';
|
|||
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 type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Push } from '@/push';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
|
|
@ -99,6 +100,8 @@ describe('InstanceAiController', () => {
|
|||
});
|
||||
|
||||
const subAgentEvalService = mock<SubAgentEvalService>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
|
||||
const controller = new InstanceAiController(
|
||||
instanceAiService,
|
||||
|
|
@ -110,6 +113,8 @@ describe('InstanceAiController', () => {
|
|||
moduleRegistry,
|
||||
push,
|
||||
urlService,
|
||||
userRepository,
|
||||
credentialsService,
|
||||
globalConfig,
|
||||
);
|
||||
|
||||
|
|
@ -1153,6 +1158,69 @@ describe('InstanceAiController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('gatewayCreateCredential', () => {
|
||||
const makeGatewayReq = (key: string) =>
|
||||
({ headers: { 'x-gateway-key': key } }) as unknown as Request;
|
||||
|
||||
const payload = {
|
||||
name: 'My Slack Cred',
|
||||
type: 'slackApi',
|
||||
data: { accessToken: 'xoxb-token' },
|
||||
};
|
||||
|
||||
it('should have no access scope (skipAuth)', () => {
|
||||
expect(scopeOf('gatewayCreateCredential')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create credential and return credentialId', async () => {
|
||||
const user = mock<User>({ id: USER_ID });
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
userRepository.findOne.mockResolvedValue(user);
|
||||
credentialsService.createUnmanagedCredential.mockResolvedValue(
|
||||
mock<Awaited<ReturnType<CredentialsService['createUnmanagedCredential']>>>({
|
||||
id: 'cred-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await controller.gatewayCreateCredential(
|
||||
makeGatewayReq('session-key'),
|
||||
res,
|
||||
payload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ credentialId: 'cred-1' });
|
||||
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(payload, user);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when using the static env-var key', async () => {
|
||||
const gatewayReq = makeGatewayReq('static-key');
|
||||
|
||||
await expect(controller.gatewayCreateCredential(gatewayReq, res, payload)).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when the user is not found', async () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
userRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.gatewayCreateCredential(makeGatewayReq('session-key'), res, payload),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when the gateway is disabled', async () => {
|
||||
const user = mock<User>({ id: USER_ID });
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
userRepository.findOne.mockResolvedValue(user);
|
||||
settingsService.isLocalGatewayDisabledForUser.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
controller.gatewayCreateCredential(makeGatewayReq('session-key'), res, payload),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGatewayKeyHeader', () => {
|
||||
it('should extract first element from array header', () => {
|
||||
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
InstanceAiConfirmRequestDto,
|
||||
InstanceAiFeedbackRequestDto,
|
||||
InstanceAiGatewayCapabilitiesDto,
|
||||
InstanceAiGatewayCreateCredentialDto,
|
||||
InstanceAiFilesystemResponseDto,
|
||||
InstanceAiRenameThreadRequestDto,
|
||||
InstanceAiSendMessageRequest,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
import type { InstanceAiAgentNode } from '@n8n/api-types';
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
|
||||
import {
|
||||
RestController,
|
||||
GlobalScope,
|
||||
|
|
@ -43,6 +44,7 @@ import { InProcessEventBus } from './event-bus/in-process-event-bus';
|
|||
import { InstanceAiMemoryService } from './instance-ai-memory.service';
|
||||
import { InstanceAiSettingsService } from './instance-ai-settings.service';
|
||||
import { InstanceAiService } from './instance-ai.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
|
|
@ -99,6 +101,8 @@ export class InstanceAiController {
|
|||
private readonly moduleRegistry: ModuleRegistry,
|
||||
private readonly push: Push,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
globalConfig: GlobalConfig,
|
||||
) {
|
||||
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
|
||||
|
|
@ -788,6 +792,18 @@ export class InstanceAiController {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('/gateway/credentials', { skipAuth: true })
|
||||
async gatewayCreateCredential(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
@Body payload: InstanceAiGatewayCreateCredentialDto,
|
||||
) {
|
||||
const user = await this.resolveGatewayUser(this.getGatewayKeyHeader(req));
|
||||
await this.assertGatewayEnabled(user.id);
|
||||
const credential = await this.credentialsService.createUnmanagedCredential(payload, user);
|
||||
return { credentialId: credential.id };
|
||||
}
|
||||
|
||||
@Get('/gateway/status')
|
||||
@GlobalScope('instanceAi:gateway')
|
||||
async gatewayStatus(req: AuthenticatedRequest) {
|
||||
|
|
@ -882,6 +898,23 @@ export class InstanceAiController {
|
|||
throw new ForbiddenError('Invalid API key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a gateway API key to its associated User. Requires a user-scoped
|
||||
* key — the static env-var key (which has no associated DB user) is rejected.
|
||||
*/
|
||||
private async resolveGatewayUser(key: string | undefined): Promise<User> {
|
||||
const userId = this.validateGatewayApiKey(key);
|
||||
if (userId === 'env-gateway') {
|
||||
throw new ForbiddenError('Credential creation requires a user-scoped gateway key');
|
||||
}
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['role', 'role.scopes'],
|
||||
});
|
||||
if (!user) throw new ForbiddenError('Invalid API key');
|
||||
return user;
|
||||
}
|
||||
|
||||
private writeSseEvent(res: FlushableResponse, stored: StoredEvent): void {
|
||||
// No `event:` field — events are discriminated by data.type per streaming-protocol.md
|
||||
res.write(`id: ${stored.id}\ndata: ${JSON.stringify(stored.event)}\n\n`);
|
||||
|
|
|
|||
|
|
@ -1734,6 +1734,9 @@ importers:
|
|||
'@n8n/api-types':
|
||||
specifier: workspace:*
|
||||
version: link:../api-types
|
||||
'@n8n/mcp-browser':
|
||||
specifier: workspace:*
|
||||
version: link:../mcp-browser
|
||||
'@n8n/sandbox-client':
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user