mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(Databricks Node): Support templated MCP registry server URLs (#37250)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9a64820a2b
commit
44ddbbd28c
|
|
@ -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' } },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const notionResult: McpRegistrySearchResult = {
|
|||
credentialType: 'notionMcpOAuth2Api',
|
||||
tools: [],
|
||||
metadata: { nodeTypeName: '@n8n/mcp-registry.notion' },
|
||||
isTemplated: false,
|
||||
};
|
||||
|
||||
describe('buildResolveIntegrationTool', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user