feat(core): Introduce mcp-apps package (no-changelog) (#31298)

This commit is contained in:
Milorad FIlipović 2026-06-01 17:39:41 +02:00 committed by GitHub
parent 90dd93c772
commit 43f7446c1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2751 additions and 450 deletions

View File

@ -123,4 +123,3 @@ jobs:
with:
pull-request-number: ${{ matrix.pr_number }}
secrets: inherit

View File

@ -113,7 +113,7 @@
"multer": "^2.1.1",
"prebuild-install": "7.1.3",
"pug": "^3.0.3",
"semver": "^7.5.4",
"semver": "catalog:",
"tar-fs": "2.1.4",
"tslib": "^2.6.2",
"tsconfig-paths": "^4.2.0",

View File

@ -61,7 +61,7 @@
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/provider-utils": "^4.0.21",
"@ai-sdk/xai": "^3.0.67",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "catalog:",
"@n8n/ai-utilities": "workspace:*",
"@openrouter/ai-sdk-provider": "catalog:",
"ai": "^6.0.116",

View File

@ -420,6 +420,12 @@ export {
type StartTestRunPayload,
} from './schemas/evaluations.schema';
export {
MCP_APPS_FLAG,
MCP_APPS_VARIANT_CONTROL,
MCP_APPS_VARIANT_ENABLED,
} from './schemas/mcp.schema';
export {
EVAL_COLLECTIONS_FLAG,
evalCollectionVersionEntrySchema,

View File

@ -0,0 +1,11 @@
// PostHog rollout flag id gating MCP Apps support (the iframe UI attached to
// the create-workflow-from-code MCP tool). Single source of truth shared
// between FE and BE so the two cannot drift. Follows the `NNN_<feature>`
// numeric-prefix convention used by every other n8n PostHog flag.
export const MCP_APPS_FLAG = '087_mcp_apps';
// Variant strings used by the `087_mcp_apps` experiment.
// - 'control': MCP Apps disabled (legacy behaviour)
// - 'variant': MCP Apps enabled
export const MCP_APPS_VARIANT_CONTROL = 'control';
export const MCP_APPS_VARIANT_ENABLED = 'variant';

View File

@ -40,7 +40,7 @@
"@anthropic-ai/sandbox-runtime": "^0.0.42",
"@inquirer/prompts": "^8.3.2",
"@jitsi/robotjs": "^0.6.21",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "catalog:",
"@n8n/mcp-browser": "workspace:*",
"@napi-rs/image": "^1.12.0",
"@vscode/ripgrep": "^1.17.1",

View File

@ -134,6 +134,14 @@ export class EndpointsConfig {
@Env('N8N_MCP_BUILDER_ENABLED')
mcpBuilderEnabled: boolean = true;
/**
* Force-enable MCP Apps support (the iframe UI attached to MCP tools).
* Acts as an operator-level override of the PostHog experiment.
* Cannot force-disable: setting this to `false` falls back to PostHog.
*/
@Env('N8N_MCP_APPS_ENABLED')
mcpAppsEnabled: boolean = false;
/** Maximum number of OAuth clients that can be registered for MCP. */
@Env('N8N_MCP_MAX_REGISTERED_CLIENTS')
mcpMaxRegisteredClients: number = 5000;

View File

@ -239,6 +239,7 @@ describe('GlobalConfig', () => {
formTest: 'form-test',
formWaiting: 'form-waiting',
mcp: 'mcp',
mcpAppsEnabled: false,
mcpBuilderEnabled: true,
mcpMaxRegisteredClients: 5000,
mcpTest: 'mcp-test',

2
packages/@n8n/mcp-apps/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

View File

@ -0,0 +1,132 @@
# @n8n/mcp-apps
UI resources and server helpers that let the n8n MCP server return **MCP
Apps** — small, sandboxed HTML/Vue experiences rendered inside MCP clients
that support the
[`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps)
extension. The package owns both the runtime UI bundles and the small server
helpers used by `packages/cli` to register them as MCP resources and tools.
## What it provides
- **Server helpers** (`@n8n/mcp-apps/server`) for registering MCP App tools
and the static HTML resources that back them.
- **Vue UI apps** under `src/apps/*`, built with Vite as standalone, fully
inlined HTML files (CSS + JS in a single document) so they can be served
directly as MCP resources.
- **i18n plumbing** powered by `vue-i18n`, with locale negotiation driven by
the host context the MCP client provides at runtime.
Today the package ships a single app, `workflow-preview`, which is rendered
after the `create_workflow_from_code` MCP tool returns and gives the user a
button to open the freshly created workflow in n8n. New apps can be added
alongside it (see [Adding a new app](#adding-a-new-app)).
## Package layout
```
src/
apps-manifest.ts # single source of truth for the apps registry
apps/ # Vue UI apps, each built into a standalone HTML
workflow-preview/
App.vue # root component
main.ts # mounts App with i18n
index.html # entry HTML (built into dist/apps/<app>.html)
tokens.scss # design tokens / global styles
url.ts # defense-in-depth URL validation
i18n/ # vue-i18n setup + host locale resolution
locales/ # flat-key locale files (en.json, …)
server/ # consumed by packages/cli
apps/ # MCP resource registrations for each UI app
constants.ts # shared URIs, MIME type, _meta keys
register-mcp-app-tool.ts
resource-loader.ts # lazy reads built HTML from dist/apps
index.ts # public entry: @n8n/mcp-apps/server
```
`apps-manifest.ts` is the canonical registry of MCP apps. Both the Vite
build (entry directory + output HTML filename per `--mode`) and the
server-side resource loader (compile-time union + runtime allow-list of
loadable HTML files) derive from it, so the build and runtime stay in
lockstep and there is no separate list to maintain.
The Vite build (`pnpm build:ui`) emits one inlined HTML file per app into
`dist/apps/<app>.html`. The TypeScript build (`pnpm build:server`) emits the
server helpers into `dist/server/`. Both run as part of `pnpm build`.
## UI runtime
Each app:
- Connects to the host via `@modelcontextprotocol/ext-apps`'s `App` class.
- Receives a `McpUiHostContext` (theme, style variables, host fonts, locale)
through `onhostcontextchanged` and reflects it on the document.
- Reads the originating tool's `structuredContent` via `ontoolresult` to
populate its own state.
- Calls `app.openLink({ url })` to ask the host to navigate — never opens
links itself.
URL handling is locked down by `isAllowedWorkflowUrl` in
`src/apps/workflow-preview/url.ts`: only `http(s)://` URLs with a non-empty
host are accepted, both when reading the tool result and right before calling
`openLink`. This is defense in depth on top of the host's own validation.
## Internationalization
Locale files live under `src/locales/` and use flat, namespaced keys
(`workflowPreview.openButton`, `workflowPreview.ariaLabel.ready`, …). The
host's BCP 47 locale is resolved to a shipped locale via `resolveLocale`,
applied to the `vue-i18n` instance, and mirrored to `<html lang>` for
assistive tech. See `src/i18n/index.ts` for the full contract.
To add a new locale:
1. Drop `<code>.json` next to `en.json` in `src/locales/`.
2. Import it in `src/i18n/index.ts` and add the code to `SUPPORTED_LOCALES`.
The schema is derived from `en.json`, so other locales are type-checked
against the same key set.
## Adding a new app
1. Create `src/apps/<app-name>/` with `index.html`, `main.ts`, and an
`App.vue` root component. Mount it through the shared `i18n` instance.
2. Add an entry to `MCP_APPS` in `src/apps-manifest.ts`:
```ts
'<app-name>': {
entry: '<app-name>', // directory under src/apps/
htmlFile: '<app-name>.html', // output under dist/apps/
},
```
This single entry teaches Vite about the new `--mode`, expands the
`McpAppHtmlFileName` type union, and adds the file to the
`loadAppHtml` runtime allow-list. `pnpm build:ui --mode <app-name>`
will then produce `dist/apps/<app-name>.html`.
3. Add the app's URI constant to `src/server/constants.ts` and a
`register<App>App` helper in `src/server/apps/` that calls
`server.resource(...)` with `loadAppHtml('<app-name>.html')`.
4. Re-export the helper and URI constant from `src/server/index.ts`.
5. Add any UI strings to `src/locales/en.json` under a new app-scoped
key prefix.
## SDK version compatibility
`src/server/sdk-version.test.ts` asserts that the
`@modelcontextprotocol/sdk` version installed via the pnpm catalog satisfies
the peer range declared by `@modelcontextprotocol/ext-apps`. CI fails the
moment those two pins drift, so bumping one without the other is caught
immediately.
## Scripts
| Command | Description |
|--------------------|---------------------------------------------------------|
| `pnpm build` | Build UI apps and server helpers |
| `pnpm build:ui` | Build the Vue apps to inlined HTML in `dist/apps/` |
| `pnpm build:server`| Build the server entry to `dist/server/` |
| `pnpm typecheck` | Run `vue-tsc` over the UI and `tsc` over the server |
| `pnpm lint` | Lint with the shared ESLint config |
| `pnpm test` | Run unit tests with Vitest |
| `pnpm test:dev` | Run Vitest in watch mode |

View File

@ -0,0 +1,30 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(
{
ignores: ['vite.config.mts', 'vitest.config.mts', 'dist/**'],
},
frontendConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
},
},
{
files: ['src/apps/**/*.vue'],
rules: {
'unicorn/filename-case': ['error', { case: 'pascalCase' }],
},
},
{
files: ['src/**/*.test.ts', 'src/__tests__/**/*.ts'],
rules: {
'n8n-local-rules/no-uncaught-json-parse': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'id-denylist': 'off',
},
},
);

View File

@ -0,0 +1,63 @@
{
"name": "@n8n/mcp-apps",
"version": "0.1.0",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",
"typesVersions": {
"*": {
"server": [
"dist/server/index.d.ts"
]
}
},
"exports": {
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist/**/*"
],
"scripts": {
"clean": "rimraf dist .turbo",
"build": "pnpm build:ui && pnpm build:server",
"build:ui": "vite build --mode workflow-preview",
"build:server": "tsc -p tsconfig.build.json",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.build.json",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest",
"format": "biome format --write src",
"format:check": "biome ci src"
},
"devDependencies": {
"@modelcontextprotocol/ext-apps": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
"@n8n/design-system": "workspace:*",
"@n8n/eslint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/semver": "^7.7.0",
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/test-utils": "catalog:frontend",
"rimraf": "catalog:",
"semver": "catalog:",
"typescript": "catalog:",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-plugin-singlefile": "catalog:",
"vite-svg-loader": "catalog:frontend",
"vitest": "catalog:",
"vue": "catalog:frontend",
"vue-i18n": "catalog:frontend",
"vue-tsc": "catalog:frontend"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "catalog:"
}
}

View File

@ -0,0 +1,24 @@
/**
* Canonical manifest of MCP apps shipped by this package. Adding a new app
* is a single edit here: add one entry, and both the Vite build (entry
* directory + output HTML filename) and the server-side resource loader
* (compile-time union + runtime allow-list of loadable files) update
* automatically. This keeps the build and runtime in lockstep, with no
* separate registries to drift out of sync.
*/
// Keys are kebab-case to match the Vite `--mode` argument (which is also the
// app's URL slug). Disabling the naming-convention lint rule lets us keep
// that convention without leaking exceptions to other identifiers.
/* eslint-disable @typescript-eslint/naming-convention */
export const MCP_APPS = {
'workflow-preview': {
/** Directory under `src/apps/` containing the Vue source for the app. */
entry: 'workflow-preview',
/** Output filename under `dist/apps/` produced by the Vite build. */
htmlFile: 'workflow-preview.html',
},
} as const;
/* eslint-enable @typescript-eslint/naming-convention */
export type McpAppId = keyof typeof MCP_APPS;
export type McpAppHtmlFileName = (typeof MCP_APPS)[McpAppId]['htmlFile'];

View File

@ -0,0 +1,141 @@
<script setup lang="ts">
import {
App,
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
type McpUiHostContext,
} from '@modelcontextprotocol/ext-apps';
import { N8nButton, N8nIcon, N8nSpinner } from '@n8n/design-system';
import { computed, onMounted, ref, shallowRef, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { isAllowedWorkflowUrl } from './url';
import { setLocaleFromHost, type MessageSchema } from '../../i18n';
type WorkflowResult = {
url?: unknown;
};
function isWorkflowResult(value: unknown): value is WorkflowResult {
return typeof value === 'object' && value !== null;
}
const { t } = useI18n<{ message: MessageSchema }>({ useScope: 'global' });
const hostContext = ref<McpUiHostContext>();
const workflowUrl = ref<string>();
const appRef = shallowRef<App>();
const ariaLabel = computed(() =>
workflowUrl.value
? t('workflowPreview.ariaLabel.ready')
: t('workflowPreview.ariaLabel.creating'),
);
watchEffect(() => {
const context = hostContext.value;
if (context?.theme) {
applyDocumentTheme(context.theme);
}
if (context?.styles?.variables) {
applyHostStyleVariables(context.styles.variables);
}
if (context?.styles?.css?.fonts) {
applyHostFonts(context.styles.css.fonts);
}
setLocaleFromHost(context?.locale);
});
async function handleOpenWorkflow() {
const app = appRef.value;
const url = workflowUrl.value;
if (!app || !url) return;
if (!isAllowedWorkflowUrl(url)) {
console.warn('[n8n MCP App] Refusing to open unexpected workflow URL', { url });
return;
}
try {
const result = await app.openLink({ url });
if (result.isError) {
console.warn('[n8n MCP App] Host denied open-link request', { url });
}
} catch (error) {
console.error('[n8n MCP App] Failed to open workflow link', error);
}
}
onMounted(async () => {
const app = new App({ name: 'n8n Workflow Creation', version: '0.1.0' });
appRef.value = app;
app.onhostcontextchanged = (params) => {
hostContext.value = { ...hostContext.value, ...params };
};
app.ontoolresult = (params) => {
const { structuredContent } = params;
const candidate = isWorkflowResult(structuredContent) ? structuredContent.url : undefined;
if (isAllowedWorkflowUrl(candidate)) {
workflowUrl.value = candidate;
return;
}
if (candidate !== undefined) {
console.warn('[n8n MCP App] Ignoring unexpected workflow URL in tool result', {
url: candidate,
});
}
// Drop any prior URL so the button can't navigate to a stale workflow
// after a tool re-run that produced an invalid result.
workflowUrl.value = undefined;
};
app.onerror = console.error;
try {
await app.connect();
hostContext.value = app.getHostContext();
} catch (error) {
console.error('[n8n MCP App] Failed to connect to host', error);
}
});
</script>
<template>
<main class="container" :aria-busy="!workflowUrl" :aria-label="ariaLabel">
<N8nButton
v-if="workflowUrl"
class="open-button"
variant="solid"
size="medium"
@click="handleOpenWorkflow"
>
{{ t('workflowPreview.openButton') }}
<template #icon>
<N8nIcon icon="arrow-up-right" />
</template>
</N8nButton>
<N8nSpinner v-else type="ring" />
</main>
</template>
<style scoped lang="scss">
@use '@n8n/design-system/css/mixins/motion';
.container {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
}
.open-button {
@include motion.fade-in-up;
}
</style>

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>n8n</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from '../../i18n';
import './tokens.scss';
createApp(App).use(i18n).mount('#app');

View File

@ -0,0 +1,7 @@
/* eslint-disable import-x/no-default-export */
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}

View File

@ -0,0 +1,42 @@
@use '@n8n/design-system/css/_primitives' as primitives;
@use '@n8n/design-system/css/_tokens' as tokens;
:root {
color-scheme: light dark;
@include primitives.primitives;
@include tokens.theme;
}
@media (prefers-color-scheme: dark) {
body:not([data-theme]) {
@include tokens.theme-dark;
}
}
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('@n8n/design-system/../assets/fonts/InterVariable.woff2') format('woff2');
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
InterVariable,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
background: transparent;
color: var(--color--text--shade-1);
}

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { isAllowedWorkflowUrl } from './url';
describe('isAllowedWorkflowUrl', () => {
describe('accepts', () => {
it.each([
['https URL with path', 'https://n8n.example.com/workflow/abc123'],
['http URL', 'http://localhost:5678/workflow/abc123'],
['https with port', 'https://n8n.example.com:8443/workflow/abc123'],
['https with query and fragment', 'https://n8n.example.com/workflow/abc?x=1#y'],
['n8n.cloud subdomain', 'https://workspace.app.n8n.cloud/workflow/abc123'],
])('%s', (_label, input) => {
expect(isAllowedWorkflowUrl(input)).toBe(true);
});
});
describe('rejects', () => {
it.each([
['javascript: scheme (XSS)', 'javascript:alert(1)'],
['data: scheme (XSS/phishing)', 'data:text/html,<script>alert(1)</script>'],
['file: scheme (local access)', 'file:///etc/passwd'],
['ftp: scheme', 'ftp://example.com/'],
['custom scheme', 'n8n://workflow/abc'],
['protocol-relative URL', '//n8n.example.com/workflow/abc'],
['relative path', '/workflow/abc'],
['empty string', ''],
['whitespace', ' '],
['plain text', 'not a url'],
])('%s', (_label, input) => {
expect(isAllowedWorkflowUrl(input)).toBe(false);
});
it.each([
['undefined', undefined],
['null', null],
['number', 123],
['object', { url: 'https://example.com' }],
['array', ['https://example.com']],
['boolean', true],
])('non-string: %s', (_label, input) => {
expect(isAllowedWorkflowUrl(input)).toBe(false);
});
});
it('narrows the type to string when true', () => {
const value: unknown = 'https://n8n.example.com/workflow/abc';
if (isAllowedWorkflowUrl(value)) {
// Should type-check without an assertion.
const upper: string = value.toUpperCase();
expect(upper).toContain('HTTPS');
} else {
throw new Error('Expected URL to be accepted');
}
});
});

View File

@ -0,0 +1,30 @@
/**
* URL schemes accepted for the workflow open-link action. Anything else
* (`javascript:`, `data:`, `file:`, custom schemes, etc.) is rejected so a
* compromised or buggy MCP host cannot trick the iframe into asking the host
* to navigate to a dangerous URL.
*/
const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:']);
/**
* Defense-in-depth check for the workflow URL received from a tool result.
* The URL ultimately ends up in `app.openLink({ url })`, which the host is
* expected to validate as well but the iframe should not blindly forward
* arbitrary strings.
*
* Returns `true` when the value parses as a `http(s)` URL with a non-empty
* host. We deliberately do not enforce a specific origin: the iframe has no
* trusted source of the expected n8n instance URL.
*/
export function isAllowedWorkflowUrl(input: unknown): input is string {
if (typeof input !== 'string' || input.length === 0) return false;
let parsed: URL;
try {
parsed = new URL(input);
} catch {
return false;
}
return ALLOWED_URL_SCHEMES.has(parsed.protocol) && parsed.hostname.length > 0;
}

View File

@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DEFAULT_LOCALE,
i18n,
resolveLocale,
setLocaleFromHost,
SUPPORTED_LOCALES,
type SupportedLocale,
} from './index';
describe('mcp-apps i18n', () => {
beforeEach(() => {
i18n.global.locale.value = DEFAULT_LOCALE;
document.documentElement.removeAttribute('lang');
});
afterEach(() => {
i18n.global.locale.value = DEFAULT_LOCALE;
document.documentElement.removeAttribute('lang');
});
describe('resolveLocale', () => {
it.each<[string, string | undefined | null, SupportedLocale]>([
['returns default when input is undefined', undefined, DEFAULT_LOCALE],
['returns default when input is null', null, DEFAULT_LOCALE],
['returns default when input is empty', '', DEFAULT_LOCALE],
['returns the matching language for an exact match', 'en', 'en'],
['returns the matching language from a BCP 47 tag', 'en-US', 'en'],
['lowercases the language tag', 'EN-gb', 'en'],
['falls back to default for unsupported languages', 'zz-ZZ', DEFAULT_LOCALE],
])('%s', (_label, input, expected) => {
expect(resolveLocale(input)).toBe(expected);
});
it('only includes supported locales', () => {
expect(SUPPORTED_LOCALES).toContain(DEFAULT_LOCALE);
});
});
describe('setLocaleFromHost', () => {
it('applies the resolved locale to the i18n instance', () => {
expect(setLocaleFromHost('en-US')).toBe('en');
expect(i18n.global.locale.value).toBe('en');
});
it('falls back to the default locale for unsupported tags', () => {
expect(setLocaleFromHost('zz-ZZ')).toBe(DEFAULT_LOCALE);
expect(i18n.global.locale.value).toBe(DEFAULT_LOCALE);
});
it('reflects the resolved locale on `<html lang>` for assistive tech', () => {
setLocaleFromHost('en-GB');
expect(document.documentElement.getAttribute('lang')).toBe('en');
});
});
});

View File

@ -0,0 +1,55 @@
import { createI18n } from 'vue-i18n';
import en from '../locales/en.json';
/**
* Locales bundled with MCP apps. When adding a new locale:
* 1. Drop a `<code>.json` file alongside `en.json` in `src/locales`.
* 2. Import it here and add the code to `SUPPORTED_LOCALES`.
* Locale files use the same flat-key style as `@n8n/i18n` (e.g.
* `workflowPreview.openButton`), namespaced by app.
*/
export const SUPPORTED_LOCALES = ['en'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: SupportedLocale = 'en';
/** Schema derived from English messages so other locales must match keys. */
export type MessageSchema = typeof en;
export const i18n = createI18n<MessageSchema, SupportedLocale, false>({
legacy: false,
locale: DEFAULT_LOCALE,
fallbackLocale: DEFAULT_LOCALE,
messages: { en },
warnHtmlMessage: false,
});
/**
* Resolve an MCP host BCP 47 locale (e.g. `de-DE`, `en-GB`) to a locale we
* actually ship. Falls back to the default when the language tag is missing or
* unsupported.
*/
export function resolveLocale(input: string | undefined | null): SupportedLocale {
if (typeof input !== 'string' || input.length === 0) return DEFAULT_LOCALE;
const lang = input.split('-')[0]?.toLowerCase();
if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) {
return lang as SupportedLocale;
}
return DEFAULT_LOCALE;
}
/**
* Apply the host-provided locale to the i18n instance and to the document's
* `<html lang>` attribute for assistive technologies.
*/
export function setLocaleFromHost(input: string | undefined | null): SupportedLocale {
const locale = resolveLocale(input);
i18n.global.locale.value = locale;
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', locale);
}
return locale;
}

View File

@ -0,0 +1,5 @@
{
"workflowPreview.ariaLabel.creating": "Creating workflow",
"workflowPreview.ariaLabel.ready": "Workflow ready to open",
"workflowPreview.openButton": "Open in n8n"
}

View File

@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerWorkflowPreviewApp } from './workflow-preview';
import { RESOURCE_MIME_TYPE, WORKFLOW_PREVIEW_APP_URI } from '../constants';
import { loadAppHtml } from '../resource-loader';
vi.mock('../resource-loader', () => ({
// eslint-disable-next-line @typescript-eslint/require-await
loadAppHtml: vi.fn(async (fileName: string) => `<html data-file="${fileName}">stub</html>`),
}));
type ResourceCallback = () => Promise<{
contents: Array<{ uri: string; mimeType: string; text: string }>;
}>;
type CapturedResource = {
name: string;
uri: string;
metadata: Record<string, unknown>;
callback: ResourceCallback;
};
describe('registerWorkflowPreviewApp', () => {
let captured: CapturedResource;
beforeEach(() => {
captured = undefined as unknown as CapturedResource;
registerWorkflowPreviewApp({
resource: (
name: string,
uri: string,
metadata: Record<string, unknown>,
callback: ResourceCallback,
) => {
captured = { name, uri, metadata, callback };
return undefined as never;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
});
afterEach(() => {
vi.clearAllMocks();
});
it('registers the workflow-preview resource with the expected URI and MIME type metadata', () => {
expect(captured.name).toBe('workflow-preview');
expect(captured.uri).toBe(WORKFLOW_PREVIEW_APP_URI);
expect(captured.metadata.mimeType).toBe(RESOURCE_MIME_TYPE);
expect(captured.metadata.description).toMatch(/workflow/i);
});
it('returns the HTML body with the expected MIME type and URI', async () => {
const result = await captured.callback();
expect(result.contents).toHaveLength(1);
const content = result.contents[0];
expect(content.uri).toBe(WORKFLOW_PREVIEW_APP_URI);
expect(content.mimeType).toBe(RESOURCE_MIME_TYPE);
expect(content.text).toContain('<html');
expect(loadAppHtml).toHaveBeenCalledWith('workflow-preview.html');
});
});

View File

@ -0,0 +1,24 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { RESOURCE_MIME_TYPE, WORKFLOW_PREVIEW_APP_URI } from '../constants';
import { loadAppHtml } from '../resource-loader';
export function registerWorkflowPreviewApp(server: Pick<McpServer, 'resource'>): void {
server.resource(
'workflow-preview',
WORKFLOW_PREVIEW_APP_URI,
{
description: 'Loading UI shown after creating a workflow from code',
mimeType: RESOURCE_MIME_TYPE,
},
async () => ({
contents: [
{
uri: WORKFLOW_PREVIEW_APP_URI,
mimeType: RESOURCE_MIME_TYPE,
text: await loadAppHtml('workflow-preview.html'),
},
],
}),
);
}

View File

@ -0,0 +1,4 @@
export const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app';
export const RESOURCE_URI_META_KEY = 'ui/resourceUri';
export const WORKFLOW_PREVIEW_APP_URI = 'ui://workflow-preview/workflow-preview.html';

View File

@ -0,0 +1,7 @@
export {
RESOURCE_MIME_TYPE,
RESOURCE_URI_META_KEY,
WORKFLOW_PREVIEW_APP_URI,
} from './constants';
export { registerMcpAppTool, type McpAppToolConfig } from './register-mcp-app-tool';
export { registerWorkflowPreviewApp } from './apps/workflow-preview';

View File

@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest';
import { RESOURCE_URI_META_KEY } from './constants';
import { registerMcpAppTool } from './register-mcp-app-tool';
const TEST_URI = 'ui://workflow-preview/workflow-preview.html';
function createServerMock() {
return {
registerTool: vi.fn((_name: string, config: unknown, _handler: unknown) => ({ config })),
};
}
describe('registerMcpAppTool', () => {
it('adds legacy ui/resourceUri when modern _meta.ui.resourceUri is provided', () => {
const server = createServerMock();
registerMcpAppTool(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
server as any,
'tool-with-modern-meta',
{
description: 'Tool with modern UI meta',
_meta: {
ui: { resourceUri: TEST_URI },
},
},
vi.fn() as unknown as Parameters<typeof registerMcpAppTool>[3],
);
const callArgs = server.registerTool.mock.calls[0];
const passedConfig = callArgs[1] as Record<string, unknown>;
const meta = passedConfig._meta as Record<string, unknown>;
const uiMeta = meta.ui as Record<string, unknown>;
expect(uiMeta.resourceUri).toBe(TEST_URI);
expect(meta[RESOURCE_URI_META_KEY]).toBe(TEST_URI);
});
it('adds modern _meta.ui.resourceUri when legacy ui/resourceUri is provided', () => {
const server = createServerMock();
registerMcpAppTool(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
server as any,
'tool-with-legacy-meta',
{
description: 'Tool with legacy UI meta',
_meta: {
[RESOURCE_URI_META_KEY]: TEST_URI,
},
},
vi.fn() as unknown as Parameters<typeof registerMcpAppTool>[3],
);
const callArgs = server.registerTool.mock.calls[0];
const passedConfig = callArgs[1] as Record<string, unknown>;
const meta = passedConfig._meta as Record<string, unknown>;
const uiMeta = meta.ui as Record<string, unknown>;
expect(meta[RESOURCE_URI_META_KEY]).toBe(TEST_URI);
expect(uiMeta.resourceUri).toBe(TEST_URI);
});
it('preserves both keys when caller already provides both', () => {
const server = createServerMock();
const customUri = 'ui://example/custom.html';
registerMcpAppTool(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
server as any,
'tool-with-both-meta',
{
_meta: {
ui: { resourceUri: customUri },
[RESOURCE_URI_META_KEY]: customUri,
},
},
vi.fn() as unknown as Parameters<typeof registerMcpAppTool>[3],
);
const callArgs = server.registerTool.mock.calls[0];
const passedConfig = callArgs[1] as Record<string, unknown>;
const meta = passedConfig._meta as Record<string, unknown>;
const uiMeta = meta.ui as Record<string, unknown>;
expect(uiMeta.resourceUri).toBe(customUri);
expect(meta[RESOURCE_URI_META_KEY]).toBe(customUri);
});
it('passes name and handler through to server.registerTool', () => {
const server = createServerMock();
const handler = vi.fn() as unknown as Parameters<typeof registerMcpAppTool>[3];
registerMcpAppTool(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
server as any,
'my-tool',
{
_meta: { ui: { resourceUri: TEST_URI } },
},
handler,
);
expect(server.registerTool).toHaveBeenCalledTimes(1);
const [name, , passedHandler] = server.registerTool.mock.calls[0];
expect(name).toBe('my-tool');
expect(passedHandler).toBe(handler);
});
});

View File

@ -0,0 +1,57 @@
import type {
McpServer,
RegisteredTool,
ToolCallback,
} from '@modelcontextprotocol/sdk/server/mcp.js';
import type z from 'zod';
import { RESOURCE_URI_META_KEY } from './constants';
export type McpAppToolConfig<InputArgs extends z.ZodRawShape = z.ZodRawShape> = {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: z.ZodRawShape;
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
_meta: Record<string, unknown>;
};
export function registerMcpAppTool<InputArgs extends z.ZodRawShape = z.ZodRawShape>(
server: Pick<McpServer, 'registerTool'>,
name: string,
config: McpAppToolConfig<InputArgs>,
handler: ToolCallback<InputArgs>,
): RegisteredTool {
return server.registerTool(
name,
{ ...config, _meta: normalizeMcpAppToolMeta(config._meta) },
handler,
);
}
function normalizeMcpAppToolMeta(meta: Record<string, unknown>): Record<string, unknown> {
const uiMeta = isRecord(meta.ui) ? meta.ui : undefined;
const modernUri = typeof uiMeta?.resourceUri === 'string' ? uiMeta.resourceUri : undefined;
const legacyUri =
typeof meta[RESOURCE_URI_META_KEY] === 'string' ? meta[RESOURCE_URI_META_KEY] : undefined;
if (modernUri && !legacyUri) {
return { ...meta, [RESOURCE_URI_META_KEY]: modernUri };
}
if (legacyUri && !modernUri) {
return { ...meta, ui: { ...(uiMeta ?? {}), resourceUri: legacyUri } };
}
return meta;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { loadAppHtml } from './resource-loader';
describe('loadAppHtml', () => {
it('rejects unknown filenames before touching the filesystem', async () => {
await expect(
// Cast through `unknown` since the type union normally guards this at
// compile time; the runtime check is the defense for callers that
// bypass typing (e.g. JS callers or runtime-built strings).
loadAppHtml('../../../etc/passwd' as unknown as 'workflow-preview.html'),
).rejects.toThrow(/Unknown MCP app HTML file/);
});
it('rejects empty filename', async () => {
await expect(loadAppHtml('' as unknown as 'workflow-preview.html')).rejects.toThrow(
/Unknown MCP app HTML file/,
);
});
it('rejects path-like filenames that bypass the union', async () => {
await expect(
loadAppHtml('dist/apps/workflow-preview.html' as unknown as 'workflow-preview.html'),
).rejects.toThrow(/Unknown MCP app HTML file/);
});
});

View File

@ -0,0 +1,43 @@
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { MCP_APPS, type McpAppHtmlFileName } from '../apps-manifest';
export type { McpAppHtmlFileName };
/**
* Runtime allow-list of loadable HTML files, derived from the apps manifest.
* Keeps the build (Vite output filenames) and runtime (server file loader)
* in lockstep there is no separate list to maintain.
*/
const APP_HTML_FILE_NAME_SET: ReadonlySet<string> = new Set(
Object.values(MCP_APPS).map((app) => app.htmlFile),
);
const appHtmlCache = new Map<McpAppHtmlFileName, string>();
/**
* Reads a prebuilt MCP app HTML file from the package's `dist/apps/`
* directory. `fileName` is constrained to filenames declared in
* `apps-manifest.ts` both at compile time (via the typed union) and at
* runtime (via the set membership check), so that future callers that route
* in user- or host-supplied strings cannot reach arbitrary files on disk.
*/
export async function loadAppHtml(fileName: McpAppHtmlFileName): Promise<string> {
if (!APP_HTML_FILE_NAME_SET.has(fileName)) {
throw new Error(`Unknown MCP app HTML file: ${fileName}`);
}
const cached = appHtmlCache.get(fileName);
if (cached !== undefined) return cached;
const html = await readFile(join(getPackageRoot(), 'dist', 'apps', fileName), 'utf8');
appHtmlCache.set(fileName, html);
return html;
}
function getPackageRoot(): string {
const requireFromHere = createRequire(__filename);
return dirname(requireFromHere.resolve('@n8n/mcp-apps/package.json'));
}

View File

@ -0,0 +1,95 @@
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { satisfies, validRange, valid } from 'semver';
import { describe, expect, it } from 'vitest';
type PackageJson = {
name: string;
version: string;
peerDependencies?: Record<string, string>;
};
/**
* Resolve a package's `package.json` by walking up from a resolved subpath.
*
* Both `@modelcontextprotocol/ext-apps` and `@modelcontextprotocol/sdk` omit
* `./package.json` from their `exports` map, so the conventional
* `require('<pkg>/package.json')` does not work for them. Additionally, the
* SDK's `.` (main) entry points to a file that ships only under subpath
* exports so we resolve through a known subpath instead.
*/
function readPackageJson(
requireFromHere: NodeRequire,
packageName: string,
resolvableSubpath: string,
): PackageJson {
let dir = dirname(requireFromHere.resolve(resolvableSubpath));
while (dir !== dirname(dir)) {
const pkgJsonPath = join(dir, 'package.json');
if (existsSync(pkgJsonPath)) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as PackageJson;
if (pkg.name === packageName) return pkg;
}
dir = dirname(dir);
}
throw new Error(`Could not locate package.json for ${packageName}`);
}
/**
* Guards against silent drift between three independently versioned pins:
*
* - `@modelcontextprotocol/ext-apps` declares a peer range for
* `@modelcontextprotocol/sdk` (e.g. `^1.24.0`).
* - The pnpm workspace catalog pins the SDK to a concrete version.
* - `@n8n/mcp-apps` and `packages/cli` reference that catalog entry.
*
* Bumping `ext-apps` or the catalog SDK pin in isolation can silently break
* the relationship; with `strict-peer-dependencies = false` in `.npmrc`, pnpm
* only warns. This test fails CI the moment the installed SDK version no
* longer satisfies ext-apps' declared peer range.
*
* We resolve both `package.json` files via Node's actual resolution, which
* honors pnpm's install layout (including catalog substitution), so the
* assertion reflects what consumers will actually run with.
*/
describe('@modelcontextprotocol/sdk compatibility with @modelcontextprotocol/ext-apps', () => {
const requireFromHere = createRequire(import.meta.url);
// `@modelcontextprotocol/ext-apps`: `.` is the main entry and resolves.
// `@modelcontextprotocol/sdk`: `.` points to files that aren't shipped;
// resolve via `./server/mcp.js`, which the runtime code already imports.
const extAppsPkg = readPackageJson(
requireFromHere,
'@modelcontextprotocol/ext-apps',
'@modelcontextprotocol/ext-apps',
);
const sdkPkg = readPackageJson(
requireFromHere,
'@modelcontextprotocol/sdk',
'@modelcontextprotocol/sdk/server/mcp.js',
);
const sdkPeerRange = extAppsPkg.peerDependencies?.['@modelcontextprotocol/sdk'];
it('declares an SDK peer range on ext-apps', () => {
expect(sdkPeerRange).toBeDefined();
expect(validRange(sdkPeerRange)).not.toBeNull();
});
it('installs a valid SDK version', () => {
expect(valid(sdkPkg.version)).not.toBeNull();
});
it('installed SDK satisfies ext-apps peer range', () => {
// If this fails, the catalog SDK pin in `pnpm-workspace.yaml` and the
// `@modelcontextprotocol/ext-apps` catalog pin must be reconciled.
expect(
satisfies(sdkPkg.version, sdkPeerRange ?? '*'),
`Installed @modelcontextprotocol/sdk@${sdkPkg.version} does not satisfy ` +
`@modelcontextprotocol/ext-apps@${extAppsPkg.version}'s peer range ` +
`"${sdkPeerRange}". Update the catalog SDK pin in pnpm-workspace.yaml ` +
'or revert the ext-apps bump.',
).toBe(true);
});
});

View File

@ -0,0 +1,14 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"module": "CommonJS",
"moduleResolution": "node",
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/server/**/*.ts", "src/apps-manifest.ts"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,24 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"noEmit": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"rootDirs": [".", "../../frontend/@n8n/design-system/src"],
"types": [
"node",
"vitest/globals",
"vite/client",
"unplugin-icons/types/vue",
"../../frontend/@n8n/design-system/src/shims-modules.d.ts"
],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@n8n/design-system*": ["../../frontend/@n8n/design-system/src*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@ -0,0 +1,80 @@
import vue from '@vitejs/plugin-vue';
import { access, rename } from 'node:fs/promises';
import { resolve } from 'node:path';
import icons from 'unplugin-icons/vite';
import { defineConfig, type Plugin } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import svgLoader from 'vite-svg-loader';
import { MCP_APPS, type McpAppId } from './src/apps-manifest';
const appsRoot = resolve(__dirname, 'src/apps');
export default defineConfig(({ mode }) => {
const app = MCP_APPS[mode as McpAppId];
if (!app) {
throw new Error(`Unknown MCP app mode: ${mode}`);
}
const appRoot = resolve(appsRoot, app.entry);
return {
root: appRoot,
plugins: [
vue(),
svgLoader({
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
cleanupIds: false,
removeViewBox: false,
},
},
},
],
},
}),
icons({ compiler: 'vue3', autoInstall: true }),
viteSingleFile(),
renameHtmlOutput('index.html', app.htmlFile),
],
resolve: {
alias: {
'@n8n/design-system': resolve(__dirname, '../../frontend/@n8n/design-system/src'),
},
},
build: {
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
emptyOutDir: true,
outDir: resolve(__dirname, 'dist/apps'),
rollupOptions: {
input: resolve(appRoot, 'index.html'),
},
},
};
});
function renameHtmlOutput(fromFileName: string, toFileName: string): Plugin {
return {
name: 'rename-mcp-app-html',
async writeBundle(options) {
if (!options.dir) return;
const from = resolve(options.dir, fromFileName);
const to = resolve(options.dir, toFileName);
try {
await access(from);
} catch {
await access(to);
return;
}
await rename(from, to);
},
};
}

View File

@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/frontend';
export default createVitestConfig({ setupFiles: [] });

View File

@ -24,7 +24,7 @@
},
"dependencies": {
"@joplin/turndown-plugin-gfm": "1.0.64",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "catalog:",
"@mozilla/readability": "^0.6.0",
"agent-browser": "catalog:",
"jsdom": "^23.0.1",

View File

@ -259,7 +259,7 @@
"@microsoft/agents-a365-tooling-extensions-langchain": "0.1.0-preview.113",
"@microsoft/agents-activity": "1.2.3",
"@microsoft/agents-hosting": "1.2.3",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "catalog:",
"@mozilla/readability": "0.6.0",
"@n8n/ai-utilities": "workspace:*",
"@n8n/client-oauth2": "workspace:*",

View File

@ -17,7 +17,7 @@
"axios": "catalog:",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"@typescript-eslint/parser": "^8.35.0",
"semver": "^7.5.4",
"semver": "catalog:",
"tmp": "0.2.4"
},
"devDependencies": {

View File

@ -1,6 +1,10 @@
const { resolve } = require('path');
const baseConfig = require('../../jest.config');
/** @type {import('jest').Config} */
module.exports = {
...require('../../jest.config'),
...baseConfig,
testEnvironmentOptions: {
url: 'http://localhost/',
},
@ -11,6 +15,15 @@ module.exports = {
'<rootDir>/test/setup-mocks.ts',
'<rootDir>/test/extend-expect.ts',
],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
// Resolve `@n8n/mcp-apps/server` to source so tests don't require the
// package's `dist/` to be built first. Tests still mock the module via
// `jest.mock(...)`; this mapper just ensures Jest can resolve the
// specifier when the dist is absent (e.g. fresh checkouts, ad-hoc
// `pnpm test ...` runs that bypass turbo's `^build` dependency).
'^@n8n/mcp-apps/server$': resolve(__dirname, '../@n8n/mcp-apps/src/server/index.ts'),
},
coveragePathIgnorePatterns: ['/src/databases/migrations/'],
testTimeout: 10_000,
prettierPath: null,

View File

@ -110,7 +110,7 @@
"@chat-adapter/state-memory": "catalog:",
"@chat-adapter/telegram": "catalog:",
"@google-cloud/secret-manager": "5.6.0",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "catalog:",
"@n8n/agents": "workspace:*",
"@n8n/ai-node-sdk": "workspace:*",
"@n8n/ai-utilities": "workspace:*",
@ -127,6 +127,7 @@
"@n8n/errors": "workspace:*",
"@n8n/expression-runtime": "workspace:*",
"@n8n/instance-ai": "workspace:*",
"@n8n/mcp-apps": "workspace:*",
"@n8n/n8n-nodes-langchain": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/syslog-client": "workspace:*",
@ -214,7 +215,7 @@
"reflect-metadata": "catalog:",
"replacestream": "4.0.3",
"samlify": "2.13.0",
"semver": "7.5.4",
"semver": "catalog:",
"shelljs": "0.8.5",
"simple-git": "catalog:",
"source-map-support": "0.5.21",

View File

@ -49,12 +49,24 @@ describe('McpController', () => {
let controller: McpController;
const logger = mock<Logger>();
const telemetry = { track: jest.fn() } as unknown as Telemetry;
const mcpService = { getServer: jest.fn() } as unknown as McpService;
const mcpService = {
getServer: jest.fn(),
resolveMcpAppsVariant: jest.fn(),
} as unknown as McpService;
const mcpSettingsService = { getEnabled: jest.fn() } as unknown as McpSettingsService;
beforeEach(() => {
jest.clearAllMocks();
// Default mock — the controller now resolves the MCP Apps variant for
// every request, so tests that don't care about the variant still need
// a sane default. Individual tests override this with `mockResolvedValue`
// when the variant matters.
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: false,
variant: 'unassigned',
});
Container.set(Logger, logger);
Container.set(Telemetry, telemetry);
Container.set(McpService, mcpService);
@ -70,6 +82,36 @@ describe('McpController', () => {
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'MCP access is disabled' });
expect(mcpService.getServer as unknown as jest.Mock).not.toHaveBeenCalled();
// MCP Apps variant resolution is skipped for rejected requests to
// avoid an unnecessary PostHog lookup.
expect(mcpService.resolveMcpAppsVariant as jest.Mock).not.toHaveBeenCalled();
});
test('tracks disabled-access init errors without MCP Apps variant fields', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(false);
const res = createRes();
await controller.build(
createReq({
mcpAuthType: 'oauth',
body: {
jsonrpc: '2.0',
method: 'initialize',
params: { clientInfo: { name: 'Claude', version: '1.0.0' } },
},
}),
res,
);
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
user_id: 'user-1',
client_name: 'Claude',
client_version: '1.0.0',
auth_type: 'oauth',
mcp_connection_status: 'error',
error: 'MCP access is disabled',
});
expect(mcpService.resolveMcpAppsVariant as jest.Mock).not.toHaveBeenCalled();
});
test('creates mcp server if MCP access is enabled', async () => {
@ -83,12 +125,16 @@ describe('McpController', () => {
expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalled();
});
test('tracks successful initialize connections with auth type', async () => {
test('tracks successful initialize connections with auth type and MCP Apps variant', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(true);
(mcpService.getServer as unknown as jest.Mock).mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
});
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: true,
variant: 'variant',
});
const res = createRes();
await controller.build(
@ -109,9 +155,106 @@ describe('McpController', () => {
client_version: '1.0.0',
auth_type: 'oauth',
mcp_connection_status: 'success',
mcp_apps_enabled: true,
mcp_apps_variant: 'variant',
});
});
test('reports the env_override variant when the flag is forced on by an operator', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(true);
(mcpService.getServer as unknown as jest.Mock).mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
});
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: true,
variant: 'env_override',
});
const res = createRes();
await controller.build(
createReq({
body: {
jsonrpc: '2.0',
method: 'initialize',
params: { clientInfo: { name: 'Claude', version: '1.0.0' } },
},
}),
res,
);
expect(telemetry.track).toHaveBeenCalledWith(
'User connected to MCP server',
expect.objectContaining({
mcp_apps_enabled: true,
mcp_apps_variant: 'env_override',
}),
);
});
test('resolves the MCP Apps variant once and forwards `enabled` to getServer on initialize', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(true);
(mcpService.getServer as unknown as jest.Mock).mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
});
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: true,
variant: 'variant',
});
const res = createRes();
await controller.build(
createReq({
body: {
jsonrpc: '2.0',
method: 'initialize',
params: { clientInfo: { name: 'Claude', version: '1.0.0' } },
},
}),
res,
);
expect(mcpService.resolveMcpAppsVariant as jest.Mock).toHaveBeenCalledTimes(1);
expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1' }),
true,
);
});
test('resolves the MCP Apps variant and forwards `enabled` to getServer on non-initialize requests', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(true);
(mcpService.getServer as unknown as jest.Mock).mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
});
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: false,
variant: 'control',
});
const res = createRes();
await controller.build(
createReq({
body: {
jsonrpc: '2.0',
method: 'toolCall',
},
}),
res,
);
// Resolution happens for every request so the registered tools stay
// consistent with what was advertised at handshake time.
expect(mcpService.resolveMcpAppsVariant as jest.Mock).toHaveBeenCalledTimes(1);
expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1' }),
false,
);
// Non-initialize requests still skip telemetry tracking.
expect(telemetry.track).not.toHaveBeenCalled();
});
test('HEAD /http returns 401 with WWW-Authenticate header for auth scheme discovery', async () => {
const req = {} as Request;
const res = createRes();
@ -136,6 +279,10 @@ describe('McpController', () => {
test('delegates to transport.handleRequest', async () => {
(mcpSettingsService.getEnabled as jest.Mock).mockResolvedValue(true);
(mcpService.resolveMcpAppsVariant as jest.Mock).mockResolvedValue({
enabled: true,
variant: 'variant',
});
(mcpService.getServer as unknown as jest.Mock).mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
@ -143,6 +290,11 @@ describe('McpController', () => {
const req = createReq();
const res = createRes();
await controller.handleGet(req, res);
expect(mcpService.resolveMcpAppsVariant as jest.Mock).toHaveBeenCalledTimes(1);
expect(mcpService.getServer as unknown as jest.Mock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-1' }),
true,
);
expect(mockHandleRequest).toHaveBeenCalledWith(req, res, undefined);
});
});

View File

@ -12,6 +12,23 @@ import { InstanceSettings } from 'n8n-core';
import type { IRun } from 'n8n-workflow';
import { createEmptyRunExecutionData, ManualExecutionCancelledError } from 'n8n-workflow';
jest.mock('@n8n/mcp-apps/server', () => ({
WORKFLOW_PREVIEW_APP_URI: 'ui://workflow-preview/workflow-preview.html',
registerWorkflowPreviewApp: jest.fn(),
registerMcpAppTool: jest.fn(
(server: { registerTool: (...args: unknown[]) => unknown }, name, config, handler) =>
server.registerTool(name, config, handler),
),
}));
import {
registerMcpAppTool,
registerWorkflowPreviewApp,
WORKFLOW_PREVIEW_APP_URI,
} from '@n8n/mcp-apps/server';
import { MCP_APPS_FLAG, MCP_APPS_VARIANT_CONTROL, MCP_APPS_VARIANT_ENABLED } from '@n8n/api-types';
import { McpService } from '../mcp.service';
import { NodeCatalogService } from '@/node-catalog';
@ -21,6 +38,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import { ExecutionService } from '@/executions/execution.service';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { UrlService } from '@/services/url.service';
@ -73,6 +91,7 @@ describe('McpService', () => {
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
mockInstance(PostHogClient),
);
});
@ -113,6 +132,7 @@ describe('McpService', () => {
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
mockInstance(PostHogClient),
);
expect(queueMcpService.isQueueMode).toBe(true);
@ -271,11 +291,101 @@ describe('McpService', () => {
});
});
describe('resolveMcpAppsVariant', () => {
const buildResolutionService = (opts: {
postHogClient: jest.Mocked<PostHogClient>;
mcpAppsEnabled?: boolean;
}) =>
new McpService(
mockLogger(),
executionsConfig,
instanceSettings,
mockInstance(WorkflowFinderService),
mockInstance(WorkflowService),
mockInstance(UrlService),
mockInstance(CredentialsService),
activeExecutions,
mockInstance(GlobalConfig, {
endpoints: {
webhook: '/webhook',
webhookTest: '/webhook-test',
mcpAppsEnabled: opts.mcpAppsEnabled ?? false,
},
}),
mockInstance(Telemetry),
mockInstance(WorkflowRunner),
mockInstance(RoleService),
mockInstance(ProjectService),
mockInstance(NodeCatalogService),
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
opts.postHogClient,
);
const user = Object.assign(new User(), { id: 'user-1' });
it('reports `env_override` when the operator force-enables MCP Apps', async () => {
const postHogClient = mockInstance(PostHogClient);
const service = buildResolutionService({ postHogClient, mcpAppsEnabled: true });
await expect(service.resolveMcpAppsVariant(user)).resolves.toEqual({
enabled: true,
variant: 'env_override',
});
expect(postHogClient.getFeatureFlags).not.toHaveBeenCalled();
});
it('reports `variant` for users in the experiment cohort', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({
[MCP_APPS_FLAG]: MCP_APPS_VARIANT_ENABLED,
});
const service = buildResolutionService({ postHogClient });
await expect(service.resolveMcpAppsVariant(user)).resolves.toEqual({
enabled: true,
variant: 'variant',
});
});
it('reports `control` for users in the control cohort', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({
[MCP_APPS_FLAG]: MCP_APPS_VARIANT_CONTROL,
});
const service = buildResolutionService({ postHogClient });
await expect(service.resolveMcpAppsVariant(user)).resolves.toEqual({
enabled: false,
variant: 'control',
});
});
it('reports `unassigned` when the flag is missing from the PostHog response', async () => {
const postHogClient = mockInstance(PostHogClient);
postHogClient.getFeatureFlags.mockResolvedValue({});
const service = buildResolutionService({ postHogClient });
await expect(service.resolveMcpAppsVariant(user)).resolves.toEqual({
enabled: false,
variant: 'unassigned',
});
});
});
describe('getServer', () => {
it('should create MCP server with registered tools', async () => {
const user = Object.assign(new User(), { id: 'user-1' });
const server = await mcpService.getServer(user);
const server = await mcpService.getServer(user, false);
expect(server).toBeDefined();
// Verify server has expected MCP server methods
@ -318,9 +428,10 @@ describe('McpService', () => {
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
mockInstance(PostHogClient),
);
const server = await service.getServer(user);
const server = await service.getServer(user, false);
expect(server).toBeDefined();
// Builder tools service should NOT have been initialized
expect(nodeCatalogService.initialize).not.toHaveBeenCalled();
@ -360,12 +471,112 @@ describe('McpService', () => {
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
mockInstance(PostHogClient),
);
const server = await service.getServer(user);
const server = await service.getServer(user, false);
expect(server).toBeDefined();
// Builder tools service should have been initialized
expect(nodeCatalogService.initialize).toHaveBeenCalled();
});
describe('MCP Apps integration', () => {
// Resolution of the MCP Apps flag (PostHog cohort, env override,
// error fallback) is covered in the `resolveMcpAppsVariant` block.
// These tests assume the caller (controller) has already resolved
// the boolean and focus on `getServer`'s tool-registration behavior.
type BuildServiceOpts = {
builderEnabled?: boolean;
postHogClient?: jest.Mocked<PostHogClient>;
};
const buildService = ({
builderEnabled = true,
postHogClient = mockInstance(PostHogClient),
}: BuildServiceOpts = {}) =>
new McpService(
mockLogger(),
executionsConfig,
instanceSettings,
mockInstance(WorkflowFinderService),
mockInstance(WorkflowService),
mockInstance(UrlService),
mockInstance(CredentialsService),
activeExecutions,
mockInstance(GlobalConfig, {
endpoints: {
webhook: '/webhook',
webhookTest: '/webhook-test',
mcpBuilderEnabled: builderEnabled,
},
}),
mockInstance(Telemetry),
mockInstance(WorkflowRunner),
mockInstance(RoleService),
mockInstance(ProjectService),
mockInstance(NodeCatalogService),
mockInstance(WorkflowCreationService),
mockInstance(NodeTypes),
mockInstance(ProjectRepository),
mockInstance(FolderRepository),
mockInstance(SharedWorkflowRepository),
mockInstance(ExecutionRepository),
mockInstance(ExecutionService),
mockInstance(DataTableProxyService),
mockInstance(CollaborationService),
postHogClient,
);
beforeEach(() => {
(registerWorkflowPreviewApp as jest.Mock).mockClear();
(registerMcpAppTool as jest.Mock).mockClear();
});
it('registers the workflow preview app and wires it to the create-workflow tool when `mcpAppsEnabled` is true', async () => {
const user = Object.assign(new User(), { id: 'user-1' });
const postHogClient = mockInstance(PostHogClient);
const service = buildService({ postHogClient });
await service.getServer(user, true);
expect(registerWorkflowPreviewApp).toHaveBeenCalledTimes(1);
expect(registerMcpAppTool).toHaveBeenCalledTimes(1);
const [, toolName, toolConfig] = (registerMcpAppTool as jest.Mock).mock.calls[0];
expect(typeof toolName).toBe('string');
const meta = (toolConfig as { _meta: { ui: { resourceUri: string } } })._meta;
expect(meta.ui.resourceUri).toBe(WORKFLOW_PREVIEW_APP_URI);
// The service trusts the caller's resolution and never falls back to PostHog.
expect(postHogClient.getFeatureFlags).not.toHaveBeenCalled();
});
it('does not register MCP apps when `mcpAppsEnabled` is false', async () => {
const user = Object.assign(new User(), { id: 'user-1' });
const postHogClient = mockInstance(PostHogClient);
const service = buildService({ postHogClient });
await service.getServer(user, false);
expect(registerWorkflowPreviewApp).not.toHaveBeenCalled();
expect(registerMcpAppTool).not.toHaveBeenCalled();
expect(postHogClient.getFeatureFlags).not.toHaveBeenCalled();
});
it('does not register MCP apps when builder is disabled, even if `mcpAppsEnabled` is true', async () => {
const user = Object.assign(new User(), { id: 'user-1' });
const postHogClient = mockInstance(PostHogClient);
const service = buildService({ builderEnabled: false, postHogClient });
await service.getServer(user, true);
expect(registerWorkflowPreviewApp).not.toHaveBeenCalled();
expect(registerMcpAppTool).not.toHaveBeenCalled();
expect(postHogClient.getFeatureFlags).not.toHaveBeenCalled();
});
});
});
});

View File

@ -87,7 +87,8 @@ export class McpController {
}
try {
await this.handleTransportRequest(req, res);
const { enabled: mcpAppsEnabled } = await this.mcpService.resolveMcpAppsVariant(req.user);
await this.handleTransportRequest(req, res, mcpAppsEnabled);
} catch (error) {
this.errorReporter.error(error);
if (!res.headersSent) {
@ -119,7 +120,7 @@ export class McpController {
const isToolCallRequest = isJSONRPCRequest(body) ? body.method === 'toolCall' : false;
const clientInfo = getClientInfo(req);
const telemetryPayload: Partial<UserConnectedToMCPEventPayload> = {
const baseTelemetryPayload: Partial<UserConnectedToMCPEventPayload> = {
user_id: req.user.id,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
@ -128,13 +129,12 @@ export class McpController {
).mcpAuthType,
};
// Deny if MCP access is disabled
const enabled = await this.mcpSettingsService.getEnabled();
if (!enabled) {
if (isInitializationRequest) {
this.trackConnectionEvent({
...telemetryPayload,
...baseTelemetryPayload,
mcp_connection_status: 'error',
error: MCP_ACCESS_DISABLED_ERROR_MESSAGE,
});
@ -143,11 +143,20 @@ export class McpController {
res.status(403).json({ message: MCP_ACCESS_DISABLED_ERROR_MESSAGE });
return;
}
const mcpAppsResolution = await this.mcpService.resolveMcpAppsVariant(req.user);
const telemetryPayload: Partial<UserConnectedToMCPEventPayload> = {
...baseTelemetryPayload,
mcp_apps_enabled: mcpAppsResolution.enabled,
mcp_apps_variant: mcpAppsResolution.variant,
};
// In stateless mode, create a new instance of transport and server for each request
// to ensure complete isolation. A single instance would cause request ID collisions
// when multiple clients connect concurrently.
try {
await this.handleTransportRequest(req, res, req.body);
await this.handleTransportRequest(req, res, mcpAppsResolution.enabled, req.body);
if (isInitializationRequest) {
this.trackConnectionEvent({
...telemetryPayload,
@ -182,12 +191,13 @@ export class McpController {
private async handleTransportRequest(
req: AuthenticatedRequest,
res: FlushableResponse,
mcpAppsEnabled: boolean,
body?: unknown,
) {
const { StreamableHTTPServerTransport } = await import(
'@modelcontextprotocol/sdk/server/streamableHttp.js'
);
const server = await this.mcpService.getServer(req.user);
const server = await this.mcpService.getServer(req.user, mcpAppsEnabled);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

View File

@ -1,4 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { MCP_APPS_FLAG, MCP_APPS_VARIANT_CONTROL, MCP_APPS_VARIANT_ENABLED } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { ExecutionsConfig, GlobalConfig } from '@n8n/config';
import {
@ -9,6 +10,11 @@ import {
User,
} from '@n8n/db';
import { Service } from '@n8n/di';
import {
registerMcpAppTool,
registerWorkflowPreviewApp,
WORKFLOW_PREVIEW_APP_URI,
} from '@n8n/mcp-apps/server';
import { InstanceSettings } from 'n8n-core';
import {
createDeferredPromise,
@ -53,6 +59,7 @@ import { CollaborationService } from '@/collaboration/collaboration.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { UrlService } from '@/services/url.service';
@ -61,6 +68,7 @@ import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowCreationService } from '@/workflows/workflow-creation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import type { McpAppsTelemetryVariant } from './mcp.types';
import { createPrepareTestPinDataTool } from './tools/prepare-workflow-pin-data.tool';
import { createTestWorkflowTool } from './tools/test-workflow.tool';
import { ExecutionService } from '@/executions/execution.service';
@ -74,6 +82,11 @@ interface PendingMcpResponse {
createdAt: Date;
}
export type McpAppsResolution = {
enabled: boolean;
variant: McpAppsTelemetryVariant;
};
@Service()
export class McpService {
/**
@ -106,9 +119,24 @@ export class McpService {
private readonly executionService: ExecutionService,
private readonly dataTableProxyService: DataTableProxyService,
private readonly collaborationService: CollaborationService,
private readonly postHogClient: PostHogClient,
) {}
async getServer(user: User) {
async resolveMcpAppsVariant(user: User): Promise<McpAppsResolution> {
if (this.globalConfig.endpoints.mcpAppsEnabled) {
return { enabled: true, variant: 'env_override' };
}
// `PostHogClient.getFeatureFlags` swallows PostHog errors internally and
// returns `{}`, so a transient outage surfaces here as `unassigned`.
const flags = await this.postHogClient.getFeatureFlags(user);
const raw = flags?.[MCP_APPS_FLAG];
if (raw === MCP_APPS_VARIANT_ENABLED) return { enabled: true, variant: 'variant' };
if (raw === MCP_APPS_VARIANT_CONTROL) return { enabled: false, variant: 'control' };
return { enabled: false, variant: 'unassigned' };
}
async getServer(user: User, mcpAppsEnabled: boolean) {
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
const builderEnabled = this.globalConfig.endpoints.mcpBuilderEnabled;
const server = new McpServer(
@ -309,7 +337,7 @@ export class McpService {
// Workflow builder tools (enabled via N8N_MCP_BUILDER_ENABLED)
if (builderEnabled) {
await this.registerBuilderTools(server, user, dataTableOps);
await this.registerBuilderTools(server, user, dataTableOps, mcpAppsEnabled);
}
return server;
@ -319,6 +347,7 @@ export class McpService {
server: InstanceType<typeof McpServer>,
user: User,
dataTableOps: ReturnType<DataTableProxyService['makeDataTableOperationsForUser']>,
mcpAppsEnabled: boolean,
) {
await this.nodeCatalogService.initialize();
@ -361,7 +390,25 @@ export class McpService {
this.projectRepository,
dataTableOps,
);
server.registerTool(createTool.name, createTool.config, createTool.handler);
if (mcpAppsEnabled) {
registerWorkflowPreviewApp(server);
registerMcpAppTool(
server,
createTool.name,
{
...createTool.config,
_meta: {
ui: {
resourceUri: WORKFLOW_PREVIEW_APP_URI,
},
},
},
createTool.handler,
);
} else {
server.registerTool(createTool.name, createTool.config, createTool.handler);
}
const searchProjectsTool = createSearchProjectsTool(
user,

View File

@ -78,6 +78,8 @@ export type JSONRPCRequest = {
id?: string | number | null;
};
export type McpAppsTelemetryVariant = 'env_override' | 'variant' | 'control' | 'unassigned';
// Telemetry payloads
export type UserConnectedToMCPEventPayload = {
user_id?: string;
@ -85,6 +87,8 @@ export type UserConnectedToMCPEventPayload = {
client_version?: string;
auth_type?: Mcpauth_type;
mcp_connection_status: 'success' | 'error';
mcp_apps_enabled?: boolean;
mcp_apps_variant?: McpAppsTelemetryVariant;
error?: string;
};

View File

@ -91,7 +91,7 @@
"pinia": "catalog:frontend",
"prettier": "^3.3.3",
"qrcode.vue": "^3.3.4",
"semver": "^7.5.4",
"semver": "catalog:",
"stream-browserify": "^3.0.0",
"timeago.js": "^4.0.2",
"typescript": "catalog:",

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import type { KeyboardShortcut } from '@/Interface';
import type { Placement } from 'element-plus';
import type { N8nTooltipProps } from '@n8n/design-system/components/N8nTooltip';
import { N8nKeyboardShortcut, N8nTooltip } from '@n8n/design-system';
interface Props {
label: string;
shortcut?: KeyboardShortcut;
placement?: Placement;
placement?: N8nTooltipProps['placement'];
disabled?: boolean;
}
withDefaults(defineProps<Props>(), { placement: 'top', shortcut: undefined });

View File

@ -966,7 +966,7 @@
"rrule": "2.8.1",
"rss-parser": "3.13.0",
"sanitize-html": "2.12.1",
"semver": "7.5.4",
"semver": "catalog:",
"showdown": "2.1.0",
"simple-git": "catalog:",
"snowflake-sdk": "2.1.0",

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,8 @@ catalog:
'@lezer/html': 1.3.13
'@lezer/javascript': 1.5.4
'@lezer/lr': 1.4.5
'@modelcontextprotocol/ext-apps': 1.3.2
'@modelcontextprotocol/sdk': 1.26.0
'@n8n/typeorm': 0.3.20-17
'@n8n_io/ai-assistant-sdk': 1.21.0
'@openrouter/ai-sdk-provider': ^2.8.0
@ -123,6 +125,7 @@ catalog:
reflect-metadata: 0.2.2
rimraf: 6.0.1
sass-embedded: ^1.98.0
semver: 7.7.3
simple-git: 3.36.0
stream-json: 1.9.1
testcontainers: ^11.13.0
@ -133,6 +136,7 @@ catalog:
uuid: 10.0.0
vite: ^8.0.2
vite-plugin-dts: ^4.5.4
vite-plugin-singlefile: 2.3.3
vitest: ^4.1.1
vitest-mock-extended: ^3.1.0
vm2: 3.11.5