mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
fix(core): Synthesize type-defs for MCP registry nodes at request time (#30887)
This commit is contained in:
parent
a99b91dd98
commit
37e47e3cec
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/modules/mcp-registry/synthesize-type-def.ts
Normal file
70
packages/cli/src/modules/mcp-registry/synthesize-type-def.ts
Normal 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'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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']) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user