From 43f7446c1e4ae832909f08899dfb59efaf13083c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 1 Jun 2026 17:39:41 +0200 Subject: [PATCH] feat(core): Introduce `mcp-apps` package (no-changelog) (#31298) --- .github/workflows/backport.yml | 1 - package.json | 2 +- packages/@n8n/agents/package.json | 2 +- packages/@n8n/api-types/src/index.ts | 6 + .../@n8n/api-types/src/schemas/mcp.schema.ts | 11 + packages/@n8n/computer-use/package.json | 2 +- .../config/src/configs/endpoints.config.ts | 8 + packages/@n8n/config/test/config.test.ts | 1 + packages/@n8n/mcp-apps/.gitignore | 2 + packages/@n8n/mcp-apps/README.md | 132 ++ packages/@n8n/mcp-apps/eslint.config.mjs | 30 + packages/@n8n/mcp-apps/package.json | 63 + packages/@n8n/mcp-apps/src/apps-manifest.ts | 24 + .../src/apps/workflow-preview/App.vue | 141 ++ .../src/apps/workflow-preview/index.html | 12 + .../src/apps/workflow-preview/main.ts | 7 + .../src/apps/workflow-preview/shims-vue.d.ts | 7 + .../src/apps/workflow-preview/tokens.scss | 42 + .../src/apps/workflow-preview/url.test.ts | 56 + .../mcp-apps/src/apps/workflow-preview/url.ts | 30 + packages/@n8n/mcp-apps/src/i18n/index.test.ts | 57 + packages/@n8n/mcp-apps/src/i18n/index.ts | 55 + packages/@n8n/mcp-apps/src/locales/en.json | 5 + .../src/server/apps/workflow-preview.test.ts | 63 + .../src/server/apps/workflow-preview.ts | 24 + .../@n8n/mcp-apps/src/server/constants.ts | 4 + packages/@n8n/mcp-apps/src/server/index.ts | 7 + .../src/server/register-mcp-app-tool.test.ts | 110 ++ .../src/server/register-mcp-app-tool.ts | 57 + .../src/server/resource-loader.test.ts | 26 + .../mcp-apps/src/server/resource-loader.ts | 43 + .../mcp-apps/src/server/sdk-version.test.ts | 95 ++ packages/@n8n/mcp-apps/tsconfig.build.json | 14 + packages/@n8n/mcp-apps/tsconfig.json | 24 + packages/@n8n/mcp-apps/vite.config.mts | 80 + packages/@n8n/mcp-apps/vitest.config.mts | 3 + packages/@n8n/mcp-browser/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- .../@n8n/scan-community-package/package.json | 2 +- packages/cli/jest.config.js | 15 +- packages/cli/package.json | 5 +- .../mcp/__tests__/mcp.controller.test.ts | 156 +- .../modules/mcp/__tests__/mcp.service.test.ts | 217 ++- .../cli/src/modules/mcp/mcp.controller.ts | 22 +- packages/cli/src/modules/mcp/mcp.service.ts | 53 +- packages/cli/src/modules/mcp/mcp.types.ts | 4 + packages/frontend/editor-ui/package.json | 2 +- .../components/KeyboardShortcutTooltip.vue | 4 +- packages/nodes-base/package.json | 2 +- pnpm-lock.yaml | 1465 ++++++++++++----- pnpm-workspace.yaml | 4 + 51 files changed, 2751 insertions(+), 450 deletions(-) create mode 100644 packages/@n8n/api-types/src/schemas/mcp.schema.ts create mode 100644 packages/@n8n/mcp-apps/.gitignore create mode 100644 packages/@n8n/mcp-apps/README.md create mode 100644 packages/@n8n/mcp-apps/eslint.config.mjs create mode 100644 packages/@n8n/mcp-apps/package.json create mode 100644 packages/@n8n/mcp-apps/src/apps-manifest.ts create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts create mode 100644 packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts create mode 100644 packages/@n8n/mcp-apps/src/i18n/index.test.ts create mode 100644 packages/@n8n/mcp-apps/src/i18n/index.ts create mode 100644 packages/@n8n/mcp-apps/src/locales/en.json create mode 100644 packages/@n8n/mcp-apps/src/server/apps/workflow-preview.test.ts create mode 100644 packages/@n8n/mcp-apps/src/server/apps/workflow-preview.ts create mode 100644 packages/@n8n/mcp-apps/src/server/constants.ts create mode 100644 packages/@n8n/mcp-apps/src/server/index.ts create mode 100644 packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts create mode 100644 packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts create mode 100644 packages/@n8n/mcp-apps/src/server/resource-loader.test.ts create mode 100644 packages/@n8n/mcp-apps/src/server/resource-loader.ts create mode 100644 packages/@n8n/mcp-apps/src/server/sdk-version.test.ts create mode 100644 packages/@n8n/mcp-apps/tsconfig.build.json create mode 100644 packages/@n8n/mcp-apps/tsconfig.json create mode 100644 packages/@n8n/mcp-apps/vite.config.mts create mode 100644 packages/@n8n/mcp-apps/vitest.config.mts diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 38091c6eef1..269ed652000 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -123,4 +123,3 @@ jobs: with: pull-request-number: ${{ matrix.pr_number }} secrets: inherit - diff --git a/package.json b/package.json index ab78f636265..b0bb915e626 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index c0335004d23..da8ea957552 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -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", diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index afaeb273a7b..b389b16a666 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/mcp.schema.ts b/packages/@n8n/api-types/src/schemas/mcp.schema.ts new file mode 100644 index 00000000000..65bb7eba66a --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/mcp.schema.ts @@ -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_` +// 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'; diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index b73fbea31a0..eb7da0293cf 100644 --- a/packages/@n8n/computer-use/package.json +++ b/packages/@n8n/computer-use/package.json @@ -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", diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts index 863312831a9..7b713ceb92b 100644 --- a/packages/@n8n/config/src/configs/endpoints.config.ts +++ b/packages/@n8n/config/src/configs/endpoints.config.ts @@ -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; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 9b9bfe46335..847d00f6c26 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -239,6 +239,7 @@ describe('GlobalConfig', () => { formTest: 'form-test', formWaiting: 'form-waiting', mcp: 'mcp', + mcpAppsEnabled: false, mcpBuilderEnabled: true, mcpMaxRegisteredClients: 5000, mcpTest: 'mcp-test', diff --git a/packages/@n8n/mcp-apps/.gitignore b/packages/@n8n/mcp-apps/.gitignore new file mode 100644 index 00000000000..de4d1f007dd --- /dev/null +++ b/packages/@n8n/mcp-apps/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/@n8n/mcp-apps/README.md b/packages/@n8n/mcp-apps/README.md new file mode 100644 index 00000000000..c7744168461 --- /dev/null +++ b/packages/@n8n/mcp-apps/README.md @@ -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/.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/.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 `` for +assistive tech. See `src/i18n/index.ts` for the full contract. + +To add a new locale: + +1. Drop `.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//` 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 + '': { + entry: '', // directory under src/apps/ + htmlFile: '.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 ` + will then produce `dist/apps/.html`. +3. Add the app's URI constant to `src/server/constants.ts` and a + `registerApp` helper in `src/server/apps/` that calls + `server.resource(...)` with `loadAppHtml('.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 | diff --git a/packages/@n8n/mcp-apps/eslint.config.mjs b/packages/@n8n/mcp-apps/eslint.config.mjs new file mode 100644 index 00000000000..3a53f52eb98 --- /dev/null +++ b/packages/@n8n/mcp-apps/eslint.config.mjs @@ -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', + }, + }, +); diff --git a/packages/@n8n/mcp-apps/package.json b/packages/@n8n/mcp-apps/package.json new file mode 100644 index 00000000000..b4624288929 --- /dev/null +++ b/packages/@n8n/mcp-apps/package.json @@ -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:" + } +} diff --git a/packages/@n8n/mcp-apps/src/apps-manifest.ts b/packages/@n8n/mcp-apps/src/apps-manifest.ts new file mode 100644 index 00000000000..e6258f80b7a --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps-manifest.ts @@ -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']; diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue b/packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue new file mode 100644 index 00000000000..bbb84bfb6fa --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html b/packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html new file mode 100644 index 00000000000..880bc9f835f --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html @@ -0,0 +1,12 @@ + + + + + + n8n + + +
+ + + diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts b/packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts new file mode 100644 index 00000000000..6bbae688382 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts @@ -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'); diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts b/packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts new file mode 100644 index 00000000000..19d952b5255 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts @@ -0,0 +1,7 @@ +/* eslint-disable import-x/no-default-export */ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss b/packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss new file mode 100644 index 00000000000..5e85f8c8f3f --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss @@ -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); +} diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts b/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts new file mode 100644 index 00000000000..626472bbc4b --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts @@ -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,'], + ['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'); + } + }); +}); diff --git a/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts b/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts new file mode 100644 index 00000000000..f8269d5b542 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts @@ -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; +} diff --git a/packages/@n8n/mcp-apps/src/i18n/index.test.ts b/packages/@n8n/mcp-apps/src/i18n/index.test.ts new file mode 100644 index 00000000000..278e69c97da --- /dev/null +++ b/packages/@n8n/mcp-apps/src/i18n/index.test.ts @@ -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 `` for assistive tech', () => { + setLocaleFromHost('en-GB'); + expect(document.documentElement.getAttribute('lang')).toBe('en'); + }); + }); +}); diff --git a/packages/@n8n/mcp-apps/src/i18n/index.ts b/packages/@n8n/mcp-apps/src/i18n/index.ts new file mode 100644 index 00000000000..94e62446598 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/i18n/index.ts @@ -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 `.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({ + 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 + * `` 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; +} diff --git a/packages/@n8n/mcp-apps/src/locales/en.json b/packages/@n8n/mcp-apps/src/locales/en.json new file mode 100644 index 00000000000..f59e12cb585 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/locales/en.json @@ -0,0 +1,5 @@ +{ + "workflowPreview.ariaLabel.creating": "Creating workflow", + "workflowPreview.ariaLabel.ready": "Workflow ready to open", + "workflowPreview.openButton": "Open in n8n" +} diff --git a/packages/@n8n/mcp-apps/src/server/apps/workflow-preview.test.ts b/packages/@n8n/mcp-apps/src/server/apps/workflow-preview.test.ts new file mode 100644 index 00000000000..df522e143f8 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/apps/workflow-preview.test.ts @@ -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) => `stub`), +})); + +type ResourceCallback = () => Promise<{ + contents: Array<{ uri: string; mimeType: string; text: string }>; +}>; + +type CapturedResource = { + name: string; + uri: string; + metadata: Record; + callback: ResourceCallback; +}; + +describe('registerWorkflowPreviewApp', () => { + let captured: CapturedResource; + + beforeEach(() => { + captured = undefined as unknown as CapturedResource; + registerWorkflowPreviewApp({ + resource: ( + name: string, + uri: string, + metadata: Record, + 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('): 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'), + }, + ], + }), + ); +} diff --git a/packages/@n8n/mcp-apps/src/server/constants.ts b/packages/@n8n/mcp-apps/src/server/constants.ts new file mode 100644 index 00000000000..e03e1d0e727 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/constants.ts @@ -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'; diff --git a/packages/@n8n/mcp-apps/src/server/index.ts b/packages/@n8n/mcp-apps/src/server/index.ts new file mode 100644 index 00000000000..c13c750478e --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/index.ts @@ -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'; diff --git a/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts b/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts new file mode 100644 index 00000000000..215646925d6 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts @@ -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[3], + ); + + const callArgs = server.registerTool.mock.calls[0]; + const passedConfig = callArgs[1] as Record; + const meta = passedConfig._meta as Record; + const uiMeta = meta.ui as Record; + + 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[3], + ); + + const callArgs = server.registerTool.mock.calls[0]; + const passedConfig = callArgs[1] as Record; + const meta = passedConfig._meta as Record; + const uiMeta = meta.ui as Record; + + 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[3], + ); + + const callArgs = server.registerTool.mock.calls[0]; + const passedConfig = callArgs[1] as Record; + const meta = passedConfig._meta as Record; + const uiMeta = meta.ui as Record; + + 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[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); + }); +}); diff --git a/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts b/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts new file mode 100644 index 00000000000..b53090390fa --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts @@ -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 = { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: z.ZodRawShape; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + _meta: Record; +}; + +export function registerMcpAppTool( + server: Pick, + name: string, + config: McpAppToolConfig, + handler: ToolCallback, +): RegisteredTool { + return server.registerTool( + name, + { ...config, _meta: normalizeMcpAppToolMeta(config._meta) }, + handler, + ); +} + +function normalizeMcpAppToolMeta(meta: Record): Record { + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/@n8n/mcp-apps/src/server/resource-loader.test.ts b/packages/@n8n/mcp-apps/src/server/resource-loader.test.ts new file mode 100644 index 00000000000..0643f0735e1 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/resource-loader.test.ts @@ -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/); + }); +}); diff --git a/packages/@n8n/mcp-apps/src/server/resource-loader.ts b/packages/@n8n/mcp-apps/src/server/resource-loader.ts new file mode 100644 index 00000000000..0173d1b45e0 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/resource-loader.ts @@ -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 = new Set( + Object.values(MCP_APPS).map((app) => app.htmlFile), +); + +const appHtmlCache = new Map(); + +/** + * 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 { + 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')); +} diff --git a/packages/@n8n/mcp-apps/src/server/sdk-version.test.ts b/packages/@n8n/mcp-apps/src/server/sdk-version.test.ts new file mode 100644 index 00000000000..f5e8ce66e43 --- /dev/null +++ b/packages/@n8n/mcp-apps/src/server/sdk-version.test.ts @@ -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; +}; + +/** + * 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('/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); + }); +}); diff --git a/packages/@n8n/mcp-apps/tsconfig.build.json b/packages/@n8n/mcp-apps/tsconfig.build.json new file mode 100644 index 00000000000..dc24c5f78e4 --- /dev/null +++ b/packages/@n8n/mcp-apps/tsconfig.build.json @@ -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"] +} diff --git a/packages/@n8n/mcp-apps/tsconfig.json b/packages/@n8n/mcp-apps/tsconfig.json new file mode 100644 index 00000000000..85ff06d59fa --- /dev/null +++ b/packages/@n8n/mcp-apps/tsconfig.json @@ -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"] +} diff --git a/packages/@n8n/mcp-apps/vite.config.mts b/packages/@n8n/mcp-apps/vite.config.mts new file mode 100644 index 00000000000..9074f2f26ed --- /dev/null +++ b/packages/@n8n/mcp-apps/vite.config.mts @@ -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); + }, + }; +} diff --git a/packages/@n8n/mcp-apps/vitest.config.mts b/packages/@n8n/mcp-apps/vitest.config.mts new file mode 100644 index 00000000000..05a3312dde7 --- /dev/null +++ b/packages/@n8n/mcp-apps/vitest.config.mts @@ -0,0 +1,3 @@ +import { createVitestConfig } from '@n8n/vitest-config/frontend'; + +export default createVitestConfig({ setupFiles: [] }); diff --git a/packages/@n8n/mcp-browser/package.json b/packages/@n8n/mcp-browser/package.json index a997f9e6881..c67909c03bb 100644 --- a/packages/@n8n/mcp-browser/package.json +++ b/packages/@n8n/mcp-browser/package.json @@ -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", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index d511e307bb7..98f4e20bbb7 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -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:*", diff --git a/packages/@n8n/scan-community-package/package.json b/packages/@n8n/scan-community-package/package.json index d13404c8cf0..22b5d296816 100644 --- a/packages/@n8n/scan-community-package/package.json +++ b/packages/@n8n/scan-community-package/package.json @@ -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": { diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 6262205bc1c..34ee691779f 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -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 = { '/test/setup-mocks.ts', '/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, diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f69326be9c..4d9a2646132 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts index 15c0ae1952c..9e328fa112a 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts @@ -49,12 +49,24 @@ describe('McpController', () => { let controller: McpController; const logger = mock(); 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); }); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts index 9f102ff5701..c49902d9961 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -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; + 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; + }; + + 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(); + }); + }); }); }); diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index b457f8bc138..fc6f0a902ae 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -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 = { + const baseTelemetryPayload: Partial = { 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 = { + ...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, }); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index a6c0d608214..c5377868fbb 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -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 { + 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, user: User, dataTableOps: ReturnType, + 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, diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index 8809d7ad8de..141c70bb408 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -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; }; diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index adbe8364372..fc34c41668e 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -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:", diff --git a/packages/frontend/editor-ui/src/app/components/KeyboardShortcutTooltip.vue b/packages/frontend/editor-ui/src/app/components/KeyboardShortcutTooltip.vue index de7671f68ca..f2946365cb5 100644 --- a/packages/frontend/editor-ui/src/app/components/KeyboardShortcutTooltip.vue +++ b/packages/frontend/editor-ui/src/app/components/KeyboardShortcutTooltip.vue @@ -1,12 +1,12 @@