[object Object]

← back to Cli Printing Press

fix(cli): parse x-mcp extension in OpenAPI parser (#702)

41c87cf866eaff009988036b73dc62a30b9d0738 · 2026-05-08 00:22:22 -0700 · Trevin Chow

* fix(cli): parse x-mcp extension in OpenAPI parser

Mirrors internal YAML's top-level `mcp:` block via `x-mcp` at the OpenAPI
root or `info` level, so large OpenAPI specs can apply the Pre-Generation
MCP Enrichment recipe (Cloudflare pattern) the SKILL recommends.

Before this change, `printing-press generate` would warn that specs above
the 50-tool threshold should set `mcp.transport`/`orchestration`/
`endpoint_tools`, but OpenAPI users had no way to act on the warning —
the parser only ever surfaced the named `x-*` extensions in
`internal/openapi/parser.go:32-46`, and `x-mcp` was not among them.

Implementation follows the established `parseTierRoutingExtension`
pattern: lookup at root or info, JSON-marshal-roundtrip into the
existing `spec.MCPConfig` struct, set on the resulting `APISpec`.
`MCPConfig`'s validation (transport allowlist, addr shape) is already
wired into `APISpec.Validate()`, so malformed `x-mcp` values fail at
parse time with a clear error.

Purely additive — specs without `x-mcp` keep current zero-value MCP
config and stdio-only endpoint-mirror behavior. All 13 golden cases
pass unchanged.

Fixes #696

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): extract parseTypedExtension helper

Both `parseTierRoutingExtension` and `parseMCPExtension` did the same
lookup → marshal → unmarshal roundtrip with only the type and key
varying. Collapsed into a single generic `parseTypedExtension[T]` so
future typed-extension parsers stay one-liners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cli): cover root-precedence, addr, threshold for x-mcp

Adds three targeted parser tests:

- TestParseMCPExtensionRootBeatsInfo: locks the documented precedence
  ("root takes precedence when both are present") so a future
  reorder of lookupOpenAPIExtension would fail loudly.
- TestParseMCPExtensionRoundTripsAddrAndThreshold: round-trips the
  Addr and OrchestrationThreshold fields, which the original four
  tests omitted. A bad json tag on either would silently drop the
  field; this test is the floor that catches that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): inline parseTypedExtension callers

Drop the parseTierRoutingExtension and parseMCPExtension wrappers --
they were one-line pass-throughs adding a call-graph layer with no
guard, no named intermediate, and no behavior boundary. parse() now
calls parseTypedExtension[T] directly with the extension constant,
which is no less readable and stops compounding indirection as more
typed extensions get added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cli): add generate-mcp-api golden case

Locks the OpenAPI x-mcp extension contract end-to-end through
generation. Per AGENTS.md ("Generator Output Stability"), passing
existing goldens does not prove coverage for new MCP behavior;
this case fills that gap.

Fixture sets x-mcp at the OpenAPI root with the Cloudflare pattern
(transport: [stdio, http], orchestration: code, endpoint_tools:
hidden). Three artifacts are checked because each would revert to
defaults if x-mcp parsing regressed:

- .printing-press.json: provenance, slug, mcp binary name
- internal/mcp/tools.go: must call RegisterCodeOrchestrationTools
  instead of registering per-endpoint mirror tools
- cmd/<api>-pp-mcp/main.go: must compile both stdio and http
  transports and expose the --transport / --addr flags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cli): tighten root-beats-info x-mcp assertion

The original test had root: [stdio, http] and info: [stdio]. A
hypothetical merge bug would still satisfy HasTransport("http"),
so the assertion did not distinguish root-wins from merge.

Switch to mutually exclusive transports (root: [http], info:
[stdio]) and add a negative assertion that the info-only
transport does not leak through. The test now fails loudly on
both regression shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): clarify parseTypedExtension and SPEC-EXTENSIONS.md prose

- Lead parseTypedExtension's doc comment with the *why* (bridges
  kin-openapi's any-tree to a typed struct) and add when-to-use
  guidance vs the direct lookupOpenAPIExtension + cast pattern
  used by every scalar x-* extension. Without this, a future
  third typed-extension author has two patterns in the file with
  no rule for which to choose.
- Drop the "Cloudflare" reference from SPEC-EXTENSIONS.md's x-mcp
  description prose. Per AGENTS.md, shared docs must use generic
  language; vendor-pattern names belong in examples or fixtures,
  not in normative reference text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cli): cover x-mcp intents round-trip and lock code_orch.go

- Add TestParseMCPExtensionRoundTripsIntents. Intents is the most
  structurally complex MCPConfig field (nested []Intent with
  []IntentParam + []IntentStep with map[string]string Bind); a bad
  json tag anywhere in that tree would silently drop fields. The
  test asserts the whole shape parses cleanly through the JSON
  marshal/unmarshal roundtrip and validates against the spec's
  resources.
- Add internal/mcp/code_orch.go to the generate-mcp-api golden
  artifacts. tools.go (already locked) only calls
  RegisterCodeOrchestrationTools; the actual <api>_search +
  <api>_execute implementations live in code_orch.go. Locking
  tools.go alone would miss content regressions in this file.
- Correct the .printing-press.json artifact comment. CLIManifest
  carries slug, mcp binary name, and tool counts -- not the
  declared transport list. Transport regressions surface in
  tools.go and main.go, not the manifest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cli): document x-mcp OpenAPI parser parity learning

Captures the bug, fix, and prevention rules from PR #702 (issue #696)
in docs/solutions/logic-errors/. Surfaces the cross-format spec parity
pattern (a documented spec capability needs both internal/spec/spec.go
and internal/openapi/parser.go updated in the same change) so future
agents touching either parser can find the convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 41c87cf866eaff009988036b73dc62a30b9d0738
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 00:22:22 2026 -0700

    fix(cli): parse x-mcp extension in OpenAPI parser (#702)
    
    * fix(cli): parse x-mcp extension in OpenAPI parser
    
    Mirrors internal YAML's top-level `mcp:` block via `x-mcp` at the OpenAPI
    root or `info` level, so large OpenAPI specs can apply the Pre-Generation
    MCP Enrichment recipe (Cloudflare pattern) the SKILL recommends.
    
    Before this change, `printing-press generate` would warn that specs above
    the 50-tool threshold should set `mcp.transport`/`orchestration`/
    `endpoint_tools`, but OpenAPI users had no way to act on the warning —
    the parser only ever surfaced the named `x-*` extensions in
    `internal/openapi/parser.go:32-46`, and `x-mcp` was not among them.
    
    Implementation follows the established `parseTierRoutingExtension`
    pattern: lookup at root or info, JSON-marshal-roundtrip into the
    existing `spec.MCPConfig` struct, set on the resulting `APISpec`.
    `MCPConfig`'s validation (transport allowlist, addr shape) is already
    wired into `APISpec.Validate()`, so malformed `x-mcp` values fail at
    parse time with a clear error.
    
    Purely additive — specs without `x-mcp` keep current zero-value MCP
    config and stdio-only endpoint-mirror behavior. All 13 golden cases
    pass unchanged.
    
    Fixes #696
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): extract parseTypedExtension helper
    
    Both `parseTierRoutingExtension` and `parseMCPExtension` did the same
    lookup → marshal → unmarshal roundtrip with only the type and key
    varying. Collapsed into a single generic `parseTypedExtension[T]` so
    future typed-extension parsers stay one-liners.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(cli): cover root-precedence, addr, threshold for x-mcp
    
    Adds three targeted parser tests:
    
    - TestParseMCPExtensionRootBeatsInfo: locks the documented precedence
      ("root takes precedence when both are present") so a future
      reorder of lookupOpenAPIExtension would fail loudly.
    - TestParseMCPExtensionRoundTripsAddrAndThreshold: round-trips the
      Addr and OrchestrationThreshold fields, which the original four
      tests omitted. A bad json tag on either would silently drop the
      field; this test is the floor that catches that.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): inline parseTypedExtension callers
    
    Drop the parseTierRoutingExtension and parseMCPExtension wrappers --
    they were one-line pass-throughs adding a call-graph layer with no
    guard, no named intermediate, and no behavior boundary. parse() now
    calls parseTypedExtension[T] directly with the extension constant,
    which is no less readable and stops compounding indirection as more
    typed extensions get added.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(cli): add generate-mcp-api golden case
    
    Locks the OpenAPI x-mcp extension contract end-to-end through
    generation. Per AGENTS.md ("Generator Output Stability"), passing
    existing goldens does not prove coverage for new MCP behavior;
    this case fills that gap.
    
    Fixture sets x-mcp at the OpenAPI root with the Cloudflare pattern
    (transport: [stdio, http], orchestration: code, endpoint_tools:
    hidden). Three artifacts are checked because each would revert to
    defaults if x-mcp parsing regressed:
    
    - .printing-press.json: provenance, slug, mcp binary name
    - internal/mcp/tools.go: must call RegisterCodeOrchestrationTools
      instead of registering per-endpoint mirror tools
    - cmd/<api>-pp-mcp/main.go: must compile both stdio and http
      transports and expose the --transport / --addr flags
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(cli): tighten root-beats-info x-mcp assertion
    
    The original test had root: [stdio, http] and info: [stdio]. A
    hypothetical merge bug would still satisfy HasTransport("http"),
    so the assertion did not distinguish root-wins from merge.
    
    Switch to mutually exclusive transports (root: [http], info:
    [stdio]) and add a negative assertion that the info-only
    transport does not leak through. The test now fails loudly on
    both regression shapes.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): clarify parseTypedExtension and SPEC-EXTENSIONS.md prose
    
    - Lead parseTypedExtension's doc comment with the *why* (bridges
      kin-openapi's any-tree to a typed struct) and add when-to-use
      guidance vs the direct lookupOpenAPIExtension + cast pattern
      used by every scalar x-* extension. Without this, a future
      third typed-extension author has two patterns in the file with
      no rule for which to choose.
    - Drop the "Cloudflare" reference from SPEC-EXTENSIONS.md's x-mcp
      description prose. Per AGENTS.md, shared docs must use generic
      language; vendor-pattern names belong in examples or fixtures,
      not in normative reference text.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * test(cli): cover x-mcp intents round-trip and lock code_orch.go
    
    - Add TestParseMCPExtensionRoundTripsIntents. Intents is the most
      structurally complex MCPConfig field (nested []Intent with
      []IntentParam + []IntentStep with map[string]string Bind); a bad
      json tag anywhere in that tree would silently drop fields. The
      test asserts the whole shape parses cleanly through the JSON
      marshal/unmarshal roundtrip and validates against the spec's
      resources.
    - Add internal/mcp/code_orch.go to the generate-mcp-api golden
      artifacts. tools.go (already locked) only calls
      RegisterCodeOrchestrationTools; the actual <api>_search +
      <api>_execute implementations live in code_orch.go. Locking
      tools.go alone would miss content regressions in this file.
    - Correct the .printing-press.json artifact comment. CLIManifest
      carries slug, mcp binary name, and tool counts -- not the
      declared transport list. Transport regressions surface in
      tools.go and main.go, not the manifest.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * docs(cli): document x-mcp OpenAPI parser parity learning
    
    Captures the bug, fix, and prevention rules from PR #702 (issue #696)
    in docs/solutions/logic-errors/. Surfaces the cross-format spec parity
    pattern (a documented spec capability needs both internal/spec/spec.go
    and internal/openapi/parser.go updated in the same change) so future
    agents touching either parser can find the convention.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 docs/SPEC-EXTENSIONS.md                            |  31 +++
 ...r-missing-x-mcp-extension-support-2026-05-08.md | 101 +++++++
 internal/openapi/parser.go                         |  28 +-
 internal/openapi/parser_test.go                    | 222 +++++++++++++++
 .../golden/cases/generate-mcp-api/artifacts.txt    |  26 ++
 testdata/golden/cases/generate-mcp-api/command.txt |   2 +
 testdata/golden/expected/generate-mcp-api/exit.txt |   1 +
 .../mcp-cloudflare/.printing-press.json            |  21 ++
 .../cmd/mcp-cloudflare-pp-mcp/main.go              |  67 +++++
 .../mcp-cloudflare/internal/mcp/code_orch.go       | 238 ++++++++++++++++
 .../mcp-cloudflare/internal/mcp/tools.go           | 310 +++++++++++++++++++++
 .../golden/expected/generate-mcp-api/stderr.txt    |   2 +
 .../golden/expected/generate-mcp-api/stdout.txt    |   0
 testdata/golden/fixtures/mcp-api.yaml              |  39 +++
 14 files changed, 1081 insertions(+), 7 deletions(-)

diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index a5a3c1e5..7265f76f 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -18,6 +18,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-origin` | `info` | Google Discovery resource fallback | No |
 | `x-providerName` | `info` | Google Discovery resource fallback | No |
 | `x-tier-routing` | root or `info` | `APISpec.TierRouting` | No |
+| `x-mcp` | root or `info` | `APISpec.MCP` | No |
 | `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
 | `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
 | `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
@@ -186,6 +187,36 @@ x-tier-routing:
         env_vars: [EXAMPLE_PAID_KEY]
 ```
 
+### `x-mcp`
+
+Declares MCP server shape for the generated CLI. Mirrors the internal YAML
+spec's top-level `mcp:` block so OpenAPI specs can opt into the same
+pre-generation MCP enrichment recipe (notably the code-orchestration pattern
+for large surfaces: `transport: [stdio, http]` + `orchestration: code` +
+`endpoint_tools: hidden`).
+
+Parsed field: `APISpec.MCP` (`spec.MCPConfig`)
+
+Rules:
+- Optional. Specs without `x-mcp` keep today's stdio-only endpoint-mirror
+  behavior.
+- May be declared at the OpenAPI root or under `info`. Root takes precedence
+  when both are present.
+- Shape mirrors the internal YAML `mcp:` block field-for-field: `transport`,
+  `addr`, `intents`, `endpoint_tools`, `orchestration`,
+  `orchestration_threshold`.
+- Validated by `validateMCP` at spec load (same allowlist as internal YAML):
+  unknown transports and malformed addresses are rejected.
+
+Example:
+
+```yaml
+x-mcp:
+  transport: [stdio, http]
+  orchestration: code
+  endpoint_tools: hidden
+```
+
 ## Security Scheme Extensions
 
 Security scheme extensions are read from
diff --git a/docs/solutions/logic-errors/openapi-parser-missing-x-mcp-extension-support-2026-05-08.md b/docs/solutions/logic-errors/openapi-parser-missing-x-mcp-extension-support-2026-05-08.md
new file mode 100644
index 00000000..8de5c7fb
--- /dev/null
+++ b/docs/solutions/logic-errors/openapi-parser-missing-x-mcp-extension-support-2026-05-08.md
@@ -0,0 +1,101 @@
+---
+title: "OpenAPI parser silently ignored x-mcp extension"
+date: 2026-05-08
+category: logic-errors
+module: internal/openapi
+problem_type: logic_error
+component: tooling
+symptoms:
+  - "x-mcp set in an OpenAPI spec is silently ignored at parse time"
+  - "Generator's >50-tool warning recommends a recipe (transport/orchestration/endpoint_tools) that has no effect on OpenAPI specs"
+  - "Pre-Generation MCP Enrichment skill section is unimplementable for OpenAPI inputs"
+  - "docs/SPEC-EXTENSIONS.md had no x-mcp entry because the parser did not support it"
+  - "Large OpenAPI specs (GitHub 819, AWS, Slack 200+) ship endpoint-mirror surfaces with bad MCP scorecard dims"
+root_cause: incomplete_setup
+resolution_type: code_fix
+severity: high
+related_components:
+  - documentation
+tags:
+  - openapi-parser
+  - mcp-config
+  - x-extensions
+  - parser-parity
+  - spec-extensions
+---
+
+# OpenAPI parser silently ignored x-mcp extension
+
+## Problem
+
+The Printing Press skill documents a "Pre-Generation MCP Enrichment" recipe for OpenAPI specs that exceed the 50-tool threshold (the Cloudflare pattern: `transport: [stdio, http]` + `orchestration: code` + `endpoint_tools: hidden`). At runtime the generator emits a warning telling the operator to enrich the spec's `mcp:` block before regenerating. For internal YAML specs this worked; for OpenAPI specs it was a no-op. The parser at `internal/openapi/parser.go` had no code path for the `x-mcp` extension — `grep "x-mcp" internal/openapi/*.go` returned zero matches — so the operator's edits were silently discarded and the generated CLI shipped with the default endpoint-mirror surface and a poor MCP scorecard.
+
+The recipe pointed operators at a switch that wasn't wired up. The warning was correct; the lever it told them to pull didn't exist for the spec format that needs it most — the large public APIs almost all ship OpenAPI (auto memory [claude]: `feedback_pre_generation_mcp_enrichment.md` documented the recipe whose unimplementability for OpenAPI was this bug).
+
+## Symptoms
+
+- Operator follows `skills/printing-press/SKILL.md` Phase 2 enrichment, adds `x-mcp:` to an OpenAPI spec, runs generate.
+- Generated CLI still ships endpoint-mirror tools; the >50-tool warning persists; scorecard MCP dims regress.
+- No parser error, no warning, no diff — the extension is read by `kin-openapi` into the untyped extension map and then never consulted.
+- The polish skill cannot recover the situation post-generation: MCP transport / orchestration / tool-design are spec-level dims, not generator-code fixes (auto memory [claude]: `feedback_polish_mcp_misclassify.md`).
+
+## What Didn't Work
+
+- **Auto-enrich at >50 tools.** Silently flipping `transport`, `orchestration`, and `endpoint_tools` based on a count would defeat the no-reflexive-defaults principle: surfaces compete for the same scarce agent-tool budget, and the right answer per API depends on judgment the generator doesn't have.
+- **Drop the recipe for OpenAPI in the skill.** That would remove the recipe exactly where it matters — the large public APIs almost all ship OpenAPI.
+- **Convert OpenAPI to internal YAML upstream.** Lossy and recreates the translation burden the OpenAPI parser exists to eliminate.
+
+## Solution
+
+Wire `x-mcp` into the OpenAPI parser using the same shape already in use for `x-tier-routing`:
+
+1. Add `extensionMCP = "x-mcp"` to the named-extension constants block.
+2. Look the extension up via `lookupOpenAPIExtension` (root, then `info`, root wins).
+3. JSON-marshal the untyped `any` tree returned by `kin-openapi`, then JSON-unmarshal into the typed `MCPConfig` struct. `MCPConfig` and its nested types (`Intent`, `IntentParam`, `IntentStep`) already carry matching `yaml:` and `json:` tags, so the roundtrip is clean.
+4. `validateMCP` is already called from `APISpec.Validate()`, so malformed input (e.g., an unknown transport) fails at parse time with a clear error rather than producing a silently broken CLI.
+
+During review, the new `parseMCPExtension` and the existing `parseTierRoutingExtension` collapsed into a generic helper since they differed only in type parameter and key:
+
+```go
+func parseTypedExtension[T any](doc *openapi3.T, key string) (T, error) {
+    var zero T
+    raw, ok := lookupOpenAPIExtension(doc, key)
+    if !ok {
+        return zero, nil
+    }
+    data, err := json.Marshal(raw)
+    if err != nil {
+        return zero, fmt.Errorf("marshaling %s: %w", key, err)
+    }
+    var cfg T
+    if err := json.Unmarshal(data, &cfg); err != nil {
+        return zero, fmt.Errorf("parsing %s: %w", key, err)
+    }
+    return cfg, nil
+}
+```
+
+Coverage: 7 unit tests (root, info, absence, root-beats-info precedence with mutually-exclusive transports + negative assertion, unknown-transport rejection, addr+threshold roundtrip, Intents nested-struct roundtrip), plus a `generate-mcp-api` golden case locking the end-to-end output (`.printing-press.json`, `internal/mcp/tools.go`, `internal/mcp/code_orch.go`, `cmd/<api>-pp-mcp/main.go`). `docs/SPEC-EXTENSIONS.md` gained an `x-mcp` section per the AGENTS.md pointer-rot rule.
+
+## Why This Works
+
+`MCPConfig`'s pre-existing `json:` tags mean the OpenAPI parser doesn't need a parallel decoder; it borrows the same struct definition the YAML parser already uses. JSON is the lowest-common-denominator bridge from `kin-openapi`'s `map[string]any` extension tree to a typed Go struct, and it composes through arbitrary nesting without per-field plumbing. Validation is centralized in `validateMCP` and runs from `APISpec.Validate()`, so the failure mode for a typo'd `x-mcp` is a parse error, not a confusingly-shipped binary.
+
+## Prevention
+
+- **Parity-check both spec parsers when shipping a documented capability.** `internal/spec/spec.go` and `internal/openapi/parser.go` are siblings; a recipe that targets the `mcp:` block must work in both, and the change that adds the recipe should touch both parsers (or explicitly call out the gap). PR #522 added the skill, the warning, and the YAML side — the OpenAPI side fell off the change.
+- **Reach for `parseTypedExtension[T]` for object-valued OpenAPI extensions.** When an extension's value is a YAML/JSON object that maps to a Go struct (with `json:` tags), the JSON-roundtrip generic is the right tool. Don't reimplement deserialization per extension.
+- **Use `lookupOpenAPIExtension` + direct type assertion for scalars.** Strings, bools, and string lists (`x-api-name`, `x-website`, etc.) don't need the roundtrip.
+- **A new deterministic generator behavior driven by an extension warrants a golden case.** Per AGENTS.md, passing `scripts/golden.sh verify` on existing cases does not prove coverage for new MCP, auth, pagination, manifest, or naming behavior. Add the fixture in the same change.
+- **Precedence assertions need mutually-exclusive values plus a negative assertion.** A test that only asserts the dominant value passes even if the parser is silently merging fields instead of replacing.
+- **When a skill instruction or runtime warning points operators at a spec lever, verify the lever is wired up end-to-end before shipping the instruction.** An unimplementable instruction is worse than a missing one — the operator wastes time, the warning loses credibility, and the bug surfaces as a "the docs are wrong" report rather than a "the parser has a gap" report.
+
+## Related Issues
+
+- Issue #696 — this bug
+- PR #702 — the fix
+- PR #522 — introduced the recipe and the YAML-side parser; missed the OpenAPI side
+- `skills/printing-press/SKILL.md:1734-1805` — pre-generation enrichment recipe
+- `internal/generator/mcp_warning.go` — the >50-tool warning that points at the recipe
+- `docs/SPEC-EXTENSIONS.md` — canonical reference for `x-*` extensions, now including `x-mcp`
+- `docs/solutions/logic-errors/inline-authorization-param-bearer-inference-2026-05-05.md` — sibling precedent for parser-extension gaps in `internal/openapi/parser.go`
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 934eb236..c11bdbe3 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -38,6 +38,7 @@ const (
 	extensionSpeakeasyExample = "x-speakeasy-example"
 	extensionTierRouting      = "x-tier-routing"
 	extensionTier             = "x-tier"
+	extensionMCP              = "x-mcp"
 	extensionAPIName          = "x-api-name"
 	extensionDisplayName      = "x-display-name"
 	extensionWebsite          = "x-website"
@@ -278,7 +279,12 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		auth = spec.AuthConfig{Type: "none"}
 	}
 
-	tierRouting, err := parseTierRoutingExtension(doc)
+	tierRouting, err := parseTypedExtension[spec.TierRoutingConfig](doc, extensionTierRouting)
+	if err != nil {
+		return nil, err
+	}
+
+	mcpConfig, err := parseTypedExtension[spec.MCPConfig](doc, extensionMCP)
 	if err != nil {
 		return nil, err
 	}
@@ -295,6 +301,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		ProxyRoutes:                 proxyRoutes,
 		Auth:                        auth,
 		TierRouting:                 tierRouting,
+		MCP:                         mcpConfig,
 		Config: spec.ConfigSpec{
 			Format: "toml",
 			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -329,18 +336,25 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	return result, nil
 }
 
-func parseTierRoutingExtension(doc *openapi3.T) (spec.TierRoutingConfig, error) {
-	raw, ok := lookupOpenAPIExtension(doc, extensionTierRouting)
+// parseTypedExtension bridges kin-openapi's untyped any-tree to a typed
+// config struct via a JSON marshal/unmarshal roundtrip; callers rely on
+// T's json tags for field mapping. Reach for it when the extension's
+// value is a YAML/JSON object that maps to a Go struct (x-tier-routing,
+// x-mcp). Use direct lookupOpenAPIExtension + type assertion for scalar
+// extensions (string, bool, []string) where the roundtrip would be waste.
+func parseTypedExtension[T any](doc *openapi3.T, key string) (T, error) {
+	var zero T
+	raw, ok := lookupOpenAPIExtension(doc, key)
 	if !ok {
-		return spec.TierRoutingConfig{}, nil
+		return zero, nil
 	}
 	data, err := json.Marshal(raw)
 	if err != nil {
-		return spec.TierRoutingConfig{}, fmt.Errorf("marshaling %s: %w", extensionTierRouting, err)
+		return zero, fmt.Errorf("marshaling %s: %w", key, err)
 	}
-	var cfg spec.TierRoutingConfig
+	var cfg T
 	if err := json.Unmarshal(data, &cfg); err != nil {
-		return spec.TierRoutingConfig{}, fmt.Errorf("parsing %s: %w", extensionTierRouting, err)
+		return zero, fmt.Errorf("parsing %s: %w", key, err)
 	}
 	return cfg, nil
 }
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 654bbc15..05fc3ed9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -3293,6 +3293,228 @@ paths:
 	assert.Equal(t, "https://global.example.com", parsed.BaseURL)
 }
 
+func TestParseMCPExtensionFromRoot(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Large API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+x-mcp:
+  transport: [stdio, http]
+  orchestration: code
+  endpoint_tools: hidden
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	assert.True(t, parsed.MCP.HasTransport("http"), "expected http transport from x-mcp")
+	assert.True(t, parsed.MCP.HasTransport("stdio"), "expected stdio transport from x-mcp")
+	assert.True(t, parsed.MCP.IsCodeOrchestration(), "expected code orchestration from x-mcp")
+	assert.Equal(t, "hidden", parsed.MCP.EndpointTools)
+}
+
+func TestParseMCPExtensionFromInfo(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Large API
+  version: 1.0.0
+  x-mcp:
+    transport: [stdio, http]
+    orchestration: code
+    endpoint_tools: hidden
+servers:
+  - url: https://api.example.com
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	assert.True(t, parsed.MCP.HasTransport("http"), "expected http transport from info.x-mcp")
+	assert.True(t, parsed.MCP.IsCodeOrchestration(), "expected code orchestration from info.x-mcp")
+	assert.Equal(t, "hidden", parsed.MCP.EndpointTools)
+}
+
+func TestParseMCPExtensionAbsentLeavesZeroValue(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Plain API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	assert.Empty(t, parsed.MCP.Transport)
+	assert.Empty(t, parsed.MCP.Orchestration)
+	assert.Empty(t, parsed.MCP.EndpointTools)
+}
+
+func TestParseMCPExtensionRootBeatsInfo(t *testing.T) {
+	t.Parallel()
+	// Root and info declare mutually exclusive transports so the test
+	// distinguishes root-wins from a hypothetical merge that would satisfy
+	// both transports simultaneously.
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Both-Levels API
+  version: 1.0.0
+  x-mcp:
+    transport: [stdio]
+servers:
+  - url: https://api.example.com
+x-mcp:
+  transport: [http]
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	assert.True(t, parsed.MCP.HasTransport("http"), "root x-mcp must take precedence over info.x-mcp")
+	assert.False(t, parsed.MCP.HasTransport("stdio"), "info.x-mcp transport must not leak through when root x-mcp is set")
+}
+
+func TestParseMCPExtensionRoundTripsAddrAndThreshold(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Full MCP API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+x-mcp:
+  transport: [stdio, http]
+  addr: ":9090"
+  orchestration_threshold: 25
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	assert.Equal(t, ":9090", parsed.MCP.Addr)
+	assert.Equal(t, 25, parsed.MCP.OrchestrationThreshold)
+}
+
+func TestParseMCPExtensionRoundTripsIntents(t *testing.T) {
+	t.Parallel()
+	// Intents is the most structurally complex MCPConfig field
+	// (nested []Intent -> []IntentParam + []IntentStep with map[string]string Bind).
+	// This test catches a bad json tag anywhere in that tree by asserting the
+	// whole shape parses cleanly through the JSON marshal/unmarshal roundtrip,
+	// then validates against the spec's resources.
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Intent API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+x-mcp:
+  intents:
+    - name: list_all_items
+      description: Fetch every item
+      params:
+        - name: limit
+          type: integer
+          required: false
+          description: Cap on items returned
+      steps:
+        - endpoint: items.list
+          bind:
+            limit: ${input.limit}
+          capture: items
+      returns: items
+paths:
+  /items:
+    get:
+      operationId: listItems
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+	require.Len(t, parsed.MCP.Intents, 1)
+	intent := parsed.MCP.Intents[0]
+	assert.Equal(t, "list_all_items", intent.Name)
+	require.Len(t, intent.Params, 1)
+	assert.Equal(t, "limit", intent.Params[0].Name)
+	assert.Equal(t, "integer", intent.Params[0].Type)
+	require.Len(t, intent.Steps, 1)
+	assert.Equal(t, "items.list", intent.Steps[0].Endpoint)
+	assert.Equal(t, "${input.limit}", intent.Steps[0].Bind["limit"])
+	assert.Equal(t, "items", intent.Steps[0].Capture)
+	assert.Equal(t, "items", intent.Returns)
+}
+
+func TestParseMCPExtensionRejectsUnknownTransport(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Bad MCP API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+x-mcp:
+  transport: [grpc]
+paths:
+  /items:
+    get:
+      summary: List items
+      responses:
+        "200":
+          description: ok
+`)
+
+	_, err := Parse(data)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "transport")
+}
+
 func findParsedEndpointByPath(t *testing.T, parsed *spec.APISpec, method, path string) spec.Endpoint {
 	t.Helper()
 	for _, resource := range parsed.Resources {
diff --git a/testdata/golden/cases/generate-mcp-api/artifacts.txt b/testdata/golden/cases/generate-mcp-api/artifacts.txt
new file mode 100644
index 00000000..091631cf
--- /dev/null
+++ b/testdata/golden/cases/generate-mcp-api/artifacts.txt
@@ -0,0 +1,26 @@
+# Locks the OpenAPI x-mcp extension contract end-to-end through generation.
+# The fixture sets x-mcp at the root with the Cloudflare pattern
+# (transport: [stdio, http], orchestration: code, endpoint_tools: hidden).
+# Without OpenAPI parser support for x-mcp, all three artifacts below would
+# revert to the stdio-only endpoint-mirror defaults, regressing #696.
+
+# Provenance manifest: slug, mcp binary name, mcp tool counts. (CLIManifest
+# does not carry the transport list -- transport regressions surface in the
+# tools.go and main.go snapshots below.)
+mcp-cloudflare/.printing-press.json
+
+# Code-orchestration mode: tools.go must call RegisterCodeOrchestrationTools
+# instead of registering per-endpoint mirror tools. If endpoint_tools=hidden
+# stops being honored, this file would re-emit per-endpoint registrations.
+mcp-cloudflare/internal/mcp/tools.go
+
+# Code-orchestration tool bodies: tools.go calls RegisterCodeOrchestrationTools,
+# but the actual <api>_search + <api>_execute implementations live here.
+# Locking tools.go alone would miss regressions in this file's content.
+mcp-cloudflare/internal/mcp/code_orch.go
+
+# HTTP transport opt-in: the mcp binary's main.go must compile both stdio
+# and http transports and expose the --transport / --addr flags. If
+# transport: [stdio, http] stops being honored, this file would revert to
+# stdio-only without the flag surface.
+mcp-cloudflare/cmd/mcp-cloudflare-pp-mcp/main.go
diff --git a/testdata/golden/cases/generate-mcp-api/command.txt b/testdata/golden/cases/generate-mcp-api/command.txt
new file mode 100644
index 00000000..66dd482f
--- /dev/null
+++ b/testdata/golden/cases/generate-mcp-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/mcp-api.yaml --output "$CASE_ACTUAL_DIR/mcp-cloudflare" --force --spec-url file://testdata/golden/fixtures/mcp-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-mcp-api/exit.txt b/testdata/golden/expected/generate-mcp-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press.json b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press.json
new file mode 100644
index 00000000..b278c199
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press.json
@@ -0,0 +1,21 @@
+{
+  "api_name": "mcp-cloudflare",
+  "api_version": "1.0.0",
+  "auth_env_vars": [
+    "MCP_CLOUDFLARE_API_KEY"
+  ],
+  "auth_type": "api_key",
+  "cli_name": "mcp-cloudflare-pp-cli",
+  "display_name": "Mcp Cloudflare",
+  "generated_at": "<GENERATED_AT>",
+  "mcp_binary": "mcp-cloudflare-pp-mcp",
+  "mcp_ready": "full",
+  "mcp_tool_count": 1,
+  "owner": "printing-press-golden",
+  "printing_press_version": "<PRINTING_PRESS_VERSION>",
+  "schema_version": 1,
+  "spec_checksum": "sha256:46c60ba489f2e138ffe6ab01531f2ed550369299820342a7f0093b94d0fc17b4",
+  "spec_format": "openapi3",
+  "spec_path": "testdata/golden/fixtures/mcp-api.yaml",
+  "spec_url": "file://testdata/golden/fixtures/mcp-api.yaml"
+}
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/cmd/mcp-cloudflare-pp-mcp/main.go b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/cmd/mcp-cloudflare-pp-mcp/main.go
new file mode 100644
index 00000000..97f6ca1f
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/cmd/mcp-cloudflare-pp-mcp/main.go
@@ -0,0 +1,67 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/server"
+	mcptools "mcp-cloudflare-pp-cli/internal/mcp"
+)
+
+// Transport selection order: --transport flag, then PP_MCP_TRANSPORT env,
+// then the first transport declared in the spec (see MCPConfig.Transport).
+// The flag surface lets one binary serve stdio locally and streamable HTTP
+// when hosted in a container or remote sandbox, matching the Anthropic
+// guidance that production agents need a remote option.
+
+const (
+	defaultHTTPAddr = ":7777"
+)
+
+func main() {
+	s := server.NewMCPServer(
+		"Mcp Cloudflare",
+		"1.0.0",
+		server.WithToolCapabilities(false),
+	)
+
+	mcptools.RegisterTools(s)
+
+	transport := flag.String("transport", defaultTransport(), "MCP transport: stdio | http")
+	addr := flag.String("addr", defaultHTTPAddr, "bind address for http transport (host:port or :port)")
+	flag.Parse()
+
+	switch strings.ToLower(*transport) {
+	case "stdio":
+		if err := server.ServeStdio(s); err != nil {
+			fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+			os.Exit(1)
+		}
+	case "http":
+		httpSrv := server.NewStreamableHTTPServer(s)
+		fmt.Fprintf(os.Stderr, "mcp-cloudflare-pp-mcp serving MCP over streamable HTTP at %s\n", *addr)
+		if err := httpSrv.Start(*addr); err != nil {
+			fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+			os.Exit(1)
+		}
+	default:
+		fmt.Fprintf(os.Stderr, "unknown --transport %q (supported: stdio, http)\n", *transport)
+		os.Exit(2)
+	}
+}
+
+// defaultTransport reads PP_MCP_TRANSPORT env when set, otherwise falls back
+// to "stdio" so running the binary with no args keeps today's behavior.
+// Container-hosted agents can pin the transport via env without a flag, which
+// matches how hosted-agent process supervisors typically pass configuration.
+func defaultTransport() string {
+	if t := os.Getenv("PP_MCP_TRANSPORT"); t != "" {
+		return t
+	}
+	return "stdio"
+}
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go
new file mode 100644
index 00000000..7ea169ef
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go
@@ -0,0 +1,238 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package mcp — code-orchestration thin surface.
+//
+// Two tools cover the entire API: <api>_search to discover endpoints, and
+// <api>_execute to invoke one. This collapses a large API (50+ endpoints)
+// to ~1K tokens of tool definitions while preserving full coverage — the
+// agent writes the composition logic in its own sandbox.
+//
+// Pattern source: Anthropic 2026-04-22 "Building agents that reach
+// production systems with MCP" — Cloudflare's MCP server covers ~2,500
+// endpoints in roughly 1K tokens via the same search+execute shape.
+
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+// RegisterCodeOrchestrationTools registers the two agent-facing tools that
+// cover the whole API surface. Called from RegisterTools in place of the
+// per-endpoint registrations when MCP.Orchestration is "code".
+func RegisterCodeOrchestrationTools(s *server.MCPServer) {
+	s.AddTool(
+		mcplib.NewTool("mcp-cloudflare_search",
+			mcplib.WithDescription("Search the mcp-cloudflare API for endpoints matching a natural-language query. Returns a ranked list of {endpoint_id, method, path, summary} entries. Call this first to find the endpoint to execute."),
+			mcplib.WithString("query", mcplib.Required(), mcplib.Description("Natural-language description of what you want to do.")),
+			mcplib.WithNumber("limit", mcplib.Description("Max endpoints to return (default 10).")),
+		),
+		handleCodeOrchSearch,
+	)
+
+	s.AddTool(
+		mcplib.NewTool("mcp-cloudflare_execute",
+			mcplib.WithDescription("Execute one mcp-cloudflare API endpoint by its endpoint_id (from mcp-cloudflare_search). Params are passed as a JSON object; path placeholders and query strings are resolved automatically."),
+			mcplib.WithString("endpoint_id", mcplib.Required(), mcplib.Description("Endpoint identifier returned by mcp-cloudflare_search (e.g., \"users.list\").")),
+			mcplib.WithObject("params", mcplib.Description("Parameters for the endpoint. Path placeholders match by name; remaining entries become query string on GET/DELETE or JSON body on POST/PUT/PATCH.")),
+		),
+		handleCodeOrchExecute,
+	)
+}
+
+// codeOrchEndpoint captures the small slice of endpoint metadata the
+// search+execute pair needs at runtime. `keywords` is a precomputed
+// lowercase stream of description + path tokens used for naive ranking;
+// anything more sophisticated belongs on the agent side.
+type codeOrchEndpoint struct {
+	ID          string
+	Method      string
+	Path        string
+	Tier        string
+	Summary     string
+	Positional  []string
+	keywords    []string
+}
+
+// codeOrchEndpoints is the generator-populated registry covering every
+// endpoint declared in the spec. Kept flat on purpose — the agent queries
+// via <api>_search, so hierarchy shows up as dotted IDs, not nested maps.
+var codeOrchEndpoints = []codeOrchEndpoint{
+	{
+		ID:      "items.list",
+		Method:  "GET",
+		Path:    "/items",
+		Summary: "List items",
+		Positional: []string{ },
+		keywords: codeOrchKeywords("items", "list", "List items", "/items"),
+	},
+}
+
+// codeOrchStopwords filters two-letter and short common-word substrings
+// that pollute ranking via the substring-contains rule. Without them, a
+// search for "list links" matches every endpoint whose description
+// contains "is enrolled" because "is" is two chars and the matcher
+// accepts kw.contains(t) || t.contains(kw).
+var codeOrchStopwords = map[string]bool{
+	"a": true, "an": true, "and": true, "are": true, "as": true,
+	"at": true, "be": true, "by": true, "for": true, "from": true,
+	"has": true, "in": true, "is": true, "it": true, "its": true,
+	"of": true, "on": true, "or": true, "that": true, "the": true,
+	"this": true, "to": true, "was": true, "will": true, "with": true,
+	"your": true, "you": true, "any": true, "all": true,
+}
+
+// codeOrchKeywords produces the lowercase token stream used for search
+// ranking. Defined at package level so the registry initializer can call it
+// inline above without pulling in a separate precompute step.
+func codeOrchKeywords(resource, endpoint, summary, path string) []string {
+	raw := strings.ToLower(resource + " " + endpoint + " " + summary + " " + path)
+	raw = strings.Map(func(r rune) rune {
+		switch r {
+		case '_', '-', '/', '{', '}', '.', ',', ':', ';':
+			return ' '
+		}
+		return r
+	}, raw)
+	out := make([]string, 0, 16)
+	seen := map[string]bool{}
+	for _, tok := range strings.Fields(raw) {
+		if len(tok) < 3 || codeOrchStopwords[tok] || seen[tok] {
+			continue
+		}
+		seen[tok] = true
+		out = append(out, tok)
+	}
+	return out
+}
+
+func handleCodeOrchSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	args := req.GetArguments()
+	query, ok := args["query"].(string)
+	if !ok || strings.TrimSpace(query) == "" {
+		return mcplib.NewToolResultError("query is required"), nil
+	}
+	limit := 10
+	if v, ok := args["limit"].(float64); ok && v > 0 {
+		limit = int(v)
+	}
+
+	terms := codeOrchKeywords("", "", query, "")
+	type scored struct {
+		ep    *codeOrchEndpoint
+		score int
+	}
+	results := make([]scored, 0, len(codeOrchEndpoints))
+	for i := range codeOrchEndpoints {
+		ep := &codeOrchEndpoints[i]
+		score := 0
+		for _, t := range terms {
+			for _, kw := range ep.keywords {
+				if kw == t {
+					score += 2
+				} else if strings.Contains(kw, t) || strings.Contains(t, kw) {
+					score++
+				}
+			}
+		}
+		if score > 0 {
+			results = append(results, scored{ep: ep, score: score})
+		}
+	}
+	sort.SliceStable(results, func(i, j int) bool { return results[i].score > results[j].score })
+	if len(results) > limit {
+		results = results[:limit]
+	}
+
+	out := make([]map[string]any, 0, len(results))
+	for _, r := range results {
+		out = append(out, map[string]any{
+			"endpoint_id": r.ep.ID,
+			"method":      r.ep.Method,
+			"path":        r.ep.Path,
+			"summary":     r.ep.Summary,
+			"score":       r.score,
+		})
+	}
+	data, _ := json.Marshal(map[string]any{"count": len(out), "results": out})
+	return mcplib.NewToolResultText(string(data)), nil
+}
+
+func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	args := req.GetArguments()
+	id, ok := args["endpoint_id"].(string)
+	if !ok || id == "" {
+		return mcplib.NewToolResultError("endpoint_id is required (call mcp-cloudflare_search first)"), nil
+	}
+
+	var ep *codeOrchEndpoint
+	for i := range codeOrchEndpoints {
+		if codeOrchEndpoints[i].ID == id {
+			ep = &codeOrchEndpoints[i]
+			break
+		}
+	}
+	if ep == nil {
+		return mcplib.NewToolResultError(fmt.Sprintf("unknown endpoint_id %q — call mcp-cloudflare_search to discover valid ids", id)), nil
+	}
+
+	params, _ := args["params"].(map[string]any)
+	if params == nil {
+		params = map[string]any{}
+	}
+
+	c, err := newMCPClient()
+	if err != nil {
+		return mcplib.NewToolResultError(err.Error()), nil
+	}
+
+	path := ep.Path
+	for _, p := range ep.Positional {
+		if v, ok := params[p]; ok {
+			path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprintf("%v", v))
+			delete(params, p)
+		}
+	}
+
+	query := map[string]string{}
+	if ep.Method == "GET" || ep.Method == "DELETE" {
+		for k, v := range params {
+			query[k] = fmt.Sprintf("%v", v)
+		}
+	}
+
+	var data json.RawMessage
+	switch ep.Method {
+	case "GET":
+		data, err = c.Get(path, query)
+	case "DELETE":
+		data, _, err = c.Delete(path)
+	default:
+		body, mErr := json.Marshal(params)
+		if mErr != nil {
+			return mcplib.NewToolResultError(fmt.Sprintf("marshaling body: %v", mErr)), nil
+		}
+		switch ep.Method {
+		case "POST":
+			data, _, err = c.Post(path, body)
+		case "PUT":
+			data, _, err = c.Put(path, body)
+		case "PATCH":
+			data, _, err = c.Patch(path, body)
+		default:
+			return mcplib.NewToolResultError(fmt.Sprintf("unsupported method %q", ep.Method)), nil
+		}
+	}
+	if err != nil {
+		return mcplib.NewToolResultError(err.Error()), nil
+	}
+	return mcplib.NewToolResultText(string(data)), nil
+}
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go
new file mode 100644
index 00000000..e90d0901
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go
@@ -0,0 +1,310 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+	"mcp-cloudflare-pp-cli/internal/cli"
+	"mcp-cloudflare-pp-cli/internal/cliutil"
+	"mcp-cloudflare-pp-cli/internal/client"
+	"mcp-cloudflare-pp-cli/internal/config"
+	"mcp-cloudflare-pp-cli/internal/mcp/cobratree"
+	"mcp-cloudflare-pp-cli/internal/store"
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+	// Code-orchestration mode — the full surface is covered by two tools
+	// (<api>_search + <api>_execute). Endpoint-mirror tools are suppressed.
+	RegisterCodeOrchestrationTools(s)
+	// SQL tool — ad-hoc analysis on synced data without API calls
+	s.AddTool(
+		mcplib.NewTool("sql",
+			mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."),
+			mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT only). Tables match resource names.")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+		),
+		handleSQL,
+	)
+
+	// Context tool — front-loaded domain knowledge for agents.
+	// Call this first to understand the API taxonomy, query patterns, and capabilities.
+	s.AddTool(
+		mcplib.NewTool("context",
+			mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+		),
+		handleContext,
+	)
+
+	// Runtime Cobra-tree mirror — exposes every user-facing command that is
+	// not already covered by a typed endpoint or framework MCP tool.
+	cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
+}
+
+type mcpParamBinding struct {
+	PublicName string
+	WireName   string
+	Location   string
+}
+
+// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+	return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+		c, err := newMCPClient()
+		if err != nil {
+			return mcplib.NewToolResultError(err.Error()), nil
+		}
+
+		// mcp-go v0.47+ made CallToolParams.Arguments an `any` to support
+		// non-map payloads; GetArguments() returns the map[string]any shape
+		// we rely on here (or an empty map when the payload is something else).
+		args := req.GetArguments()
+
+		// positionalParams mixes real URL path params with CLI positional
+		// args that map to query params (e.g. `search <query>` -> ?query=);
+		// the placeholder check below disambiguates them at runtime.
+		path := pathTemplate
+		knownArgs := make(map[string]bool, len(bindings))
+		pathParams := make(map[string]bool, len(positionalParams))
+		params := make(map[string]string)
+		bodyArgs := make(map[string]any)
+		for _, binding := range bindings {
+			knownArgs[binding.PublicName] = true
+			v, ok := args[binding.PublicName]
+			if !ok {
+				continue
+			}
+			switch binding.Location {
+			case "path":
+				placeholder := "{" + binding.WireName + "}"
+				pathParams[binding.PublicName] = true
+				path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+			case "body":
+				bodyArgs[binding.WireName] = v
+			default:
+				params[binding.WireName] = fmt.Sprintf("%v", v)
+			}
+		}
+		for _, p := range positionalParams {
+			placeholder := "{" + p + "}"
+			if !strings.Contains(pathTemplate, placeholder) {
+				continue
+			}
+			pathParams[p] = true
+			if v, ok := args[p]; ok {
+				path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+			}
+		}
+
+		for k, v := range args {
+			if pathParams[k] || knownArgs[k] {
+				continue
+			}
+			switch method {
+			case "POST", "PUT", "PATCH":
+				bodyArgs[k] = v
+			default:
+				params[k] = fmt.Sprintf("%v", v)
+			}
+		}
+
+		var data json.RawMessage
+		switch method {
+		case "GET":
+			data, err = c.Get(path, params)
+		case "POST":
+			body, _ := json.Marshal(bodyArgs)
+			data, _, err = c.Post(path, body)
+		case "PUT":
+			body, _ := json.Marshal(bodyArgs)
+			data, _, err = c.Put(path, body)
+		case "PATCH":
+			body, _ := json.Marshal(bodyArgs)
+			data, _, err = c.Patch(path, body)
+		case "DELETE":
+			data, _, err = c.Delete(path)
+		default:
+			return mcplib.NewToolResultError("unsupported method: " + method), nil
+		}
+
+		if err != nil {
+			msg := err.Error()
+			switch {
+			case strings.Contains(msg, "HTTP 409"):
+				return mcplib.NewToolResultText("already exists (no-op)"), nil
+			case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+				return mcplib.NewToolResultError("authentication error: " + cliutil.SanitizeErrorBody(msg) +
+					"\nhint: the API rejected the request — this usually means auth is missing or invalid." +
+					"\n      Set your API key: export MCP_CLOUDFLARE_API_KEY=<your-key>" +
+					"\n      Run 'mcp-cloudflare-pp-cli doctor' to check auth status."), nil
+			case strings.Contains(msg, "HTTP 401"):
+				return mcplib.NewToolResultError("authentication failed: " + cliutil.SanitizeErrorBody(msg) +
+					"\nhint: check your API key." +
+					"\n      Set it with: export MCP_CLOUDFLARE_API_KEY=<your-key>" +
+					"\n      Run 'mcp-cloudflare-pp-cli doctor' to check auth status."), nil
+			case strings.Contains(msg, "HTTP 403"):
+				return mcplib.NewToolResultError("permission denied: " + cliutil.SanitizeErrorBody(msg) +
+					"\nhint: your credentials are valid but lack access to this resource." +
+					"\n      Set it with: export MCP_CLOUDFLARE_API_KEY=<your-key>" +
+					"\n      Run 'mcp-cloudflare-pp-cli doctor' to check auth status."), nil
+			case strings.Contains(msg, "HTTP 404"):
+				if method == "DELETE" {
+					return mcplib.NewToolResultText("already deleted (no-op)"), nil
+				}
+				return mcplib.NewToolResultError("not found: " + msg), nil
+			case strings.Contains(msg, "HTTP 429"):
+				return mcplib.NewToolResultError("rate limited: " + msg), nil
+			default:
+				return mcplib.NewToolResultError(msg), nil
+			}
+		}
+
+		// For GET responses, wrap bare arrays with count metadata
+		if method == "GET" {
+			trimmed := strings.TrimSpace(string(data))
+			if len(trimmed) > 0 && trimmed[0] == '[' {
+				var items []json.RawMessage
+				if json.Unmarshal(data, &items) == nil {
+					wrapped := map[string]any{
+						"count": len(items),
+						"items": items,
+					}
+					out, _ := json.Marshal(wrapped)
+					return mcplib.NewToolResultText(string(out)), nil
+				}
+			}
+		}
+		return mcplib.NewToolResultText(string(data)), nil
+	}
+}
+
+func newMCPClient() (*client.Client, error) {
+	home, _ := os.UserHomeDir()
+	cfgPath := filepath.Join(home, ".config", "mcp-cloudflare-pp-cli", "config.toml")
+	cfg, err := config.Load(cfgPath)
+	if err != nil {
+		return nil, fmt.Errorf("loading config: %w", err)
+	}
+	c := client.New(cfg, 30*time.Second, 0)
+	// Agents calling through MCP need fresh data every call. The on-disk
+	// response cache survives across MCP server invocations, so a
+	// DELETE/PATCH followed by a GET would otherwise return the
+	// pre-mutation snapshot for up to the cache TTL. The interactive CLI
+	// constructs its own client and is unaffected.
+	c.NoCache = true
+	return c, nil
+}
+
+func dbPath() string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".local", "share", "mcp-cloudflare-pp-cli", "data.db")
+}
+// Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).
+// The CLI's defaultDBPath() in the cli package uses the same canonical path.
+
+func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	args := req.GetArguments()
+	query, ok := args["query"].(string)
+	if !ok || query == "" {
+		return mcplib.NewToolResultError("query is required"), nil
+	}
+
+	// Block write operations
+	upper := strings.ToUpper(strings.TrimSpace(query))
+	for _, prefix := range []string{"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE"} {
+		if strings.HasPrefix(upper, prefix) {
+			return mcplib.NewToolResultError("only SELECT queries are allowed"), nil
+		}
+	}
+
+	db, err := store.OpenWithContext(ctx, dbPath())
+	if err != nil {
+		return mcplib.NewToolResultError(fmt.Sprintf("opening database: %v", err)), nil
+	}
+	defer db.Close()
+
+	rows, err := db.Query(query)
+	if err != nil {
+		return mcplib.NewToolResultError(fmt.Sprintf("query failed: %v", err)), nil
+	}
+	defer rows.Close()
+
+	cols, _ := rows.Columns()
+	var results []map[string]any
+	for rows.Next() {
+		values := make([]any, len(cols))
+		ptrs := make([]any, len(cols))
+		for i := range values {
+			ptrs[i] = &values[i]
+		}
+		rows.Scan(ptrs...)
+		row := make(map[string]any)
+		for i, col := range cols {
+			row[col] = values[i]
+		}
+		results = append(results, row)
+	}
+
+	data, _ := json.MarshalIndent(results, "", "  ")
+	return mcplib.NewToolResultText(string(data)), nil
+}
+
+func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	ctx := map[string]any{
+		"api":         "mcp-cloudflare",
+		"description": "Golden fixture exercising x-mcp on an OpenAPI spec.",
+		"archetype":   "generic",
+		"tool_count":  1,
+		// tool_surface tells agents which surface a capability lives on.
+		"tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion mcp-cloudflare-pp-cli binary.",
+		"auth": map[string]any{
+			"type": "api_key",
+			"env_vars": []map[string]any{
+				{
+					"name": "MCP_CLOUDFLARE_API_KEY",
+					"kind": "per_call",
+					"required": true,
+					"sensitive": true,
+					"description": "Set to your API credential.",
+				},
+			},
+		},
+		"resources": []map[string]any{
+			{
+				"name": "items",
+				"description": "Manage items",
+				"endpoints": []string{"list",  },
+				"syncable": true,
+			},
+		},
+		"query_tips": []string{
+			"Pagination uses cursor-based paging. Pass after parameter for subsequent pages.",
+			"Control page size with the limit parameter (default 100).",
+			"Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.",
+			"Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.",
+			"Prefer sql/search over repeated API calls when the data is already synced.",
+		},
+	}
+	data, _ := json.MarshalIndent(ctx, "", "  ")
+	return mcplib.NewToolResultText(string(data)), nil
+}
+
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
+func RegisterNovelFeatureTools(s *server.MCPServer) {
+	_ = s
+}
diff --git a/testdata/golden/expected/generate-mcp-api/stderr.txt b/testdata/golden/expected/generate-mcp-api/stderr.txt
new file mode 100644
index 00000000..aa60dccb
--- /dev/null
+++ b/testdata/golden/expected/generate-mcp-api/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated mcp-cloudflare at <ARTIFACT_DIR>/generate-mcp-api/mcp-cloudflare
diff --git a/testdata/golden/expected/generate-mcp-api/stdout.txt b/testdata/golden/expected/generate-mcp-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/mcp-api.yaml b/testdata/golden/fixtures/mcp-api.yaml
new file mode 100644
index 00000000..940c4895
--- /dev/null
+++ b/testdata/golden/fixtures/mcp-api.yaml
@@ -0,0 +1,39 @@
+openapi: "3.0.3"
+info:
+  title: MCP Cloudflare API
+  version: "1.0.0"
+  description: Golden fixture exercising x-mcp on an OpenAPI spec.
+servers:
+  - url: https://api.example.com/v1
+security:
+  - ApiKeyAuth: []
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: X-API-Key
+x-mcp:
+  transport: [stdio, http]
+  orchestration: code
+  endpoint_tools: hidden
+paths:
+  /items:
+    get:
+      tags: [items]
+      operationId: listItems
+      summary: List items
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    id:
+                      type: string
+                    name:
+                      type: string

← e601b211 chore(main): release 4.0.5 (#700)  ·  back to Cli Printing Press  ·  fix(cli): gate orphan token scaffolding on auth surface (#70 35d7e842 →