From 2f75531a3520d20a205b666f77782d9aa42de055 Mon Sep 17 00:00:00 2001 From: Arnold Wang Date: Wed, 30 Sep 2026 14:11:39 -0700 Subject: [PATCH 1/2] [feat][MCP] Make public routing mode explicit --- .github/test_mcp_routing.py | 149 ++++++++++++++++++ .github/workflows/ci.yaml | 2 + README.md | 15 ++ charts/retool/Chart.yaml | 2 +- charts/retool/templates/_helpers.tpl | 21 +++ charts/retool/templates/httproute.yaml | 4 +- charts/retool/templates/ingress.yaml | 8 +- .../templates/validate_mcp_routing.yaml | 2 + charts/retool/values.yaml | 51 +++--- values.yaml | 51 +++--- 10 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 .github/test_mcp_routing.py create mode 100644 charts/retool/templates/validate_mcp_routing.yaml diff --git a/.github/test_mcp_routing.py b/.github/test_mcp_routing.py new file mode 100644 index 00000000..790f9d77 --- /dev/null +++ b/.github/test_mcp_routing.py @@ -0,0 +1,149 @@ +"""Check MCP public routes and migration errors in rendered Helm manifests.""" + +import re +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BASE = [ + "helm", "template", "routing", "charts/retool", + "--values", "charts/retool/ci/test-install-values.yaml", + "--values", "charts/retool/ci/test-mcp-enabled-option.yaml", + "--set", "ingress.hosts[0].host=retool.example.com", + "--set", "ingress.hosts[0].paths[0].path=/", +] +MAIN = "routing-retool" +MCP = "routing-retool-mcp" +BACKEND_API = "routing-retool-backend-internal" + + +def render(*settings): + command = BASE.copy() + for setting in settings: + command.extend(("--set", setting)) + return subprocess.run(command, cwd=ROOT, text=True, capture_output=True) + + +def manifest(output, filename): + marker = f"# Source: retool/templates/{filename}\n" + return output.split(marker, 1)[1].split("\n---\n", 1)[0] + + +def ingress_routes(output): + document = manifest(output, "ingress.yaml") + return re.findall( + r"(?m)^\s+- path: (\S+)\n\s+pathType: (\S+)\n\s+backend:\n\s+service:\n\s+name: (\S+)\n\s+port:\n\s+number: (\d+)", + document, + ) + + +def http_routes(output): + document = manifest(output, "httproute.yaml") + return re.findall( + r"(?m)^\s+- matches:\n\s+- path:\n\s+type: (\S+)\n\s+value: (\S+)\n\s+backendRefs:\n\s+- name: (\S+)\n\s+port: (\d+)", + document, + ) + + +INGRESS_DIRECT = [ + ("/.well-known/oauth-authorization-server", "Exact", BACKEND_API, "3001"), + ("/.well-known/oauth-protected-resource", "Exact", MCP, "4010"), + ("/mcp", "Prefix", MCP, "4010"), + ("/", "ImplementationSpecific", MAIN, "3000"), +] +HTTP_DIRECT = [ + ("Exact", "/.well-known/oauth-authorization-server", BACKEND_API, "3001"), + ("Exact", "/.well-known/oauth-protected-resource", MCP, "4010"), + ("PathPrefix", "/mcp", MCP, "4010"), + ("PathPrefix", "/", MAIN, "3000"), +] + + +class RoutingTests(unittest.TestCase): + def assert_rendered(self, *settings): + result = render(*settings) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def assert_rejected(self, message, *settings): + result = render(*settings) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn(message, result.stderr) + + def test_default_backend_relay_uses_main_service_for_both_route_types(self): + output = self.assert_rendered() + self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]]) + self.assertEqual(http_routes(output), [HTTP_DIRECT[-1]]) + self.assertIn('value: "http://routing-retool-mcp:4010"', manifest(output, "deployment_backend.yaml")) + self.assertIn("- name: MCP_SERVICE_INGRESS_DOMAIN", manifest(output, "deployment_backend.yaml")) + + def test_direct_renders_legacy_mappings_for_both_route_types(self): + output = self.assert_rendered("mcp.routing.mode=direct") + self.assertEqual(ingress_routes(output), INGRESS_DIRECT) + self.assertEqual(http_routes(output), HTTP_DIRECT) + self.assertIn("- name: MCP_SERVICE_INGRESS_DOMAIN", manifest(output, "deployment_backend.yaml")) + + def test_hostname_ingress_branch_follows_mode(self): + output = self.assert_rendered("ingress.hostName=retool.example.com") + self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]]) + output = self.assert_rendered("ingress.hostName=retool.example.com", "mcp.routing.mode=direct") + self.assertEqual(ingress_routes(output), INGRESS_DIRECT) + + def test_explicit_true_legacy_flags_work_in_direct_mode(self): + output = self.assert_rendered("mcp.routing.mode=direct", "mcp.ingress.enabled=true", "mcp.httpRoute.enabled=true") + self.assertEqual(ingress_routes(output), INGRESS_DIRECT) + self.assertEqual(http_routes(output), HTTP_DIRECT) + + def test_disabled_mcp_has_only_main_routes(self): + output = self.assert_rendered("mcp.enabled=false", "mcp.routing.mode=direct", "mcp.ingress.enabled=true", "mcp.httpRoute.enabled=true") + self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]]) + self.assertEqual(http_routes(output), [HTTP_DIRECT[-1]]) + self.assertNotIn("- name: MCP_SERVICE_INGRESS_DOMAIN", manifest(output, "deployment_backend.yaml")) + + def test_explicit_false_skips_direct_rules_on_that_surface(self): + output = self.assert_rendered("mcp.routing.mode=direct", "mcp.ingress.enabled=false") + self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]]) + self.assertEqual(http_routes(output), HTTP_DIRECT) + output = self.assert_rendered("mcp.routing.mode=direct", "mcp.httpRoute.enabled=false") + self.assertEqual(ingress_routes(output), INGRESS_DIRECT) + self.assertEqual(http_routes(output), [HTTP_DIRECT[-1]]) + + def test_custom_legacy_ports_survive_in_direct_mode(self): + output = self.assert_rendered( + "mcp.routing.mode=direct", + "mcp.ingress.paths[0].path=/.well-known/oauth-authorization-server", + "mcp.ingress.paths[0].pathType=Exact", + "mcp.ingress.paths[0].target=backendInternal", + "mcp.ingress.paths[0].port=3002", + "mcp.httpRoute.rules[0].path=/mcp", + "mcp.httpRoute.rules[0].pathType=PathPrefix", + "mcp.httpRoute.rules[0].port=4020", + ) + self.assertEqual(ingress_routes(output)[0], ("/.well-known/oauth-authorization-server", "Exact", BACKEND_API, "3002")) + self.assertEqual(http_routes(output)[0], ("PathPrefix", "/mcp", MCP, "4020")) + + def test_explicit_false_is_accepted_in_backend_relay(self): + output = self.assert_rendered("mcp.ingress.enabled=false", "mcp.httpRoute.enabled=false") + self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]]) + self.assertEqual(http_routes(output), [HTTP_DIRECT[-1]]) + + def test_old_explicit_true_flags_fail_with_migration_guidance(self): + for surface in ("ingress", "httpRoute"): + with self.subTest(surface=surface): + self.assert_rejected( + f"mcp.{surface}.enabled=true conflicts with mcp.routing.mode=backendRelay", + f"mcp.{surface}.enabled=true", + ) + + def test_unknown_mode_fails_even_if_mcp_and_public_routes_are_disabled(self): + self.assert_rejected('mcp.routing.mode must be "backendRelay" or "direct"', "mcp.routing.mode=other") + self.assert_rejected( + 'mcp.routing.mode must be "backendRelay" or "direct"', + "mcp.routing.mode=other", "mcp.enabled=false", "ingress.enabled=false", "httpRoute.enabled=false", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 59f18746..91783706 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -72,6 +72,8 @@ jobs: run: ct lint --config .github/ct.yaml - name: Verify MCP Helm test render and HTTP behavior run: python3 .github/test_mcp_helm_test.py + - name: Verify MCP routing modes and migration errors + run: python3 .github/test_mcp_routing.py # We don't use helm-docs yet, can set this up later # diff --git a/README.md b/README.md index 1145eec0..653c77e4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,21 @@ This is the repository for the official Retool Helm chart. For release notes, se For any inquiries regarding deploying Retool on Helm, please feel free to reach out to us at support@retool.com or search our [Community Forums](https://community.retool.com/) and post your question there. +## MCP public routing + +With `mcp.enabled: true`, `mcp.routing.mode` defaults to `backendRelay` for Retool 4.0.7 and later. The normal `/` Ingress or HTTPRoute sends all public paths to the main Retool Service, and its backend relays `/mcp` to the MCP Service using the chart-provided `MCP_SERVICE_INGRESS_DOMAIN`. For Retool before 4.0.7, set `mcp.routing.mode: direct`; those versions need dedicated MCP and OAuth discovery routes. Choose the mode explicitly for your server version, including builds with PR or custom image tags. + +| Mode | Public path | Service | +| --- | --- | --- | +| `backendRelay` | All paths, including `/mcp` and `/.well-known/*` | Main Retool Service, port 3000 | +| `direct` | `/.well-known/oauth-authorization-server` (exact) | Backend API Service, port 3001 | +| `direct` | `/.well-known/oauth-protected-resource` (exact) and `/mcp` (prefix) | MCP Service, port 4010 | +| `direct` | `/` (prefix) | Main Retool Service, port 3000 | + +These are also the mappings to use when ingress is managed outside the chart. In `direct` mode, place the dedicated paths before the main `/` path. The service names are ``, `-backend-internal`, and `-mcp`, where `` is the chart release's full name. + +When upgrading existing values files, remove `mcp.ingress.enabled: true` and `mcp.httpRoute.enabled: true` to use `backendRelay`, or set `mcp.routing.mode: direct` for an older server. An explicit legacy `true` setting conflicts with `backendRelay` and stops chart rendering with migration guidance. Both flags now default to `null`, which follows the mode. An explicit `false` keeps the corresponding chart-managed direct routes off when you supply them externally. + ## MCP public URL smoke test When MCP is enabled, set `mcp.test.publicUrl` to the public Retool origin to add an optional `helm test` Job: diff --git a/charts/retool/Chart.yaml b/charts/retool/Chart.yaml index 6137c4c6..74560255 100644 --- a/charts/retool/Chart.yaml +++ b/charts/retool/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: retool description: A Helm chart for Kubernetes type: application -version: 6.12.2 +version: 6.12.3 maintainers: - name: Retool Engineering email: engineering+helm@retool.com diff --git a/charts/retool/templates/_helpers.tpl b/charts/retool/templates/_helpers.tpl index 46d27120..d285e9a3 100644 --- a/charts/retool/templates/_helpers.tpl +++ b/charts/retool/templates/_helpers.tpl @@ -51,6 +51,27 @@ env.BASE_DOMAIN. Secret-backed values cannot be resolved at template time. {{- trimSuffix "/" (trimPrefix "http://" (trimPrefix "https://" (toString $domain))) -}} {{- end }} +{{/* Validate the public MCP routing choice, including legacy opt-in flags. */}} +{{- define "retool.mcp.routingMode" -}} +{{- $mcp := .Values.mcp | default dict -}} +{{- $routing := $mcp.routing | default dict -}} +{{- $mode := $routing.mode -}} +{{- if not (has $mode (list "backendRelay" "direct")) -}} +{{- fail (printf "mcp.routing.mode must be \"backendRelay\" or \"direct\" (got %q)" (toString $mode)) -}} +{{- end -}} +{{- range $surface := list "ingress" "httpRoute" -}} + {{- $legacy := get $mcp $surface | default dict -}} + {{- $enabled := get $legacy "enabled" -}} + {{- if and (not (empty $enabled)) (not (kindIs "bool" $enabled)) -}} + {{- fail (printf "mcp.%s.enabled must be true, false, or null" $surface) -}} + {{- end -}} + {{- if and $mcp.enabled (eq $mode "backendRelay") $enabled -}} + {{- fail (printf "mcp.%s.enabled=true conflicts with mcp.routing.mode=backendRelay: remove mcp.%s.enabled or set it to false to route through the main Retool Service; use mcp.routing.mode=direct for Retool before 4.0.7" $surface $surface) -}} + {{- end -}} +{{- end -}} +{{- $mode -}} +{{- end }} + {{/* MCP Service env var for the main Retool backend. Explicit backend env settings take precedence over the chart-generated in-cluster Service URL. diff --git a/charts/retool/templates/httproute.yaml b/charts/retool/templates/httproute.yaml index 77b39854..6dcf5a09 100644 --- a/charts/retool/templates/httproute.yaml +++ b/charts/retool/templates/httproute.yaml @@ -4,6 +4,8 @@ {{- $mcp := .Values.mcp | default dict -}} {{- $mcpBackendMetadata := $mcp.backendMetadata | default dict -}} {{- $mcpHttpRoute := $mcp.httpRoute | default dict -}} +{{- $mcpRoutingMode := include "retool.mcp.routingMode" . -}} +{{- $mcpDirectHttpRoute := and $mcp.enabled (eq $mcpRoutingMode "direct") (or (not (kindIs "bool" $mcpHttpRoute.enabled)) $mcpHttpRoute.enabled) -}} {{- $backendInternalService := $mcpBackendMetadata.service | default dict -}} {{- $backendInternalPort := $backendInternalService.externalPort | default 3001 -}} apiVersion: gateway.networking.k8s.io/v1 @@ -40,7 +42,7 @@ spec: port: {{ .port }} {{- end }} {{- end }} - {{- if ( and ((.Values.mcp).enabled) $mcpHttpRoute.enabled ) }} + {{- if $mcpDirectHttpRoute }} {{- range $mcpHttpRoute.rules }} {{- include "retool.httpRoute.mcpRule" (dict "root" $ "rule" . "backendInternalPort" $backendInternalPort) | nindent 4 }} {{- end }} diff --git a/charts/retool/templates/ingress.yaml b/charts/retool/templates/ingress.yaml index 0fa630fb..6cef83b1 100644 --- a/charts/retool/templates/ingress.yaml +++ b/charts/retool/templates/ingress.yaml @@ -4,6 +4,8 @@ {{- $mcp := .Values.mcp | default dict -}} {{- $mcpBackendMetadata := $mcp.backendMetadata | default dict -}} {{- $mcpIngress := $mcp.ingress | default dict -}} +{{- $mcpRoutingMode := include "retool.mcp.routingMode" . -}} +{{- $mcpDirectIngress := and $mcp.enabled (eq $mcpRoutingMode "direct") (or (not (kindIs "bool" $mcpIngress.enabled)) $mcpIngress.enabled) -}} {{- $backendInternalService := $mcpBackendMetadata.service | default dict -}} {{- $backendInternalPort := $backendInternalService.externalPort | default 3001 -}} {{- $pathType := .Values.ingress.pathType -}} @@ -49,12 +51,12 @@ spec: number: {{ .port }} {{- end }} {{- end }} - {{- if ( and ((.Values.mcp).enabled) $mcpIngress.enabled ) }} + {{- if $mcpDirectIngress }} {{- range $mcpIngress.paths }} {{- include "retool.ingress.mcpPath" (dict "root" $ "path" . "backendInternalPort" $backendInternalPort) | nindent 10 }} {{- end }} {{- end }} - - path: + - path: / {{- if and $pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.Version) }} pathType: {{ $pathType }} {{- end }} @@ -90,7 +92,7 @@ spec: number: {{ .port }} {{- end }} {{- end }} - {{- if ( and (($.Values.mcp).enabled) $mcpIngress.enabled ) }} + {{- if $mcpDirectIngress }} {{- range $mcpIngress.paths }} {{- include "retool.ingress.mcpPath" (dict "root" $ "path" . "backendInternalPort" $backendInternalPort) | nindent 10 }} {{- end }} diff --git a/charts/retool/templates/validate_mcp_routing.yaml b/charts/retool/templates/validate_mcp_routing.yaml new file mode 100644 index 00000000..da71cf99 --- /dev/null +++ b/charts/retool/templates/validate_mcp_routing.yaml @@ -0,0 +1,2 @@ +{{- /* Run mode and migration validation even when neither public route is enabled. */ -}} +{{- $mode := include "retool.mcp.routingMode" . -}} diff --git a/charts/retool/values.yaml b/charts/retool/values.yaml index b7a64cd2..6267e73b 100644 --- a/charts/retool/values.yaml +++ b/charts/retool/values.yaml @@ -809,7 +809,7 @@ mcp: limits: memory: "4096Mi" - # Backend API Service for the OAuth authorization-server metadata route. + # Backend API Service for the direct-mode OAuth authorization-server route. backendMetadata: service: enabled: true @@ -819,28 +819,29 @@ mcp: annotations: {} labels: {} - # Retool 4.0.7 and later support a simplified ingress setup: route all public - # paths, including /mcp and /.well-known, to the main Retool Service on port - # 3000. The main backend must also be able to relay /mcp to the in-cluster MCP - # Service. When mcp.enabled is true, the chart configures the backend with - # MCP_SERVICE_INGRESS_DOMAIN=http://-mcp:. - # You can override it through the top-level env, environmentSecrets, or - # environmentVariables settings. - # - # With that setting, omit or disable the MCP-specific ingress or HTTPRoute - # rules below and keep only the normal "/" route to :3000. Replace - # with this chart release's full name. - # - # Retool versions before 4.0.7 require the explicit MCP routes below, - # rendered before the main Retool route. External ingress must preserve this - # order and target mapping: - # Exact /.well-known/oauth-authorization-server -> -backend-internal:3001 - # Exact /.well-known/oauth-protected-resource -> -mcp:4010 - # Prefix /mcp -> -mcp:4010 - # Prefix / -> :3000 + routing: + # Retool 4.0.7 and later support backendRelay: send every public path, + # including /mcp and both /.well-known OAuth discovery paths, to the main + # Retool Service (:3000). Its backend relays /mcp to the MCP + # Service. The chart sets MCP_SERVICE_INGRESS_DOMAIN to the in-cluster MCP + # Service URL when MCP is enabled unless you override that backend env var. + # Retool before 4.0.7 needs direct routing because its main backend does + # not relay /mcp. Set direct to render the dedicated rules below. + # For ingress managed outside this chart, use these public mappings: + # backendRelay: all paths, including /mcp and /.well-known/* -> :3000 + # direct: Exact /.well-known/oauth-authorization-server -> -backend-internal:3001 + # Exact /.well-known/oauth-protected-resource -> -mcp:4010 + # Prefix /mcp -> -mcp:4010 + # Prefix / -> :3000 + # Replace with this chart release's full name. Do not infer the + # mode from image.tag; PR and custom image tags may not identify a version. + mode: backendRelay + ingress: - # Also requires mcp.enabled. - enabled: true + # null follows routing.mode (on for direct, off for backendRelay). Set false + # when an external ingress owns direct routes. Explicit true conflicts with + # backendRelay; remove it or select direct when migrating old values files. + enabled: null paths: - path: /.well-known/oauth-authorization-server pathType: Exact @@ -852,8 +853,10 @@ mcp: # Equivalent Gateway API routes. httpRoute: - # Also requires mcp.enabled. - enabled: true + # null follows routing.mode (on for direct, off for backendRelay). Set false + # when an external HTTPRoute owns direct routes. Explicit true conflicts + # with backendRelay; remove it or select direct when migrating old values. + enabled: null rules: - path: /.well-known/oauth-authorization-server pathType: Exact diff --git a/values.yaml b/values.yaml index b7a64cd2..6267e73b 100644 --- a/values.yaml +++ b/values.yaml @@ -809,7 +809,7 @@ mcp: limits: memory: "4096Mi" - # Backend API Service for the OAuth authorization-server metadata route. + # Backend API Service for the direct-mode OAuth authorization-server route. backendMetadata: service: enabled: true @@ -819,28 +819,29 @@ mcp: annotations: {} labels: {} - # Retool 4.0.7 and later support a simplified ingress setup: route all public - # paths, including /mcp and /.well-known, to the main Retool Service on port - # 3000. The main backend must also be able to relay /mcp to the in-cluster MCP - # Service. When mcp.enabled is true, the chart configures the backend with - # MCP_SERVICE_INGRESS_DOMAIN=http://-mcp:. - # You can override it through the top-level env, environmentSecrets, or - # environmentVariables settings. - # - # With that setting, omit or disable the MCP-specific ingress or HTTPRoute - # rules below and keep only the normal "/" route to :3000. Replace - # with this chart release's full name. - # - # Retool versions before 4.0.7 require the explicit MCP routes below, - # rendered before the main Retool route. External ingress must preserve this - # order and target mapping: - # Exact /.well-known/oauth-authorization-server -> -backend-internal:3001 - # Exact /.well-known/oauth-protected-resource -> -mcp:4010 - # Prefix /mcp -> -mcp:4010 - # Prefix / -> :3000 + routing: + # Retool 4.0.7 and later support backendRelay: send every public path, + # including /mcp and both /.well-known OAuth discovery paths, to the main + # Retool Service (:3000). Its backend relays /mcp to the MCP + # Service. The chart sets MCP_SERVICE_INGRESS_DOMAIN to the in-cluster MCP + # Service URL when MCP is enabled unless you override that backend env var. + # Retool before 4.0.7 needs direct routing because its main backend does + # not relay /mcp. Set direct to render the dedicated rules below. + # For ingress managed outside this chart, use these public mappings: + # backendRelay: all paths, including /mcp and /.well-known/* -> :3000 + # direct: Exact /.well-known/oauth-authorization-server -> -backend-internal:3001 + # Exact /.well-known/oauth-protected-resource -> -mcp:4010 + # Prefix /mcp -> -mcp:4010 + # Prefix / -> :3000 + # Replace with this chart release's full name. Do not infer the + # mode from image.tag; PR and custom image tags may not identify a version. + mode: backendRelay + ingress: - # Also requires mcp.enabled. - enabled: true + # null follows routing.mode (on for direct, off for backendRelay). Set false + # when an external ingress owns direct routes. Explicit true conflicts with + # backendRelay; remove it or select direct when migrating old values files. + enabled: null paths: - path: /.well-known/oauth-authorization-server pathType: Exact @@ -852,8 +853,10 @@ mcp: # Equivalent Gateway API routes. httpRoute: - # Also requires mcp.enabled. - enabled: true + # null follows routing.mode (on for direct, off for backendRelay). Set false + # when an external HTTPRoute owns direct routes. Explicit true conflicts + # with backendRelay; remove it or select direct when migrating old values. + enabled: null rules: - path: /.well-known/oauth-authorization-server pathType: Exact From dc98d392225d8df9f7cb374d00aee3ffc46cf6f5 Mon Sep 17 00:00:00 2001 From: Arnold Wang Date: Wed, 30 Sep 2026 14:30:51 -0700 Subject: [PATCH 2/2] [BFA-130] Default MCP public origin from BASE_DOMAIN --- .github/test_mcp_routing.py | 76 ++++++++++++++++++- README.md | 34 +++++++++ charts/retool/Chart.yaml | 2 +- charts/retool/ci/test-install-values.yaml | 2 +- charts/retool/ci/test-mcp-enabled-option.yaml | 4 +- charts/retool/templates/_helpers.tpl | 21 +++++ charts/retool/templates/deployment_mcp.yaml | 20 ++++- .../templates/validate_mcp_routing.yaml | 43 +++++++++++ charts/retool/values.yaml | 11 +-- values.yaml | 11 +-- 10 files changed, 207 insertions(+), 17 deletions(-) diff --git a/.github/test_mcp_routing.py b/.github/test_mcp_routing.py index 790f9d77..a9ebb7ce 100644 --- a/.github/test_mcp_routing.py +++ b/.github/test_mcp_routing.py @@ -13,6 +13,8 @@ "--values", "charts/retool/ci/test-mcp-enabled-option.yaml", "--set", "ingress.hosts[0].host=retool.example.com", "--set", "ingress.hosts[0].paths[0].path=/", + "--set", "env.BASE_DOMAIN=https://retool.example.com", + "--set", "mcp.config.mcpServiceExternalUrl=https://retool.example.com", ] MAIN = "routing-retool" MCP = "routing-retool-mcp" @@ -28,7 +30,7 @@ def render(*settings): def manifest(output, filename): marker = f"# Source: retool/templates/{filename}\n" - return output.split(marker, 1)[1].split("\n---\n", 1)[0] + return output.rsplit(marker, 1)[1].split("\n---\n", 1)[0] def ingress_routes(output): @@ -144,6 +146,78 @@ def test_unknown_mode_fails_even_if_mcp_and_public_routes_are_disabled(self): "mcp.routing.mode=other", "mcp.enabled=false", "ingress.enabled=false", "httpRoute.enabled=false", ) + def test_base_domain_is_the_mcp_public_fallback_in_both_modes(self): + for mode in ("backendRelay", "direct"): + with self.subTest(mode=mode): + output = self.assert_rendered( + f"mcp.routing.mode={mode}", + "mcp.config.mcpServiceExternalUrl=", + "mcp.config.oauthMainDomain=", + ) + mcp = manifest(output, "deployment_mcp.yaml") + self.assertIn('- name: "BASE_DOMAIN"\n value: "https://retool.example.com"', mcp) + self.assertIn('name: OAUTH_MAIN_DOMAIN\n value: "retool.example.com"', mcp) + self.assertNotIn("- name: MCP_SERVICE_EXTERNAL_URL", mcp) + + def test_explicit_origin_override_is_shared_with_backend(self): + output = self.assert_rendered() + for deployment in ("deployment_mcp.yaml", "deployment_backend.yaml"): + self.assertIn( + 'name: MCP_SERVICE_EXTERNAL_URL\n value: "https://retool.example.com"', + manifest(output, deployment), + ) + + def test_environment_overrides_are_not_duplicated(self): + output = self.assert_rendered( + "env.MCP_SERVICE_EXTERNAL_URL=https://retool.example.com", + "mcp.environmentVariables[0].name=MCP_SERVICE_EXTERNAL_URL", + "mcp.environmentVariables[0].value=https://retool.example.com", + ) + for deployment in ("deployment_backend.yaml", "deployment_mcp.yaml"): + self.assertEqual( + len(re.findall(r'name: "?MCP_SERVICE_EXTERNAL_URL"?', manifest(output, deployment))), 1, + ) + + def test_mismatched_chart_managed_hosts_fail_in_both_modes(self): + for mode in ("backendRelay", "direct"): + with self.subTest(mode=mode): + self.assert_rejected( + 'env.BASE_DOMAIN host "retool.example.com" does not match a chart-managed ingress host', + f"mcp.routing.mode={mode}", "ingress.hosts[0].host=other.example.com", + ) + self.assert_rejected( + 'does not match a chart-managed HTTPRoute hostname', + f"mcp.routing.mode={mode}", "httpRoute.hostnames[0]=other.example.com", + ) + self.assert_rejected( + 'mcp.config.mcpServiceExternalUrl host "other.example.com" does not match a chart-managed ingress host', + "mcp.environmentVariables[0].name=MCP_SERVICE_EXTERNAL_URL", + "mcp.environmentVariables[0].value=https://other.example.com", + ) + + def test_external_ingress_and_custom_host_remain_supported(self): + self.assert_rendered( + "ingress.enabled=false", "httpRoute.enabled=false", + "env.BASE_DOMAIN=https://public.example.com", + ) + self.assert_rendered("ingress.hosts[1].host=custom.space.example.org") + + def test_unresolved_base_domain_can_be_secret_backed(self): + command = BASE.copy() + base_index = command.index("env.BASE_DOMAIN=https://retool.example.com") + del command[base_index - 1:base_index + 1] + result = subprocess.run( + command + [ + "--set", "mcp.config.mcpServiceExternalUrl=", + "--set-json", 'env.BASE_DOMAIN={"valueFrom":{"secretKeyRef":{"name":"public-origin","key":"url"}}}', + ], + cwd=ROOT, text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + mcp = manifest(result.stdout, "deployment_mcp.yaml") + self.assertIn('name: "BASE_DOMAIN"', mcp) + self.assertIn('name: public-origin', mcp) + if __name__ == "__main__": unittest.main() diff --git a/README.md b/README.md index 653c77e4..a72e70e1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,40 @@ For any inquiries regarding deploying Retool on Helm, please feel free to reach ## MCP public routing +### One public origin + +For a single-host installation, set `env.BASE_DOMAIN` to the public Retool +origin, including `https://`, and serve that host through the chart-managed +Ingress or HTTPRoute (or your external ingress). The chart passes this value to +the backend and MCP process. The server uses it when a request reaches an +internal Service host; it keeps a valid public request host for custom Space +domains. `RETOOL_BACKEND_URL` remains an internal destination and is never a +client-facing URL. + +The chart derives `OAUTH_MAIN_DOMAIN` from `BASE_DOMAIN` when its value is +available at render time. A secret-backed `BASE_DOMAIN` is passed through but +cannot be checked against route hosts during rendering. Explicit +`mcp.config.oauthMainDomain`, `mcp.config.mcpServiceExternalUrl` (or its legacy +`retoolUrl` alias), and `mcp.environmentVariables` take precedence. An explicit +`MCP_SERVICE_EXTERNAL_URL` pins the advertised origin, including on custom +Space domains; omit it when those domains should be advertised per request. +The chart copies an explicit MCP external URL to the backend unless the +backend has its own explicit value. Keep explicit values equal across both +workloads so discovery and upload links agree. + +If your proxy changes the Host or scheme before forwarding, set +`env.MCP_TRUSTED_PROXY_CIDRS` to the comma-separated CIDRs of the immediate +trusted proxy peers. The chart passes it to the MCP process too. Only requests +from those peers may use `X-Forwarded-Host` and `X-Forwarded-Proto` for MCP +advertised URLs. Include every relevant proxy peer range when custom Space +domains reach Retool through an internal Host. Otherwise the server falls back +to `BASE_DOMAIN` for an internal Host. + +When the chart knows the Ingress host or HTTPRoute hostnames, MCP rendering +checks that `BASE_DOMAIN` and any explicit MCP external URL match a managed +host. Multiple hosts are allowed for custom domains. With externally managed +ingress, disable the chart-managed route and provide equivalent public paths. + With `mcp.enabled: true`, `mcp.routing.mode` defaults to `backendRelay` for Retool 4.0.7 and later. The normal `/` Ingress or HTTPRoute sends all public paths to the main Retool Service, and its backend relays `/mcp` to the MCP Service using the chart-provided `MCP_SERVICE_INGRESS_DOMAIN`. For Retool before 4.0.7, set `mcp.routing.mode: direct`; those versions need dedicated MCP and OAuth discovery routes. Choose the mode explicitly for your server version, including builds with PR or custom image tags. | Mode | Public path | Service | diff --git a/charts/retool/Chart.yaml b/charts/retool/Chart.yaml index 74560255..a8c07496 100644 --- a/charts/retool/Chart.yaml +++ b/charts/retool/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: retool description: A Helm chart for Kubernetes type: application -version: 6.12.3 +version: 6.12.4 maintainers: - name: Retool Engineering email: engineering+helm@retool.com diff --git a/charts/retool/ci/test-install-values.yaml b/charts/retool/ci/test-install-values.yaml index 3bedd015..1cf1cc08 100644 --- a/charts/retool/ci/test-install-values.yaml +++ b/charts/retool/ci/test-install-values.yaml @@ -54,7 +54,7 @@ commandline: env: # required env var for backend to start up, but not actually registered - BASE_DOMAIN: "https://helm-ci.retool.dev" + BASE_DOMAIN: "https://retool.example.com" # CI runs all pods at once in kind. Avoid failing chart install on transient # DNS/startup ordering while the code executor service is coming up. IGNORE_CODE_EXECUTOR_STARTUP_CHECK: "true" diff --git a/charts/retool/ci/test-mcp-enabled-option.yaml b/charts/retool/ci/test-mcp-enabled-option.yaml index a5a3d537..95cd2007 100644 --- a/charts/retool/ci/test-mcp-enabled-option.yaml +++ b/charts/retool/ci/test-mcp-enabled-option.yaml @@ -2,8 +2,8 @@ mcp: enabled: true replicaCount: 2 config: - mcpServiceExternalUrl: https://example.retool.com - oauthMainDomain: example.retool.com + mcpServiceExternalUrl: https://retool.example.com + oauthMainDomain: retool.example.com agentSandboxJwtPrivateKey: test-agent-sandbox-jwt-private-key enabledToolsets: - apps diff --git a/charts/retool/templates/_helpers.tpl b/charts/retool/templates/_helpers.tpl index d285e9a3..bea06459 100644 --- a/charts/retool/templates/_helpers.tpl +++ b/charts/retool/templates/_helpers.tpl @@ -78,6 +78,27 @@ take precedence over the chart-generated in-cluster Service URL. */}} {{- define "retool.mcp.backendEnvVars" -}} {{- if .Values.mcp.enabled }} +{{- $backendHasExternalUrl := eq (include "retool.envVarIsExplicit" (dict "root" . "name" "MCP_SERVICE_EXTERNAL_URL")) "1" -}} +{{- if not $backendHasExternalUrl -}} +{{- $mcpExternalEnv := dict -}} +{{- range .Values.mcp.environmentVariables -}} +{{- if eq .name "MCP_SERVICE_EXTERNAL_URL" -}}{{- $mcpExternalEnv = . -}}{{- end -}} +{{- end -}} +{{- if $mcpExternalEnv -}} +{{- toYaml (list $mcpExternalEnv) }} +{{- else -}} +{{- $mcpConfig := .Values.mcp.config | default dict -}} +{{- $externalUrl := $mcpConfig.mcpServiceExternalUrl | default $mcpConfig.retoolUrl | default "" -}} +{{- if $externalUrl -}} +{{- $externalUrl = trimSuffix "/" (toString $externalUrl) -}} +{{- if not (or (hasPrefix "http://" $externalUrl) (hasPrefix "https://" $externalUrl)) -}} +{{- $externalUrl = printf "https://%s" $externalUrl -}} +{{- end -}} +- name: MCP_SERVICE_EXTERNAL_URL + value: {{ $externalUrl | quote }} +{{- end -}} +{{- end -}} +{{- end -}} {{- $backendHasMcpServiceIngressDomain := hasKey (.Values.env | default dict) "MCP_SERVICE_INGRESS_DOMAIN" -}} {{- range .Values.environmentVariables }} {{- if eq .name "MCP_SERVICE_INGRESS_DOMAIN" }} diff --git a/charts/retool/templates/deployment_mcp.yaml b/charts/retool/templates/deployment_mcp.yaml index 78b220ce..7c251f51 100644 --- a/charts/retool/templates/deployment_mcp.yaml +++ b/charts/retool/templates/deployment_mcp.yaml @@ -7,6 +7,16 @@ {{- $mcpServiceExternalUrl = printf "https://%s" $mcpServiceExternalUrl }} {{- end }} {{- $mcpOAuthMainDomain := include "retool.mcp.oauthMainDomain" . }} + {{- $mcpBaseDomain := get (.Values.env | default dict) "BASE_DOMAIN" }} + {{- $mcpTrustedProxies := get (.Values.env | default dict) "MCP_TRUSTED_PROXY_CIDRS" }} + {{- $hasBaseDomainEnv := false }} + {{- $hasTrustedProxiesEnv := false }} + {{- $hasExternalUrlEnv := false }} + {{- range .Values.mcp.environmentVariables }} + {{- if eq .name "BASE_DOMAIN" }}{{- $hasBaseDomainEnv = true }}{{- end }} + {{- if eq .name "MCP_TRUSTED_PROXY_CIDRS" }}{{- $hasTrustedProxiesEnv = true }}{{- end }} + {{- if eq .name "MCP_SERVICE_EXTERNAL_URL" }}{{- $hasExternalUrlEnv = true }}{{- end }} + {{- end }} {{- $hasOAuthMainDomainEnv := false }} {{- range .Values.mcp.environmentVariables }} {{- if eq .name "OAUTH_MAIN_DOMAIN" }} @@ -36,7 +46,7 @@ {{- $mcpAgentSandboxJwtPrivateKeySecretName = .Values.rr.agentSandbox.externalSecret.name }} {{- end }} {{- end }} - {{- if not (or $mcpOAuthMainDomain $hasOAuthMainDomainEnv) }} + {{- if not (or $mcpOAuthMainDomain $hasOAuthMainDomainEnv $mcpBaseDomain $hasBaseDomainEnv) }} {{- fail "Please set .Values.mcp.config.oauthMainDomain, .Values.env.BASE_DOMAIN, or an OAUTH_MAIN_DOMAIN entry in .Values.mcp.environmentVariables when the MCP server is enabled (.Values.mcp.enabled)" }} {{- end }} {{- if not (or $mcpConfig.oauthIntrospectionAuthTokenSecretName $mcpConfig.oauthIntrospectionAuthToken $hasOAuthIntrospectionAuthTokenEnv $chartOwnedConfigSecret) }} @@ -162,10 +172,16 @@ spec: - name: RETOOL_GIT_SERVER_URL value: {{ $retoolGitServerUrl | quote }} {{- end }} - {{- if $mcpServiceExternalUrl }} + {{- if and $mcpServiceExternalUrl (not $hasExternalUrlEnv) }} - name: MCP_SERVICE_EXTERNAL_URL value: {{ $mcpServiceExternalUrl | quote }} {{- end }} + {{- if and $mcpBaseDomain (not $hasBaseDomainEnv) }} + {{- include "retool.env" (dict "BASE_DOMAIN" $mcpBaseDomain) | nindent 10 }} + {{- end }} + {{- if and $mcpTrustedProxies (not $hasTrustedProxiesEnv) }} + {{- include "retool.env" (dict "MCP_TRUSTED_PROXY_CIDRS" $mcpTrustedProxies) | nindent 10 }} + {{- end }} {{- if and $mcpOAuthMainDomain (not $hasOAuthMainDomainEnv) }} - name: OAUTH_MAIN_DOMAIN value: {{ $mcpOAuthMainDomain | quote }} diff --git a/charts/retool/templates/validate_mcp_routing.yaml b/charts/retool/templates/validate_mcp_routing.yaml index da71cf99..955cddfb 100644 --- a/charts/retool/templates/validate_mcp_routing.yaml +++ b/charts/retool/templates/validate_mcp_routing.yaml @@ -1,2 +1,45 @@ {{- /* Run mode and migration validation even when neither public route is enabled. */ -}} {{- $mode := include "retool.mcp.routingMode" . -}} +{{- if .Values.mcp.enabled -}} +{{- $base := get (.Values.env | default dict) "BASE_DOMAIN" -}} +{{- if kindIs "map" $base -}}{{- $base = get $base "value" -}}{{- end -}} +{{- $external := (.Values.mcp.config | default dict).mcpServiceExternalUrl | default (.Values.mcp.config | default dict).retoolUrl | default "" -}} +{{- range .Values.mcp.environmentVariables -}} + {{- if eq .name "BASE_DOMAIN" -}}{{- $base = .value | default "" -}}{{- end -}} + {{- if eq .name "MCP_SERVICE_EXTERNAL_URL" -}}{{- $external = .value | default "" -}}{{- end -}} +{{- end -}} +{{- $sources := dict "env.BASE_DOMAIN" $base "mcp.config.mcpServiceExternalUrl" $external -}} +{{- range $source, $raw := $sources -}} + {{- if $raw -}} + {{- $url := toString $raw -}} + {{- if and (eq $source "mcp.config.mcpServiceExternalUrl") (not (regexMatch "^https?://" $url)) -}} + {{- $url = printf "https://%s" $url -}} + {{- end -}} + {{- if not (regexMatch "^https?://[a-zA-Z0-9.-]+(:[0-9]+)?/?$" $url) -}} + {{- fail (printf "%s must be an HTTP(S) origin without a path (got %q)" $source $url) -}} + {{- end -}} + {{- $host := regexReplaceAll "^https?://" $url "" | trimSuffix "/" | lower -}} + {{- $host = regexReplaceAll ":[0-9]+$" $host "" -}} + {{- if $.Values.ingress.enabled -}} + {{- $hosts := list -}} + {{- if $.Values.ingress.hostName -}}{{- $hosts = append $hosts $.Values.ingress.hostName -}}{{- else -}} + {{- range $.Values.ingress.hosts -}}{{- if .host -}}{{- $hosts = append $hosts .host -}}{{- end -}}{{- end -}} + {{- end -}} + {{- if $hosts -}} + {{- $matched := false -}} + {{- range $routeHost := $hosts -}} + {{- if or (eq (lower $routeHost) $host) (and (hasPrefix "*." $routeHost) (hasSuffix (trimPrefix "*" (lower $routeHost)) $host)) -}}{{- $matched = true -}}{{- end -}} + {{- end -}} + {{- if not $matched -}}{{- fail (printf "%s host %q does not match a chart-managed ingress host (%s)" $source $host (join ", " $hosts)) -}}{{- end -}} + {{- end -}} + {{- end -}} + {{- if and $.Values.httpRoute.enabled $.Values.httpRoute.hostnames -}} + {{- $matched := false -}} + {{- range $routeHost := $.Values.httpRoute.hostnames -}} + {{- if or (eq (lower $routeHost) $host) (and (hasPrefix "*." $routeHost) (hasSuffix (trimPrefix "*" (lower $routeHost)) $host)) -}}{{- $matched = true -}}{{- end -}} + {{- end -}} + {{- if not $matched -}}{{- fail (printf "%s host %q does not match a chart-managed HTTPRoute hostname (%s)" $source $host (join ", " $.Values.httpRoute.hostnames)) -}}{{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/retool/values.yaml b/charts/retool/values.yaml index 6267e73b..d9d69736 100644 --- a/charts/retool/values.yaml +++ b/charts/retool/values.yaml @@ -755,16 +755,17 @@ mcp: # # retoolBackendUrl. # retoolGitServerUrl: # - # # Public Retool origin for client-facing upload URLs when the request Host - # # header is insufficient. Do not include /mcp. + # # Optional explicit public origin for all MCP discovery and upload URLs. + # # Overrides per-request custom Space domains. Usually omit this and set + # # env.BASE_DOMAIN; do not include /mcp. # mcpServiceExternalUrl: # # # Deprecated alias for mcpServiceExternalUrl. # retoolUrl: # - # # Public Retool host serving OAuth metadata and /api/oauth2/*. Defaults to - # # env.BASE_DOMAIN and is usually the Retool application host, not an - # # oauth.* subdomain. URL-shaped values are accepted with the scheme removed. + # # Public Retool host for MCP diagnostics. Defaults to env.BASE_DOMAIN + # # when it can be resolved at render time; not an oauth.* subdomain. + # # URL-shaped values are accepted with the scheme removed. # oauthMainDomain: # # # Secret containing the OAuth introspection token shared by MCP and the diff --git a/values.yaml b/values.yaml index 6267e73b..d9d69736 100644 --- a/values.yaml +++ b/values.yaml @@ -755,16 +755,17 @@ mcp: # # retoolBackendUrl. # retoolGitServerUrl: # - # # Public Retool origin for client-facing upload URLs when the request Host - # # header is insufficient. Do not include /mcp. + # # Optional explicit public origin for all MCP discovery and upload URLs. + # # Overrides per-request custom Space domains. Usually omit this and set + # # env.BASE_DOMAIN; do not include /mcp. # mcpServiceExternalUrl: # # # Deprecated alias for mcpServiceExternalUrl. # retoolUrl: # - # # Public Retool host serving OAuth metadata and /api/oauth2/*. Defaults to - # # env.BASE_DOMAIN and is usually the Retool application host, not an - # # oauth.* subdomain. URL-shaped values are accepted with the scheme removed. + # # Public Retool host for MCP diagnostics. Defaults to env.BASE_DOMAIN + # # when it can be resolved at render time; not an oauth.* subdomain. + # # URL-shaped values are accepted with the scheme removed. # oauthMainDomain: # # # Secret containing the OAuth introspection token shared by MCP and the