Skip to content
Open
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
166 changes: 166 additions & 0 deletions .github/test_mcp_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""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_direct_preserves_legacy_routes_for_both_route_types(self):
output = self.assert_rendered()
self.assertEqual(ingress_routes(output), INGRESS_DIRECT)
self.assertEqual(http_routes(output), HTTP_DIRECT)
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_backend_relay_uses_main_service_for_both_route_types(self):
output = self.assert_rendered("mcp.routing.mode=backendRelay")
self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]])
self.assertEqual(http_routes(output), [HTTP_DIRECT[-1]])
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)
output = self.assert_rendered("ingress.hostName=retool.example.com", "mcp.routing.mode=backendRelay")
self.assertEqual(ingress_routes(output), [INGRESS_DIRECT[-1]])

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.routing.mode=backendRelay", "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",
"mcp.routing.mode=backendRelay",
f"mcp.{surface}.enabled=true",
)

def test_legacy_flags_reject_non_boolean_values(self):
for surface in ("ingress", "httpRoute"):
for value in ("0", ""):
with self.subTest(surface=surface, value=value):
self.assert_rejected(
f"mcp.{surface}.enabled must be true, false, or null",
"mcp.routing.mode=direct",
f"mcp.{surface}.enabled={value}",
)

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_missing_mode_gives_upgrade_guidance(self):
self.assert_rejected(
"mcp.routing.mode is missing: when upgrading from an older chart, use --reset-then-reuse-values",
"mcp.routing=null",
)


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
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.3
maintainers:
- name: Retool Engineering
email: engineering+helm@retool.com
Expand Down
24 changes: 24 additions & 0 deletions charts/retool/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ 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 (hasKey $routing "mode") -}}
{{- fail "mcp.routing.mode is missing: when upgrading from an older chart, use --reset-then-reuse-values so Helm loads the new chart defaults, or explicitly set mcp.routing.mode=direct (compatible default) or backendRelay (Retool 4.0.7+)" -}}
{{- end -}}
{{- 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 (hasKey $legacy "enabled") (not (kindIs "bool" $enabled)) (not (kindIs "invalid" $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 to keep dedicated routes" $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.
Expand Down
4 changes: 3 additions & 1 deletion charts/retool/templates/httproute.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
8 changes: 5 additions & 3 deletions charts/retool/templates/ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions charts/retool/templates/validate_mcp_routing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{{- /* Run mode and migration validation even when neither public route is enabled. */ -}}
{{- $mode := include "retool.mcp.routingMode" . -}}
Comment on lines +1 to +2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wait why do we still need to run the validation helper if neither the ingress nor httproute are enabled?

61 changes: 34 additions & 27 deletions charts/retool/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -699,9 +699,8 @@ multiplayer:
# oauthIntrospectionAuthTokenSecretName: retool-mcp-oauth
# oauthIntrospectionAuthTokenSecretKey: token
#
# The chart injects the token into both MCP and the backend. The MCP ingress
# routes below are enabled by default; reproduce their mapping when ingress is
# managed outside this chart.
# The chart injects the token into both MCP and the backend. Public MCP routing
# is selected by mcp.routing.mode below.
mcp:
# Set to true to deploy the MCP server.
enabled: false
Expand Down Expand Up @@ -819,7 +818,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
Expand All @@ -829,28 +828,34 @@ 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://<fullname>-mcp:<service.externalPort>.
# 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 <fullname>:3000. Replace
# <fullname> 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 -> <fullname>-backend-internal:3001
# Exact /.well-known/oauth-protected-resource -> <fullname>-mcp:4010
# Prefix /mcp -> <fullname>-mcp:4010
# Prefix / -> <fullname>:3000
routing:
# direct remains the default so older installations keep their public MCP
# routes. Retool before 4.0.7 requires direct because its main backend
# cannot relay /mcp. Retool 4.0.7 and later also support direct; set
# backendRelay explicitly to send every public path, including /mcp and
# both /.well-known OAuth discovery paths, to the main Retool Service
# (<fullname>: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.
# For ingress managed outside this chart, use these public mappings:
# backendRelay: all paths, including /mcp and /.well-known/* -> <fullname>:3000
# direct: Exact /.well-known/oauth-authorization-server -> <fullname>-backend-internal:3001
# Exact /.well-known/oauth-protected-resource -> <fullname>-mcp:4010
# Prefix /mcp -> <fullname>-mcp:4010
# Prefix / -> <fullname>:3000
# Replace <fullname> 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.
# When upgrading an older chart, use helm upgrade --reset-then-reuse-values
# to load these new defaults and preserve saved overrides. Plain
# --reuse-values can retain the old chart defaults. If that flag is not
# available, pass your custom values file with -f and omit --reuse-values.
mode: direct

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
Expand All @@ -862,8 +867,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
Expand Down
Loading
Loading