feat(core): Implement preview-workflow app (#31647)

This commit is contained in:
Milorad FIlipović
2026-06-08 09:35:30 +02:00
committed by GitHub
parent 0433d273eb
commit bef0edaccf
35 changed files with 1451 additions and 230 deletions
+27 -5
View File
@@ -18,9 +18,11 @@ helpers used by `packages/cli` to register them as MCP resources and tools.
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)).
after the `create_workflow_from_code` MCP tool returns. It loads the sanitized
workflow graph through the existing `get_workflow_details` MCP tool, renders the
existing n8n demo canvas in an iframe, and keeps 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
@@ -33,7 +35,15 @@ src/
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
types.ts # workflow preview data types
type-guards.ts # workflow preview data guards
composables/
use-workflow-preview.ts # workflow preview state and host tool handling
utils/
url.ts # defense-in-depth URL validation
components/ # reusable MCP app Vue components
workflow-preview/ # workflow-preview-specific reusable components
composables/ # reusable MCP host/runtime composables
i18n/ # vue-i18n setup + host locale resolution
locales/ # flat-key locale files (en.json, …)
server/ # consumed by packages/cli
@@ -42,6 +52,7 @@ src/
register-mcp-app-tool.ts
resource-loader.ts # lazy reads built HTML from dist/apps
index.ts # public entry: @n8n/mcp-apps/server
utils/ # framework-agnostic client helpers
```
`apps-manifest.ts` is the canonical registry of MCP apps. Both the Vite
@@ -63,14 +74,25 @@ Each app:
through `onhostcontextchanged` and reflects it on the document.
- Reads the originating tool's `structuredContent` via `ontoolresult` to
populate its own state.
- Calls `app.callServerTool(...)` when it needs fresh n8n data from the MCP
server. The workflow preview uses this to call `get_workflow_details` with
the created workflow ID.
- 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
`src/apps/workflow-preview/utils/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.
The workflow preview iframe uses a server-provided `previewUrl` when available.
Otherwise it uses the existing n8n preview service because instance routes are
commonly blocked or unreachable from MCP hosts. The resource metadata declares
broad `frameDomains` for `http` and `https` so hosts that enforce MCP Apps CSP
can load instance-specific or configured preview URLs. The framed n8n server's
own frame policy still applies, so the app falls back to the open-workflow
button when the preview cannot load.
## Internationalization
Locale files live under `src/locales/` and use flat, namespaced keys
@@ -1,140 +1,90 @@
<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 { N8nSpinner } from '@n8n/design-system';
import { isAllowedWorkflowUrl } from './url';
import { setLocaleFromHost, type MessageSchema } from '../../i18n';
import McpAppContainer from '@mcp-apps/components/mcp-app-container.vue';
import McpFallbackCard from '@mcp-apps/components/mcp-fallback-card.vue';
import OpenInN8nButton from '@mcp-apps/components/open-in-n8n-button.vue';
import WorkflowPreviewCard from '@mcp-apps/components/workflow-preview/workflow-preview-card.vue';
import { useMcpHostApp } from '@mcp-apps/composables/use-mcp-host-app';
import { useMcpHostContextStyles } from '@mcp-apps/composables/use-mcp-host-context-styles';
import { useI18n } from '@mcp-apps/i18n';
type WorkflowResult = {
url?: unknown;
};
import { useWorkflowPreview } from './composables/use-workflow-preview';
function isWorkflowResult(value: unknown): value is WorkflowResult {
return typeof value === 'object' && value !== null;
}
const { t } = useI18n();
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);
const { app, hostContext, toolResult } = useMcpHostApp({
name: 'n8n Workflow Preview',
version: '0.1.0',
});
async function handleOpenWorkflow() {
const app = appRef.value;
const url = workflowUrl.value;
if (!app || !url) return;
useMcpHostContextStyles(hostContext);
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);
}
});
const {
workflowUrl,
workflowName,
previewUrl,
previewWorkflow,
previewError,
previewLoading,
previewSent,
previewTheme,
ariaLabel,
isPreviewVisible,
nodeCountLabel,
handleOpenWorkflow,
} = useWorkflowPreview({ app, hostContext, toolResult });
</script>
<template>
<main class="container" :aria-busy="!workflowUrl" :aria-label="ariaLabel">
<N8nButton
v-if="workflowUrl"
class="open-button"
variant="solid"
size="medium"
@click="handleOpenWorkflow"
<McpAppContainer
:busy="!workflowUrl || previewLoading || (isPreviewVisible && !previewSent)"
:label="ariaLabel"
>
<WorkflowPreviewCard
v-if="isPreviewVisible && workflowUrl && previewUrl && previewWorkflow"
:workflow="previewWorkflow"
:workflow-url="workflowUrl"
:workflow-name="workflowName"
:node-count-label="nodeCountLabel"
:preview-url="previewUrl"
:preview-sent="previewSent"
:preview-theme="previewTheme"
@open="handleOpenWorkflow"
@preview-error="previewError = $event"
@preview-sent-change="previewSent = $event"
/>
<McpFallbackCard
v-else-if="workflowUrl && !previewError && (previewLoading || previewUrl)"
:title="t('workflowPreview.fallbackTitle')"
:description="t('workflowPreview.loadingPreview')"
loading
>
{{ t('workflowPreview.openButton') }}
<template #icon>
<N8nIcon icon="arrow-up-right" />
</template>
</N8nButton>
<OpenInN8nButton @click="handleOpenWorkflow" />
</McpFallbackCard>
<McpFallbackCard
v-else-if="workflowUrl"
:title="t('workflowPreview.fallbackTitle')"
:description="previewError ?? t('workflowPreview.fallbackDescription')"
icon="workflow"
>
<OpenInN8nButton
class="open-button"
variant="solid"
size="medium"
@click="handleOpenWorkflow"
/>
</McpFallbackCard>
<N8nSpinner v-else type="ring" />
</main>
</McpAppContainer>
</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;
}
@@ -0,0 +1,193 @@
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, ref, shallowRef } from 'vue';
import { WORKFLOW_PREVIEW_ORIGIN } from '@mcp-apps/server/constants';
import { useWorkflowPreview } from './use-workflow-preview';
vi.mock('@mcp-apps/i18n', () => ({
useI18n: () => ({
t: (key: string, params?: { count?: number }) =>
params?.count === undefined ? key : `${key}:${params.count}`,
}),
}));
const DEFAULT_WORKFLOW_DEMO_URL = `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots`;
type WorkflowDetailsResult = {
isError: false;
structuredContent: {
workflow: { id: string; nodes: unknown[]; connections: Record<string, unknown> };
};
};
const flushPromises = async () => {
await Promise.resolve();
await nextTick();
};
function createDeferred<T>() {
let resolve: (value: T) => void;
let reject: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return {
promise,
resolve: resolve!,
reject: reject!,
};
}
describe('useWorkflowPreview', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('clears stale workflow URL state when a rerun returns an invalid URL', async () => {
const toolResult = shallowRef<unknown>();
const preview = useWorkflowPreview({
app: shallowRef<App>(),
hostContext: ref<McpUiHostContext>(),
toolResult,
});
toolResult.value = { url: 'https://n8n.example.com/workflow/abc123' };
await nextTick();
expect(preview.workflowUrl.value).toBe('https://n8n.example.com/workflow/abc123');
expect(preview.previewUrl.value).toBe(DEFAULT_WORKFLOW_DEMO_URL);
toolResult.value = { url: 'javascript:alert(1)' };
await nextTick();
expect(preview.workflowUrl.value).toBeUndefined();
expect(preview.previewUrl.value).toBeUndefined();
});
it('clears stale workflow URL state when a rerun returns no URL', async () => {
const toolResult = shallowRef<unknown>();
const preview = useWorkflowPreview({
app: shallowRef<App>(),
hostContext: ref<McpUiHostContext>(),
toolResult,
});
toolResult.value = { url: 'https://n8n.example.com/workflow/abc123' };
await nextTick();
expect(preview.workflowUrl.value).toBe('https://n8n.example.com/workflow/abc123');
expect(preview.previewUrl.value).toBe(DEFAULT_WORKFLOW_DEMO_URL);
toolResult.value = { name: 'Failed rerun' };
await nextTick();
expect(preview.workflowUrl.value).toBeUndefined();
expect(preview.previewUrl.value).toBeUndefined();
});
it('ignores stale workflow detail responses when a newer rerun starts', async () => {
const firstLoad = createDeferred<WorkflowDetailsResult>();
const secondLoad = createDeferred<WorkflowDetailsResult>();
const callServerTool = vi
.fn()
.mockReturnValueOnce(firstLoad.promise)
.mockReturnValueOnce(secondLoad.promise);
const toolResult = shallowRef<unknown>();
const preview = useWorkflowPreview({
app: shallowRef({ callServerTool } as unknown as App),
hostContext: ref<McpUiHostContext>(),
toolResult,
});
toolResult.value = {
url: 'https://n8n.example.com/workflow/first',
workflowId: 'first',
};
await nextTick();
toolResult.value = {
url: 'https://n8n.example.com/workflow/second',
workflowId: 'second',
};
await nextTick();
expect(callServerTool).toHaveBeenCalledTimes(2);
expect(callServerTool).toHaveBeenNthCalledWith(1, {
name: 'get_workflow_details',
arguments: { workflowId: 'first' },
});
expect(callServerTool).toHaveBeenNthCalledWith(2, {
name: 'get_workflow_details',
arguments: { workflowId: 'second' },
});
expect(preview.previewWorkflow.value).toBeUndefined();
secondLoad.resolve({
isError: false,
structuredContent: { workflow: { id: 'second', nodes: [], connections: {} } },
});
await flushPromises();
expect(preview.previewWorkflow.value?.id).toBe('second');
firstLoad.resolve({
isError: false,
structuredContent: { workflow: { id: 'first', nodes: [], connections: {} } },
});
await flushPromises();
expect(preview.previewWorkflow.value?.id).toBe('second');
});
it('reloads workflow details when a rerun reuses the same workflow ID', async () => {
const firstLoad = createDeferred<WorkflowDetailsResult>();
const secondLoad = createDeferred<WorkflowDetailsResult>();
const callServerTool = vi
.fn()
.mockReturnValueOnce(firstLoad.promise)
.mockReturnValueOnce(secondLoad.promise);
const toolResult = shallowRef<unknown>();
const preview = useWorkflowPreview({
app: shallowRef({ callServerTool } as unknown as App),
hostContext: ref<McpUiHostContext>(),
toolResult,
});
toolResult.value = {
url: 'https://n8n.example.com/workflow/shared',
workflowId: 'shared',
};
await nextTick();
firstLoad.resolve({
isError: false,
structuredContent: { workflow: { id: 'initial', nodes: [], connections: {} } },
});
await flushPromises();
expect(preview.previewWorkflow.value?.id).toBe('initial');
toolResult.value = {
url: 'https://n8n.example.com/workflow/shared',
workflowId: 'shared',
};
await nextTick();
expect(preview.previewWorkflow.value).toBeUndefined();
expect(callServerTool).toHaveBeenCalledTimes(2);
secondLoad.resolve({
isError: false,
structuredContent: { workflow: { id: 'rerun', nodes: [], connections: {} } },
});
await flushPromises();
expect(preview.previewWorkflow.value?.id).toBe('rerun');
});
});
@@ -0,0 +1,209 @@
import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps';
import { computed, ref, shallowRef, type Ref, type ShallowRef, watch } from 'vue';
import { useI18n } from '@mcp-apps/i18n';
import { isRecord } from '@mcp-apps/utils/guards';
import { isWorkflowPreviewData, isWorkflowResult } from '../type-guards';
import type { WorkflowPreviewData } from '../types';
import { applyWorkflowDemoTheme, isAllowedWorkflowUrl, resolveWorkflowDemoUrl } from '../utils/url';
type UseWorkflowPreviewOptions = {
app: Readonly<ShallowRef<App | undefined>>;
hostContext: Readonly<Ref<McpUiHostContext | undefined>>;
toolResult: Readonly<ShallowRef<unknown>>;
};
export function useWorkflowPreview({ app, hostContext, toolResult }: UseWorkflowPreviewOptions) {
const { t } = useI18n();
const workflowUrl = ref<string>();
const workflowId = ref<string>();
const workflowName = ref<string>();
const workflowNodeCount = ref<number>();
const previewBaseUrl = ref<string>();
const previewWorkflow = shallowRef<WorkflowPreviewData>();
const previewError = ref<string>();
const previewLoading = ref(false);
const previewSent = ref(false);
const previewTheme = computed(() => hostContext.value?.theme);
const workflowDetailsRevision = ref(0);
let latestPreviewLoadRequestId = 0;
const ariaLabel = computed(() =>
previewWorkflow.value
? t('workflowPreview.ariaLabel.preview')
: workflowUrl.value
? t('workflowPreview.ariaLabel.ready')
: t('workflowPreview.ariaLabel.creating'),
);
const previewUrl = ref<string>();
const isPreviewVisible = computed(
() => !!previewUrl.value && !!previewWorkflow.value && !previewError.value,
);
const nodeCountLabel = computed(() => {
const count = workflowNodeCount.value;
if (count === undefined) return undefined;
return count === 1
? t('workflowPreview.nodeCount.one')
: t('workflowPreview.nodeCount.many', { count });
});
watch(
[workflowId, app, workflowDetailsRevision],
([id, mcpApp]) => {
if (!id || !mcpApp) return;
void loadPreviewWorkflow(mcpApp, id);
},
{ immediate: true },
);
watch(toolResult, (structuredContent) => {
applyToolResult(structuredContent);
});
watch(previewTheme, () => {
previewUrl.value = buildPreviewUrl();
});
async function handleOpenWorkflow() {
const mcpApp = app.value;
const url = workflowUrl.value;
if (!mcpApp || !url) return;
if (!isAllowedWorkflowUrl(url)) {
console.warn('[n8n MCP App] Refusing to open unexpected workflow URL', { url });
return;
}
try {
const result = await mcpApp.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);
}
}
async function loadPreviewWorkflow(mcpApp: App, id: string) {
const requestId = ++latestPreviewLoadRequestId;
previewLoading.value = true;
previewError.value = undefined;
try {
const result = await mcpApp.callServerTool({
name: 'get_workflow_details',
arguments: { workflowId: id },
});
if (!isLatestPreviewLoadRequest(requestId)) return;
if (result.isError) {
previewError.value = t('workflowPreview.error.detailsUnavailable');
return;
}
const structuredContent = isRecord(result.structuredContent)
? result.structuredContent
: undefined;
const workflow = structuredContent?.workflow;
if (!isWorkflowPreviewData(workflow)) {
previewError.value = t('workflowPreview.error.invalidWorkflow');
return;
}
previewWorkflow.value = workflow;
} catch (error) {
if (!isLatestPreviewLoadRequest(requestId)) return;
console.warn('[n8n MCP App] Failed to load workflow preview data', error);
previewError.value = t('workflowPreview.error.detailsUnavailable');
} finally {
if (isLatestPreviewLoadRequest(requestId)) {
previewLoading.value = false;
}
}
}
function isLatestPreviewLoadRequest(requestId: number) {
return requestId === latestPreviewLoadRequestId;
}
function resetPreviewState() {
latestPreviewLoadRequestId += 1;
previewWorkflow.value = undefined;
previewError.value = undefined;
previewLoading.value = false;
previewSent.value = false;
}
function applyToolResult(structuredContent: unknown) {
if (!isWorkflowResult(structuredContent)) return;
resetPreviewState();
const candidateUrl = structuredContent.url;
if (isAllowedWorkflowUrl(candidateUrl)) {
workflowUrl.value = candidateUrl;
previewBaseUrl.value = resolveWorkflowDemoUrl({
workflowUrl: candidateUrl,
previewUrl: structuredContent.previewUrl,
});
previewUrl.value = buildPreviewUrl();
} else {
workflowUrl.value = undefined;
previewBaseUrl.value = undefined;
previewUrl.value = undefined;
if (candidateUrl !== undefined) {
console.warn('[n8n MCP App] Ignoring unexpected workflow URL in tool result', {
url: candidateUrl,
});
}
}
if (typeof structuredContent.workflowId === 'string') {
workflowId.value = structuredContent.workflowId;
} else {
workflowId.value = undefined;
}
workflowDetailsRevision.value += 1;
if (typeof structuredContent.name === 'string') {
workflowName.value = structuredContent.name;
}
if (typeof structuredContent.nodeCount === 'number') {
workflowNodeCount.value = structuredContent.nodeCount;
}
}
function buildPreviewUrl() {
return applyWorkflowDemoTheme({
previewUrl: previewBaseUrl.value,
workflowUrl: workflowUrl.value,
theme: previewTheme.value,
});
}
return {
workflowUrl,
workflowName,
previewUrl,
previewWorkflow,
previewError,
previewLoading,
previewSent,
previewTheme,
ariaLabel,
isPreviewVisible,
nodeCountLabel,
handleOpenWorkflow,
};
}
@@ -1,7 +1,8 @@
import { createApp } from 'vue';
import { i18n } from '@mcp-apps/i18n';
import App from './App.vue';
import { i18n } from '../../i18n';
import './tokens.scss';
createApp(App).use(i18n).mount('#app');
@@ -0,0 +1,17 @@
import { isRecord } from '@mcp-apps/utils/guards';
import type { WorkflowPreviewData, WorkflowResult } from './types';
export function isWorkflowResult(value: unknown): value is WorkflowResult {
return isRecord(value);
}
export function isWorkflowPreviewData(value: unknown): value is WorkflowPreviewData {
return (
isRecord(value) &&
typeof value.id === 'string' &&
(value.name === undefined || value.name === null || typeof value.name === 'string') &&
Array.isArray(value.nodes) &&
isRecord(value.connections)
);
}
@@ -0,0 +1,16 @@
export type WorkflowResult = {
workflowId?: unknown;
url?: unknown;
previewUrl?: unknown;
name?: unknown;
nodeCount?: unknown;
};
export type WorkflowPreviewData = {
id: string;
name?: string | null;
nodes: unknown[];
connections: Record<string, unknown>;
settings?: unknown;
meta?: unknown;
};
@@ -1,56 +0,0 @@
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');
}
});
});
@@ -1,30 +0,0 @@
/**
* 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;
}
@@ -0,0 +1,202 @@
import { describe, expect, it } from 'vitest';
import {
applyWorkflowDemoTheme,
isAllowedWorkflowDemoUrl,
isAllowedWorkflowUrl,
resolveWorkflowDemoUrl,
} from './url';
import { WORKFLOW_PREVIEW_ORIGIN } from '../../../server/constants';
const DEFAULT_WORKFLOW_DEMO_URL = `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots`;
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');
}
});
});
describe('isAllowedWorkflowDemoUrl', () => {
it.each([
['root demo URL', `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true`],
['base-path demo URL', `${WORKFLOW_PREVIEW_ORIGIN}/n8n/workflows/demo`],
])('accepts fixed preview service %s', (_label, input) => {
expect(isAllowedWorkflowDemoUrl(input)).toBe(true);
});
it('accepts a demo URL from the workflow URL origin', () => {
expect(
isAllowedWorkflowDemoUrl(
'https://n8n.example.com/workflows/demo?hideControls=true',
'https://n8n.example.com/workflow/abc123',
),
).toBe(true);
});
it.each([
['workflow URL', 'https://n8n.example.com/workflow/abc123'],
['untrusted demo URL', 'https://preview.example.com/workflows/demo?hideControls=true'],
['unsafe URL', 'javascript:alert(1)'],
])('rejects %s', (_label, input) => {
expect(isAllowedWorkflowDemoUrl(input)).toBe(false);
});
});
describe('resolveWorkflowDemoUrl', () => {
it('prefers an explicit fixed-service preview URL', () => {
expect(
resolveWorkflowDemoUrl({
workflowUrl: 'https://workspace.app.n8n.cloud/workflow/abc123',
previewUrl: `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true`,
}),
).toBe(`${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true`);
});
it('prefers an explicit preview URL from the workflow URL origin', () => {
expect(
resolveWorkflowDemoUrl({
workflowUrl: 'https://workspace.app.n8n.cloud/workflow/abc123',
previewUrl: 'https://workspace.app.n8n.cloud/workflows/demo?hideControls=true',
}),
).toBe('https://workspace.app.n8n.cloud/workflows/demo?hideControls=true');
});
it('ignores an explicit preview URL from an untrusted origin', () => {
expect(
resolveWorkflowDemoUrl({
workflowUrl: 'https://workspace.app.n8n.cloud/workflow/abc123',
previewUrl: 'https://preview.example.com/workflows/demo?hideControls=true',
}),
).toBe(DEFAULT_WORKFLOW_DEMO_URL);
});
it('ignores an explicit preview URL that is not a demo URL', () => {
expect(
resolveWorkflowDemoUrl({
workflowUrl: 'https://self-hosted.example.com/workflow/abc123',
previewUrl: 'https://preview.example.com/workflow/abc123',
}),
).toBe(DEFAULT_WORKFLOW_DEMO_URL);
});
it.each([
['n8n Cloud URL', 'https://workspace.app.n8n.cloud/workflow/abc123'],
['local URL', 'http://localhost:5678/workflow/abc123'],
['self-hosted URL', 'https://self-hosted.example.com/workflow/abc123'],
['unexpected valid path', 'https://self-hosted.example.com/rest/workflows/abc123'],
])('uses the shared preview service for %s', (_label, workflowUrl) => {
expect(resolveWorkflowDemoUrl({ workflowUrl })).toBe(DEFAULT_WORKFLOW_DEMO_URL);
});
it('returns undefined when no safe workflow URL is available', () => {
expect(resolveWorkflowDemoUrl({ workflowUrl: 'javascript:alert(1)' })).toBeUndefined();
});
});
describe('applyWorkflowDemoTheme', () => {
it('adds the theme query parameter', () => {
expect(
applyWorkflowDemoTheme({
previewUrl: `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true`,
theme: 'dark',
}),
).toBe(
`${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots&theme=dark`,
);
});
it('overrides an existing theme query parameter', () => {
expect(
applyWorkflowDemoTheme({
previewUrl: `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&theme=light`,
theme: 'dark',
}),
).toBe(
`${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&theme=dark&canOpenNDV=false&canvasBackground=dots`,
);
});
it('removes the theme query parameter when theme is missing', () => {
expect(
applyWorkflowDemoTheme({
previewUrl: `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&theme=dark`,
theme: undefined,
}),
).toBe(
`${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots`,
);
});
it('returns undefined when no preview URL is available', () => {
expect(applyWorkflowDemoTheme({ previewUrl: undefined, theme: 'light' })).toBeUndefined();
});
it('adds the theme query parameter for a workflow-origin preview URL', () => {
expect(
applyWorkflowDemoTheme({
previewUrl: 'https://n8n.example.com/workflows/demo?hideControls=true',
workflowUrl: 'https://n8n.example.com/workflow/abc123',
theme: 'light',
}),
).toBe(
'https://n8n.example.com/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots&theme=light',
);
});
it('returns undefined for an untrusted preview URL', () => {
expect(
applyWorkflowDemoTheme({
previewUrl: 'https://preview.example.com/workflows/demo?hideControls=true',
workflowUrl: 'https://n8n.example.com/workflow/abc123',
theme: 'light',
}),
).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
import { WORKFLOW_PREVIEW_ORIGIN } from '../../../server/constants';
/**
* 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:']);
const DEFAULT_WORKFLOW_DEMO_URL = `${WORKFLOW_PREVIEW_ORIGIN}/workflows/demo?hideControls=true&canOpenNDV=false&canvasBackground=dots`;
type WorkflowPreviewTheme = 'light' | 'dark';
const WORKFLOW_DEMO_PATH_SUFFIX = '/workflows/demo';
/**
* 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;
}
export function isAllowedWorkflowDemoUrl(input: unknown, workflowUrl?: unknown): input is string {
if (!isAllowedWorkflowUrl(input)) return false;
const parsed = new URL(input);
if (!parsed.pathname.endsWith(WORKFLOW_DEMO_PATH_SUFFIX)) return false;
if (parsed.origin === WORKFLOW_PREVIEW_ORIGIN) return true;
if (!isAllowedWorkflowUrl(workflowUrl)) return false;
return parsed.origin === new URL(workflowUrl).origin;
}
export function resolveWorkflowDemoUrl({
workflowUrl,
previewUrl,
}: {
workflowUrl: unknown;
previewUrl?: unknown;
}): string | undefined {
if (!isAllowedWorkflowUrl(workflowUrl)) return undefined;
if (isAllowedWorkflowDemoUrl(previewUrl, workflowUrl)) return previewUrl;
return DEFAULT_WORKFLOW_DEMO_URL;
}
export function applyWorkflowDemoTheme({
previewUrl,
workflowUrl,
theme,
}: {
previewUrl: string | undefined;
workflowUrl?: string | undefined;
theme: WorkflowPreviewTheme | undefined;
}): string | undefined {
if (!isAllowedWorkflowDemoUrl(previewUrl, workflowUrl)) return undefined;
const parsed = new URL(previewUrl);
parsed.searchParams.set('canOpenNDV', 'false');
parsed.searchParams.set('canvasBackground', 'dots');
if (theme) {
parsed.searchParams.set('theme', theme);
} else {
parsed.searchParams.delete('theme');
}
return parsed.toString();
}
@@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{
busy: boolean;
label: string;
}>();
</script>
<template>
<main class="container" :aria-busy="busy" :aria-label="label">
<slot />
</main>
</template>
<style scoped lang="scss">
.container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--spacing--xs);
background: transparent;
}
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { N8nIcon, N8nSpinner, type IconName } from '@n8n/design-system';
defineProps<{
title: string;
description?: string;
icon?: IconName;
loading?: boolean;
}>();
</script>
<template>
<section class="fallback-card">
<N8nSpinner v-if="loading" type="ring" />
<N8nIcon v-else-if="icon" :icon="icon" class="fallback-icon" />
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
<slot />
</section>
</template>
<style scoped lang="scss">
.fallback-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--xs);
width: 100%;
padding: var(--spacing--lg);
border: var(--border);
border-radius: var(--radius--md);
background: var(--background--surface);
box-shadow:
var(--shadow--xs),
inset var(--shadow--outline);
overflow: hidden;
text-align: center;
}
.fallback-card h1,
.fallback-card p {
margin: 0;
}
.fallback-card h1 {
font-size: var(--font-size--md);
line-height: var(--line-height--xl);
color: var(--text-color);
}
.fallback-card p {
color: var(--text-color--subtle);
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
}
.fallback-icon {
color: var(--icon-color--strong);
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { N8nButton, N8nIcon } from '@n8n/design-system';
import { useI18n } from '@mcp-apps/i18n';
withDefaults(
defineProps<{
variant?: 'solid' | 'subtle';
size?: 'small' | 'medium';
}>(),
{
variant: 'subtle',
size: 'small',
},
);
const emit = defineEmits<{
click: [];
}>();
const { t } = useI18n();
</script>
<template>
<N8nButton :variant="variant" :size="size" @click="emit('click')">
{{ t('workflowPreview.openButton') }}
<template #icon>
<N8nIcon icon="arrow-up-right" />
</template>
</N8nButton>
</template>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { N8nSpinner } from '@n8n/design-system';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { WorkflowPreviewData } from '@mcp-apps/apps/workflow-preview/types';
import { useI18n } from '@mcp-apps/i18n';
import { readJsonMessage } from '@mcp-apps/utils/post-message';
import OpenInN8nButton from '../open-in-n8n-button.vue';
const props = defineProps<{
workflow: WorkflowPreviewData;
workflowUrl: string;
workflowName?: string;
nodeCountLabel?: string;
previewUrl: string;
previewSent: boolean;
previewTheme?: 'light' | 'dark';
}>();
const emit = defineEmits<{
open: [];
previewError: [message: string];
previewSentChange: [value: boolean];
}>();
const { t } = useI18n();
// Give the embedded editor enough time to load and emit its postMessage handshake.
const PREVIEW_READY_TIMEOUT_MS = 8000;
const previewReady = ref(false);
const previewReadyOrigin = ref<string>();
const iframeRef = ref<HTMLIFrameElement>();
let previewReadyTimeout: ReturnType<typeof setTimeout> | undefined;
watch(
() => [props.previewUrl, props.workflow] as const,
() => {
previewReady.value = false;
previewReadyOrigin.value = undefined;
emit('previewSentChange', false);
clearPreviewReadyTimeout();
previewReadyTimeout = setTimeout(() => {
if (!previewReady.value) {
emit('previewError', t('workflowPreview.error.previewUnavailable'));
}
}, PREVIEW_READY_TIMEOUT_MS);
},
{ immediate: true },
);
watch([previewReady, () => props.workflow], () => {
void maybeSendWorkflowToPreview();
});
function clearPreviewReadyTimeout() {
if (!previewReadyTimeout) return;
clearTimeout(previewReadyTimeout);
previewReadyTimeout = undefined;
}
async function maybeSendWorkflowToPreview() {
const iframe = iframeRef.value;
const previewOrigin = previewReadyOrigin.value;
if (!iframe?.contentWindow || !previewOrigin || !previewReady.value || props.previewSent) {
return;
}
await nextTick();
iframe.contentWindow.postMessage(
JSON.stringify({
command: 'openWorkflow',
workflow: props.workflow,
canOpenNDV: false,
hideNodeIssues: true,
suppressNotifications: true,
}),
previewOrigin,
);
emit('previewSentChange', true);
}
function getPreviewOrigin() {
try {
return new URL(props.previewUrl).origin;
} catch {
return undefined;
}
}
function handlePreviewMessage(event: MessageEvent) {
if (event.source !== iframeRef.value?.contentWindow) return;
const message = readJsonMessage(event.data);
if (!message) return;
if (message.command === 'n8nReady') {
if (event.origin === 'null') return;
previewReadyOrigin.value = event.origin;
previewReady.value = true;
clearPreviewReadyTimeout();
} else if (message.command === 'error') {
if (event.origin !== previewReadyOrigin.value && event.origin !== getPreviewOrigin()) return;
emit('previewError', t('workflowPreview.error.previewUnavailable'));
}
}
onMounted(() => {
window.addEventListener('message', handlePreviewMessage);
});
onBeforeUnmount(() => {
window.removeEventListener('message', handlePreviewMessage);
clearPreviewReadyTimeout();
});
</script>
<template>
<section class="preview-card">
<header class="preview-header">
<div class="workflow-meta">
<p class="eyebrow">{{ t('workflowPreview.readyLabel') }}</p>
<h1>{{ workflowName ?? t('workflowPreview.untitledWorkflow') }}</h1>
<p v-if="nodeCountLabel" class="node-count">
{{ nodeCountLabel }}
</p>
</div>
<OpenInN8nButton @click="emit('open')" />
</header>
<div class="iframe-shell">
<N8nSpinner v-if="!previewSent" class="preview-spinner" type="ring" />
<iframe
ref="iframeRef"
class="preview-frame"
:class="{ 'is-ready': previewSent }"
:src="previewUrl"
:title="t('workflowPreview.frameTitle')"
/>
</div>
</section>
</template>
<style scoped lang="scss">
.preview-card {
display: flex;
flex-direction: column;
width: 100%;
border: var(--border);
border-radius: var(--radius--md);
background: var(--background--surface);
box-shadow:
var(--shadow--xs),
inset var(--shadow--outline);
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
border-bottom: var(--border);
}
.workflow-meta {
min-width: 0;
}
.workflow-meta h1,
.workflow-meta p {
margin: 0;
}
.workflow-meta h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size--md);
line-height: var(--line-height--xl);
color: var(--text-color);
}
.eyebrow,
.node-count {
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
color: var(--text-color--subtler);
}
.iframe-shell {
position: relative;
flex: 1;
min-height: 280px;
background: var(--canvas--color--background);
}
.preview-frame {
width: 100%;
height: 100%;
min-height: 280px;
border: 0;
opacity: 0;
transition: opacity var(--duration--snappy) var(--easing--ease-out);
}
.preview-frame.is-ready {
opacity: 1;
}
.preview-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
</style>
@@ -0,0 +1,41 @@
import { App, type McpUiHostContext } from '@modelcontextprotocol/ext-apps';
import { onMounted, ref, shallowRef } from 'vue';
type UseMcpHostAppOptions = {
name: string;
version: string;
};
export function useMcpHostApp({ name, version }: UseMcpHostAppOptions) {
const app = shallowRef<App>();
const hostContext = ref<McpUiHostContext>();
const toolResult = shallowRef<unknown>();
onMounted(async () => {
const mcpApp = new App({ name, version });
app.value = mcpApp;
mcpApp.onhostcontextchanged = (params) => {
hostContext.value = { ...hostContext.value, ...params };
};
mcpApp.ontoolresult = (params) => {
toolResult.value = params.structuredContent;
};
mcpApp.onerror = console.error;
try {
await mcpApp.connect();
hostContext.value = mcpApp.getHostContext();
} catch (error) {
console.error('[n8n MCP App] Failed to connect to host', error);
}
});
return {
app,
hostContext,
toolResult,
};
}
@@ -0,0 +1,30 @@
import {
applyDocumentTheme,
applyHostFonts,
applyHostStyleVariables,
type McpUiHostContext,
} from '@modelcontextprotocol/ext-apps';
import type { Ref } from 'vue';
import { watchEffect } from 'vue';
import { setLocaleFromHost } from '@mcp-apps/i18n';
export function useMcpHostContextStyles(hostContext: Ref<McpUiHostContext | undefined>) {
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);
});
}
+5 -1
View File
@@ -1,4 +1,4 @@
import { createI18n } from 'vue-i18n';
import { createI18n, useI18n as useVueI18n } from 'vue-i18n';
import en from '../locales/en.json';
@@ -25,6 +25,10 @@ export const i18n = createI18n<MessageSchema, SupportedLocale, false>({
warnHtmlMessage: false,
});
export function useI18n() {
return useVueI18n<{ message: MessageSchema }>({ useScope: 'global' });
}
/**
* 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
+13 -1
View File
@@ -1,5 +1,17 @@
{
"workflowPreview.ariaLabel.creating": "Creating workflow",
"workflowPreview.ariaLabel.preview": "Workflow preview",
"workflowPreview.ariaLabel.ready": "Workflow ready to open",
"workflowPreview.openButton": "Open in n8n"
"workflowPreview.error.detailsUnavailable": "Preview couldn't load. Open the workflow in n8n to view it.",
"workflowPreview.error.invalidWorkflow": "Preview couldn't load because the workflow data is incomplete. Open the workflow in n8n to view it.",
"workflowPreview.error.previewUnavailable": "Looks like your mcp client doesn't support workflow previews. Open the workflow in n8n to view it.",
"workflowPreview.fallbackDescription": "Open the workflow in n8n to view and edit it.",
"workflowPreview.fallbackTitle": "Workflow created",
"workflowPreview.frameTitle": "n8n workflow preview",
"workflowPreview.loadingPreview": "Loading preview",
"workflowPreview.nodeCount.many": "{count} nodes",
"workflowPreview.nodeCount.one": "1 node",
"workflowPreview.openButton": "Open in n8n",
"workflowPreview.readyLabel": "Workflow created",
"workflowPreview.untitledWorkflow": "Untitled workflow"
}
@@ -1,7 +1,11 @@
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 {
RESOURCE_MIME_TYPE,
WORKFLOW_PREVIEW_APP_URI,
WORKFLOW_PREVIEW_FRAME_DOMAINS,
} from '../constants';
import { loadAppHtml } from '../resource-loader';
import { MCP_APP_TELEMETRY_GLOBAL, type McpAppTelemetryConfig } from '../telemetry-config';
@@ -19,7 +23,8 @@ type ResourceContent = {
text: string;
_meta?: {
ui?: {
csp?: { resourceDomains?: string[]; connectDomains?: string[] };
csp?: { frameDomains?: string[]; resourceDomains?: string[]; connectDomains?: string[] };
prefersBorder?: boolean;
};
};
};
@@ -80,6 +85,16 @@ describe('registerWorkflowPreviewApp', () => {
expect(captured.uri).toBe(WORKFLOW_PREVIEW_APP_URI);
expect(captured.metadata.mimeType).toBe(RESOURCE_MIME_TYPE);
expect(captured.metadata.description).toMatch(/workflow/i);
expect(captured.metadata._meta).toEqual({
ui: {
csp: {
frameDomains: [...WORKFLOW_PREVIEW_FRAME_DOMAINS],
resourceDomains: ['https://cdn-rs.n8n.io'],
connectDomains: [instanceOrigin],
},
prefersBorder: false,
},
});
});
it('returns the HTML body with the expected MIME type and URI', async () => {
@@ -90,14 +105,17 @@ describe('registerWorkflowPreviewApp', () => {
expect(content.uri).toBe(WORKFLOW_PREVIEW_APP_URI);
expect(content.mimeType).toBe(RESOURCE_MIME_TYPE);
expect(content.text).toContain('<html');
expect(content._meta?.ui?.csp?.frameDomains).toEqual([...WORKFLOW_PREVIEW_FRAME_DOMAINS]);
expect(loadAppHtml).toHaveBeenCalledWith('workflow-preview.html');
});
it('declares CSP for the RudderStack CDN and the instance origin', async () => {
const { _meta } = (await captured.callback()).contents[0];
const csp = _meta?.ui?.csp;
expect(csp?.frameDomains).toEqual([...WORKFLOW_PREVIEW_FRAME_DOMAINS]);
expect(csp?.resourceDomains).toEqual(['https://cdn-rs.n8n.io']);
expect(csp?.connectDomains).toEqual([instanceOrigin]);
expect(_meta?.ui?.prefersBorder).toBe(false);
});
it('omits telemetry CSP domains when no instance origin is provided', async () => {
@@ -119,8 +137,10 @@ describe('registerWorkflowPreviewApp', () => {
const { _meta } = (await captured.callback()).contents[0];
const csp = _meta?.ui?.csp;
expect(csp?.frameDomains).toEqual([...WORKFLOW_PREVIEW_FRAME_DOMAINS]);
expect(csp?.resourceDomains).toEqual([]);
expect(csp?.connectDomains).toEqual([]);
expect(_meta?.ui?.prefersBorder).toBe(false);
});
it('injects the telemetry runtime config into the HTML', async () => {
@@ -1,6 +1,11 @@
import type { McpUiResourceMeta } from '@modelcontextprotocol/ext-apps';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { RESOURCE_MIME_TYPE, WORKFLOW_PREVIEW_APP_URI } from '../constants';
import {
RESOURCE_MIME_TYPE,
WORKFLOW_PREVIEW_APP_URI,
WORKFLOW_PREVIEW_FRAME_DOMAINS,
} from '../constants';
import { loadAppHtml } from '../resource-loader';
import {
injectTelemetryConfig,
@@ -17,24 +22,33 @@ export interface RegisterWorkflowPreviewAppOptions {
onResourceRead?: () => void;
}
function getWorkflowPreviewUiMeta(instanceOrigin?: string): McpUiResourceMeta {
return {
csp: {
frameDomains: [...WORKFLOW_PREVIEW_FRAME_DOMAINS],
resourceDomains: instanceOrigin ? [RUDDERSTACK_CDN_ORIGIN] : [],
connectDomains: instanceOrigin ? [instanceOrigin] : [],
},
prefersBorder: false,
};
}
export function registerWorkflowPreviewApp(
server: Pick<McpServer, 'resource'>,
options: RegisterWorkflowPreviewAppOptions,
): void {
const { instanceOrigin, telemetry, onResourceRead } = options;
const telemetryCsp = instanceOrigin
? {
resourceDomains: [RUDDERSTACK_CDN_ORIGIN],
connectDomains: [instanceOrigin],
}
: { resourceDomains: [], connectDomains: [] };
const uiMeta = getWorkflowPreviewUiMeta(instanceOrigin);
server.resource(
'workflow-preview',
WORKFLOW_PREVIEW_APP_URI,
{
description: 'Loading UI shown after creating a workflow from code',
description: 'Workflow preview shown after creating a workflow from code',
mimeType: RESOURCE_MIME_TYPE,
_meta: {
ui: uiMeta,
},
},
async () => {
const html = await loadAppHtml('workflow-preview.html');
@@ -52,9 +66,7 @@ export function registerWorkflowPreviewApp(
mimeType: RESOURCE_MIME_TYPE,
text: injectTelemetryConfig(html, telemetry),
_meta: {
ui: {
csp: telemetryCsp,
},
ui: uiMeta,
},
},
],
@@ -2,3 +2,10 @@ 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';
export const WORKFLOW_PREVIEW_ORIGIN = 'https://n8n-preview-service.internal.n8n.cloud';
export const WORKFLOW_PREVIEW_FRAME_DOMAINS = [
WORKFLOW_PREVIEW_ORIGIN,
'https://*',
'http://localhost:*',
'http://127.0.0.1:*',
] as const;
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,12 @@
import { isRecord } from '@mcp-apps/utils/guards';
export function readJsonMessage(data: unknown): Record<string, unknown> | undefined {
if (typeof data !== 'string' || !data.includes('"command"')) return undefined;
try {
const parsed: unknown = JSON.parse(data);
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
+1
View File
@@ -17,6 +17,7 @@
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@mcp-apps/*": ["src/*"],
"@n8n/design-system*": ["../../frontend/@n8n/design-system/src*"]
}
},
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig(({ mode }) => {
],
resolve: {
alias: {
'@mcp-apps': resolve(__dirname, 'src'),
'@n8n/design-system': resolve(__dirname, '../../frontend/@n8n/design-system/src'),
},
},
+10 -1
View File
@@ -1,3 +1,12 @@
import { createVitestConfig } from '@n8n/vitest-config/frontend';
import { resolve } from 'node:path';
import { mergeConfig } from 'vitest/config';
export default createVitestConfig({ setupFiles: [] });
export default mergeConfig(createVitestConfig({ setupFiles: [] }), {
resolve: {
alias: {
'@mcp-apps': resolve(__dirname, 'src'),
'@n8n/design-system': resolve(__dirname, '../../frontend/@n8n/design-system/src'),
},
},
});
@@ -396,6 +396,32 @@ describe('WorkflowPreview', () => {
});
});
describe('canOpenNDV prop', () => {
it('should include canOpenNDV=false in iframe src when canOpenNDV prop is false', () => {
const { container } = renderComponent({
pinia,
props: {
canOpenNDV: false,
},
});
const iframe = container.querySelector('iframe');
expect(iframe?.getAttribute('src')).toContain('canOpenNDV=false');
});
it('should not include canOpenNDV param when canOpenNDV prop is true', () => {
const { container } = renderComponent({
pinia,
props: {
canOpenNDV: true,
},
});
const iframe = container.querySelector('iframe');
expect(iframe?.getAttribute('src')).not.toContain('canOpenNDV');
});
});
describe('ready event', () => {
it('should emit ready event when iframe sends n8nReady command', async () => {
const { emitted } = renderComponent({
@@ -69,6 +69,9 @@ const iframeSrc = computed(() => {
if (props.canExecute) {
params.set('canExecute', 'true');
}
if (!props.canOpenNDV) {
params.set('canOpenNDV', 'false');
}
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
});
@@ -3,7 +3,7 @@ import { shallowRef } from 'vue';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { jsonParse } from 'n8n-workflow';
import { usePostMessageHandler } from './usePostMessageHandler';
import { usePostMessageControls, usePostMessageHandler } from './usePostMessageHandler';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
@@ -183,6 +183,20 @@ describe('usePostMessageHandler', () => {
cleanup();
});
it('should initialize whether node details can open from the route query', () => {
mockRoute.query = { canOpenNDV: 'false' };
const { canOpenNDV } = usePostMessageControls();
const { setup, cleanup } = usePostMessageHandler({
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
expect(canOpenNDV.value).toBe(false);
cleanup();
});
});
describe('openWorkflow command', () => {
@@ -363,6 +377,51 @@ describe('usePostMessageHandler', () => {
cleanup();
});
it('should set and reset whether node details can open', async () => {
const { canOpenNDV } = usePostMessageControls();
const { setup, cleanup } = usePostMessageHandler({
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
dispatchPostMessage({
command: 'openWorkflow',
workflow: { nodes: [], connections: {} },
canOpenNDV: false,
});
await vi.waitFor(() => {
expect(mockImportWorkflowExact).toHaveBeenCalled();
});
expect(canOpenNDV.value).toBe(false);
cleanup();
expect(canOpenNDV.value).toBe(true);
});
it('should keep node details disabled when the route query disables them', async () => {
mockRoute.query = { canOpenNDV: 'false' };
const { canOpenNDV } = usePostMessageControls();
const { setup, cleanup } = usePostMessageHandler({
currentWorkflowDocumentStore: shallowRef(null),
});
setup();
dispatchPostMessage({
command: 'openWorkflow',
workflow: { nodes: [], connections: {} },
canOpenNDV: true,
});
await vi.waitFor(() => {
expect(mockImportWorkflowExact).toHaveBeenCalled();
});
expect(canOpenNDV.value).toBe(false);
cleanup();
});
});
describe('openExecution command', () => {
@@ -1,4 +1,4 @@
import { nextTick, type ShallowRef } from 'vue';
import { nextTick, ref, type ShallowRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/app/constants';
@@ -32,6 +32,18 @@ interface PostMessageHandlerDeps {
currentWorkflowDocumentStore: ShallowRef<WorkflowDocumentStore | null>;
}
// Shared by the demo-route postMessage handler and NodeView controls in this iframe.
// setup() initializes it from the route, and cleanup() must reset it on unmount.
const canOpenNDV = ref(true);
export function usePostMessageControls() {
return { canOpenNDV };
}
function canOpenNDVFromRouteQuery(queryValue: unknown) {
return queryValue !== 'false';
}
export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMessageHandlerDeps) {
const i18n = useI18n();
const toast = useToast();
@@ -73,9 +85,13 @@ export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMess
workflow: WorkflowDataUpdate;
projectId?: string;
tidyUp?: boolean;
canOpenNDV?: boolean;
suppressNotifications?: boolean;
allowErrorNotifications?: boolean;
}) {
canOpenNDV.value =
canOpenNDVFromRouteQuery(route.query.canOpenNDV) && json.canOpenNDV !== false;
uiStore.setNotificationsSuppressed(json.suppressNotifications === true, {
allowErrors: json.allowErrorNotifications === true,
});
@@ -276,12 +292,14 @@ export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMess
}
function setup() {
canOpenNDV.value = canOpenNDVFromRouteQuery(route.query.canOpenNDV);
window.addEventListener('message', onPostMessageReceived);
emitPostMessageReady();
}
function cleanup() {
window.removeEventListener('message', onPostMessageReceived);
canOpenNDV.value = true;
}
return {
@@ -119,6 +119,7 @@ import {
} from '@/features/workflows/canvas/canvas.utils';
import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/useCanvasLayout';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { usePostMessageControls } from '@/app/composables/usePostMessageHandler';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
import { useWorkflowExtraction } from '@/app/composables/useWorkflowExtraction';
@@ -169,6 +170,7 @@ const LazySetupWorkflowCredentialsButton = defineAsyncComponent(
const $style = useCssModule();
const router = useRouter();
const route = useRoute();
const { canOpenNDV } = usePostMessageControls();
const i18n = useI18n();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
@@ -292,6 +294,8 @@ const hideCanvasControls = computed(() => {
return route.query.hideControls === 'true';
});
const stripedCanvasBackground = computed(() => route.query.canvasBackground !== 'dots');
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas);
const isReadOnlyEnvironment = computed(() => {
@@ -513,6 +517,8 @@ function onClickNode(_id: string, event: VueFlowXYPosition) {
}
async function onSetNodeActivated(id: string, event?: MouseEvent) {
if (isDemoRoute.value && !canOpenNDV.value) return;
// Handle Ctrl/Cmd + Double Click case
if (event?.metaKey || event?.ctrlKey) {
const didOpen = await tryToOpenSubworkflowInNewTab(id);
@@ -1954,6 +1960,7 @@ onBeforeUnmount(() => {
:executing="isWorkflowRunning"
:key-bindings="keyBindingsEnabled"
:suppress-interaction="experimentalNdvStore.isMapperOpen"
:striped-background="stripedCanvasBackground"
:hide-controls="hideCanvasControls"
:initial-viewport="workflowDocumentStore?.viewport"
@update:nodes:position="onUpdateNodesPosition"
@@ -291,6 +291,16 @@ describe('Canvas', () => {
expect(patternCanvas?.innerHTML).toContain('<path');
expect(patternCanvas?.innerHTML).not.toContain('<circle');
});
it('should render default background in read-only mode when striped background is disabled', () => {
const { container } = renderComponent({
props: { readOnly: true, stripedBackground: false },
});
const patternCanvas = container.querySelector('#pattern-canvas');
expect(patternCanvas).toBeInTheDocument();
expect(patternCanvas?.innerHTML).toContain('<circle');
expect(patternCanvas?.innerHTML).not.toContain('<path');
});
});
describe('simulate', () => {
@@ -155,6 +155,7 @@ const props = withDefaults(
loading?: boolean;
suppressInteraction?: boolean;
hideControls?: boolean;
stripedBackground?: boolean;
showNodeGroups?: boolean;
initialViewport?: ViewportTransform | null;
}>(),
@@ -171,6 +172,7 @@ const props = withDefaults(
loading: false,
suppressInteraction: false,
hideControls: false,
stripedBackground: true,
showNodeGroups: true,
},
);
@@ -1237,7 +1239,7 @@ defineExpose({
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<slot name="canvas-background" v-bind="{ viewport }">
<CanvasBackground :viewport="viewport" :striped="readOnly" />
<CanvasBackground :viewport="viewport" :striped="readOnly && stripedBackground" />
</slot>
<CanvasNodeGroupsLayer
@@ -27,6 +27,7 @@ const props = withDefaults(
canExecute?: boolean;
executing?: boolean;
suppressInteraction?: boolean;
stripedBackground?: boolean;
initialViewport?: ViewportTransform | null;
}>(),
{
@@ -35,6 +36,7 @@ const props = withDefaults(
fallbackNodes: () => [],
showFallbackNodes: true,
suppressInteraction: false,
stripedBackground: true,
},
);
@@ -163,6 +165,7 @@ defineExpose({
:can-execute="canExecute"
:executing="executing"
:suppress-interaction="suppressInteraction"
:striped-background="stripedBackground"
:initial-viewport="initialViewport"
v-bind="$attrs"
/>