fix(core): Synthesize type-defs for MCP registry nodes at request time (#30887)

This commit is contained in:
Bernhard Wittmann 2026-05-28 09:51:16 +02:00 committed by GitHub
parent a99b91dd98
commit 37e47e3cec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 209 additions and 3 deletions

View File

@ -108,6 +108,8 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { DataTableService } from '@/modules/data-table/data-table.service';
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
import { synthesizeMcpRegistryTypeDef } from '@/modules/mcp-registry/synthesize-type-def';
import { SourceControlPreferencesService } from '@/modules/source-control.ee/source-control-preferences.service.ee';
import { userHasScopes } from '@/permissions.ee/check-access';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
@ -1970,13 +1972,31 @@ export class InstanceAiAdapterService {
},
getNodeTypeDefinition: async (nodeType, options) => {
const nodes = await getNodes();
// Synthetic MCP registry nodes have no on-disk type-def, so the
// standard resolver would 404 on them. Match either the bare slug
// (e.g. `notion`) or the package-prefixed form, then synthesise
// the TypeScript content from the in-memory description.
const registryNode =
nodes.find((n) => n.name === `${MCP_REGISTRY_PACKAGE_NAME}.${nodeType}`) ??
(nodeType.startsWith(`${MCP_REGISTRY_PACKAGE_NAME}.`)
? nodes.find((n) => n.name === nodeType)
: undefined);
if (registryNode) {
const builderHint = registryNode.builderHint?.searchHint;
return {
content: synthesizeMcpRegistryTypeDef(registryNode),
...(builderHint ? { builderHint } : {}),
};
}
const result = resolveNodeTypeDefinition(nodeType, this.getNodeDefinitionDirs(), options);
if (result.error) {
return { content: '', error: result.error };
}
const nodes = await getNodes();
const nodeDesc = findNodeByVersion(
nodes,
nodeType,

View File

@ -0,0 +1,69 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { serverToNodeDescription } from '../node-description-transform';
import { notionMockServer, linearMockServer } from '../registry/mock-servers';
import { synthesizeMcpRegistryTypeDef } from '../synthesize-type-def';
const baseDescription: INodeTypeDescription = {
displayName: 'MCP Registry Client Tool',
name: 'mcpRegistryClientTool',
icon: 'fa:server',
group: ['output'],
version: 1,
description: 'Connect to a registry-backed MCP server',
defaults: { name: 'MCP Registry Client Tool' },
inputs: [],
outputs: [],
codex: {
alias: ['MCP', 'Model Context Protocol'],
categories: ['AI'],
subcategories: { AI: ['Model Context Protocol'] },
},
credentials: [],
properties: [
{
displayName: 'Endpoint URL',
name: 'endpointUrl',
type: 'hidden',
default: '',
},
{
displayName: 'Server Transport',
name: 'serverTransport',
type: 'hidden',
default: 'httpStreamable',
},
{
displayName: 'Tools to Include',
name: 'include',
type: 'options',
default: 'all',
options: [],
},
],
};
describe('synthesizeMcpRegistryTypeDef', () => {
it('produces TypeScript content for the Notion registry node', () => {
const description = serverToNodeDescription(notionMockServer, baseDescription);
expect(description).not.toBeNull();
const content = synthesizeMcpRegistryTypeDef(description!);
expect(content).toContain('notionMcpOAuth2Api');
expect(content).toContain('export');
// Hidden fields should not leak into the agent-facing schema.
expect(content).not.toContain('endpointUrl');
expect(content).not.toContain('serverTransport');
});
it('produces TypeScript content for the Linear registry node', () => {
const description = serverToNodeDescription(linearMockServer, baseDescription);
expect(description).not.toBeNull();
const content = synthesizeMcpRegistryTypeDef(description!);
expect(content).toContain('linearMcpOAuth2Api');
expect(content).toContain('export');
});
});

View File

@ -0,0 +1,70 @@
import { generateNodeTypeFile } from '@n8n/workflow-sdk';
import type { NodeTypeDescription as SdkNodeTypeDescription } from '@n8n/workflow-sdk';
import type { INodeTypeDescription } from 'n8n-workflow';
/**
* Generate TypeScript type-definition content for a synthetic MCP registry
* node by running its in-memory description through the SDK's standard
* generator. The output shape matches the on-disk `dist/node-definitions/`
* files produced for native nodes at build time, so consumers
* (Agent Builder's `get_node_types`, Instance AI's `type-definition`) can
* treat it identically.
*
* Hidden properties (pre-configured connection details like the endpoint
* URL and server transport) are stripped before generation so the agent's
* schema only surfaces parameters the agent is meant to set.
*/
export function synthesizeMcpRegistryTypeDef(description: INodeTypeDescription): string {
const visibleDescription = {
...description,
properties: description.properties.filter((property) => property.type !== 'hidden'),
};
if (!isSdkNodeTypeDescription(visibleDescription)) {
throw new Error(`Cannot synthesize MCP registry type definition for ${description.name}`);
}
return generateNodeTypeFile(visibleDescription);
}
function isSdkNodeTypeDescription(
description: INodeTypeDescription,
): description is INodeTypeDescription & SdkNodeTypeDescription {
return (
Array.isArray(description.group) &&
Array.isArray(description.properties) &&
typeof description.inputs !== 'string' &&
typeof description.outputs !== 'string' &&
hasSdkConnections(description.inputs) &&
hasSdkConnections(description.outputs) &&
hasSdkCredentials(description.credentials)
);
}
function hasSdkConnections(
connections: INodeTypeDescription['inputs'] | INodeTypeDescription['outputs'],
): connections is (INodeTypeDescription['inputs'] | INodeTypeDescription['outputs']) &
SdkNodeTypeDescription['inputs'] {
return (
Array.isArray(connections) &&
connections.every(
(connection) =>
typeof connection === 'string' ||
(typeof connection.type === 'string' &&
(connection.displayName === undefined || typeof connection.displayName === 'string')),
)
);
}
function hasSdkCredentials(
credentials: INodeTypeDescription['credentials'],
): credentials is SdkNodeTypeDescription['credentials'] {
return (
credentials === undefined ||
credentials.every(
(credential) =>
typeof credential.name === 'string' &&
(credential.required === undefined || typeof credential.required === 'boolean'),
)
);
}

View File

@ -6,9 +6,12 @@ import type {
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import * as fs from 'fs/promises';
import type { INodeTypeDescription } from 'n8n-workflow';
import * as path from 'path';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
import { synthesizeMcpRegistryTypeDef } from '@/modules/mcp-registry/synthesize-type-def';
export type NodeFilter = (nodeId: string) => boolean;
@ -42,6 +45,13 @@ export class NodeCatalogService {
private nodeDefinitionDirs: string[] = [];
/**
* Synthetic MCP registry node descriptions indexed by their prefixed name
* (e.g. `@n8n/mcp-registry.notion`). Used by `getNodeTypes` to synthesise
* type-def content for registry slugs, which have no on-disk artifact.
*/
private mcpRegistryDescriptions = new Map<string, INodeTypeDescription>();
private initPromise: Promise<void> | undefined;
/**
@ -126,8 +136,33 @@ export class NodeCatalogService {
const cached = this.getCache.get(cacheKey);
if (cached) return cached;
const { getNodeTypes } = await import('@n8n/ai-utilities/node-catalog');
const result = getNodeTypes(nodeIds, { nodeDefinitionDirs: this.nodeDefinitionDirs });
const registryIds: NodeRequest[] = [];
const onDiskIds: NodeRequest[] = [];
for (const id of nodeIds) {
const nodeId = typeof id === 'string' ? id : id.nodeId;
if (nodeId.startsWith(`${MCP_REGISTRY_PACKAGE_NAME}.`)) {
registryIds.push(id);
} else {
onDiskIds.push(id);
}
}
const parts: string[] = [];
for (const id of registryIds) {
const nodeId = typeof id === 'string' ? id : id.nodeId;
const description = this.mcpRegistryDescriptions.get(nodeId);
if (description) {
parts.push(synthesizeMcpRegistryTypeDef(description));
}
}
if (onDiskIds.length > 0) {
const { getNodeTypes } = await import('@n8n/ai-utilities/node-catalog');
parts.push(getNodeTypes(onDiskIds, { nodeDefinitionDirs: this.nodeDefinitionDirs }));
}
const result = parts.join('\n\n');
this.getCache.set(cacheKey, result);
return result;
}
@ -152,6 +187,7 @@ export class NodeCatalogService {
const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes();
this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions);
this.indexMcpRegistryDescriptions(nodeTypeDescriptions);
this.nodeDefinitionDirs = await this.resolveBuiltinNodeDefinitionDirs();
setSchemaBaseDirs(this.nodeDefinitionDirs);
@ -168,6 +204,7 @@ export class NodeCatalogService {
const { NodeTypeParser: NodeTypeParserClass } = await import('@n8n/ai-utilities/node-catalog');
const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes();
this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions);
this.indexMcpRegistryDescriptions(nodeTypeDescriptions);
this.searchStates.clear();
@ -179,6 +216,16 @@ export class NodeCatalogService {
});
}
private indexMcpRegistryDescriptions(descriptions: INodeTypeDescription[]): void {
this.mcpRegistryDescriptions.clear();
const prefix = `${MCP_REGISTRY_PACKAGE_NAME}.`;
for (const description of descriptions) {
if (description.name.startsWith(prefix)) {
this.mcpRegistryDescriptions.set(description.name, description);
}
}
}
private async resolveBuiltinNodeDefinitionDirs(): Promise<string[]> {
const dirs: string[] = [];
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {