Expand ai-scoping auth gate: external URL shape, public/confidential client, IdP authorize-params notes
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 48/100
Línea de trabajo
Start with Step 6.1 in skills/ai-scoping/SKILL.md and the OAuth2Auth/OIDCAuth variants in src/mcp_builder/schema/models.py, then trace rendering through src/mcp_builder/generate/plan.py and src/mcp_builder/generate/renderers/manifests.py. Review the named Jinja templates, fixtures, and tests; done means the optional fields validate, flow through generation, render the requested URLs and client-secret block, and all unit and integration tests pass.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Context
Follow-up from PR #99 review (see https://github.com/StacklokLabs/mcp-builder/pull/99#discussion_r3135714246 and https://github.com/StacklokLabs/mcp-builder/pull/99#discussion_r3135735903).
The current ai-scoping auth gate (Step 6.1 in skills/ai-scoping/SKILL.md) only captures information about the upstream OAuth provider being wrapped. Two classes of information that the user usually knows at scoping time — and that silently bake PoC-specific assumptions into the generated manifests when not asked — are missing:
-
External URL shape — the URL under which the ToolHive-issued auth endpoint will be reachable in the target cluster. This value is used in five interlocked places in the generated manifests:
mcpexternalauthconfig.yaml(embeddedAuthServer.issuerandupstreamProviders[0].*.redirectUri),mcpserver.yaml(oidcConfigRef.audienceandoidcConfigRef.resourceUrl),ingress.yaml(rules[0].host+path), andmcpoidcconfig.yaml(spec.inline.issuer). Templates currently emithttps://mcp.REPLACE_ME_DOMAIN/{server_name}— a PoC-cluster-specific shape that cannot be corrected by domain substitution alone, because other clusters use patterns likehttps://<domain>/<server_name>/mcp,https://<server_name>.<domain>/mcp, etc. See PR #99 comment for context. -
Public vs. confidential OAuth client — whether the upstream OAuth app uses PKCE alone (public client) or requires a
client_secret(confidential client). Templates currently always emitclientSecretRefas a commented-out example block and instruct the user to uncomment it if confidential. This is conservative but wrong for the common case: Google (web apps), GitHub, Slack, and most enterprise OAuth apps are confidential. The user knows this at scoping time; asking lets us render theclientSecretRefblock live.
Additionally, two notes-only captures would reduce foot-guns in Phase 2 review without touching the generated CRDs:
-
IdP-specific authorize-endpoint parameters beyond scopes — some upstreams require extra parameters in the
/authorizecall that OpenAPI does not model. Examples: Atlassian requiresaudience=api.atlassian.comor the response is ID-token-only; Slack requiresuser_scopefor user-context tokens. These do not map to CRD fields but are critical to document so the Phase 2 reviewer catches them. -
Redirect-URI registration reminder — the emitted
redirectUriinmcpexternalauthconfig.yamlmust be registered with the upstream IdP (OAuth consent screen on Google, app registration on GitHub, etc.) before the first login works. Scoping could surface this as: "Before deploy, register<emitted redirectUri>with<provider>at<console URL>."
Scope
In scope:
- Add two new gates to
skills/ai-scoping/SKILL.mdStep 6.1 covering items 1 and 2 above. - Extend the
OAuth2AuthandOIDCAuthpydantic variants insrc/mcp_builder/schema/models.pywith two new optional fields (see field names below). - Thread the new fields through
src/mcp_builder/generate/plan.py(pass-through, same as existing auth fields). - Update the four Jinja templates (
authconfig_embedded_oauth2.yaml.jinja2,authconfig_embedded_oidc.yaml.jinja2,mcpserver.yaml.jinja2,mcpoidcconfig.yaml.jinja2,ingress.yaml.jinja2) to honor the external URL shape when present, and to renderclientSecretReflive (not commented) whenclient_type == confidential. - Update
skills/ai-scoping/assets/scoping-summary-template.mdto surface the new fields in the summary. - Update
skills/deploy-assist/SKILL.mdStep 4 substitution rules to recognize the new placeholder(s) OR to detect when the scope already baked in a concrete URL and skip domain substitution. - Update existing
e2e/fixtures/real/*.yamlfixtures (and the unit test fixturestest_scope_oauth.yaml,test_scope_oidc.yaml) to include the new optional fields where reasonable defaults apply. Where the target deployment is unknown, the field stays absent. - Update tests: unit tests for the new schema fields (valid + cross-variant rejection stays working), integration tests verifying the generated manifests render correctly with and without the new fields.
Out of scope (explicitly):
- Items 3 and 4 (notes-only captures). Those are prompt-only additions to the auth gate — the schema already has an
auth.notes: str | Nonefree-text field. They should be folded into the SKILL.md gate prose in the same PR but do not need schema or template changes. - Changing the ingress controller annotations (AWS ALB-specific
alb.ingress.kubernetes.io/*,ingressClassName: alb,group.name: mcp-servers). Those are a separate ingress-abstraction concern and should be addressed by a dedicated issue — leave them for now. - Per-tool scope matrix (tool → scope mapping) — YAGNI; the OpenAPI
security: [...]operation-level overrides already model this at the spec layer. - Configurable token lifespans (
accessTokenLifespan,refreshTokenLifespan,authCodeLifespanin the auth-config templates). Hardcoded1h/168h/10mworks for now. - Multi-tenant deployment model (one server per tenant vs. shared). That is a deploy-time decision, not a scoping one.
audience/resourceUrloverrides inmcpserver.yamlthat differ from the external URL. Rarely needed; add only if a use case materializes.
Detailed design
Schema additions
Add two optional fields to both OAuth2Auth and OIDCAuth in src/mcp_builder/schema/models.py. Do not add them to APIKeyAuth or NoAuth (neither has an OAuth client registration concept). extra="forbid" on the variants means a user who writes external_url_template under api_key gets a validation error — that is the desired behavior.
external_url_template: str | None = None
# The full URL template under which this ToolHive-issued auth endpoint
# will be reachable from OAuth clients, using the literal placeholder
# `<server_name>` (no braces) where the server name should be inserted.
# Examples:
# "https://mcp.example.com/<server_name>"
# "https://example.com/<server_name>/mcp"
# "https://<server_name>.example.com/mcp"
# Optional; if absent, templates render a REPLACE_ME placeholder and the
# deploy-assist skill (or the user manually) rewrites at deploy time.
client_type: Literal["public", "confidential"] | None = None
# Whether the OAuth client registered with the upstream identity
# provider is a public client (PKCE only, no client_secret) or a
# confidential client (uses client_secret). When "confidential", the
# generated MCPExternalAuthConfig renders clientSecretRef live (pointing
# at a Secret the user must create). When "public" or None, the block
# is rendered as a commented example.
Rationale for the placeholder literal <server_name> (angle brackets, not {}): pydantic does not treat it as a template, and it survives yaml.safe_load round-trips without quoting concerns. The renderer substitutes it with plan.server_name at render time.
Validator: when external_url_template is present, require it to (a) start with https://, (b) contain the literal substring <server_name> exactly once. Implement as a model_validator(mode="after") on each variant.
Scoping prompt additions
In skills/ai-scoping/SKILL.md Step 6.1, after the existing auth USER GATE and before "Scopes — always populate scopes_available from the spec", add two sub-gates. Both should be optional (user can press enter to defer). Exact prose:
**External URL shape (optional).** The generated MCPExternalAuthConfig, MCPServer, and Ingress manifests all reference the same external URL — the URL under which the ToolHive-issued auth endpoint will be reachable in the target cluster. If you know where this server will deploy, give the URL template using `<server_name>` (angle brackets, no substitution) as a placeholder. Examples:
- `https://mcp.example.com/<server_name>` — shared host, path-per-server
- `https://example.com/<server_name>/mcp` — path-at-root
- `https://<server_name>.example.com/mcp` — subdomain-per-server
If you don't know the target cluster yet, press enter. Generated manifests will emit a `REPLACE_ME` placeholder and the `deploy-assist` skill (or a manual edit pass) fills it in from the target cluster's conventions.
Write the concrete template to `auth.external_url_template` in the scope YAML. Skip this field entirely when deferred — do not emit an empty string.
**OAuth client type (optional).** Does the OAuth app you registered (or plan to register) with the upstream identity provider use PKCE alone (public client, no client secret) or require a client secret (confidential client)?
- Most web-app and server-side OAuth clients are **confidential** (Google web apps, GitHub, Slack, Okta, Keycloak). Choose this if the IdP issued you a client_secret when you registered the app.
- SPAs, mobile apps, and some modern SDKs use **public** clients with PKCE only.
If you don't know, press enter. Generated manifests render `clientSecretRef` as a commented example; the deployer uncomments if needed.
Write to `auth.client_type` as either `"public"` or `"confidential"`. Skip the field when deferred.
**Note on IdP-specific authorize parameters.** Some upstreams require extra parameters in the /authorize call that are not modeled by OpenAPI (Atlassian: `audience=api.atlassian.com`; Slack: `user_scope`; etc.). If you know of any, add them to `auth.notes` — they become breadcrumbs for the Phase 2 human reviewer.
**Note on redirect URI registration.** The emitted `redirectUri` in `mcpexternalauthconfig.yaml` must be registered with the upstream IdP before the first login works (OAuth consent screen on Google, app registration page on GitHub/Atlassian/etc.). If you picked an `external_url_template` above, compute the redirect URI as `{substituted_external_url}/oauth/callback` and include a one-line "before deploy, register X with Y at Z" reminder in `auth.notes`.
Template updates
Thread new context variables through src/mcp_builder/generate/renderers/manifests.py:
_render_embedded_oauth2and_render_embedded_oidctakeauth.external_url_templateandauth.client_type, substitute<server_name>withplan.server_nameto produce a concreteexternal_url, and pass bothexternal_url(orNone) andclient_typeinto the Jinja rendering context. Add a new helper_substitute_external_url(template: str | None, server_name: str) -> str | Nonecolocated with_derive_provider_name.- Similarly wire
external_urlthroughrender_mcpserver(used foraudience/resourceUrl),render_mcpoidc_config(used forissuer), andrender_ingress(parse the template to derivehost+path, which is more involved — see below).
In the four templates that currently emit https://mcp.REPLACE_ME_DOMAIN/{{ server_name }}, replace that literal with a Jinja conditional:
{% if external_url %}{{ external_url }}{% else %}https://mcp.REPLACE_ME_DOMAIN/{{ server_name }}{% endif %}
Factor this into a Jinja macro ({% macro external_url_or_placeholder(url, server_name) %}...{% endmacro %}) in a shared _macros.jinja2 file to avoid duplicating the conditional five times.
For ingress.yaml.jinja2, the host and path come from parsing the concrete external URL. Implement a helper in manifests.py:
def _split_external_url(url: str) -> tuple[str, str]:
"""Split an external URL into (ingress_host, ingress_path).
Examples:
"https://mcp.example.com/google-drive" -> ("mcp.example.com", "/google-drive")
"https://example.com/google-drive/mcp" -> ("example.com", "/google-drive/mcp")
"https://google-drive.example.com/mcp" -> ("google-drive.example.com", "/mcp")
"""
Then pass both host and path into render_ingress. If external_url is None, fall back to host="mcp.REPLACE_ME_DOMAIN" and path="/{{ server_name }}" (current behavior).
For clientSecretRef: currently rendered as a commented block. When client_type == "confidential", render live:
{% if client_type == "confidential" %}
clientSecretRef:
name: {{ server_name }}-oauth-secret
key: client-secret
{% else %}
# - clientSecretRef: uncomment ...
{% endif %}
When client_type == "confidential", also emit a new deploy/secret-oauth.yaml (a K8s Secret template with name: {{ server_name }}-oauth-secret, stringData: { client-secret: REPLACE_ME }). Add a new Jinja template secret_oauth.yaml.jinja2 and plumb it through render_manifests. Update deploy-assist SKILL.md to flag this file for manual fill-in.
Fixture updates
tests/unit/fixtures/test_scope_oauth.yamlandtest_scope_oidc.yaml: addauth.external_url_template: "https://mcp.example.com/<server_name>"andauth.client_type: "confidential"so the new code paths are exercised.- Add a second pair of fixtures (
test_scope_oauth_public.yaml,test_scope_oidc_defer.yaml) that exercise theclient_type: publicand missing-external_url_templatepaths. e2e/fixtures/real/*.yaml: addclient_typefor every OAuth2/OIDC fixture based on the real IdP's documentation (Google: confidential; GitHub: confidential; Slack: confidential; Spotify: confidential; Zoom: confidential; BambooHR: confidential; Jira: confidential — almost all enterprise OAuth apps are). Do not addexternal_url_templateto e2e fixtures — those are deployment-neutral.
Test updates
tests/unit/test_schema.py: add aTestExternalUrlTemplateclass with cases for valid template, missing<server_name>, double<server_name>, non-https scheme, and rejection when set onAPIKeyAuth/NoAuth. Add aTestClientTypeclass with cases for"public","confidential",None, and invalid strings.tests/integration/test_cli.py: extendTestRunPipelineOAuthandTestRunPipelineOIDCwith assertions that (a) whenexternal_url_templateis set, the generatedmcpexternalauthconfig.yamlhas the concreteissuerandredirectUri, themcpserver.yamlhas the concreteaudience/resourceUrl, themcpoidcconfig.yamlhas the concreteissuer, and theingress.yamlhas the concretehost+path; (b) whenclient_type: confidential, theclientSecretRefblock is rendered live anddeploy/secret-oauth.yamlis emitted.
Deploy-assist updates
In skills/deploy-assist/SKILL.md:
- Step 4
mcpexternalauthconfig.yaml/mcpserver.yaml/mcpoidcconfig.yaml/ingress.yamlhandling: before substitutingREPLACE_ME_DOMAIN, check whether the file already has a concrete URL (noREPLACE_ME_*token in the relevant fields). If it does, skip the domain substitution for that field and treat it as user-authoritative. - Step 4
secret-oauth.yaml(new): if this file is present, note it in the "What the user still needs to do" checklist as a secret requiring a realclient-secretvalue from the upstream IdP. - Step 5 placeholder assertions: extend the allowed-exceptions list to cover
client-secret: REPLACE_MEinsecret-oauth.yaml(parallel totoken: REPLACE_MEinsecret.yamlandclientId: REPLACE_MEinmcpexternalauthconfig.yaml).
Acceptance criteria
-
task checkgreen with new schema + templates + tests. - Scoping a real OIDC fixture (e.g., google_drive) with
external_url_template: "https://mcp.example.com/<server_name>"andclient_type: "confidential"produces generated manifests where:mcpexternalauthconfig.yamlissuer=https://mcp.example.com/google-drive,redirectUri=https://mcp.example.com/google-drive/oauth/callback,clientSecretRefblock is live.mcpoidcconfig.yamlissuer=https://mcp.example.com/google-drive.mcpserver.yamlaudience=resourceUrl=https://mcp.example.com/google-drive.ingress.yamlhost=mcp.example.com,path=/google-drive.deploy/secret-oauth.yamlexists withstringData.client-secret: REPLACE_ME.
- Scoping the same fixture with neither field set produces the current PoC-flavored default URLs (unchanged behavior, backward-compatible).
- Cross-variant rejection:
external_url_templateunderapi_keyauth is a validation error at scope-load time. - Scoping summary (
scoping-summary.md) surfaces both new fields under the "Auth Detection" section when present.
Non-goals
See "Out of scope" above. Explicitly calling out that this issue does not touch ingress controller annotations, token lifespans, or multi-tenant deployment models — those are tracked separately (or not tracked, if not justified by use cases yet).
References
- PR #99: https://github.com/StacklokLabs/mcp-builder/pull/99
- Review thread that prompted this: https://github.com/StacklokLabs/mcp-builder/pull/99#discussion_r3135714246
- PR #99 reply discussing the scoping extension idea: (will be added once this issue is created and linked back)
- Lenguaje dominante
- Python
- Estrellas
- 0
- Forks
- 1
- Métricas de merge de PR
- Sin PR fusionados en 30 d
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 77/100
TomerAberbach/profiler-md#733 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
its-me-prash/vwgroup-connect-ha#1858 ·
Los mantenedores suelen responder en 1 día
-
area: client bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
Deleting all alarms or timers shows deleted ones until the next update when there are none leftAbiertobug
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
leikoilja/ha-google-home#1107 ·
Los mantenedores suelen responder en 1 día
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Graphify-Labs/graphify#4524 · 1 comentario ·
Los mantenedores suelen responder en 1 día