Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions .github/test_mcp_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""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=/",
"--set", "env.BASE_DOMAIN=https://retool.example.com",
"--set", "mcp.config.mcpServiceExternalUrl=https://retool.example.com",
]
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.rsplit(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",
)

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()
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,55 @@ 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

### 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 |
| --- | --- | --- |
| `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 `<fullname>`, `<fullname>-backend-internal`, and `<fullname>-mcp`, where `<fullname>` 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:
Expand Down
2 changes: 1 addition & 1 deletion charts/retool/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: retool
description: A Helm chart for Kubernetes
type: application
version: 6.12.2
version: 6.12.4
maintainers:
- name: Retool Engineering
email: engineering+helm@retool.com
Expand Down
2 changes: 1 addition & 1 deletion charts/retool/ci/test-install-values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions charts/retool/ci/test-mcp-enabled-option.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions charts/retool/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,54 @@ 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.
*/}}
{{- 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) }}
Comment on lines +87 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backend gets bare hostname

If MCP_SERVICE_EXTERNAL_URL in mcp.environmentVariables has no scheme, the new check adds https:// to validate it, but this copy sends the unchanged value to the backend. The backend can then produce bad discovery or upload links. Reject the bare hostname or normalize it before copying it.

{{- 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" }}
Expand Down
Loading
Loading