feat(Databricks Node): Support templated MCP registry server URLs (#37250)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thanasis G 2026-09-03 14:40:34 +00:00 committed by GitHub
parent 9a64820a2b
commit 44ddbbd28c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 747 additions and 70 deletions

View File

@ -91,14 +91,20 @@ describe('McpRegistryClientTool', () => {
endpointUrl: 'https://mcp.notion.com/mcp',
endpointHostname: 'mcp.notion.com',
transport: 'httpStreamable',
isTemplated: false,
}
: undefined,
prepareConnection: ({ connection }) => ({
ok: true,
value: {
...connection,
nodeTypeName: connection.nodeTypeName,
credentialType: connection.credentialType,
transport: connection.transport,
endpointUrl: connection.isTemplated
? 'https://mcp.notion.com/mcp'
: connection.endpointUrl,
headers: { authorization: 'Bearer test' },
allowedDomains: connection.endpointHostname,
allowedDomains: connection.isTemplated ? 'mcp.notion.com' : connection.endpointHostname,
},
}),
});
@ -331,6 +337,7 @@ describe('McpRegistryClientTool', () => {
endpointUrl: 'https://mcp.notion.com/mcp',
endpointHostname: 'mcp.notion.com',
transport: 'httpStreamable',
isTemplated: false,
},
credentialData: { oauthTokenData: { access_token: 'token' } },
}),

View File

@ -3,6 +3,7 @@ import {
type ILoadOptionsFunctions,
type INodeExecutionData,
type INodePropertyOptions,
getConfiguredEndpointUrl,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
@ -184,7 +185,7 @@ export class McpRegistryClientTool implements INodeType {
return await loadMcpToolOptions(this, {
authentication,
transport: connection.transport,
endpointUrl: connection.endpointUrl,
endpointUrl: getConfiguredEndpointUrl(connection),
registryCredential: {
connection,
prepareConnection: (input) => McpRegistryClientTool.prepareConnection(input),
@ -216,7 +217,7 @@ function resolveConfig(
return {
authentication,
transport: connection.transport,
endpointUrl: connection.endpointUrl,
endpointUrl: getConfiguredEndpointUrl(connection),
registryCredential: {
connection,
prepareConnection: (input) => McpRegistryClientTool.prepareConnection(input),

View File

@ -6,6 +6,7 @@ import { createResultError, createResultOk } from '@n8n/utils/result';
import type {
IExecuteFunctions,
INode,
LiteralMcpRegistryConnection,
NodeEgressFilter,
PrepareMcpRegistryConnectionInput,
} from 'n8n-workflow';
@ -843,12 +844,13 @@ describe('utils', () => {
access_token: 'refreshed-token',
});
ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
const connection = {
const connection: LiteralMcpRegistryConnection = {
nodeTypeName: '@n8n/mcp-registry.test',
credentialType: 'testMcpOAuth2Api' as const,
credentialType: 'testMcpOAuth2Api',
endpointUrl: 'https://example.com/mcp',
endpointHostname: 'example.com',
transport: 'httpStreamable' as const,
transport: 'httpStreamable',
isTemplated: false,
};
const prepareConnection = vi.fn((input: PrepareMcpRegistryConnectionInput) => ({
ok: true as const,

View File

@ -23,6 +23,7 @@ const notionResult: McpRegistrySearchResult = {
credentialType: 'notionMcpOAuth2Api',
tools: [],
metadata: { nodeTypeName: '@n8n/mcp-registry.notion' },
isTemplated: false,
};
describe('buildResolveIntegrationTool', () => {

View File

@ -29,6 +29,7 @@ const notionResult: McpRegistrySearchResult = {
{ name: 'notion-create-pages', title: 'Create pages in Markdown' },
],
metadata: { nodeTypeName: '@n8n/mcp-registry.notion' },
isTemplated: false,
};
describe('buildSearchMcpServersTool', () => {

View File

@ -440,6 +440,7 @@ describe('buildMcpClientForServer — service-specific McpOAuth2Api refresh', ()
endpointUrl: 'https://example.test/mcp',
endpointHostname: 'example.test',
transport: 'httpStreamable',
isTemplated: false,
}),
},
);
@ -630,6 +631,46 @@ describe('buildMcpClientForServer — unresolvable credential', () => {
);
expect(proxyFetchMock).not.toHaveBeenCalled();
});
it('keeps a templated URL parseable so the credential error is what surfaces', async () => {
const credentialProvider = mock<CredentialProvider>();
// No `oauthTokenData`, so `prepareMcpRegistryConnection` rejects before it
// substitutes the URL, leaving the raw `$self`-expression behind.
credentialProvider.resolve.mockResolvedValue({ host: 'https://tenant.test' } as never);
const oauthService = mock<OauthService>();
const templatedUrl = '={{$self["host"]}}/api/2.0/mcp/genie';
await buildMcpClientForServer(
makeServer({
url: templatedUrl,
authentication: 'databricksGenieMcpOAuth2Api' as never,
credential: 'cred-1',
metadata: { nodeTypeName: '@n8n/mcp-registry.databricksGenie' },
}),
{
credentialProvider,
oauthService,
projectId: 'proj-1',
proxyFetch,
resolveRegistryConnection: async () => ({
nodeTypeName: '@n8n/mcp-registry.databricksGenie',
credentialType: 'databricksGenieMcpOAuth2Api',
urlTemplate: templatedUrl,
transport: 'httpStreamable',
isTemplated: true,
}),
},
);
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ url: string; fetch: typeof fetch }>];
expect(configs[0].url).not.toBe(templatedUrl);
expect(() => new URL(configs[0].url)).not.toThrow();
await expect(configs[0].fetch(configs[0].url)).rejects.toThrow(
'does not contain an OAuth2 access token',
);
expect(proxyFetchMock).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------

View File

@ -85,6 +85,9 @@ export interface BuildMcpClientDeps {
onToolCallSettled?: McpServerConfig['onToolCallSettled'];
}
/** Stand-in for a URL that could not be built. `.invalid` never resolves (RFC 2606), and `authFetch` rejects before any request is sent. */
const UNRESOLVED_CREDENTIAL_URL = 'https://credential-unresolved.invalid/';
/**
* Build a connected-but-lazy SDK `McpClient` for a single JSON-config MCP
* server entry. The returned client opens its transport on first use
@ -153,26 +156,39 @@ export async function buildMcpClientForServer(
// An unresolved credential fails at connect time so the real reason travels
// the SDK's connection-failure channel (surfaced as a `warning` chunk);
// connecting unauthenticated returns an opaque 401/403 instead. Rejecting
// rather than throwing keeps both `promise-function-async` and
// `require-await` satisfied.
const authFetch: typeof fetch = credentialError
? async () =>
await Promise.reject(
new OperationalError(
`Could not resolve the credential for MCP server "${server.name}": ${credentialError.message}`,
),
)
: createAuthFetch({
baseFetch: proxyFetch,
initialHeaders,
onUnauthorized,
allowedDomains,
});
// connecting unauthenticated returns an opaque 401/403 instead.
//
// The url and the fetch are chosen together on purpose. A templated registry
// URL is still an unresolved `$self`-expression when the credential fails,
// and the SDK rejects that URL before it ever calls fetch, so the real cause
// would be replaced by an opaque "Invalid URL". The placeholder keeps the
// connect on the path where the rejecting fetch reports the real reason, and
// is only ever paired with that fetch, which sends no request.
const { url, fetch: authFetch } = credentialError
? {
url: UNRESOLVED_CREDENTIAL_URL,
// Rejecting rather than throwing keeps both `promise-function-async`
// and `require-await` satisfied.
fetch: (async () =>
await Promise.reject(
new OperationalError(
`Could not resolve the credential for MCP server "${server.name}": ${credentialError.message}`,
),
)) as typeof fetch,
}
: {
url: runtimeUrl,
fetch: createAuthFetch({
baseFetch: proxyFetch,
initialHeaders,
onUnauthorized,
allowedDomains,
}),
};
const sdkServerConfig: McpServerConfig = {
name: server.name,
url: runtimeUrl,
url,
transport: runtimeTransport,
fetch: authFetch,
toolFilter: server.toolFilter,

View File

@ -4568,6 +4568,15 @@ describe('MCP registry discovery', () => {
credentialType: 'googleDriveMcpOAuth2Api',
tools: [{ name: 'list_files', title: 'List files' }],
metadata: { nodeTypeName: '@n8n/mcp-registry.googleDrive' },
isTemplated: false,
};
const templatedHit = {
...registryHit,
slug: 'databricks-genie',
name: 'databricksGenie',
title: 'Databricks Genie',
url: '={{$self["host"]}}/api/2.0/mcp/genie',
isTemplated: true,
};
it('is absent from the context unless the gate passed', () => {
@ -4611,6 +4620,28 @@ describe('MCP registry discovery', () => {
expect(results.map((result) => result.slug)).toEqual(['notion']);
});
// This path cannot resolve a templated url, so `createConnection` refuses
// such a row. Offering it would end at a credential picker and an error.
it('drops a templated server from search results', async () => {
stubContainer({
registrySearch: vi.fn().mockResolvedValue([registryHit, templatedHit]),
});
const context = createAdapter().createContext(user, { mcpConnectionsEnabled: true });
const results = await context.mcpService!.search(['drive', 'genie']);
expect(results.map((result) => result.slug)).toEqual(['google-drive']);
});
it('drops a templated server from an exact slug lookup', async () => {
stubContainer({
registryResolveBySlugs: vi.fn().mockResolvedValue([templatedHit]),
});
const context = createAdapter().createContext(user, { mcpConnectionsEnabled: true });
expect(await context.mcpService!.getServers(['databricks-genie'])).toEqual([]);
});
it('resolves exact slugs through the same summary shape', async () => {
const { resolveBySlugs } = stubContainer({
registryResolveBySlugs: vi.fn().mockResolvedValue([registryHit]),

View File

@ -524,14 +524,19 @@ export class InstanceAiAdapterService {
}
private createMcpAdapter(user: User): InstanceAiMcpService {
// Templated rows are dropped rather than offered: this path reads credentials
// without resolving expressions, so `createConnection` refuses them. Offering
// one would walk the user to a credential picker and then an error.
const toSummaries = (servers: McpRegistrySearchResult[]): McpRegistryServerSummary[] =>
servers.map((server) => ({
slug: server.slug,
title: server.title,
description: server.description,
credentialType: server.credentialType,
tools: server.tools.map((tool) => tool.name),
}));
servers
.filter((server) => !server.isTemplated)
.map((server) => ({
slug: server.slug,
title: server.title,
description: server.description,
credentialType: server.credentialType,
tools: server.tools.map((tool) => tool.name),
}));
return {
/** Connected servers are filtered out: `connected` answers what the user has,

View File

@ -7,6 +7,7 @@ import { mock } from 'vitest-mock-extended';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { EventService } from '@/events/event.service';
@ -280,6 +281,30 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
it('skips connections whose server URL is a template', async () => {
// This path decrypts the credential without resolving expressions, so the
// template would stay unresolved. The row is dropped instead of offered.
const { service, connectionRepository, mcpRegistryService, logger } = createService();
connectionRepository.findBy.mockResolvedValue([
{ id: '3', userId: user.id, serverSlug: 'genie', credentialId: credential.id },
] as InstanceAiMcpRegistryConnection[]);
mcpRegistryService.getBySlugs.mockResolvedValue([
makeRegistryServer('genie', {
remotes: [
{ type: 'streamable-http-templated', url: '={{$self["host"]}}/api/2.0/mcp/genie' },
],
}),
]);
const result = await service.getRegistryMcpServers(user);
expect(result).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
'Skipping MCP registry connection with a templated server URL',
expect.objectContaining({ connectionId: '3', serverSlug: 'genie' }),
);
});
it('does not attach custom fetch for non-oauth servers', async () => {
const {
service,
@ -853,6 +878,26 @@ describe('InstanceAiMcpRegistryService', () => {
);
});
it('refuses a server whose URL is a template', async () => {
// This path cannot resolve the template, so the connection would persist
// and read as connected while `getRegistryMcpServers` skips it.
const { service, connectionRepository, mcpRegistryService, credentialsFinderService } =
createService();
mcpRegistryService.get.mockResolvedValue(
makeRegistryServer('genie', {
remotes: [
{ type: 'streamable-http-templated', url: '={{$self["host"]}}/api/2.0/mcp/genie' },
],
}),
);
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
await expect(
service.createConnection(user, { serverSlug: 'genie', credentialId: 'cred-1' }),
).rejects.toBeInstanceOf(BadRequestError);
expect(connectionRepository.save).not.toHaveBeenCalled();
});
it('throws NotFoundError when the server slug is unknown', async () => {
const { service, mcpRegistryService, eventService } = createService();
mcpRegistryService.get.mockResolvedValue(undefined);

View File

@ -11,11 +11,12 @@ import { OutboundHttp } from '@n8n/backend-network';
import { isUniqueConstraintError, type CredentialsEntity, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { McpServerConfig } from '@n8n/instance-ai';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, LiteralMcpRegistryConnection } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
@ -40,7 +41,8 @@ interface ResolvedRegistryServer {
serverSlug: string;
credentialId: string;
authType: McpRegistryServer['authType'];
connection: NonNullable<ReturnType<typeof resolveMcpRegistryConnection>>;
/** Never templated: this path cannot resolve a template, so those are skipped. */
connection: LiteralMcpRegistryConnection;
}
const MCP_REGISTRY_SERVER_PREFIX = 'mcp_';
@ -139,6 +141,15 @@ export class InstanceAiMcpRegistryService {
throw new NotFoundError(`Unknown MCP registry server: ${input.serverSlug}`);
}
// This path cannot resolve a templated server URL, so `getRegistryMcpServers`
// skips such a row at load time. Reject it here too, otherwise the connection
// persists and reads as connected while contributing nothing.
if (resolveMcpRegistryConnection(server)?.isTemplated) {
throw new BadRequestError(
`MCP registry server "${input.serverSlug}" cannot be connected here`,
);
}
// v1 invariant: at most one connection per (user, serverSlug). To switch
// credentials the user must disconnect first (the FE orchestrates this
// as a two-step swap). The DB unique index is currently looser; this
@ -401,6 +412,18 @@ export class InstanceAiMcpRegistryService {
return null;
}
// This path reads the credential without resolving expressions, so a
// templated row's URL would stay an unresolved template. Skipping keeps
// the row out of the picker instead of offering a connection that breaks.
if (connection.isTemplated) {
this.logger.warn('Skipping MCP registry connection with a templated server URL', {
connectionId,
serverSlug,
credentialId,
});
return null;
}
return {
serverSlug,
credentialId,

View File

@ -12,6 +12,15 @@ const connection: McpRegistryConnection = {
endpointUrl: 'https://example.com/mcp',
endpointHostname: 'example.com',
transport: 'httpStreamable',
isTemplated: false,
};
const templatedConnection: McpRegistryConnection = {
nodeTypeName: '@n8n/mcp-registry.example',
credentialType: 'exampleMcpOAuth2Api',
urlTemplate: '={{$self["host"]}}/api/2.0/mcp/genie',
transport: 'httpStreamable',
isTemplated: true,
};
describe('resolveMcpRegistryConnection', () => {
@ -37,6 +46,33 @@ describe('resolveMcpRegistryConnection', () => {
}),
).toBeNull();
});
it('resolves a templated remote without parsing it as a URL', () => {
const result = resolveMcpRegistryConnection({
...notionMockServer,
remotes: [{ type: 'streamable-http-templated', url: '={{$self["host"]}}/api/2.0/mcp/genie' }],
});
expect(result).toEqual({
nodeTypeName: '@n8n/mcp-registry.notion',
credentialType: 'notionMcpOAuth2Api',
urlTemplate: '={{$self["host"]}}/api/2.0/mcp/genie',
transport: 'httpStreamable',
isTemplated: true,
});
});
it('prefers a templated streamable-http remote over sse', () => {
const result = resolveMcpRegistryConnection({
...notionMockServer,
remotes: [
{ type: 'sse', url: 'https://mcp.notion.com/sse' },
{ type: 'streamable-http-templated', url: '={{$self["host"]}}/mcp' },
],
});
expect(result).toMatchObject({ isTemplated: true, urlTemplate: '={{$self["host"]}}/mcp' });
});
});
describe('prepareMcpRegistryConnection', () => {
@ -65,10 +101,72 @@ describe('prepareMcpRegistryConnection', () => {
expect(result).toEqual({
ok: true,
value: {
...connection,
nodeTypeName: connection.nodeTypeName,
credentialType: connection.credentialType,
transport: connection.transport,
endpointUrl: 'https://example.com/mcp',
headers: { Authorization: 'Bearer refreshed-token' },
allowedDomains: 'example.com',
},
});
});
it('resolves a templated connection and pins the domain to the resolved host', () => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialData: {
oauthTokenData: { access_token: 'token' },
serverUrl: 'https://acme.cloud.databricks.com/api/2.0/mcp/genie',
// Deliberately disagrees with serverUrl: the pin follows the URL
// actually called, never a separate field that can drift from it.
allowedDomains: 'attacker.test',
},
});
expect(result).toEqual({
ok: true,
value: {
nodeTypeName: templatedConnection.nodeTypeName,
credentialType: templatedConnection.credentialType,
transport: templatedConnection.transport,
headers: { Authorization: 'Bearer token' },
endpointUrl: 'https://acme.cloud.databricks.com/api/2.0/mcp/genie',
allowedDomains: 'acme.cloud.databricks.com',
},
});
});
it.each([
['an unresolved template', '={{$self["host"]}}/api/2.0/mcp/genie'],
['a non-URL string', 'not-a-url'],
['a non-HTTP scheme', 'file:///etc/passwd'],
])('rejects a templated connection whose serverUrl is %s', (_label, serverUrl) => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialData: { oauthTokenData: { access_token: 'token' }, serverUrl },
});
expect(result).toEqual({
ok: false,
error: {
code: 'unresolved_server_url',
message: 'Credential type "exampleMcpOAuth2Api" did not resolve a server URL',
},
});
});
it('rejects a templated connection when the credential has no resolved serverUrl', () => {
const result = prepareMcpRegistryConnection({
connection: templatedConnection,
credentialData: { oauthTokenData: { access_token: 'token' } },
});
expect(result).toEqual({
ok: false,
error: {
code: 'unresolved_server_url',
message: 'Credential type "exampleMcpOAuth2Api" did not resolve a server URL',
},
});
});
});

View File

@ -2,7 +2,11 @@ import { mock } from 'vitest-mock-extended';
import { McpRegistryController } from '../mcp-registry.controller';
import type { McpRegistryService } from '../registry/mcp-registry.service';
import { linearMockServer, notionMockServer } from '../registry/mock-servers';
import {
databricksGenieTemplatedMockServer,
linearMockServer,
notionMockServer,
} from '../registry/mock-servers';
describe('McpRegistryController', () => {
const service = mock<McpRegistryService>();
@ -35,6 +39,16 @@ describe('McpRegistryController', () => {
expect(notion).not.toHaveProperty('origin');
});
it('drops a server whose URL is a template', async () => {
// Instance AI fills its connection picker from here and cannot resolve a
// template, so offering the row would end at a refused connection.
service.getAll.mockResolvedValue([notionMockServer, databricksGenieTemplatedMockServer]);
const result = await controller.listServers();
expect(result.map((s) => s.slug)).toEqual(['notion']);
});
it('returns an empty array when the registry has no servers', async () => {
service.getAll.mockResolvedValue([]);

View File

@ -7,6 +7,7 @@ import {
} from '../node-description-transform';
import type { McpRegistryServer } from '../registry/mcp-registry.types';
import {
databricksGenieTemplatedMockServer,
gmailDirectExtendMockServer,
notionMockServer,
slackExtendingMockServer,
@ -376,6 +377,28 @@ describe('serverToNodeDescription', () => {
expect(description?.credentials).toEqual([]);
});
it('builds a tile for a templated streamable-http-templated remote, unresolved endpointUrl and all', () => {
const description = serverToNodeDescription(
databricksGenieTemplatedMockServer,
baseDescription,
(name) => name === 'databricksOAuth2Api',
);
expect(description).not.toBeNull();
expect(description?.credentials).toEqual([
{ name: 'databricksGenieMcpOAuth2Api', required: true },
]);
// The node's own endpointUrl parameter still gets patched with the raw
// template; the runtime never reads it once a credential serverUrl is
// resolvable (see McpRegistryClientTool), but the description build
// itself does not special-case a templated remote.
const endpointUrl = description?.properties.find((p) => p.name === 'endpointUrl');
const serverTransport = description?.properties.find((p) => p.name === 'serverTransport');
expect(serverTransport?.default).toBe('httpStreamable');
expect(endpointUrl?.default).toBe('={{$self["host"].replace(/\\/$/, "")}}/api/2.0/mcp/genie');
});
});
});
@ -609,6 +632,133 @@ describe('serverToCredentialDescription', () => {
expect(description?.extends).toEqual(['mcpOAuth2Api']);
});
it('writes a $self-expression serverUrl and allowedDomains for a streamable-http-templated remote', () => {
const description = serverToCredentialDescription(
databricksGenieTemplatedMockServer,
(name) => name === 'databricksOAuth2Api',
);
expect(description).toEqual({
name: 'databricksGenieMcpOAuth2Api',
displayName: 'Databricks Genie MCP OAuth2',
extends: ['databricksOAuth2Api'],
icon: 'node:@n8n/mcp-registry.databricksGenie',
properties: [
{ displayName: 'scope', name: 'scope', type: 'hidden', default: 'genie offline_access' },
{
displayName: 'grantType',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'customScopes',
name: 'customScopes',
type: 'hidden',
default: false,
},
{
displayName: 'Server URL',
name: 'serverUrl',
type: 'hidden',
default: '={{$self["host"].replace(/\\/$/, "")}}/api/2.0/mcp/genie',
},
{
displayName: 'Allowed HTTP Request Domains',
name: 'allowedHttpRequestDomains',
type: 'hidden',
default: 'domains',
},
{
displayName: 'Allowed Domains',
name: 'allowedDomains',
type: 'hidden',
default: '={{$self["host"].extractDomain()}}',
},
],
});
});
});
it('drops the row for a templated remote paired with plain oauth2 auth, no host-bearing field to resolve against', () => {
const server: McpRegistryServer = {
...notionMockServer,
remotes: [{ type: 'streamable-http-templated', url: '={{$self["host"]}}/mcp' }],
};
expect(serverToCredentialDescription(server, isKnownCredentialType)).toBeNull();
});
it('drops the row when the remote type is unrecognised, the back-compat gate for future remote types', () => {
const server: McpRegistryServer = {
...notionMockServer,
remotes: [{ type: 'some-future-remote-type' as never, url: 'https://mcp.notion.com/mcp' }],
};
expect(serverToCredentialDescription(server, isKnownCredentialType)).toBeNull();
});
describe('back-compat: an old n8n instance loading a streamable-http-templated row', () => {
/**
* Verbatim snapshot of pickRemote/resolveCredentialRemote as they shipped
* before this ticket (commit e60c43112c3, the tip this feature branched
* from). Neither function knows about `streamable-http-templated`, so this
* proves the exact pre-existing code drops the row, not a stand-in that
* merely resembles it. Two independent guards do the dropping, checked
* separately below: the unrecognised-type match in pickRemote, and the
* new URL() parse in resolveCredentialRemote, so a future change to
* either one alone still leaves old instances safe.
*/
function oldPickRemote(
server: McpRegistryServer,
): { transport: 'httpStreamable' | 'sse'; endpointUrl: string } | null {
const streamable = server.remotes.find((r) => r.type === 'streamable-http');
if (streamable) return { transport: 'httpStreamable', endpointUrl: streamable.url };
const sse = server.remotes.find((r) => r.type === 'sse');
if (sse) return { transport: 'sse', endpointUrl: sse.url };
return null;
}
function oldResolveCredentialRemote(
server: McpRegistryServer,
): { endpointUrl: string; hostname: string } | null {
const remote = oldPickRemote(server);
if (!remote) return null;
try {
return { endpointUrl: remote.endpointUrl, hostname: new URL(remote.endpointUrl).hostname };
} catch {
return null;
}
}
it('old pickRemote never matches streamable-http-templated, so the row is invisible to it', () => {
expect(oldPickRemote(databricksGenieTemplatedMockServer)).toBeNull();
});
it('even if a row used the plain streamable-http type with a template-string url, new URL() rejects it independently', () => {
const serverWithOldTypeName: McpRegistryServer = {
...databricksGenieTemplatedMockServer,
remotes: databricksGenieTemplatedMockServer.remotes.map((remote) => ({
...remote,
type: 'streamable-http',
})),
};
expect(oldResolveCredentialRemote(serverWithOldTypeName)).toBeNull();
});
it('the current, patched code builds a tile old code could never build for the same row', () => {
expect(
serverToCredentialDescription(
databricksGenieTemplatedMockServer,
(name) => name === 'databricksOAuth2Api',
),
).not.toBeNull(); // current code DOES build a tile once the parent credential is known...
expect(oldPickRemote(databricksGenieTemplatedMockServer)).toBeNull(); // ...but old code drops it regardless
});
});
});

View File

@ -1,5 +1,6 @@
import { camelCase } from 'change-case';
import {
getConfiguredEndpointUrl,
getMcpAuthHeaders,
type McpOAuth2CredentialType,
type McpRegistryConnection,
@ -9,6 +10,8 @@ import {
import type { McpRegistryServer } from './registry/mcp-registry.types';
export { getConfiguredEndpointUrl };
export const MCP_REGISTRY_PACKAGE_NAME = '@n8n/mcp-registry';
export const LANGCHAIN_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
export const MCP_REGISTRY_BASE_NODE_NAME = 'mcpRegistryClientTool';
@ -24,18 +27,36 @@ export function resolveMcpRegistryConnection(
server: McpRegistryServer,
): McpRegistryConnection | null {
const remote =
server.remotes.find(({ type }) => type === 'streamable-http') ??
server.remotes.find(({ type }) => type === 'sse');
server.remotes.find(
({ type }) => type === 'streamable-http' || type === 'streamable-http-templated',
) ?? server.remotes.find(({ type }) => type === 'sse');
if (!remote) return null;
const nodeTypeName = `${MCP_REGISTRY_PACKAGE_NAME}.${camelCase(server.slug)}`;
const credentialType = getMcpRegistryCredentialTypeName(server);
// A templated remote's url is an unresolved `$self`-expression, not a
// literal URL, resolves per-credential once `prepareMcpRegistryConnection`
// has the decrypted credential data.
if (remote.type === 'streamable-http-templated') {
return {
nodeTypeName,
credentialType,
urlTemplate: remote.url,
transport: 'httpStreamable',
isTemplated: true,
};
}
try {
const endpoint = new URL(remote.url);
return {
nodeTypeName: `${MCP_REGISTRY_PACKAGE_NAME}.${camelCase(server.slug)}`,
credentialType: getMcpRegistryCredentialTypeName(server),
nodeTypeName,
credentialType,
endpointUrl: endpoint.toString(),
endpointHostname: endpoint.hostname,
transport: remote.type === 'streamable-http' ? 'httpStreamable' : 'sse',
isTemplated: false,
};
} catch {
return null;
@ -60,16 +81,60 @@ export function prepareMcpRegistryConnection({
};
}
const { nodeTypeName, credentialType, transport } = connection;
if (connection.isTemplated) {
const serverUrl = credentialData.serverUrl;
// An unresolved expression is still a non-empty string, so the URL has to
// be parsed here. Otherwise it travels on and fails far from its cause.
const endpoint =
typeof serverUrl === 'string' && serverUrl.length > 0 ? parseUrl(serverUrl) : undefined;
if (!endpoint) {
return {
ok: false,
error: {
code: 'unresolved_server_url',
message: `Credential type "${connection.credentialType}" did not resolve a server URL`,
},
};
}
return {
ok: true,
value: {
nodeTypeName,
credentialType,
transport,
endpointUrl: endpoint.toString(),
headers,
// Pinned to the host actually being called, so the restriction can
// never guard a different host than the request goes to.
allowedDomains: endpoint.hostname,
},
};
}
return {
ok: true,
value: {
...connection,
nodeTypeName,
credentialType,
transport,
endpointUrl: connection.endpointUrl,
headers,
allowedDomains: connection.endpointHostname,
},
};
}
function parseUrl(value: string): URL | undefined {
try {
const url = new URL(value);
return /^https?:$/.test(url.protocol) ? url : undefined;
} catch {
return undefined;
}
}
export function toAgentMcpTransport(
transport: McpRegistryConnection['transport'],
): 'streamableHttp' | 'sse' {

View File

@ -1,6 +1,7 @@
import type { McpRegistryServerResponse } from '@n8n/api-types';
import { Get, RestController } from '@n8n/decorators';
import { resolveMcpRegistryConnection } from './mcp-registry-connection';
import { getMcpRegistryCredentialTypeName } from './node-description-transform';
import { McpRegistryService } from './registry/mcp-registry.service';
import type { McpRegistryServer } from './registry/mcp-registry.types';
@ -9,10 +10,17 @@ import type { McpRegistryServer } from './registry/mcp-registry.types';
export class McpRegistryController {
constructor(private readonly service: McpRegistryService) {}
/**
* Only Instance AI reads this, to fill its tool-connection picker. A
* templated row is dropped: that path cannot resolve the template, so
* `createConnection` refuses it and offering it leads nowhere.
*/
@Get('/servers')
async listServers(): Promise<McpRegistryServerResponse[]> {
const servers = await this.service.getAll({ includeDeprecated: false });
return servers.map(toResponse);
return servers
.filter((server) => !resolveMcpRegistryConnection(server)?.isTemplated)
.map(toResponse);
}
}

View File

@ -11,6 +11,7 @@ import {
getMcpRegistryCredentialTypeName,
MCP_BASE_OAUTH2_CREDENTIAL_NAME,
MCP_REGISTRY_PACKAGE_NAME,
getConfiguredEndpointUrl,
resolveMcpRegistryConnection,
} from './mcp-registry-connection';
import {
@ -53,18 +54,6 @@ function getMcpRegistryCredentialHeader(
};
}
/**
* Picks the server's remote endpoint and parses its hostname.
*/
function resolveCredentialRemote(
server: McpRegistryServer,
): { endpointUrl: string; hostname: string } | null {
const connection = resolveMcpRegistryConnection(server);
return connection
? { endpointUrl: connection.endpointUrl, hostname: connection.endpointHostname }
: null;
}
/**
* Locks the synthetic credential's HTTP requests to the MCP server's hostname.
*/
@ -86,11 +75,14 @@ function buildDomainRestrictionProperties(hostname: string): INodeProperties[] {
}
/**
* Registry MCP server → service-specific credential type for OAuth2 auth type
* Registry MCP server → service-specific credential type for OAuth2 auth type.
* Plain `oauth2` credentials have no user-editable, host-bearing field of
* their own to resolve a templated URL against, so templated remotes are
* unsupported here and drop the row, same as an unparseable URL.
*/
function serverToOAuth2CredentialDescription(server: McpRegistryServer): ICredentialType | null {
const remote = resolveCredentialRemote(server);
if (!remote) return null;
const remote = resolveMcpRegistryConnection(server);
if (!remote || remote.isTemplated) return null;
return {
...getMcpRegistryCredentialHeader(server),
@ -106,7 +98,7 @@ function serverToOAuth2CredentialDescription(server: McpRegistryServer): ICreden
displayName: 'Server URL',
name: 'serverUrl',
type: 'hidden',
default: remote.endpointUrl,
default: getConfiguredEndpointUrl(remote),
},
{
displayName: 'Resource URL',
@ -114,7 +106,7 @@ function serverToOAuth2CredentialDescription(server: McpRegistryServer): ICreden
type: 'hidden',
default: '',
},
...buildDomainRestrictionProperties(remote.hostname),
...buildDomainRestrictionProperties(remote.endpointHostname),
],
};
}
@ -145,7 +137,11 @@ function getValidatedExtendsCredential(
}
/**
* Builds a dedicated credential type extending a known n8n credential.
* Builds a dedicated credential type extending a known n8n credential. A
* templated remote has no literal hostname, so the endpoint and the domain
* pin are both written as `$self`-expressions resolved against the parent
* credential's own `host` field, the same field the Strapi-authored URL
* template itself already depends on.
*/
function serverToExtendedCredentialDescription(
server: McpRegistryServer,
@ -154,7 +150,7 @@ function serverToExtendedCredentialDescription(
const validated = getValidatedExtendsCredential(server, isKnownCredentialType);
if (!validated) return null;
const remote = resolveCredentialRemote(server);
const remote = resolveMcpRegistryConnection(server);
if (!remote) return null;
const overrideProperties: INodeProperties[] = Object.entries(validated.overrides).map(
@ -166,10 +162,26 @@ function serverToExtendedCredentialDescription(
}),
);
const serverUrlProperty: INodeProperties = {
displayName: 'Server URL',
name: 'serverUrl',
type: 'hidden',
default: getConfiguredEndpointUrl(remote),
};
const serverUrlOverride = remote.isTemplated ? [serverUrlProperty] : [];
const allowedDomainsDefault = remote.isTemplated
? '={{$self["host"].extractDomain()}}'
: remote.endpointHostname;
return {
...getMcpRegistryCredentialHeader(server),
extends: [validated.parentType],
properties: [...overrideProperties, ...buildDomainRestrictionProperties(remote.hostname)],
properties: [
...overrideProperties,
...serverUrlOverride,
...buildDomainRestrictionProperties(allowedDomainsDefault),
],
};
}
@ -294,7 +306,7 @@ export function serverToNodeDescription(
description.properties = withRemoteDefaults(
description.properties,
connection.transport,
connection.endpointUrl,
getConfiguredEndpointUrl(connection),
);
description.builderHint = {
...description.builderHint,

View File

@ -47,6 +47,7 @@ describe('searchMcpRegistryServers', () => {
credentialType: 'githubMcpOAuth2Api',
tools: [{ name: 'create_issue', title: 'Create issue' }],
metadata: { nodeTypeName: '@n8n/mcp-registry.github' },
isTemplated: false,
});
});
@ -85,4 +86,26 @@ describe('searchMcpRegistryServers', () => {
const noRemote = server({ slug: 'no-remote', remotes: [] });
expect(searchMcpRegistryServers([noRemote], ['no-remote'])).toEqual([]);
});
it('keeps a streamable-http-templated tile in results, surfacing its unresolved url as-is', () => {
const templated = server({
slug: 'databricks-genie',
title: 'Databricks Genie',
remotes: [{ type: 'streamable-http-templated', url: '={{$self["host"]}}/api/2.0/mcp/genie' }],
});
const [result] = searchMcpRegistryServers([templated], ['databricks-genie']);
expect(result.transport).toBe('streamableHttp');
expect(result.url).toBe('={{$self["host"]}}/api/2.0/mcp/genie');
expect(result.metadata).toEqual({ nodeTypeName: '@n8n/mcp-registry.databricksGenie' });
// Marks the url as unresolved so a consumer that cannot resolve it can skip.
expect(result.isTemplated).toBe(true);
});
it('marks a literal tile as not templated', () => {
const [result] = searchMcpRegistryServers([server({ slug: 'notion' })], ['notion']);
expect(result.isTemplated).toBe(false);
});
});

View File

@ -7,7 +7,11 @@
import { camelCase } from 'change-case';
import type { McpRegistryServer } from './mcp-registry.types';
import { resolveMcpRegistryConnection, toAgentMcpTransport } from '../mcp-registry-connection';
import {
getConfiguredEndpointUrl,
resolveMcpRegistryConnection,
toAgentMcpTransport,
} from '../mcp-registry-connection';
export interface McpRegistrySearchResult {
slug: string;
@ -20,6 +24,9 @@ export interface McpRegistrySearchResult {
credentialType: string;
tools: Array<{ name: string; title?: string }>;
metadata: { nodeTypeName: string };
/** `url` is an unresolved `$self`-expression, not a literal endpoint. Consumers
* that cannot resolve it against a credential have to skip the row. */
isTemplated: boolean;
}
function toSearchResult(server: McpRegistryServer): McpRegistrySearchResult | null {
@ -30,7 +37,7 @@ function toSearchResult(server: McpRegistryServer): McpRegistrySearchResult | nu
name: camelCase(server.slug),
title: server.title,
description: server.tagline,
url: connection.endpointUrl,
url: getConfiguredEndpointUrl(connection),
transport: toAgentMcpTransport(connection.transport),
authentication: connection.credentialType,
credentialType: connection.credentialType,
@ -39,6 +46,7 @@ function toSearchResult(server: McpRegistryServer): McpRegistrySearchResult | nu
...(tool.title ? { title: tool.title } : {}),
})),
metadata: { nodeTypeName: connection.nodeTypeName },
isTemplated: connection.isTemplated === true,
};
}

View File

@ -26,6 +26,10 @@ export const mcpRegistryExtendsCredentialSchema = z.object({
authentication: z.enum(['body', 'header']).nullish(),
useDynamicClientRegistration: z.boolean().nullish(),
serverUrl: z.string().nullish(),
// Lets a row that fixes `scope` also hide the parent's "Custom Scopes"
// toggle (and its dependent notice/enabledScopes fields), which would
// otherwise render with no effect once `scope` is overridden.
customScopes: z.boolean().nullish(),
});
export type McpRegistryExtendsCredential = z.infer<typeof mcpRegistryExtendsCredentialSchema>;
@ -60,7 +64,7 @@ export type McpRegistryIcon = {
theme?: 'light' | 'dark';
};
export type McpRegistryRemoteType = 'streamable-http' | 'sse';
export type McpRegistryRemoteType = 'streamable-http' | 'sse' | 'streamable-http-templated';
export type McpRegistryRemote = {
type: McpRegistryRemoteType;

View File

@ -85,6 +85,46 @@ export const gmailDirectExtendMockServer: McpRegistryServer = {
},
};
export const databricksGenieTemplatedMockServer: McpRegistryServer = {
name: 'com.databricks/genie-mcp',
slug: 'databricks-genie',
title: 'Databricks Genie',
description: 'Databricks Genie MCP server, resolved per-customer from the workspace host.',
tagline: 'Connect to Databricks Genie',
version: '1.0.0',
updatedAt: '2026-08-20T10:00:00.000Z',
icons: [
{
src: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNCIgZmlsbD0iI0ZGMzYyMSIvPjwvc3ZnPg==',
mimeType: 'image/svg+xml',
},
],
websiteUrl: 'https://databricks.com',
authType: 'extendsCredential',
remotes: [
{
type: 'streamable-http-templated',
// Trailing slash stripped in case the customer pastes the host straight
// from the browser, matching DatabricksOAuth2Api's own accessTokenUrl/authUrl.
url: '={{$self["host"].replace(/\\/$/, "")}}/api/2.0/mcp/genie',
},
],
tools: [],
isOfficial: true,
origin: 'registry',
status: 'active',
extendsCredential: {
extends: 'databricksOAuth2Api',
scope: 'genie offline_access',
grantType: 'authorizationCode',
// databricksOAuth2Api already provides a per-host authUrl default, no
// override needed. customScopes is hidden here because Genie's scope is
// fixed above; without this the parent's "Custom Scopes" toggle and its
// dependent fields would render with no effect.
customScopes: false,
},
};
export const linearMockServer: McpRegistryServer = {
name: 'app.linear/linear',
slug: 'linear',

View File

@ -1645,6 +1645,40 @@ describe('McpAgentToolsService', () => {
});
expect(listMcpServerToolsMock).not.toHaveBeenCalled();
});
it('accepts a templated registry URL and passes it through for resolution', async () => {
outboundHttp.transport.mockReturnValue({ asCustomFetch: () => vi.fn() } as never);
listMcpServerToolsMock.mockResolvedValue([{ name: 'genie_ask', description: 'Ask' }]);
const templatedUrl = '={{$self["host"]}}/api/2.0/mcp/genie';
const result = await callTool('verify_agent_mcp_server', {
...input,
url: templatedUrl,
metadata: { nodeTypeName: '@n8n/mcp-registry.databricksGenie' },
});
expect(listMcpServerToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ url: templatedUrl }),
expect.objectContaining({ projectId: 'project-1' }),
);
expect(result.structuredContent).toEqual({
ok: true,
tools: [{ name: 'genie_ask', description: 'Ask' }],
});
});
it('rejects a templated URL with no registry node to resolve it', async () => {
const result = await callTool('verify_agent_mcp_server', {
...input,
url: '={{$self["host"]}}/api/2.0/mcp/genie',
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
error: 'A templated server URL needs metadata.nodeTypeName so the registry can resolve it',
});
expect(listMcpServerToolsMock).not.toHaveBeenCalled();
});
});
describe('get_agent', () => {

View File

@ -138,6 +138,13 @@ const httpUrlSchema = z
.url()
.refine((value) => /^https?:\/\//i.test(value), { message: 'Must be a valid HTTP(S) URL' });
/**
* A registry server's endpoint can be an unresolved `$self`-expression instead
* of a literal URL. The registry connection substitutes it per-credential
* before the connection is opened, so it only validates as a URL after that.
*/
const mcpEndpointUrlSchema = z.union([httpUrlSchema, z.string().startsWith('=')]);
const agentIdentityShape = {
agentId: z.string().min(1).describe('Agent ID'),
} satisfies z.ZodRawShape;
@ -302,7 +309,9 @@ const verifyMcpServerInput = {
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9_-]+$/),
url: httpUrlSchema.describe('HTTP(S) MCP server endpoint'),
url: mcpEndpointUrlSchema.describe(
'HTTP(S) MCP server endpoint, or a registry `$self`-expression when metadata.nodeTypeName is set',
),
transport: z.enum(['sse', 'streamableHttp']).optional().default('streamableHttp'),
authentication: z
.union([McpAuthenticationSchemaTypes, z.string().endsWith('McpOAuth2Api')])
@ -1603,6 +1612,11 @@ export class McpAgentToolsService {
private async verifyMcpServer(user: User, input: VerifyMcpServerInput) {
await this.assertScope(user, input.projectId, 'agent:read');
if (input.url.startsWith('=') && !input.metadata?.nodeTypeName) {
throw new UserError(
'A templated server URL needs metadata.nodeTypeName so the registry can resolve it',
);
}
const credentialProvider = this.credentialProvider(user, input.projectId);
if (input.authentication !== 'none') {
if (!input.credential) {

View File

@ -5,12 +5,40 @@ import type { ICredentialDataDecryptedObject } from './interfaces';
/** Covers `mcpOAuth2Api` and registry-specific variants like `notionMcpOAuth2Api`. */
export type McpOAuth2CredentialType = 'mcpOAuth2Api' | `${string}McpOAuth2Api`;
export interface McpRegistryConnection {
interface McpRegistryConnectionBase {
nodeTypeName: string;
credentialType: McpOAuth2CredentialType;
transport: 'httpStreamable' | 'sse';
}
/** A row whose endpoint is a literal URL, known before any credential is read. */
export interface LiteralMcpRegistryConnection extends McpRegistryConnectionBase {
isTemplated?: false;
endpointUrl: string;
endpointHostname: string;
transport: 'httpStreamable' | 'sse';
}
/**
* A row whose endpoint is a `$self`-expression (e.g.
* `={{$self["host"]}}/api/2.0/mcp/genie`) rather than a URL. It only becomes
* one once `prepareMcpRegistryConnection` resolves it against the credential,
* so it deliberately has no `endpointUrl` to read by mistake.
*/
export interface TemplatedMcpRegistryConnection extends McpRegistryConnectionBase {
isTemplated: true;
urlTemplate: string;
}
export type McpRegistryConnection = LiteralMcpRegistryConnection | TemplatedMcpRegistryConnection;
/**
* The endpoint as the registry configured it: a literal URL, or the unresolved
* template for a templated row. Only for describing the row (node defaults,
* search results). Anything that opens a connection needs the resolved URL from
* `prepareMcpRegistryConnection`.
*/
export function getConfiguredEndpointUrl(connection: McpRegistryConnection): string {
return connection.isTemplated ? connection.urlTemplate : connection.endpointUrl;
}
export interface PrepareMcpRegistryConnectionInput {
@ -22,15 +50,21 @@ export interface PrepareMcpRegistryConnectionInput {
export type PrepareMcpRegistryConnectionResult =
| {
ok: true;
value: McpRegistryConnection & {
value: {
nodeTypeName: string;
credentialType: McpOAuth2CredentialType;
transport: 'httpStreamable' | 'sse';
/** Always a literal URL, templated or not. */
endpointUrl: string;
headers: Record<string, string>;
/** Host the credential is pinned to, taken from `endpointUrl`. */
allowedDomains: string;
};
}
| {
ok: false;
error: {
code: 'missing_access_token' | 'not_registered';
code: 'missing_access_token' | 'not_registered' | 'unresolved_server_url';
message: string;
};
};