mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(core): Implement preview-workflow app (#31647)
This commit is contained in:
committed by
GitHub
parent
0433d273eb
commit
bef0edaccf
@@ -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;
|
||||
}
|
||||
|
||||
+193
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@mcp-apps/*": ["src/*"],
|
||||
"@n8n/design-system*": ["../../frontend/@n8n/design-system/src*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user