← 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
M docs/SPEC-EXTENSIONS.mdA docs/solutions/logic-errors/openapi-parser-missing-x-mcp-extension-support-2026-05-08.mdM internal/openapi/parser.goM internal/openapi/parser_test.goA testdata/golden/cases/generate-mcp-api/artifacts.txtA testdata/golden/cases/generate-mcp-api/command.txtA testdata/golden/expected/generate-mcp-api/exit.txtA testdata/golden/expected/generate-mcp-api/mcp-cloudflare/.printing-press.jsonA testdata/golden/expected/generate-mcp-api/mcp-cloudflare/cmd/mcp-cloudflare-pp-mcp/main.goA testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.goA testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.goA testdata/golden/expected/generate-mcp-api/stderr.txtA testdata/golden/expected/generate-mcp-api/stdout.txtA testdata/golden/fixtures/mcp-api.yaml
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 →