Skip to content

Commit 07e55b2

Browse files
jiaenrenclaude
andcommitted
Reduce the MCP configuration to the four values only a deployer knows
What remains after this is resourceUrl, the OIDC configuration URL, the client ID, and a Secret holding the client secret. Everything else is derived, inherited, or defaulted. **Secret paths follow the mount.** clientSecretFile and redis.passwordFile had to equal the existing Secret's mount path plus a fixed filename -- the chart failed to render otherwise, and the volume already hardcodes both filenames. They are derived from the mount now, and the mount itself defaults, so an existing-Secret deployment states neither. Three fail blocks and their cross-checks go with them. A deployment supplying the secret another way, such as Vault injection, still states clientSecretFile, the one case where it is genuinely input. **The Redis password is opt-in.** Deriving the password file from the mount left it keyed on redisPasswordKey, defaulting to redis-password -- so every existing-Secret deployment carried that key whether or not its Redis wanted a password, and one whose Redis needs none failed to mount a key it had no reason to create. Naming the key is what opts in now. **The access-token issuer is optional.** It was required even though only a provider issuing v1-format access tokens needs it: every other issues from the issuer its discovery document advertises, which is what FastMCP defaults to. The verifier falls back to the discovery issuer. **Authentication is not a mode.** An earlier commit in this stack removed oidcProxy.enabled from values.yaml but left the template reading it, so it fell through to its default of false. Every OIDC variable sits behind that gate, which means a values file written to the documented contract rendered with the whole auth block missing and the container failed at start on absent configuration; the fixture only passed because it still carried the flag. The gate, the environment variable and the flag are gone, and the required messages name MCP rather than a proxy that could be switched off. **The MCP audience joins the provider that already exists.** Enabling MCP required a gateway.envoy.jwt.providers entry carrying the resource URL as its audience, and in every deployment that entry was a copy of one already present -- same issuer, JWKS URI, claim and cluster -- differing only in audience, because the relayed upstream token comes from the identity provider already configured for this deployment's own clients. Envoy accepts several audiences per provider, so the resource URL is appended to the entry whose issuer matches. The issuer is derivable, since OpenID Connect Discovery defines the configuration URL as the issuer plus /.well-known/openid-configuration; accessTokenIssuer still overrides it. The comparison ignores a trailing slash, and an entry already carrying the audience does not get it twice. Failing to find a provider now names the issuer rather than saying an entry is required, which was the error a deployer hit after supplying one. **The signing key is resolved.** The derived signing and storage keys are only as strong as the client secret, and nothing checked it; the service now refuses to start below 32 characters, which rejects a hand-written placeholder. The HKDF derivation is pinned locally rather than calling fastmcp.server.auth.jwt_issuer.derive_jwt_key, because an upstream change to that derivation would silently make every stored registration and token undecryptable; the bytes are identical today and test_auth asserts that equivalence against FastMCP so a divergence fails the build. The production-readiness warning telling operators to assess the derived key is replaced by that entropy requirement -- it does not survive reading FastMCP 3.4.7, where high-entropy material goes through HKDF and the PBKDF2 path with a length warning is reserved for low-entropy strings. **mcp:Access is retired.** The action mapped to exactly one path, /mcp, where ext_authz is disabled -- so it was never evaluated, and a role granting it implied an access control that did not run. The wholesale gate is the identity-provider scope: JWTVerifier rejects a token without it, so a caller cannot reach the protocol endpoint. Per-operation authorization is unchanged, because every tool call reaches OSMO through /api where gateway JWT validation and semantic RBAC apply as they do for the CLI and UI. resourceTypeMCP and ResourceTypeMCP go too. Existing deployments need no migration: IsValidAction has no callers, so a role row still granting mcp:Access keeps an action matching no path, which is already true today. Rendering the proxy fixture is byte-identical apart from the removed enable switch. Each change is covered by a render assertion and negative-tested by reintroducing the defect it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 283ed14 commit 07e55b2

14 files changed

Lines changed: 248 additions & 170 deletions

File tree

deployments/charts/osmo/values.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1644,7 +1644,6 @@ configuration:
16441644
- app:*
16451645
- auth:Token
16461646
- credentials:*
1647-
- mcp:Access
16481647
- pool:List
16491648
- profile:Read
16501649
- profile:Update

deployments/charts/service/templates/_gateway-envoy-config.tpl

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ setting detects this rotation and triggers Envoy to reload.
3535
{{- $mcpPath := "/mcp" }}
3636
{{- $mcpMetadataPath := "/.well-known/oauth-protected-resource/mcp" }}
3737
{{- $mcpResourceUrl := "" }}
38+
{{- $mcpTokenIssuer := "" }}
3839
{{- $mcpMetadataUrl := "" }}
3940
{{- $skipAuthPaths := concat (default (list) $envoy.skipAuthPaths) (default (list) $envoy.extraSkipAuthPaths) }}
4041
{{- $authnSkipPaths := $skipAuthPaths }}
@@ -49,8 +50,28 @@ setting detects this rotation and triggers Envoy to reload.
4950
{{- fail "services.mcp.enabled requires gateway.authz.enabled=true" }}
5051
{{- end }}
5152
{{- $mcpResourceUrl = include "osmo.mcp-resource-url" . }}
52-
{{- if not $envoy.jwt.providers }}
53-
{{- fail "services.mcp.enabled requires at least one gateway.envoy.jwt.providers entry" }}
53+
{{- /*
54+
The relayed upstream token carries the MCP resource URL as its audience, so the
55+
Gateway has to accept that audience from the identity provider MCP authenticates
56+
against. That provider is already configured for this deployment's own clients,
57+
and differs only in audience -- so the audience is appended to it rather than
58+
asking for a second, near-identical entry.
59+
60+
OpenID Connect Discovery defines the configuration URL as the issuer followed by
61+
/.well-known/openid-configuration, so the issuer is derivable. accessTokenIssuer
62+
overrides it for providers whose access tokens are issued elsewhere, as an
63+
application configured for v1-format tokens does.
64+
*/ -}}
65+
{{- $mcpTokenIssuer = $mcpOidcProxy.oidc.accessTokenIssuer | default (trimSuffix "/.well-known/openid-configuration" (required "services.mcp.oidcProxy.oidc.configUrl is required when MCP is enabled" $mcpOidcProxy.oidc.configUrl)) }}
66+
{{- $mcpTokenIssuer = trimSuffix "/" $mcpTokenIssuer }}
67+
{{- $mcpIssuerProviders := 0 }}
68+
{{- range $provider := $envoy.jwt.providers }}
69+
{{- if eq (trimSuffix "/" $provider.issuer) $mcpTokenIssuer }}
70+
{{- $mcpIssuerProviders = add1 $mcpIssuerProviders }}
71+
{{- end }}
72+
{{- end }}
73+
{{- if eq $mcpIssuerProviders 0 }}
74+
{{- fail (printf "services.mcp.enabled requires a gateway.envoy.jwt.providers entry with issuer %s, which is where MCP's relayed tokens come from" $mcpTokenIssuer) }}
5475
{{- end }}
5576
{{- $mcpServiceName := required "services.mcp.serviceName is required when MCP is enabled" $mcp.serviceName }}
5677
{{- $mcpImageName := required "services.mcp.imageName is required when MCP is enabled" $mcp.imageName }}
@@ -675,6 +696,9 @@ data:
675696
issuer: {{ $provider.issuer }}
676697
audiences:
677698
- {{ $provider.audience }}
699+
{{- if and $mcpEnabled (eq (trimSuffix "/" $provider.issuer) $mcpTokenIssuer) (ne $provider.audience $mcpResourceUrl) }}
700+
- {{ $mcpResourceUrl }}
701+
{{- end }}
678702
forward: true
679703
payload_in_metadata: verified_jwt
680704
from_headers:

deployments/charts/service/templates/mcp-service.yaml

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
{{- if .Values.services.mcp.enabled }}
1818
{{- $mcp := .Values.services.mcp }}
1919
{{- $oidcProxy := $mcp.oidcProxy }}
20-
{{- $oidcProxyEnabled := $oidcProxy.enabled | default false }}
2120
{{- $imageTag := $mcp.imageTag | default .Values.global.osmoImageTag }}
2221
{{- $mcpResourceUrl := include "osmo.mcp-resource-url" . }}
2322
{{- $gatewayUrl := trimSuffix "/mcp" $mcpResourceUrl }}
@@ -28,27 +27,31 @@
2827
{{- $oidcConfigUrl := "" }}
2928
{{- $oidcClientId := "" }}
3029
{{- $oidcClientSecretFile := "" }}
30+
{{- $redisPasswordFile := "" }}
31+
{{- $secretMountPath := "" }}
3132
{{- $oidcAccessTokenIssuer := "" }}
3233
{{- $oidcAccessTokenRequiredScope := "" }}
3334
{{- $redisKeyPrefix := "" }}
3435
{{- $redisUrl := "" }}
35-
{{- if $oidcProxyEnabled }}
36-
{{- $oidcConfigUrl = required "services.mcp.oidcProxy.oidc.configUrl is required when the OIDC proxy is enabled" $oidcProxy.oidc.configUrl }}
37-
{{- $oidcClientId = required "services.mcp.oidcProxy.oidc.clientId is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientId }}
38-
{{- $oidcClientSecretFile = required "services.mcp.oidcProxy.oidc.clientSecretFile is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientSecretFile }}
39-
{{- $oidcAccessTokenIssuer = required "services.mcp.oidcProxy.oidc.accessTokenIssuer is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenIssuer }}
40-
{{- $oidcAccessTokenRequiredScope = required "services.mcp.oidcProxy.oidc.accessTokenRequiredScope is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenRequiredScope }}
36+
{{- $oidcConfigUrl = required "services.mcp.oidcProxy.oidc.configUrl is required when MCP is enabled" $oidcProxy.oidc.configUrl }}
37+
{{- $oidcClientId = required "services.mcp.oidcProxy.oidc.clientId is required when MCP is enabled" $oidcProxy.oidc.clientId }}
38+
{{- $oidcAccessTokenIssuer = $oidcProxy.oidc.accessTokenIssuer | default "" }}
39+
{{- $oidcAccessTokenRequiredScope = required "services.mcp.oidcProxy.oidc.accessTokenRequiredScope is required when MCP is enabled" $oidcProxy.oidc.accessTokenRequiredScope }}
4140
{{- if not (regexMatch "^[A-Za-z0-9:._~-]{1,128}$" $oidcAccessTokenRequiredScope) }}
4241
{{- fail "services.mcp.oidcProxy.oidc.accessTokenRequiredScope must be one non-empty scope" }}
4342
{{- end }}
4443
{{- $oidcHttpsUrlPattern := "^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._~%!$&'()*+,;=:@/-]*)?$" }}
4544
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcConfigUrl) }}
4645
{{- fail "services.mcp.oidcProxy.oidc.configUrl must be an absolute HTTPS URL without query or fragment" }}
4746
{{- end }}
48-
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenIssuer) }}
47+
{{- if and $oidcAccessTokenIssuer (not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenIssuer)) }}
4948
{{- fail "services.mcp.oidcProxy.oidc.accessTokenIssuer must be an absolute HTTPS issuer without query or fragment" }}
5049
{{- end }}
51-
{{- range $name, $url := dict "configUrl" $oidcConfigUrl "accessTokenIssuer" $oidcAccessTokenIssuer }}
50+
{{- $urlsToCheck := dict "configUrl" $oidcConfigUrl }}
51+
{{- if $oidcAccessTokenIssuer }}
52+
{{- $_ := set $urlsToCheck "accessTokenIssuer" $oidcAccessTokenIssuer }}
53+
{{- end }}
54+
{{- range $name, $url := $urlsToCheck }}
5255
{{- $portMatch := regexFind ":[0-9]+(/|$)" $url }}
5356
{{- if $portMatch }}
5457
{{- $urlPort := trimSuffix "/" (trimPrefix ":" $portMatch) | int }}
@@ -57,9 +60,6 @@
5760
{{- end }}
5861
{{- end }}
5962
{{- end }}
60-
{{- if not (hasPrefix "/" $oidcClientSecretFile) }}
61-
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be an absolute path" }}
62-
{{- end }}
6363
{{- $redisHost := .Values.services.redis.serviceName | required "services.redis.serviceName is required when the MCP OIDC proxy is enabled" }}
6464
{{- $redisPort := .Values.services.redis.port }}
6565
{{- if or (lt (int $oidcProxy.redis.dbNumber) 0) (gt (int $oidcProxy.redis.dbNumber) 15) }}
@@ -88,25 +88,23 @@
8888
{{- fail "services.mcp.oidcProxy.upstreamTimeoutSeconds must be between 1 and 60" }}
8989
{{- end }}
9090
{{- if $oidcProxy.existingSecret.name }}
91-
{{- $mountPath := required "services.mcp.oidcProxy.existingSecret.mountPath is required when an existing Secret is configured" $oidcProxy.existingSecret.mountPath }}
92-
{{- if not (hasPrefix "/" $mountPath) }}
91+
{{- $secretMountPath = $oidcProxy.existingSecret.mountPath | default "/etc/osmo/mcp-auth" | trimSuffix "/" }}
92+
{{- if not (hasPrefix "/" $secretMountPath) }}
9393
{{- fail "services.mcp.oidcProxy.existingSecret.mountPath must be an absolute path" }}
9494
{{- end }}
95-
{{- $clientSecretKey := required "services.mcp.oidcProxy.existingSecret.clientSecretKey is required" $oidcProxy.existingSecret.clientSecretKey }}
96-
{{- $normalizedMountPath := trimSuffix "/" $mountPath }}
97-
{{- if ne $oidcClientSecretFile (printf "%s/client-secret" $normalizedMountPath) }}
98-
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be <existingSecret.mountPath>/client-secret" }}
99-
{{- end }}
100-
{{- if and $oidcProxy.redis.passwordFile (ne $oidcProxy.redis.passwordFile (printf "%s/redis-password" $normalizedMountPath)) }}
101-
{{- fail "services.mcp.oidcProxy.redis.passwordFile must be <existingSecret.mountPath>/redis-password" }}
95+
{{- $oidcClientSecretFile = printf "%s/client-secret" $secretMountPath }}
96+
{{- if $oidcProxy.existingSecret.redisPasswordKey }}
97+
{{- $redisPasswordFile = printf "%s/redis-password" $secretMountPath }}
10298
{{- end }}
103-
{{- if and $oidcProxy.redis.passwordFile (not $oidcProxy.existingSecret.redisPasswordKey) }}
104-
{{- fail "services.mcp.oidcProxy.existingSecret.redisPasswordKey is required when redis.passwordFile is configured" }}
99+
{{- else }}
100+
{{- $oidcClientSecretFile = required "services.mcp.oidcProxy.oidc.clientSecretFile is required when no existingSecret is configured" $oidcProxy.oidc.clientSecretFile }}
101+
{{- $redisPasswordFile = $oidcProxy.redis.passwordFile | default "" }}
105102
{{- end }}
103+
{{- if not (hasPrefix "/" $oidcClientSecretFile) }}
104+
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be an absolute path" }}
106105
{{- end }}
107106
{{- $redisScheme := ternary "rediss" "redis" .Values.services.redis.tlsEnabled }}
108107
{{- $redisUrl = printf "%s://%s:%v/%v" $redisScheme $redisHost $redisPort $oidcProxy.redis.dbNumber }}
109-
{{- end }}
110108
{{- if not (kindIs "slice" $mcp.allowedOrigins) }}
111109
{{- fail "services.mcp.allowedOrigins must be a list" }}
112110
{{- end }}
@@ -121,7 +119,6 @@
121119
"OSMO_GATEWAY_URL"
122120
"OSMO_MCP_REQUEST_TIMEOUT_SECONDS"
123121
"OSMO_MCP_ALLOWED_ORIGINS"
124-
"OSMO_MCP_AUTH_ENABLED"
125122
"OSMO_MCP_AUTH_RESOURCE_URL"
126123
"OSMO_MCP_AUTH_REDIS_URL"
127124
"OSMO_MCP_AUTH_REDIS_PASSWORD_FILE"
@@ -208,16 +205,13 @@ spec:
208205
value: {{ $requestTimeoutSeconds | quote }}
209206
- name: OSMO_MCP_ALLOWED_ORIGINS
210207
value: {{ join "," $mcp.allowedOrigins | quote }}
211-
- name: OSMO_MCP_AUTH_ENABLED
212-
value: {{ $oidcProxyEnabled | quote }}
213-
{{- if $oidcProxyEnabled }}
214208
- name: OSMO_MCP_AUTH_RESOURCE_URL
215209
value: {{ $mcpResourceUrl | quote }}
216210
- name: OSMO_MCP_AUTH_REDIS_URL
217211
value: {{ $redisUrl | quote }}
218-
{{- if $oidcProxy.redis.passwordFile }}
212+
{{- if $redisPasswordFile }}
219213
- name: OSMO_MCP_AUTH_REDIS_PASSWORD_FILE
220-
value: {{ $oidcProxy.redis.passwordFile | quote }}
214+
value: {{ $redisPasswordFile | quote }}
221215
{{- end }}
222216
- name: OSMO_MCP_AUTH_REDIS_KEY_PREFIX
223217
value: {{ $redisKeyPrefix | quote }}
@@ -241,17 +235,16 @@ spec:
241235
value: {{ $oidcProxy.refreshTokenTtlSeconds | int | quote }}
242236
- name: OSMO_MCP_AUTH_UPSTREAM_TIMEOUT_SECONDS
243237
value: {{ $oidcProxy.upstreamTimeoutSeconds | int | quote }}
244-
{{- end }}
245238
{{- with $mcp.extraEnv }}
246239
{{- toYaml . | nindent 8 }}
247240
{{- end }}
248241
resources:
249242
{{- toYaml $mcp.resources | nindent 10 }}
250-
{{- if or (and $oidcProxyEnabled $oidcProxy.existingSecret.name) $mcp.extraVolumeMounts (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
243+
{{- if or $oidcProxy.existingSecret.name $mcp.extraVolumeMounts (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
251244
volumeMounts:
252-
{{- if and $oidcProxyEnabled $oidcProxy.existingSecret.name }}
245+
{{- if $oidcProxy.existingSecret.name }}
253246
- name: mcp-auth-secrets
254-
mountPath: {{ $oidcProxy.existingSecret.mountPath }}
247+
mountPath: {{ $secretMountPath }}
255248
readOnly: true
256249
{{- end }}
257250
{{- with $mcp.extraVolumeMounts }}
@@ -267,16 +260,16 @@ spec:
267260
{{- include "osmo.upstream-probe-yaml" (dict "probe" $mcp.readinessProbe "context" .) | nindent 10 }}
268261
startupProbe:
269262
{{- include "osmo.upstream-probe-yaml" (dict "probe" $mcp.startupProbe "context" .) | nindent 10 }}
270-
{{- if or (and $oidcProxyEnabled $oidcProxy.existingSecret.name) $mcp.extraVolumes (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
263+
{{- if or $oidcProxy.existingSecret.name $mcp.extraVolumes (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
271264
volumes:
272-
{{- if and $oidcProxyEnabled $oidcProxy.existingSecret.name }}
265+
{{- if $oidcProxy.existingSecret.name }}
273266
- name: mcp-auth-secrets
274267
secret:
275268
secretName: {{ $oidcProxy.existingSecret.name }}
276269
items:
277270
- key: {{ $oidcProxy.existingSecret.clientSecretKey }}
278271
path: client-secret
279-
{{- if $oidcProxy.redis.passwordFile }}
272+
{{- if $redisPasswordFile }}
280273
- key: {{ $oidcProxy.existingSecret.redisPasswordKey }}
281274
path: redis-password
282275
{{- end }}

deployments/charts/service/tests/mcp-proxy-values.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ services:
2222
allowedOrigins:
2323
- http://localhost:6274
2424
oidcProxy:
25-
enabled: true
2625
oidc:
2726
configUrl: https://login.example.com/example-tenant/v2.0/.well-known/openid-configuration
2827
clientId: example-mcp-proxy-client
@@ -34,6 +33,7 @@ services:
3433
keyPrefix: test:mcp-auth
3534
existingSecret:
3635
name: mcp-oidc-proxy-secrets
36+
redisPasswordKey: redis-password
3737

3838
gateway:
3939
networkPolicies:

deployments/charts/service/tests/render-tests.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ grep -q "table.concat(safe_roles, ',')" <<<"$mcp_render"
132132
# Redis connection details come from services.redis; only the database is local.
133133
grep -q 'value: "rediss://redis:6379/14"' <<<"$mcp_workload"
134134

135+
# A Redis needing no password must not force a key into the Secret.
136+
no_redis_pw=$(helm template mcp-nopw "$CHART_DIR" --values "$mcp_values" \
137+
--set 'services.mcp.oidcProxy.existingSecret.redisPasswordKey=')
138+
if grep -q 'redis-password' <<<"$no_redis_pw"; then
139+
echo 'MCP demands a redis-password key when none was asked for' >&2
140+
exit 1
141+
fi
142+
143+
# Authentication is not a mode, so nothing advertises it as one. The fixture
144+
# sets no enabled flag; the OIDC variables above must render regardless.
145+
if grep -q 'name: OSMO_MCP_AUTH_ENABLED' <<<"$mcp_workload"; then
146+
echo 'MCP still renders an auth-enabled switch' >&2
147+
exit 1
148+
fi
149+
135150
# Values the deployment derives must not reappear as deployer inputs.
136151
for derived in OSMO_MCP_AUTH_ISSUER_URL OSMO_MCP_AUTH_SCOPE \
137152
OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_AUDIENCE \
@@ -142,6 +157,35 @@ for derived in OSMO_MCP_AUTH_ISSUER_URL OSMO_MCP_AUTH_SCOPE \
142157
fi
143158
done
144159

160+
# Only a provider issuing v1-format access tokens needs the issuer stated. With
161+
# it unset the issuer comes from the configuration URL, and the gateway provider
162+
# for that issuer is the one the MCP audience is added to.
163+
no_issuer=$(helm template mcp-no-issuer "$CHART_DIR" --values "$mcp_values" \
164+
--set 'services.mcp.oidcProxy.oidc.accessTokenIssuer=' \
165+
--set 'gateway.envoy.jwt.providers[0].issuer=https://login.example.com/example-tenant/v2.0')
166+
grep -q 'issuer: https://login.example.com/example-tenant/v2.0' <<<"$no_issuer"
167+
168+
# The secret file paths follow the mount, so a deployer states neither of them.
169+
mount_render=$(helm template mcp-mount "$CHART_DIR" --values "$mcp_values" \
170+
--set 'services.mcp.oidcProxy.existingSecret.mountPath=/var/run/mcp')
171+
grep -q 'value: "/var/run/mcp/client-secret"' <<<"$mount_render"
172+
grep -q 'value: "/var/run/mcp/redis-password"' <<<"$mount_render"
173+
grep -q 'mountPath: /var/run/mcp' <<<"$mount_render"
174+
175+
# The MCP audience is added to the provider for its issuer, so no deployment
176+
# writes a second provider differing only in audience.
177+
aud_render=$(helm template mcp-aud "$CHART_DIR" --values "$mcp_values" \
178+
--set 'gateway.envoy.jwt.providers[0].audience=some-client-id')
179+
grep -q -- '- some-client-id' <<<"$aud_render"
180+
grep -q -- '- https://osmo.example.com/mcp' <<<"$aud_render"
181+
182+
# A provider already carrying that audience must not have it added twice.
183+
dupes=$(grep -c -- '- https://osmo.example.com/mcp' <<<"$mcp_render" || true)
184+
if [ "$dupes" -ne 1 ]; then
185+
echo "MCP audience appears $dupes times on the gateway provider, want 1" >&2
186+
exit 1
187+
fi
188+
145189
# The proxy keeps its state in Redis, so scaling out must render.
146190
helm template mcp-scale "$CHART_DIR" --values "$mcp_values" \
147191
--set 'services.mcp.replicas=2' >/dev/null

deployments/charts/service/values.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ services:
165165
## Upstream OpenID Connect provider. Register the fixed redirect URL
166166
## <resourceUrl origin>/mcp/auth/callback on this confidential application.
167167
##
168+
## clientSecretFile is derived from existingSecret.mountPath when an
169+
## existing Secret supplies the credential. State it only when the secret
170+
## arrives another way, such as Vault injection.
171+
##
172+
## accessTokenIssuer is optional. Leave it empty unless the provider
173+
## issues access tokens from an issuer its discovery document does not
174+
## advertise, as an Entra application configured for v1 tokens does.
175+
##
168176
oidc:
169177
configUrl: ""
170178
clientId: ""
@@ -195,7 +203,10 @@ services:
195203
name: ""
196204
mountPath: /etc/osmo/mcp-auth
197205
clientSecretKey: client-secret
198-
redisPasswordKey: redis-password
206+
## Set only when the Redis in services.redis requires a password. The
207+
## file path is derived from mountPath; naming the key is what opts in.
208+
##
209+
redisPasswordKey: ""
199210

200211
## Additional pod labels and annotations.
201212
##
@@ -758,7 +769,6 @@ services:
758769
- "app:*"
759770
- "auth:Token"
760771
- "credentials:*"
761-
- "mcp:Access"
762772
- "pool:List"
763773
- "profile:Read"
764774
- "profile:Update"

docs/deployment_guide/advanced_config/mcp.rst

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ cannot elevate the user. See :ref:`mcp_identity_permissions` and
5353
Shared Prerequisites
5454
====================
5555

56-
Before enabling either mode:
56+
Before enabling MCP:
5757

5858
* Keep ``gateway.envoy.enabled`` and ``gateway.authz.enabled`` set to ``true``.
5959
* Configure a ``gateway.envoy.jwt.providers`` entry that validates the bearer
6060
token used for downstream ``/api`` requests and resolves its identity and
61-
roles to the intended OSMO user.
61+
roles to the intended OSMO user. The chart adds the MCP resource URL to the
62+
audiences of the entry whose issuer matches the one MCP authenticates
63+
against, so no second entry is needed for MCP itself.
6264
* Publish the release Gateway on one HTTPS hostname. Set
6365
``services.mcp.resourceUrl`` to that origin plus the exact ``/mcp`` path.
6466
* Ensure that the MCP pod can resolve and reach the public Gateway origin.
@@ -147,16 +149,13 @@ sessions and makes old encrypted state, including DCR registrations, unusable.
147149
Users must authenticate again, and DCR clients might need to remove and re-add
148150
the MCP entry before login.
149151

150-
.. warning::
151-
152-
OSMO currently relies on FastMCP's default derived signing key to avoid a
153-
second operator-managed secret. FastMCP documents that default as a
154-
development or local-testing convenience and recommends an explicit
155-
independent signing key for production. The current OSMO chart does not
156-
expose that independent-key option. Assess this limitation before a
157-
production rollout and require a high-entropy upstream client secret. See
158-
the `FastMCP OIDC proxy signing-key guidance
159-
<https://gofastmcp.com/servers/auth/oidc-proxy#param-jwt-signing-key>`_.
152+
Because both keys come from the client secret, the strength they provide is the
153+
strength of that secret. FastMCP passes it through HKDF as high-entropy key
154+
material, a path distinct from the password-based derivation it reserves for
155+
low-entropy operator-supplied strings. The service therefore requires a client
156+
secret of at least 32 characters and refuses to start below it. Identity
157+
providers issue secrets well above that length; the check exists to reject a
158+
hand-written placeholder.
160159

161160
Configure Helm Values
162161
---------------------

src/service/mcp/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,8 @@ failures.
411411
## Deployment validation
412412

413413
The MCP smoke target requires an MCP-enabled deployment with JWT
414-
authentication. Its token needs `mcp:Access`, `profile:Read`, and
415-
`workflow:Create` for `OETF_POOL`.
414+
authentication. Its token needs `profile:Read` and `workflow:Create`
415+
for `OETF_POOL`.
416416

417417
```bash
418418
bazel run //test/oetf:run -- --env <mcp-enabled-env> --tags mcp

0 commit comments

Comments
 (0)