From 44ddbbd28ce5c2aaf6676df438c1ecf69dbe3f8d Mon Sep 17 00:00:00 2001 From: Thanasis G <96360514+gthanasis@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:40:34 +0000 Subject: [PATCH] feat(Databricks Node): Support templated MCP registry server URLs (#37250) Co-authored-by: Claude Opus 5 --- .../McpRegistryClientTool.node.test.ts | 11 +- .../McpRegistryClientTool.node.ts | 5 +- .../nodes/mcp/shared/__test__/utils.test.ts | 8 +- .../resolve-integration.tool.test.ts | 1 + .../__tests__/search-mcp-servers.tool.test.ts | 1 + .../__tests__/mcp-client-factory.test.ts | 41 +++++ .../agents/json-config/mcp-client-factory.ts | 50 ++++-- .../instance-ai.adapter.service.test.ts | 31 ++++ .../instance-ai.adapter.service.ts | 19 ++- .../instance-ai-mcp-registry.service.test.ts | 45 ++++++ .../mcp/instance-ai-mcp-registry.service.ts | 27 +++- .../__tests__/mcp-registry-connection.test.ts | 100 +++++++++++- .../__tests__/mcp-registry.controller.test.ts | 16 +- .../node-description-transform.test.ts | 150 ++++++++++++++++++ .../mcp-registry/mcp-registry-connection.ts | 75 ++++++++- .../mcp-registry/mcp-registry.controller.ts | 10 +- .../node-description-transform.ts | 54 ++++--- .../__tests__/mcp-registry-search.test.ts | 23 +++ .../registry/mcp-registry-search.ts | 12 +- .../registry/mcp-registry.types.ts | 6 +- .../mcp-registry/registry/mock-servers.ts | 40 +++++ .../mcp/__tests__/agent-tools.service.test.ts | 34 ++++ .../mcp/tools/agents/agent-tools.service.ts | 16 +- packages/workflow/src/mcp-helpers.ts | 42 ++++- 24 files changed, 747 insertions(+), 70 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts index e4500c142a2..a8bea829f4e 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.test.ts @@ -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' } }, }), diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts index 01dc8ce56f0..2db94c996c2 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpRegistryClientTool/McpRegistryClientTool.node.ts @@ -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), diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts index 44f3d86d3c8..cca25544cf0 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts @@ -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, diff --git a/packages/cli/src/modules/agents/builder/__tests__/resolve-integration.tool.test.ts b/packages/cli/src/modules/agents/builder/__tests__/resolve-integration.tool.test.ts index b8258bbb882..e60f96a5786 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/resolve-integration.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/resolve-integration.tool.test.ts @@ -23,6 +23,7 @@ const notionResult: McpRegistrySearchResult = { credentialType: 'notionMcpOAuth2Api', tools: [], metadata: { nodeTypeName: '@n8n/mcp-registry.notion' }, + isTemplated: false, }; describe('buildResolveIntegrationTool', () => { diff --git a/packages/cli/src/modules/agents/builder/__tests__/search-mcp-servers.tool.test.ts b/packages/cli/src/modules/agents/builder/__tests__/search-mcp-servers.tool.test.ts index 3e3d03ddb42..a025031e5b3 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/search-mcp-servers.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/search-mcp-servers.tool.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts index 1d35f631c97..0e174102a4f 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/mcp-client-factory.test.ts @@ -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(); + // 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(); + + 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(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts index 05a7851d8f1..dd1e355f9d6 100644 --- a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts +++ b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts @@ -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, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 261e8991539..c9113753406 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -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]), diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index d0aa8cbad15..d94752a9532 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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, diff --git a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts index 073c267aedc..10e960ecfe8 100644 --- a/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts +++ b/packages/cli/src/modules/instance-ai/mcp/__tests__/instance-ai-mcp-registry.service.test.ts @@ -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); diff --git a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts index a569220f1ec..1d80d59f87e 100644 --- a/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts +++ b/packages/cli/src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts @@ -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>; + /** 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, diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts index e28a705ee81..96a8bd92d70 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry-connection.test.ts @@ -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', + }, + }); + }); }); diff --git a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts index 6a075f230c7..75b1fdb7eb3 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/mcp-registry.controller.test.ts @@ -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(); @@ -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([]); diff --git a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts index 0cd6776e165..cd4667437df 100644 --- a/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts +++ b/packages/cli/src/modules/mcp-registry/__tests__/node-description-transform.test.ts @@ -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 + }); }); }); diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts b/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts index 982be032674..2fbb17e65e9 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry-connection.ts @@ -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' { diff --git a/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts index 3920cf7baa3..dc54638cee0 100644 --- a/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts +++ b/packages/cli/src/modules/mcp-registry/mcp-registry.controller.ts @@ -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 { const servers = await this.service.getAll({ includeDeprecated: false }); - return servers.map(toResponse); + return servers + .filter((server) => !resolveMcpRegistryConnection(server)?.isTemplated) + .map(toResponse); } } diff --git a/packages/cli/src/modules/mcp-registry/node-description-transform.ts b/packages/cli/src/modules/mcp-registry/node-description-transform.ts index f9a3c0ce470..a8a2d99e9a4 100644 --- a/packages/cli/src/modules/mcp-registry/node-description-transform.ts +++ b/packages/cli/src/modules/mcp-registry/node-description-transform.ts @@ -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, diff --git a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts index 411f072c13f..d6e4d6f9488 100644 --- a/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts +++ b/packages/cli/src/modules/mcp-registry/registry/__tests__/mcp-registry-search.test.ts @@ -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); + }); }); diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts index 5ae6012ff78..c5ebd641c16 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry-search.ts @@ -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, }; } diff --git a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts index 2f1a54b9eca..fb1310be871 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mcp-registry.types.ts @@ -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; @@ -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; diff --git a/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts index 8b8d1974231..935378dc205 100644 --- a/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts +++ b/packages/cli/src/modules/mcp-registry/registry/mock-servers.ts @@ -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', diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index d4e0a124427..547f429853b 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 6551e06f10d..5433ba58194 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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) { diff --git a/packages/workflow/src/mcp-helpers.ts b/packages/workflow/src/mcp-helpers.ts index e387a757512..4fef52260f2 100644 --- a/packages/workflow/src/mcp-helpers.ts +++ b/packages/workflow/src/mcp-helpers.ts @@ -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; + /** 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; }; };