← back to Cli Printing Press
fix(cli): wire composed apiKey + bearer sibling headers through parser, templates, and MCP manifest (#1359)
2437cab76049ef0cbf81a6e49cf53595c31c8649 · 2026-05-13 23:08:04 -0700 · Trevin Chow
* fix(cli): wire composed apiKey + bearer sibling headers through parser, templates, and MCP manifest
When an OpenAPI spec declared both an apiKey scheme and an OAuth2/bearer
scheme (ServiceTitan ST-App-Key + OAuth, Stripe-Signature + bearer,
Atlassian-Token + bearer), selectSecurityScheme picked the bearer half
and the apiKey sibling was silently dropped. The sibling's x-auth-vars
per_call entries never reached Config, the apiKey header never went out
on requests, and every call returned 401 even with a valid bearer.
The parser now scans non-winning apiKey-in-header security schemes for
x-auth-vars per_call entries and propagates them onto
AuthConfig.AdditionalHeaders. The generator emits a Config struct field
+ os.Getenv loader + req.Header.Set after the primary Authorization
header. The MCPB manifest's user_config + launch env block surfaces the
sibling credential so Claude Desktop installs prompt for it at first
run — without this the install path silently 401s.
Validator rejects duplicate headers, duplicate sibling env-var names,
and collisions with the primary EnvVarSpecs so authoring mistakes
surface at parse time instead of as generated-code compile failures.
Closes #1303
* fix(cli): restrict composed-auth sibling collection to AND-group requirements
Greptile flagged that the previous collectAdditionalAuthHeaders iterated
all non-winner schemes in components.securitySchemes regardless of how
the spec's `security:` block grouped them. OpenAPI security is a list of
requirement objects: each object is an AND-group, and the list is OR. A
spec offering two independent auth options (each in its own requirement
object) would have the loser silently promoted as a required sibling,
emitting a spurious req.Header.Set on every Bearer request.
Now collectAdditionalAuthHeaders only promotes schemes co-located with
the winner in the same security requirement object. When doc.Security
is empty (no AND grouping declared at the root level), no siblings are
promoted -- conservative behavior keeps single-scheme CLIs unchanged.
Also addresses Greptile's other two findings: collapse the redundant
else-nil branch on populateMCPMetadata; add direct unit tests for the
six error paths in validateAdditionalAuthHeaders, plus a new
parser test for the OR-alternative case.
Refs #1303
* fix(cli): keep sibling-only auth manifests visible to MCP user_config
The early-return guard at the top of mcpbUserConfigAuthEnvVars bailed
out when no primary auth env vars were present, even if the manifest
carried sibling-scheme AdditionalHeaders. Hand-crafted manifests (and
any future auth shape where the primary credential bypasses env vars)
would silently produce no user_config and no env forwarding for the
sibling — the exact 401 symptom this PR is meant to prevent.
Now the guard also requires AuthAdditionalHeaders to be empty before
returning nil; the sibling-header loop is always reachable.
Refs #1303
Files touched
M internal/generator/generator_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/config.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/mcpb_manifest.goM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit 2437cab76049ef0cbf81a6e49cf53595c31c8649
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed May 13 23:08:04 2026 -0700
fix(cli): wire composed apiKey + bearer sibling headers through parser, templates, and MCP manifest (#1359)
* fix(cli): wire composed apiKey + bearer sibling headers through parser, templates, and MCP manifest
When an OpenAPI spec declared both an apiKey scheme and an OAuth2/bearer
scheme (ServiceTitan ST-App-Key + OAuth, Stripe-Signature + bearer,
Atlassian-Token + bearer), selectSecurityScheme picked the bearer half
and the apiKey sibling was silently dropped. The sibling's x-auth-vars
per_call entries never reached Config, the apiKey header never went out
on requests, and every call returned 401 even with a valid bearer.
The parser now scans non-winning apiKey-in-header security schemes for
x-auth-vars per_call entries and propagates them onto
AuthConfig.AdditionalHeaders. The generator emits a Config struct field
+ os.Getenv loader + req.Header.Set after the primary Authorization
header. The MCPB manifest's user_config + launch env block surfaces the
sibling credential so Claude Desktop installs prompt for it at first
run — without this the install path silently 401s.
Validator rejects duplicate headers, duplicate sibling env-var names,
and collisions with the primary EnvVarSpecs so authoring mistakes
surface at parse time instead of as generated-code compile failures.
Closes #1303
* fix(cli): restrict composed-auth sibling collection to AND-group requirements
Greptile flagged that the previous collectAdditionalAuthHeaders iterated
all non-winner schemes in components.securitySchemes regardless of how
the spec's `security:` block grouped them. OpenAPI security is a list of
requirement objects: each object is an AND-group, and the list is OR. A
spec offering two independent auth options (each in its own requirement
object) would have the loser silently promoted as a required sibling,
emitting a spurious req.Header.Set on every Bearer request.
Now collectAdditionalAuthHeaders only promotes schemes co-located with
the winner in the same security requirement object. When doc.Security
is empty (no AND grouping declared at the root level), no siblings are
promoted -- conservative behavior keeps single-scheme CLIs unchanged.
Also addresses Greptile's other two findings: collapse the redundant
else-nil branch on populateMCPMetadata; add direct unit tests for the
six error paths in validateAdditionalAuthHeaders, plus a new
parser test for the OR-alternative case.
Refs #1303
* fix(cli): keep sibling-only auth manifests visible to MCP user_config
The early-return guard at the top of mcpbUserConfigAuthEnvVars bailed
out when no primary auth env vars were present, even if the manifest
carried sibling-scheme AdditionalHeaders. Hand-crafted manifests (and
any future auth shape where the primary credential bypasses env vars)
would silently produce no user_config and no env forwarding for the
sibling — the exact 401 symptom this PR is meant to prevent.
Now the guard also requires AuthAdditionalHeaders to be empty before
returning nil; the sibling-header loop is always reachable.
Refs #1303
---
internal/generator/generator_test.go | 107 +++++++++++++
internal/generator/templates/client.go.tmpl | 12 ++
internal/generator/templates/config.go.tmpl | 13 ++
internal/openapi/parser.go | 92 ++++++++++++
internal/openapi/parser_test.go | 223 ++++++++++++++++++++++++++++
internal/pipeline/climanifest.go | 45 +++---
internal/pipeline/climanifest_test.go | 77 ++++++++++
internal/pipeline/mcpb_manifest.go | 22 ++-
internal/spec/spec.go | 67 +++++++++
internal/spec/spec_test.go | 112 ++++++++++++++
10 files changed, 749 insertions(+), 21 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 5d8e3a54..182469b8 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -922,6 +922,113 @@ func TestSaveBearerTokenClearsEnvBackedField(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+// Composed apiKey + OAuth2 cc (ServiceTitan shape): the primary auth is the
+// OAuth bearer, but the API also requires a per-call ST-App-Key header carried
+// by a sibling apiKey scheme. AdditionalHeaders on AuthConfig drives three
+// generator emissions: a Config struct field, an os.Getenv loader in Load(),
+// and a req.Header.Set in the client request hot-path. Without all three, the
+// composed-auth API returns 401 even with a valid bearer.
+func TestGenerateComposedApiKeyPlusBearerEmitsAdditionalHeader(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "stcompose",
+ Version: "0.1.0",
+ BaseURL: "https://api.servicetitan.io",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://auth.servicetitan.io/connect/token",
+ EnvVars: []string{"ST_CLIENT_ID", "ST_CLIENT_SECRET"},
+ EnvVarSpecs: []spec.AuthEnvVar{
+ {Name: "ST_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true},
+ {Name: "ST_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true, Sensitive: true},
+ },
+ AdditionalHeaders: []spec.AdditionalAuthHeader{
+ {
+ Header: "ST-App-Key",
+ In: "header",
+ Scheme: "apiKeyHeader",
+ EnvVar: spec.AuthEnvVar{
+ Name: "ST_APP_KEY",
+ Kind: spec.AuthEnvVarKindPerCall,
+ Required: true,
+ Sensitive: true,
+ },
+ },
+ },
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/stcompose-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "customers": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/customers"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ configSrc := string(configBytes)
+ assert.Regexp(t, `StAppKey\s+string`, configSrc,
+ "Config struct must carry a field for the sibling apiKey env var")
+ assert.Contains(t, configSrc, `os.Getenv("ST_APP_KEY")`,
+ "Load() must read ST_APP_KEY from env")
+ assert.Contains(t, configSrc, `cfg.StAppKey = v`,
+ "Load() must assign ST_APP_KEY into the Config field")
+
+ clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ clientSrc := string(clientBytes)
+ assert.Contains(t, clientSrc, `req.Header.Set("ST-App-Key", v)`,
+ "client must set ST-App-Key on every outbound request when configured")
+}
+
+// OAuth2 client_credentials specs without a sibling apiKey scheme must not
+// emit the additional-header block. Guards against the previous emission
+// regressing into "every OAuth CLI now ships a useless extra Config field".
+func TestGenerateOAuth2WithoutAdditionalHeadersIsClean(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "ccclean",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ OAuth2Grant: spec.OAuth2GrantClientCredentials,
+ TokenURL: "https://api.example.com/oauth/token",
+ EnvVars: []string{"CCCLEAN_CLIENT_ID", "CCCLEAN_CLIENT_SECRET"},
+ },
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/ccclean-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ clientBytes, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(clientBytes), "Composed-scheme per-call headers",
+ "specs without sibling per_call schemes must not emit the additional-header block")
+}
+
func TestGenerateOAuth2ClientCredentialsAuthTemplate(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 3141ed7e..c0c304f4 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -944,6 +944,18 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
{{- end}}
}
+{{- if .Auth.AdditionalHeaders}}
+ // Composed-scheme per-call headers carried by sibling apiKey schemes.
+ // Sent independently of authHeader: the API requires both the primary
+ // auth header and each sibling header on every request.
+ if c.Config != nil {
+{{- range .Auth.AdditionalHeaders}}
+ if v := c.Config.{{resolveEnvVarField .EnvVar.Name}}; v != "" {
+ req.Header.Set("{{.Header}}", v)
+ }
+{{- end}}
+ }
+{{- end}}
{{- range .RequiredHeaders}}
req.Header.Set("{{.Name}}", "{{.Value}}")
{{- end}}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 46a00d08..cb07be28 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -68,6 +68,11 @@ type Config struct {
{{- end}}
{{- end}}
{{- end}}
+{{- range .Auth.AdditionalHeaders}}
+{{- if not (envVarIsBuiltinField .EnvVar.Name)}}
+ {{envVarField .EnvVar.Name}} string `{{configTag $.Config.Format}}:"{{envVarPlaceholder .EnvVar.Name}}"`
+{{- end}}
+{{- end}}
{{- if .EndpointTemplateVars}}
// TemplateVars holds the runtime values for {placeholder} markers in
// BaseURL and the request path (e.g. Shopify's {shop}/{version}). Populated
@@ -134,6 +139,14 @@ func Load(configPath string) (*Config, error) {
cfg.AuthSource = "env:{{.}}"
}
{{- end}}
+{{- end}}
+{{- range .Auth.AdditionalHeaders}}
+ // Sibling-scheme per-call credential sent on every request alongside the
+ // primary auth. AuthSource intentionally not stamped: the primary auth
+ // remains the canonical surface for doctor/auth-status reporting.
+ if v := os.Getenv("{{.EnvVar.Name}}"); v != "" {
+ cfg.{{resolveEnvVarField .EnvVar.Name}} = v
+ }
{{- end}}
// Label config-file-derived credentials so doctor can distinguish
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index b89f9da5..8b3ec59f 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -660,9 +660,101 @@ func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescript
applyAuthOverrideExtensions(&auth, scheme.Extensions)
applyAuthEnvVarDefaults(&auth, envPrefix)
applyAuthVarsRichOverride(&auth, scheme.Extensions, fmt.Sprintf("components.securitySchemes.%s.%s", schemeName, extensionAuthVars))
+ auth.AdditionalHeaders = collectAdditionalAuthHeaders(doc, schemeName)
return auth
}
+// collectAdditionalAuthHeaders scans AND-group siblings of the winning
+// security scheme for x-auth-vars per_call entries. Composed auth shapes
+// (apiKey + OAuth bearer, Stripe-Signature + bearer, ST-App-Key + bearer)
+// declare the apiKey scheme in the same security requirement object as the
+// bearer; selectSecurityScheme picks the bearer half, and without this sweep
+// the apiKey half is silently dropped.
+//
+// AND vs OR matters: OpenAPI security is a list of requirement objects, where
+// each object is an AND-group (all schemes named must be satisfied) and the
+// list itself is OR (any one object suffices). A spec offering BearerAuth and
+// ApiKeyAuth as alternatives (each in its own requirement object) must NOT
+// promote the unused alternative as a sibling — that would emit a spurious
+// header on every Bearer-authenticated request. Only schemes co-located with
+// the winner in a requirement object are promoted.
+//
+// Only apiKey-typed siblings with `in: header` and an `x-auth-vars` per_call
+// declaration are considered. When doc.Security is empty (no root-level AND
+// grouping declared), no siblings are promoted: without an explicit
+// requirement object the AND/OR relationship is ambiguous and conservative
+// behavior is to emit nothing.
+func collectAdditionalAuthHeaders(doc *openapi3.T, winner string) []spec.AdditionalAuthHeader {
+ if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) <= 1 {
+ return nil
+ }
+ if winner == "" || len(doc.Security) == 0 {
+ return nil
+ }
+
+ siblingSet := map[string]struct{}{}
+ var siblings []string
+ for _, req := range doc.Security {
+ if _, ok := req[winner]; !ok {
+ continue
+ }
+ for name := range req {
+ if name == winner {
+ continue
+ }
+ if _, dup := siblingSet[name]; dup {
+ continue
+ }
+ siblingSet[name] = struct{}{}
+ siblings = append(siblings, name)
+ }
+ }
+ sort.Strings(siblings)
+
+ var headers []spec.AdditionalAuthHeader
+ for _, name := range siblings {
+ scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
+ if scheme == nil {
+ continue
+ }
+ if !strings.EqualFold(scheme.Type, "apiKey") {
+ continue
+ }
+ header := strings.TrimSpace(scheme.Name)
+ if header == "" {
+ continue
+ }
+ // apiKey schemes must declare `in` per OpenAPI 3.x; an empty value is a
+ // spec authoring mistake and would otherwise silently emit a header.
+ if !strings.EqualFold(strings.TrimSpace(scheme.In), "header") {
+ continue
+ }
+ raw, ok := scheme.Extensions[extensionAuthVars]
+ if !ok || raw == nil {
+ continue
+ }
+ envVars, err := authVarsExtension(raw)
+ if err != nil || len(envVars) == 0 {
+ continue
+ }
+ for _, ev := range envVars {
+ if ev.EffectiveKind() != spec.AuthEnvVarKindPerCall {
+ continue
+ }
+ if strings.TrimSpace(ev.Name) == "" {
+ continue
+ }
+ headers = append(headers, spec.AdditionalAuthHeader{
+ Header: header,
+ In: "header",
+ Scheme: name,
+ EnvVar: ev,
+ })
+ }
+ }
+ return headers
+}
+
func isGenericAPIKeySchemeSuffix(suffix string) bool {
normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(suffix)), "_", "")
switch normalized {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index d0e3a554..d0ead1c3 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -3026,6 +3026,229 @@ paths:
assert.True(t, parsed.Auth.EnvVarSpecs[0].Sensitive)
}
+// ServiceTitan-shape: an apiKey scheme carrying a per-tenant header credential
+// sits alongside an OAuth2 client_credentials scheme. selectSecurityScheme
+// picks the OAuth2 half (bearer Authorization); without sibling-scheme
+// collection, the apiKey half is dropped and every request returns 401.
+func TestComposedApiKeyPlusOAuthCollectsAdditionalHeader(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: ServiceTitan-shape
+ version: "1.0.0"
+servers:
+ - url: https://api.servicetitan.io
+security:
+ - apiKeyHeader: []
+ oauth: []
+components:
+ securitySchemes:
+ apiKeyHeader:
+ type: apiKey
+ in: header
+ name: ST-App-Key
+ x-auth-vars:
+ - name: ST_APP_KEY
+ kind: per_call
+ required: true
+ sensitive: true
+ oauth:
+ type: oauth2
+ flows:
+ clientCredentials:
+ tokenUrl: https://auth.servicetitan.io/connect/token
+ scopes: {}
+paths:
+ /tenant/{tenant}/customers:
+ get:
+ operationId: listCustomers
+ parameters:
+ - name: tenant
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type, "OAuth2 cc beats apiKey-header in scheme priority")
+ assert.Equal(t, "oauth", parsed.Auth.Scheme)
+ require.Len(t, parsed.Auth.AdditionalHeaders, 1, "sibling apiKey scheme must surface as an additional header")
+ additional := parsed.Auth.AdditionalHeaders[0]
+ assert.Equal(t, "ST-App-Key", additional.Header)
+ assert.Equal(t, "header", additional.In)
+ assert.Equal(t, "apiKeyHeader", additional.Scheme)
+ assert.Equal(t, "ST_APP_KEY", additional.EnvVar.Name)
+ assert.Equal(t, spec.AuthEnvVarKindPerCall, additional.EnvVar.Kind)
+ assert.True(t, additional.EnvVar.Required)
+ assert.True(t, additional.EnvVar.Sensitive)
+}
+
+// Single-scheme apiKey (no sibling OAuth) must keep its existing single-scheme
+// emission path: the per_call envvar lives on EnvVarSpecs, and
+// AdditionalHeaders stays empty so the generator does not emit duplicate
+// Config fields or req.Header.Set calls.
+func TestSingleApiKeySchemeDoesNotPopulateAdditionalHeaders(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Single ApiKey
+ version: "1.0.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ x-auth-vars:
+ - name: EXAMPLE_API_KEY
+ kind: per_call
+ required: true
+ sensitive: true
+paths:
+ /items:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ assert.Equal(t, "api_key", parsed.Auth.Type)
+ assert.Empty(t, parsed.Auth.AdditionalHeaders, "single-scheme path must not duplicate the primary envvar as additional")
+}
+
+// OR-alternative auth: two security requirement objects, each with a single
+// scheme — the API accepts EITHER scheme, not both. Sibling detection must
+// NOT promote the unused alternative as an additional header. The winning
+// scheme runs alone; the alternative is only relevant if the user picks it.
+func TestSiblingApiKeyInDifferentRequirementGroupIsSkipped(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: or-alternatives
+ version: "1.0.0"
+servers:
+ - url: https://api.example.com
+security:
+ - bearer: []
+ - apiKey: []
+components:
+ securitySchemes:
+ bearer:
+ type: http
+ scheme: bearer
+ apiKey:
+ type: apiKey
+ in: header
+ name: X-Alternative-Key
+ x-auth-vars:
+ - name: EXAMPLE_ALTERNATIVE_KEY
+ kind: per_call
+ required: true
+ sensitive: true
+paths:
+ /items:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+ assert.Empty(t, parsed.Auth.AdditionalHeaders,
+ "OR alternative schemes must not surface as required siblings")
+}
+
+// A sibling apiKey scheme that omits the x-auth-vars extension is silently
+// skipped: collectAdditionalAuthHeaders never invents env-var names. The
+// scheme's per-call credential simply isn't covered, and the primary auth
+// remains untouched.
+func TestSiblingApiKeyWithoutAuthVarsIsSkipped(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: no-auth-vars-sibling
+ version: "1.0.0"
+servers:
+ - url: https://api.example.com
+security:
+ - bearer: []
+ apiKey: []
+components:
+ securitySchemes:
+ bearer:
+ type: http
+ scheme: bearer
+ apiKey:
+ type: apiKey
+ in: header
+ name: X-Sibling-Key
+paths:
+ /items:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+ assert.Empty(t, parsed.Auth.AdditionalHeaders)
+}
+
+// Sibling apiKey-in-query schemes are skipped: the issue this addresses is
+// header-only (ST-App-Key, Stripe-Signature, Atlassian-Token). A query-param
+// sibling would imply mixing query-auth with bearer Authorization, which is
+// not a shape this fix supports, and pretending it works would silently emit
+// broken code.
+func TestSiblingApiKeyInQueryIsSkipped(t *testing.T) {
+ t.Parallel()
+
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: query-sibling
+ version: "1.0.0"
+servers:
+ - url: https://api.example.com
+security:
+ - bearer: []
+ queryKey: []
+components:
+ securitySchemes:
+ bearer:
+ type: http
+ scheme: bearer
+ queryKey:
+ type: apiKey
+ in: query
+ name: api_key
+ x-auth-vars:
+ - name: EXAMPLE_QUERY_KEY
+ kind: per_call
+ required: true
+paths:
+ /items:
+ get:
+ responses:
+ "200":
+ description: OK
+`)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+ assert.Empty(t, parsed.Auth.AdditionalHeaders)
+}
+
func TestOpenAPIAuthClassifiesCookieAndOAuth2ClientCredentialsEnvVars(t *testing.T) {
t.Parallel()
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index a2a613fe..1c4feb6c 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -58,25 +58,31 @@ type CLIManifest struct {
// Printer is the original printer's GitHub handle, preserved across regens.
Printer string `json:"printer,omitempty"`
// PrinterName is the optional display name rendered beside the printer handle.
- PrinterName string `json:"printer_name,omitempty"`
- SpecURL string `json:"spec_url,omitempty"`
- SpecPath string `json:"spec_path,omitempty"`
- SpecFormat string `json:"spec_format,omitempty"`
- SpecChecksum string `json:"spec_checksum,omitempty"`
- RunID string `json:"run_id,omitempty"`
- CatalogEntry string `json:"catalog_entry,omitempty"`
- Category string `json:"category,omitempty"`
- Description string `json:"description,omitempty"`
- MCPBinary string `json:"mcp_binary,omitempty"`
- MCPToolCount int `json:"mcp_tool_count,omitempty"`
- MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
- MCPReady string `json:"mcp_ready,omitempty"`
- APIVersion string `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
- AuthType string `json:"auth_type,omitempty"`
- AuthEnvVars []string `json:"auth_env_vars,omitempty"`
- AuthEnvVarSpecs []spec.AuthEnvVar `json:"auth_env_var_specs,omitempty"`
- EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
- EndpointTemplateEnvOverrides map[string]string `json:"endpoint_template_env_overrides,omitempty"`
+ PrinterName string `json:"printer_name,omitempty"`
+ SpecURL string `json:"spec_url,omitempty"`
+ SpecPath string `json:"spec_path,omitempty"`
+ SpecFormat string `json:"spec_format,omitempty"`
+ SpecChecksum string `json:"spec_checksum,omitempty"`
+ RunID string `json:"run_id,omitempty"`
+ CatalogEntry string `json:"catalog_entry,omitempty"`
+ Category string `json:"category,omitempty"`
+ Description string `json:"description,omitempty"`
+ MCPBinary string `json:"mcp_binary,omitempty"`
+ MCPToolCount int `json:"mcp_tool_count,omitempty"`
+ MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
+ MCPReady string `json:"mcp_ready,omitempty"`
+ APIVersion string `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
+ AuthType string `json:"auth_type,omitempty"`
+ AuthEnvVars []string `json:"auth_env_vars,omitempty"`
+ AuthEnvVarSpecs []spec.AuthEnvVar `json:"auth_env_var_specs,omitempty"`
+ // AuthAdditionalHeaders mirrors AuthConfig.AdditionalHeaders so the MCPB
+ // manifest's user_config block prompts for sibling-scheme per-call
+ // credentials (e.g. an apiKey header alongside an OAuth bearer). Without
+ // this field, agents installing the printed CLI via Claude Desktop never
+ // see the second credential prompt and every request returns 401.
+ AuthAdditionalHeaders []spec.AdditionalAuthHeader `json:"auth_additional_headers,omitempty"`
+ EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
+ EndpointTemplateEnvOverrides map[string]string `json:"endpoint_template_env_overrides,omitempty"`
// AuthKeyURL is the page where users register for an API key. Used by
// downstream emitters (MCPB manifest user_config descriptions, doctor
// hints) to point users at the right credential source.
@@ -408,6 +414,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
if !spec.AllAuthEnvVarSpecsInferred(envVarSpecs) {
m.AuthEnvVarSpecs = envVarSpecs
}
+ m.AuthAdditionalHeaders = parsed.Auth.AdditionalHeaders
m.EndpointTemplateVars = parsed.EndpointTemplateVars
m.EndpointTemplateEnvOverrides = parsed.EndpointTemplateEnvOverrides
m.AuthKeyURL = parsed.Auth.KeyURL
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index fce21ea7..f75094eb 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -919,6 +919,83 @@ func TestWriteMCPBManifest(t *testing.T) {
assert.NotContains(t, got.UserConfig, "rich_session")
})
+ t.Run("composed apiKey + bearer surfaces sibling creds in user_config and env", func(t *testing.T) {
+ dir := t.TempDir()
+ writeManifest(t, dir, CLIManifest{
+ APIName: "stcompose",
+ DisplayName: "ServiceTitan Compose",
+ MCPBinary: "stcompose-pp-mcp",
+ MCPReady: "full",
+ AuthType: "bearer_token",
+ AuthEnvVars: []string{"ST_CLIENT_ID", "ST_CLIENT_SECRET"},
+ AuthEnvVarSpecs: []spec.AuthEnvVar{
+ {Name: "ST_CLIENT_ID", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true, Sensitive: false},
+ {Name: "ST_CLIENT_SECRET", Kind: spec.AuthEnvVarKindAuthFlowInput, Required: true, Sensitive: true},
+ },
+ AuthAdditionalHeaders: []spec.AdditionalAuthHeader{
+ {
+ Header: "ST-App-Key",
+ In: "header",
+ Scheme: "apiKeyHeader",
+ EnvVar: spec.AuthEnvVar{
+ Name: "ST_APP_KEY",
+ Kind: spec.AuthEnvVarKindPerCall,
+ Required: true,
+ Sensitive: true,
+ },
+ },
+ },
+ })
+
+ require.NoError(t, WriteMCPBManifest(dir))
+ got := readMCPBManifest(t, dir)
+
+ assert.Equal(t, "${user_config.st_app_key}", got.Server.MCPConfig.Env["ST_APP_KEY"],
+ "sibling credential must forward through the launch env block")
+ uc, ok := got.UserConfig["st_app_key"]
+ require.True(t, ok, "user_config must prompt for the sibling apiKey credential")
+ assert.True(t, uc.Required)
+ assert.True(t, uc.Sensitive)
+ })
+
+ t.Run("sibling header credential surfaces even when primary env vars are absent", func(t *testing.T) {
+ // Defensive: a manifest carrying AuthAdditionalHeaders but no primary
+ // env vars (no AuthEnvVarSpecs, no AuthEnvVars) must still emit the
+ // sibling credential into user_config and the env block. The generator
+ // does not produce this combination today, but hand-crafted manifests
+ // and future auth shapes (e.g. OAuth where the primary credential is
+ // minted via a non-env code path) would otherwise silently 401.
+ dir := t.TempDir()
+ writeManifest(t, dir, CLIManifest{
+ APIName: "siblings-only",
+ MCPBinary: "siblings-only-pp-mcp",
+ MCPReady: "full",
+ AuthType: "bearer_token",
+ AuthAdditionalHeaders: []spec.AdditionalAuthHeader{
+ {
+ Header: "X-Sibling-Key",
+ In: "header",
+ Scheme: "apiKeyHeader",
+ EnvVar: spec.AuthEnvVar{
+ Name: "SIBLINGS_ONLY_KEY",
+ Kind: spec.AuthEnvVarKindPerCall,
+ Required: true,
+ Sensitive: true,
+ },
+ },
+ },
+ })
+
+ require.NoError(t, WriteMCPBManifest(dir))
+ got := readMCPBManifest(t, dir)
+
+ assert.Equal(t, "${user_config.siblings_only_key}", got.Server.MCPConfig.Env["SIBLINGS_ONLY_KEY"])
+ uc, ok := got.UserConfig["siblings_only_key"]
+ require.True(t, ok, "sibling credential must surface in user_config")
+ assert.True(t, uc.Required)
+ assert.True(t, uc.Sensitive)
+ })
+
t.Run("auth metadata overrides user_config title and description", func(t *testing.T) {
dir := t.TempDir()
writeManifest(t, dir, CLIManifest{
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index 338fa5e4..c4e359ca 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -362,10 +362,11 @@ func mcpbUserConfigAuthEnvVars(m CLIManifest) []spec.AuthEnvVar {
envVarSpecs[i].Required = required
}
}
- if len(envVarSpecs) == 0 {
+ if len(envVarSpecs) == 0 && len(m.AuthAdditionalHeaders) == 0 {
return nil
}
- filtered := make([]spec.AuthEnvVar, 0, len(envVarSpecs))
+ filtered := make([]spec.AuthEnvVar, 0, len(envVarSpecs)+len(m.AuthAdditionalHeaders))
+ seen := make(map[string]struct{}, len(envVarSpecs))
for _, envVar := range envVarSpecs {
if envVar.Name == "" {
continue
@@ -373,11 +374,28 @@ func mcpbUserConfigAuthEnvVars(m CLIManifest) []spec.AuthEnvVar {
switch envVar.Kind {
case "", spec.AuthEnvVarKindPerCall:
envVar.Kind = spec.AuthEnvVarKindPerCall
+ seen[envVar.Name] = struct{}{}
filtered = append(filtered, envVar)
case spec.AuthEnvVarKindAuthFlowInput, spec.AuthEnvVarKindHarvested:
continue
}
}
+ // Sibling-scheme credentials (e.g. an apiKey header alongside an OAuth
+ // bearer) ride the same user_config + env-forwarding path so MCP hosts
+ // prompt for them at install time. Without this, composed-auth specs ship
+ // install bundles that silently 401 at first request.
+ for _, ah := range m.AuthAdditionalHeaders {
+ ev := ah.EnvVar
+ if ev.Name == "" {
+ continue
+ }
+ if _, dup := seen[ev.Name]; dup {
+ continue
+ }
+ ev.Kind = spec.AuthEnvVarKindPerCall
+ seen[ev.Name] = struct{}{}
+ filtered = append(filtered, ev)
+ }
return filtered
}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7829be6d..a7dd4729 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -570,6 +570,28 @@ type AuthConfig struct {
// to a Google-shaped default that silently breaks other providers.
// Used by the authorization_code flow only; ignored for other grants.
RefreshTokenMechanism string `yaml:"refresh_token_mechanism,omitempty" json:"refresh_token_mechanism,omitempty"`
+
+ // AdditionalHeaders carries per-call credentials from non-winning sibling
+ // security schemes. Composed apiKey + OAuth (or apiKey + bearer) shapes
+ // declare both schemes in components.securitySchemes; selectSecurityScheme
+ // picks one as the primary (Authorization-bearer half) and the parser then
+ // scans the rest for apiKey schemes carrying x-auth-vars[*].kind: per_call,
+ // so the apiKey header gets sent alongside the primary auth. Generator
+ // emits a Config field + os.Getenv loader per entry, plus a req.Header.Set
+ // after the primary auth header on every request.
+ AdditionalHeaders []AdditionalAuthHeader `yaml:"additional_headers,omitempty" json:"additional_headers,omitempty"`
+}
+
+// AdditionalAuthHeader pairs a sibling-scheme header destination with the
+// per-call env var that supplies its value. Only In == "header" is emitted by
+// the generator today; the field is serialized so parsed specs round-trip
+// cleanly and validators can distinguish placements without relying on the
+// destination string.
+type AdditionalAuthHeader struct {
+ Header string `yaml:"header" json:"header"`
+ In string `yaml:"in,omitempty" json:"in,omitempty"`
+ Scheme string `yaml:"scheme,omitempty" json:"scheme,omitempty"`
+ EnvVar AuthEnvVar `yaml:"env_var" json:"env_var"`
}
const (
@@ -1916,6 +1938,9 @@ func (s *APISpec) Validate() error {
if err := validateAuthEnvVarSpecs("auth", s.Auth); err != nil {
return err
}
+ if err := validateAdditionalAuthHeaders("auth", s.Auth); err != nil {
+ return err
+ }
if err := validateTierRouting(s); err != nil {
return err
}
@@ -2087,6 +2112,48 @@ func validatePublicParamNameList(context string, params []Param) error {
return nil
}
+// validateAdditionalAuthHeaders checks that each composed-auth sibling entry
+// names a destination header and a per_call env var, and that no two siblings
+// (or a sibling and a primary EnvVarSpec) share a header or env-var name.
+// Collisions would emit duplicate Config struct fields or duplicate
+// req.Header.Set calls, so a hard error at parse time is preferable to silent
+// generation drift or a compile failure in the generated CLI.
+func validateAdditionalAuthHeaders(context string, auth AuthConfig) error {
+ seenHeaders := make(map[string]struct{}, len(auth.AdditionalHeaders))
+ primaryNames := make(map[string]struct{}, len(auth.EnvVarSpecs))
+ for _, ev := range auth.EnvVarSpecs {
+ if name := strings.TrimSpace(ev.Name); name != "" {
+ primaryNames[name] = struct{}{}
+ }
+ }
+ seenNames := make(map[string]struct{}, len(auth.AdditionalHeaders))
+ for i, ah := range auth.AdditionalHeaders {
+ header := strings.TrimSpace(ah.Header)
+ if header == "" {
+ return fmt.Errorf("%s.additional_headers[%d].header is required", context, i)
+ }
+ if _, dup := seenHeaders[header]; dup {
+ return fmt.Errorf("%s.additional_headers contains duplicate header %q", context, header)
+ }
+ seenHeaders[header] = struct{}{}
+ name := strings.TrimSpace(ah.EnvVar.Name)
+ if name == "" {
+ return fmt.Errorf("%s.additional_headers[%d].env_var.name is required", context, i)
+ }
+ if _, dup := seenNames[name]; dup {
+ return fmt.Errorf("%s.additional_headers contains duplicate env_var.name %q", context, name)
+ }
+ if _, dup := primaryNames[name]; dup {
+ return fmt.Errorf("%s.additional_headers[%d].env_var.name %q collides with env_var_specs", context, i, name)
+ }
+ seenNames[name] = struct{}{}
+ if ah.EnvVar.EffectiveKind() != AuthEnvVarKindPerCall {
+ return fmt.Errorf("%s.additional_headers[%d].env_var.kind must be %q (got %q)", context, i, AuthEnvVarKindPerCall, ah.EnvVar.Kind)
+ }
+ }
+ return nil
+}
+
func validateAuthEnvVarSpecs(context string, auth AuthConfig) error {
seen := map[string]struct{}{}
for i, envVar := range auth.EnvVarSpecs {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 7822326b..4381794a 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -316,6 +316,118 @@ func TestValidation(t *testing.T) {
}
}
+// validateAdditionalAuthHeaders covers six distinct error paths; this table
+// hits each one and confirms the happy path still validates.
+func TestValidateAdditionalAuthHeadersErrors(t *testing.T) {
+ t.Parallel()
+
+ baseSpec := func(auth AuthConfig) APISpec {
+ return APISpec{
+ Name: "auth-api",
+ BaseURL: "https://api.example.com",
+ Auth: auth,
+ Resources: map[string]Resource{
+ "items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+ },
+ }
+ }
+ perCall := func(name string) AuthEnvVar {
+ return AuthEnvVar{Name: name, Kind: AuthEnvVarKindPerCall, Required: true, Sensitive: true}
+ }
+ tests := []struct {
+ name string
+ auth AuthConfig
+ wantErr string
+ }{
+ {
+ name: "happy path: per_call sibling with header validates",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "ST-App-Key", In: "header", EnvVar: perCall("ST_APP_KEY")},
+ },
+ },
+ },
+ {
+ name: "missing header",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "", EnvVar: perCall("ST_APP_KEY")},
+ },
+ },
+ wantErr: "auth.additional_headers[0].header is required",
+ },
+ {
+ name: "missing env_var name",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "ST-App-Key", EnvVar: AuthEnvVar{Kind: AuthEnvVarKindPerCall}},
+ },
+ },
+ wantErr: "auth.additional_headers[0].env_var.name is required",
+ },
+ {
+ name: "duplicate header",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "X-Same", EnvVar: perCall("FIRST_KEY")},
+ {Header: "X-Same", EnvVar: perCall("SECOND_KEY")},
+ },
+ },
+ wantErr: `auth.additional_headers contains duplicate header "X-Same"`,
+ },
+ {
+ name: "duplicate env_var name",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "X-First", EnvVar: perCall("SAME_KEY")},
+ {Header: "X-Second", EnvVar: perCall("SAME_KEY")},
+ },
+ },
+ wantErr: `auth.additional_headers contains duplicate env_var.name "SAME_KEY"`,
+ },
+ {
+ name: "collision with primary EnvVarSpecs",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ EnvVarSpecs: []AuthEnvVar{{Name: "SHARED_KEY", Kind: AuthEnvVarKindPerCall, Required: true}},
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "ST-App-Key", EnvVar: perCall("SHARED_KEY")},
+ },
+ },
+ wantErr: `auth.additional_headers[0].env_var.name "SHARED_KEY" collides with env_var_specs`,
+ },
+ {
+ name: "non-per_call kind",
+ auth: AuthConfig{
+ Type: "bearer_token",
+ AdditionalHeaders: []AdditionalAuthHeader{
+ {Header: "ST-App-Key", EnvVar: AuthEnvVar{Name: "ST_APP_KEY", Kind: AuthEnvVarKindAuthFlowInput, Required: true}},
+ },
+ },
+ wantErr: `auth.additional_headers[0].env_var.kind must be "per_call" (got "auth_flow_input")`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ candidate := baseSpec(tt.auth)
+ err := candidate.Validate()
+ if tt.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
func TestAuthEnvVarSpecsNormalizeAndValidate(t *testing.T) {
baseSpec := func(auth AuthConfig) APISpec {
return APISpec{
← 51b758c7 fix(cli): consume x-tenant-env-var so per-tenant sync paths
·
back to Cli Printing Press
·
fix(cli): refuse publish package --dest overlay when mirror 1ff68537 →