mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 18:27:09 +02:00
feat(core): Introduce mcp-apps package (no-changelog) (#31298)
This commit is contained in:
parent
90dd93c772
commit
43f7446c1e
1
.github/workflows/backport.yml
vendored
1
.github/workflows/backport.yml
vendored
|
|
@ -123,4 +123,3 @@ jobs:
|
|||
with:
|
||||
pull-request-number: ${{ matrix.pr_number }}
|
||||
secrets: inherit
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
packages/@n8n/api-types/src/schemas/mcp.schema.ts
Normal file
11
packages/@n8n/api-types/src/schemas/mcp.schema.ts
Normal 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';
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
2
packages/@n8n/mcp-apps/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
node_modules
|
||||
132
packages/@n8n/mcp-apps/README.md
Normal file
132
packages/@n8n/mcp-apps/README.md
Normal 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 |
|
||||
30
packages/@n8n/mcp-apps/eslint.config.mjs
Normal file
30
packages/@n8n/mcp-apps/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
63
packages/@n8n/mcp-apps/package.json
Normal file
63
packages/@n8n/mcp-apps/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
24
packages/@n8n/mcp-apps/src/apps-manifest.ts
Normal file
24
packages/@n8n/mcp-apps/src/apps-manifest.ts
Normal 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'];
|
||||
141
packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue
Normal file
141
packages/@n8n/mcp-apps/src/apps/workflow-preview/App.vue
Normal 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>
|
||||
12
packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html
Normal file
12
packages/@n8n/mcp-apps/src/apps/workflow-preview/index.html
Normal 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>
|
||||
7
packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts
Normal file
7
packages/@n8n/mcp-apps/src/apps/workflow-preview/main.ts
Normal 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');
|
||||
7
packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts
vendored
Normal file
7
packages/@n8n/mcp-apps/src/apps/workflow-preview/shims-vue.d.ts
vendored
Normal 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;
|
||||
}
|
||||
42
packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss
Normal file
42
packages/@n8n/mcp-apps/src/apps/workflow-preview/tokens.scss
Normal 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);
|
||||
}
|
||||
56
packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts
Normal file
56
packages/@n8n/mcp-apps/src/apps/workflow-preview/url.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
30
packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts
Normal file
30
packages/@n8n/mcp-apps/src/apps/workflow-preview/url.ts
Normal 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;
|
||||
}
|
||||
57
packages/@n8n/mcp-apps/src/i18n/index.test.ts
Normal file
57
packages/@n8n/mcp-apps/src/i18n/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
55
packages/@n8n/mcp-apps/src/i18n/index.ts
Normal file
55
packages/@n8n/mcp-apps/src/i18n/index.ts
Normal 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;
|
||||
}
|
||||
5
packages/@n8n/mcp-apps/src/locales/en.json
Normal file
5
packages/@n8n/mcp-apps/src/locales/en.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"workflowPreview.ariaLabel.creating": "Creating workflow",
|
||||
"workflowPreview.ariaLabel.ready": "Workflow ready to open",
|
||||
"workflowPreview.openButton": "Open in n8n"
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
24
packages/@n8n/mcp-apps/src/server/apps/workflow-preview.ts
Normal file
24
packages/@n8n/mcp-apps/src/server/apps/workflow-preview.ts
Normal 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'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
4
packages/@n8n/mcp-apps/src/server/constants.ts
Normal file
4
packages/@n8n/mcp-apps/src/server/constants.ts
Normal 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';
|
||||
7
packages/@n8n/mcp-apps/src/server/index.ts
Normal file
7
packages/@n8n/mcp-apps/src/server/index.ts
Normal 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';
|
||||
110
packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts
Normal file
110
packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts
Normal file
57
packages/@n8n/mcp-apps/src/server/register-mcp-app-tool.ts
Normal 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);
|
||||
}
|
||||
26
packages/@n8n/mcp-apps/src/server/resource-loader.test.ts
Normal file
26
packages/@n8n/mcp-apps/src/server/resource-loader.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
43
packages/@n8n/mcp-apps/src/server/resource-loader.ts
Normal file
43
packages/@n8n/mcp-apps/src/server/resource-loader.ts
Normal 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'));
|
||||
}
|
||||
95
packages/@n8n/mcp-apps/src/server/sdk-version.test.ts
Normal file
95
packages/@n8n/mcp-apps/src/server/sdk-version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
packages/@n8n/mcp-apps/tsconfig.build.json
Normal file
14
packages/@n8n/mcp-apps/tsconfig.build.json
Normal 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"]
|
||||
}
|
||||
24
packages/@n8n/mcp-apps/tsconfig.json
Normal file
24
packages/@n8n/mcp-apps/tsconfig.json
Normal 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"]
|
||||
}
|
||||
80
packages/@n8n/mcp-apps/vite.config.mts
Normal file
80
packages/@n8n/mcp-apps/vite.config.mts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
3
packages/@n8n/mcp-apps/vitest.config.mts
Normal file
3
packages/@n8n/mcp-apps/vitest.config.mts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createVitestConfig } from '@n8n/vitest-config/frontend';
|
||||
|
||||
export default createVitestConfig({ setupFiles: [] });
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1465
pnpm-lock.yaml
1465
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user