[object Object]

← 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

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 →