← back to Cli Printing Press
feat(cli): default mcp.transport=[stdio, http] for small APIs (#1629)
14bc18a4dcba96b219a6b82d17321c5a21636ad2 · 2026-05-18 10:20:09 -0700 · Trevin Chow
* feat(cli): default mcp.transport=[stdio, http] for small APIs
Specs that leave the mcp.transport field unset now get http compiled in
alongside stdio when the typed-endpoint surface is at or under
spec.DefaultRemoteTransportEndpointThreshold (30). Stdio-only servers
can only reach clients that can spawn a subprocess; cloud-hosted agents
need a remote transport. Adding http at small surface sizes costs
nothing in tool count or agent context and lifts mcp_remote_transport
from 5/10 to 10/10 in the scorecard.
Explicit transport lists pass through unchanged: a spec that opts into
mcp.transport: [stdio] stays stdio-only even at small endpoint counts.
Larger APIs continue to fall back to stdio-only by default, where the
dominant problem is tool-count, not reach, and warnUnenrichedLargeMCPSurface
keeps recommending the orchestration pattern.
Resolution lives on a new APISpec.EffectiveMCPTransports helper so the
template branch reads from a single source of truth that knows both the
configured field and the endpoint count. MCPConfig.EffectiveTransports
remains the unconditioned view for spec validation and mcp_audit.
Closes #1603
* docs(cli): clarify Transport field comment per Greptile review
The inline struct-tag comment on MCPConfig.Transport said "empty == [stdio]",
but after the small-API default the empty case resolves via
APISpec.EffectiveMCPTransports to [stdio, http] for small APIs and to
[stdio] only for larger ones. Update the comment to reflect that.
Files touched
M docs/GLOSSARY.mdM docs/SPEC-EXTENSIONS.mdM internal/generator/generator_test.goM internal/generator/mcp_warning.goM internal/generator/templates/main_mcp.go.tmplM internal/spec/spec.goM internal/spec/spec_test.goM skills/printing-press-polish/SKILL.mdM skills/printing-press/SKILL.mdM testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.goM testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.goM testdata/golden/expected/generate-golden-api/scorecard.json
Diff
commit 14bc18a4dcba96b219a6b82d17321c5a21636ad2
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 18 10:20:09 2026 -0700
feat(cli): default mcp.transport=[stdio, http] for small APIs (#1629)
* feat(cli): default mcp.transport=[stdio, http] for small APIs
Specs that leave the mcp.transport field unset now get http compiled in
alongside stdio when the typed-endpoint surface is at or under
spec.DefaultRemoteTransportEndpointThreshold (30). Stdio-only servers
can only reach clients that can spawn a subprocess; cloud-hosted agents
need a remote transport. Adding http at small surface sizes costs
nothing in tool count or agent context and lifts mcp_remote_transport
from 5/10 to 10/10 in the scorecard.
Explicit transport lists pass through unchanged: a spec that opts into
mcp.transport: [stdio] stays stdio-only even at small endpoint counts.
Larger APIs continue to fall back to stdio-only by default, where the
dominant problem is tool-count, not reach, and warnUnenrichedLargeMCPSurface
keeps recommending the orchestration pattern.
Resolution lives on a new APISpec.EffectiveMCPTransports helper so the
template branch reads from a single source of truth that knows both the
configured field and the endpoint count. MCPConfig.EffectiveTransports
remains the unconditioned view for spec validation and mcp_audit.
Closes #1603
* docs(cli): clarify Transport field comment per Greptile review
The inline struct-tag comment on MCPConfig.Transport said "empty == [stdio]",
but after the small-API default the empty case resolves via
APISpec.EffectiveMCPTransports to [stdio, http] for small APIs and to
[stdio] only for larger ones. Update the comment to reflect that.
---
docs/GLOSSARY.md | 2 +-
docs/SPEC-EXTENSIONS.md | 7 +-
internal/generator/generator_test.go | 92 +++++++++++++++++++---
internal/generator/mcp_warning.go | 16 +---
internal/generator/templates/main_mcp.go.tmpl | 2 +-
internal/spec/spec.go | 87 ++++++++++++++++++--
internal/spec/spec_test.go | 60 ++++++++++++++
skills/printing-press-polish/SKILL.md | 4 +-
skills/printing-press/SKILL.md | 5 +-
.../cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go | 46 ++++++++++-
.../cmd/printing-press-golden-pp-mcp/main.go | 46 ++++++++++-
.../expected/generate-golden-api/scorecard.json | 6 +-
12 files changed, 325 insertions(+), 48 deletions(-)
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index a60948cc..02fe5aab 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -44,7 +44,7 @@ Default disambiguation conventions:
| **doctor** | Self-diagnostic command shipped inside every printed CLI for end-users to run. Checks environment, auth config, and connectivity at the user's runtime. Unlike dogfood (which validates at generation time), doctor runs post-install. |
| **auth doctor** | Subcommand on the printing-press binary (`printing-press auth doctor`). Scans every installed printed CLI's `tools-manifest.json` under `~/printing-press/library/<api>/` and reports env-var status (`ok` / `suspicious` / `not_set` / `no_auth` / `unknown`) with redacted fingerprints. Diagnostic only — never gates, never probes the network. Lives in `internal/authdoctor/`. |
| **mcp-audit** | Subcommand on the printing-press binary (`printing-press mcp-audit`). Walks every library CLI and reports transport, tool-design, and per-CLI recommendations for the `mcp:` spec surface introduced in the U1-U3 machine work (remote transport, intent tools, code-orchestration). Diagnostic only — exit 0 regardless of findings. Supports `--json` for machine-readable output. |
-| **mcp spec surface** | Opt-in fields on the spec's `mcp:` block introduced April 2026 to reach production agent-hosts: `transport: [stdio, http]` (remote-capable via streamable HTTP), `intents:` (multi-step composed MCP tools), `orchestration: code` (Cloudflare-style thin `<api>_search` + `<api>_execute` surface for 50+ endpoint APIs), `endpoint_tools: hidden` (suppress raw per-endpoint tools). Empty `mcp:` keeps today's stdio-only endpoint-mirror emission byte-compatible. |
+| **mcp spec surface** | Opt-in fields on the spec's `mcp:` block introduced April 2026 to reach production agent-hosts: `transport: [stdio, http]` (remote-capable via streamable HTTP), `intents:` (multi-step composed MCP tools), `orchestration: code` (Cloudflare-style thin `<api>_search` + `<api>_execute` surface for 50+ endpoint APIs), `endpoint_tools: hidden` (suppress raw per-endpoint tools). Empty `mcp:` keeps the endpoint-mirror emission; small APIs (at or under `spec.DefaultRemoteTransportEndpointThreshold` typed endpoints) auto-compile both stdio and http transports via `APISpec.EffectiveMCPTransports`, larger APIs stay stdio-only by default. Setting `transport` explicitly bypasses the default. |
| **local library** | `~/printing-press/library/<api-slug>/` — where printed CLIs land after a successful run. Directory is keyed by API slug (e.g., `notion`), not CLI name. Local directory, not a git repo. |
| **public library repo** | The GitHub repo [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library) — public catalog of finished CLIs organized by category. `/printing-press-publish` pushes here. |
| **publish (pipeline)** | The pipeline step that moves a working CLI into the local library and writes the `.printing-press.json` provenance manifest. |
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 3b12411d..60c76770 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -200,8 +200,11 @@ for large surfaces: `transport: [stdio, http]` + `orchestration: code` +
Parsed field: `APISpec.MCP` (`spec.MCPConfig`)
Rules:
-- Optional. Specs without `x-mcp` keep today's stdio-only endpoint-mirror
- behavior.
+- Optional. Specs without `x-mcp` get the endpoint-mirror surface; small APIs
+ (typed-endpoint count at or below `spec.DefaultRemoteTransportEndpointThreshold`,
+ currently 30) also get the http transport compiled in alongside stdio so the
+ same binary can reach cloud-hosted agents. Setting `transport` explicitly
+ (including `transport: [stdio]`) bypasses the default and is honored as-is.
- 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`,
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 731bfabb..714c3067 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -10153,17 +10153,20 @@ func TestGenerateResourceBaseURLOverrideRoutesAgentDispatchSurfaces(t *testing.T
runGoCommand(t, outputDir, "build", "./...")
}
-// TestGenerateMCPMainStdioDefault confirms that a spec with no mcp: block
-// produces the same stdio-only MCP entry point we've always emitted. Remote
-// transport is opt-in; the default stays on the current behavior so existing
-// published CLIs regenerate byte-compatibly. Guards against the template
-// accidentally pulling in flag / StreamableHTTP imports for stdio-only specs.
-func TestGenerateMCPMainStdioDefault(t *testing.T) {
+// TestGenerateMCPMainSmallAPIDefaultsHTTP confirms that a small-API spec
+// (typed-endpoint count at or below spec.DefaultRemoteTransportEndpointThreshold)
+// with no mcp: block gets http compiled in alongside stdio. The default lifts
+// mcp_remote_transport from 5/10 to 10/10 at zero cost: there's no extra tool,
+// no extra agent-context overhead, and the same binary can now reach
+// cloud-hosted agents that cannot spawn a subprocess. Issue #1603.
+func TestGenerateMCPMainSmallAPIDefaultsHTTP(t *testing.T) {
t.Parallel()
apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "loops.yaml"))
require.NoError(t, err)
require.Empty(t, apiSpec.MCP.Transport, "baseline loops spec should not declare MCP transports")
+ require.LessOrEqual(t, apiSpec.TypedEndpointCount(), spec.DefaultRemoteTransportEndpointThreshold,
+ "loops fixture must stay small enough to exercise the auto-http default")
outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
gen := New(apiSpec, outputDir)
@@ -10174,10 +10177,79 @@ func TestGenerateMCPMainStdioDefault(t *testing.T) {
require.NoError(t, err)
body := string(data)
- assert.Contains(t, body, "server.ServeStdio(s)", "stdio-only spec must still call ServeStdio")
- assert.NotContains(t, body, "flag.String", "stdio-only spec must not pull in the flag package")
- assert.NotContains(t, body, "NewStreamableHTTPServer", "stdio-only spec must not reference the HTTP transport")
- assert.NotContains(t, body, "PP_MCP_TRANSPORT", "stdio-only spec must not reference the transport env override")
+ for _, want := range []string{
+ `"flag"`,
+ `"strings"`,
+ `server.ServeStdio(s)`,
+ `server.NewStreamableHTTPServer(s)`,
+ `flag.String("transport"`,
+ `PP_MCP_TRANSPORT`,
+ } {
+ assert.Contains(t, body, want, "small-API auto-http default should emit %q", want)
+ }
+}
+
+// TestGenerateMCPMainExplicitStdioOnlyHonored covers the negative half of
+// the issue #1603 acceptance criteria: an explicit `mcp.transport: [stdio]`
+// is honored even at small endpoint counts. The auto-http default only fires
+// when the field is empty; an opt-out should not be silently overridden.
+func TestGenerateMCPMainExplicitStdioOnlyHonored(t *testing.T) {
+ t.Parallel()
+
+ apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "loops.yaml"))
+ require.NoError(t, err)
+ apiSpec.MCP = spec.MCPConfig{Transport: []string{"stdio"}}
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ mainPath := filepath.Join(outputDir, "cmd", naming.MCP(apiSpec.Name), "main.go")
+ data, err := os.ReadFile(mainPath)
+ require.NoError(t, err)
+ body := string(data)
+
+ assert.Contains(t, body, "server.ServeStdio(s)", "explicit stdio-only spec must still call ServeStdio")
+ assert.NotContains(t, body, "flag.String", "explicit stdio-only spec must not pull in the flag package")
+ assert.NotContains(t, body, "NewStreamableHTTPServer", "explicit stdio-only spec must not reference the HTTP transport")
+ assert.NotContains(t, body, "PP_MCP_TRANSPORT", "explicit stdio-only spec must not reference the transport env override")
+}
+
+// TestGenerateMCPMainLargeAPIStaysStdioOnly confirms that the auto-http
+// default only fires for small APIs. Above the endpoint threshold the
+// generator defers to the orchestration-pattern recommendation in
+// warnUnenrichedLargeMCPSurface; auto-extending transport there would be
+// the wrong fix because the dominant problem is tool-count, not reach.
+func TestGenerateMCPMainLargeAPIStaysStdioOnly(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "demo",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{},
+ }
+ // Synthesize an above-threshold typed-endpoint surface.
+ r := spec.Resource{Endpoints: map[string]spec.Endpoint{}}
+ for i := range spec.DefaultRemoteTransportEndpointThreshold + 1 {
+ name := fmt.Sprintf("get_%d", i)
+ r.Endpoints[name] = spec.Endpoint{Method: "GET", Path: fmt.Sprintf("/items/%d", i)}
+ }
+ apiSpec.Resources["items"] = r
+ require.Greater(t, apiSpec.TypedEndpointCount(), spec.DefaultRemoteTransportEndpointThreshold,
+ "synthetic spec must exceed the small-API threshold")
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ mainPath := filepath.Join(outputDir, "cmd", naming.MCP(apiSpec.Name), "main.go")
+ data, err := os.ReadFile(mainPath)
+ require.NoError(t, err)
+ body := string(data)
+
+ assert.Contains(t, body, "server.ServeStdio(s)", "large-API default must still call ServeStdio")
+ assert.NotContains(t, body, "NewStreamableHTTPServer", "large-API default must not auto-enable http transport")
}
// TestGenerateMCPMainRemoteOptIn confirms that declaring mcp.transport: [stdio, http]
diff --git a/internal/generator/mcp_warning.go b/internal/generator/mcp_warning.go
index 8da3aa67..f0fd4da5 100644
--- a/internal/generator/mcp_warning.go
+++ b/internal/generator/mcp_warning.go
@@ -29,23 +29,9 @@ func warnUnenrichedLargeMCPSurface(s *spec.APISpec, w io.Writer) {
return
}
threshold := s.MCP.EffectiveOrchestrationThreshold()
- total := countTypedEndpoints(s)
+ total := s.TypedEndpointCount()
if total <= threshold || s.MCP.IsCodeOrchestration() {
return
}
fmt.Fprintf(w, largeMCPSurfaceWarning, total, threshold)
}
-
-func countTypedEndpoints(s *spec.APISpec) int {
- if s == nil {
- return 0
- }
- n := 0
- for _, r := range s.Resources {
- n += len(r.Endpoints)
- for _, sub := range r.SubResources {
- n += len(sub.Endpoints)
- }
- }
- return n
-}
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index da73b5ae..93a9faee 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -3,7 +3,7 @@
package main
-{{- if .MCP.HasTransport "http"}}
+{{- if .HasMCPTransport "http"}}
import (
"flag"
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7a66a156..6bf8a94b 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -160,7 +160,7 @@ type APISpec struct {
ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
Cache CacheConfig `yaml:"cache,omitempty" json:"cache"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
Share ShareConfig `yaml:"share,omitempty" json:"share"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
- MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, the emitted MCP binary is stdio-only (today's default). Opting into http adds a --transport/--addr flag surface so the same binary can serve cloud-hosted agents.
+ MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, small APIs (typed-endpoint count <= DefaultRemoteTransportEndpointThreshold) get stdio+http compiled in by APISpec.EffectiveMCPTransports so the same binary can serve cloud-hosted agents. Larger APIs stay stdio-only by default. Opting into http explicitly adds a --transport/--addr flag surface regardless of size.
Throttling ThrottlingConfig `yaml:"throttling,omitempty" json:"throttling"` // cost-based throttling config; when Enabled with a recognized Shape, the generator emits a ThrottleState (generic harness) plus a per-Shape parser that reads the API's cost bucket. Only the "shopify" Shape ships in v1.
}
@@ -1039,8 +1039,15 @@ type ShareConfig struct {
DefaultBranch string `yaml:"default_branch,omitempty" json:"default_branch,omitempty"` // optional default branch for push/pull; blank means "main"
}
-// MCPConfig declares how the generated MCP server binary is shaped. When empty
-// the generator emits today's behavior: a stdio-only server via server.ServeStdio.
+// MCPConfig declares how the generated MCP server binary is shaped. When the
+// Transport list is empty, the resolved transport set is computed by
+// APISpec.EffectiveMCPTransports: small APIs (<= DefaultRemoteTransportEndpointThreshold
+// typed endpoints) get [stdio, http] so the same binary can serve cloud-hosted
+// agents at no tool-count cost, and larger APIs fall back to stdio-only so the
+// existing tools-manifest stays untouched until the spec author opts into the
+// orchestration pattern. Setting Transport explicitly bypasses the default and
+// is honored as-is.
+//
// Opting http into Transport adds a --transport flag (stdio|http) and, for http,
// an --addr flag so the same binary can also serve an HTTP streamable transport.
//
@@ -1050,11 +1057,13 @@ type ShareConfig struct {
// Declaring transports in the spec rather than inferring at generate time keeps
// the decision visible and reviewable in the published CLI's source spec.
//
-// Allowed Transport values: "stdio", "http". An empty list is treated as
-// ["stdio"] for backward compatibility. Unknown values are rejected at spec
-// load; this prevents silent drift when new transports are introduced.
+// Allowed Transport values: "stdio", "http". An empty Transport list is
+// resolved per the rule above; MCPConfig.EffectiveTransports remains the
+// unconditioned view of just the configured field and still returns ["stdio"]
+// when Transport is empty. Unknown values are rejected at spec load; this
+// prevents silent drift when new transports are introduced.
type MCPConfig struct {
- Transport []string `yaml:"transport,omitempty" json:"transport,omitempty"` // allowed transports the generated binary compiles support for; empty == [stdio]. Runtime transport is chosen via the --transport flag and PP_MCP_TRANSPORT env.
+ Transport []string `yaml:"transport,omitempty" json:"transport,omitempty"` // allowed transports the generated binary compiles support for; empty resolves via APISpec.EffectiveMCPTransports (stdio+http for small APIs, stdio-only for larger ones). Runtime transport is chosen via the --transport flag and PP_MCP_TRANSPORT env.
Addr string `yaml:"addr,omitempty" json:"addr,omitempty"` // default bind address for the http transport (e.g., ":7777"). Blank means runtime default (":7777"). Ignored unless http is in Transport.
Intents []Intent `yaml:"intents,omitempty" json:"intents,omitempty"` // higher-level MCP tools that compose multiple endpoint calls. The agent sees one intent tool; the generator emits a handler that fans out to the declared endpoints sequentially. Anti-pattern to fight: one-tool-per-endpoint mirrors that force agents to stitch primitives.
EndpointTools string `yaml:"endpoint_tools,omitempty" json:"endpoint_tools,omitempty"` // "visible" (default) keeps the per-endpoint MCP tools; "hidden" suppresses them so only intents + generator-emitted tools appear. Use "hidden" when intents fully cover the surface and raw endpoints would be noise.
@@ -2834,6 +2843,15 @@ func validateMCP(m MCPConfig, resources map[string]Resource) error {
// context; code-orchestration covers the full surface in a pair of tools.
const DefaultOrchestrationThreshold = 50
+// DefaultRemoteTransportEndpointThreshold is the typed-endpoint count at or
+// below which the generator auto-enables the http transport alongside stdio
+// when the spec leaves mcp.transport unset. Stdio-only servers cannot reach
+// cloud-hosted agents, and at small surface sizes adding http has no cost in
+// tool count or agent context. The 30-endpoint cutoff matches the polish
+// skill's "zero-cost win at <30 endpoints" guidance. Larger APIs are left to
+// the orchestration-pattern recommendation in warnUnenrichedLargeMCPSurface.
+const DefaultRemoteTransportEndpointThreshold = 30
+
// EffectiveOrchestrationThreshold returns the resolved threshold, applying
// the built-in default when the spec leaves it unset.
func (m MCPConfig) EffectiveOrchestrationThreshold() int {
@@ -2850,6 +2868,61 @@ func (m MCPConfig) IsCodeOrchestration() bool {
return m.Orchestration == "code"
}
+// EffectiveMCPTransports returns the transport list the generated MCP binary
+// should compile support for, taking endpoint count into account. When the
+// spec leaves mcp.transport unset and the typed-endpoint surface is at or
+// below DefaultRemoteTransportEndpointThreshold, both stdio and http are
+// returned so the same binary can reach cloud-hosted agents. Explicit
+// transport lists are passed through unchanged, so a spec that opts into
+// stdio-only is honored even at small endpoint counts.
+//
+// Use this from generator code paths that need the resolved list (templates,
+// metadata renderers). MCPConfig.EffectiveTransports remains the unconditioned
+// view of just the configured field and is still the right helper for spec
+// validation and mcp_audit.
+func (s *APISpec) EffectiveMCPTransports() []string {
+ if s == nil {
+ return []string{"stdio"}
+ }
+ if len(s.MCP.Transport) > 0 {
+ return s.MCP.Transport
+ }
+ if s.TypedEndpointCount() <= DefaultRemoteTransportEndpointThreshold {
+ return []string{"stdio", "http"}
+ }
+ return []string{"stdio"}
+}
+
+// HasMCPTransport reports whether t is among the effective MCP transports for
+// this spec, taking the small-API http default into account. Case-insensitive
+// on the comparison to mirror MCPConfig.HasTransport.
+func (s *APISpec) HasMCPTransport(t string) bool {
+ for _, v := range s.EffectiveMCPTransports() {
+ if strings.EqualFold(v, t) {
+ return true
+ }
+ }
+ return false
+}
+
+// TypedEndpointCount returns the number of typed endpoints across all
+// resources and sub-resources. Shared by EffectiveMCPTransports (small-API
+// auto-http default) and the generator's large-surface MCP-warning emitter
+// so the two endpoint-count thresholds read from a single source of truth.
+func (s *APISpec) TypedEndpointCount() int {
+ if s == nil {
+ return 0
+ }
+ n := 0
+ for _, r := range s.Resources {
+ n += len(r.Endpoints)
+ for _, sub := range r.SubResources {
+ n += len(sub.Endpoints)
+ }
+ }
+ return n
+}
+
// intentNameRe enforces snake_case for MCP intent tool names so they line up
// with the snake_case convention used for endpoint-mirror tool names.
var intentNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index ff3ca40d..57bc93de 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2,6 +2,7 @@ package spec
import (
"bytes"
+ "fmt"
"os"
"strings"
"testing"
@@ -2665,6 +2666,65 @@ func TestMCPConfigAcceptsValidShapes(t *testing.T) {
}
}
+// TestEffectiveMCPTransportsSmallAPIDefault locks the issue #1603 contract:
+// when mcp.transport is unset and the typed-endpoint surface is small, the
+// resolved transport list adds http alongside stdio so the same binary can
+// reach cloud-hosted agents. Explicit transport lists (including ["stdio"])
+// bypass the default and are honored as-is.
+func TestEffectiveMCPTransportsSmallAPIDefault(t *testing.T) {
+ t.Parallel()
+
+ mkSpec := func(endpoints int, transport []string) *APISpec {
+ s := &APISpec{
+ Name: "demo",
+ BaseURL: "https://api.example.com",
+ Auth: AuthConfig{Type: "none"},
+ Resources: map[string]Resource{},
+ MCP: MCPConfig{Transport: transport},
+ }
+ r := Resource{Endpoints: map[string]Endpoint{}}
+ for i := range endpoints {
+ r.Endpoints[fmt.Sprintf("get_%d", i)] = Endpoint{Method: "GET", Path: fmt.Sprintf("/items/%d", i)}
+ }
+ s.Resources["items"] = r
+ return s
+ }
+
+ tests := []struct {
+ name string
+ endpoints int
+ transport []string
+ want []string
+ wantHTTP bool
+ }{
+ {name: "small API empty transport gets stdio+http", endpoints: 6, transport: nil, want: []string{"stdio", "http"}, wantHTTP: true},
+ {name: "boundary at threshold still gets http", endpoints: DefaultRemoteTransportEndpointThreshold, transport: nil, want: []string{"stdio", "http"}, wantHTTP: true},
+ {name: "above threshold stays stdio-only", endpoints: DefaultRemoteTransportEndpointThreshold + 1, transport: nil, want: []string{"stdio"}, wantHTTP: false},
+ {name: "explicit stdio is honored even at small scale", endpoints: 6, transport: []string{"stdio"}, want: []string{"stdio"}, wantHTTP: false},
+ {name: "explicit stdio,http passes through", endpoints: 6, transport: []string{"stdio", "http"}, want: []string{"stdio", "http"}, wantHTTP: true},
+ {name: "explicit http-only passes through", endpoints: 100, transport: []string{"http"}, want: []string{"http"}, wantHTTP: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := mkSpec(tt.endpoints, tt.transport)
+ assert.Equal(t, tt.want, s.EffectiveMCPTransports())
+ assert.Equal(t, tt.wantHTTP, s.HasMCPTransport("http"))
+ // Case-insensitive lookup mirrors MCPConfig.HasTransport.
+ assert.Equal(t, tt.wantHTTP, s.HasMCPTransport("HTTP"))
+ })
+ }
+
+ // Nil receiver is safe and returns the stdio fallback.
+ var nilSpec *APISpec
+ assert.Equal(t, []string{"stdio"}, nilSpec.EffectiveMCPTransports())
+ assert.False(t, nilSpec.HasMCPTransport("http"))
+
+ // MCPConfig.EffectiveTransports stays unconditioned on endpoint count;
+ // it's still the right helper for spec validation and mcp_audit.
+ assert.Equal(t, []string{"stdio"}, MCPConfig{}.EffectiveTransports())
+}
+
func TestHTTPTransportValidationAndDefaults(t *testing.T) {
t.Parallel()
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index 13ea48d4..aaa29abc 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -591,12 +591,12 @@ When `mcp_token_efficiency`, `mcp_tool_design`, `mcp_remote_transport`, or `mcp_
| Weak dim | Spec field that fixes it | What to add to `spec.yaml`'s `mcp:` block |
|---|---|---|
-| `mcp_remote_transport` | `mcp.transport` | `transport: [stdio, http]` (default is stdio-only; HTTP costs nothing and lets the same binary serve cloud-hosted agents) |
+| `mcp_remote_transport` | `mcp.transport` | `transport: [stdio, http]` (small APIs at or under `spec.DefaultRemoteTransportEndpointThreshold` typed endpoints already get this by default; the override is only needed when the spec opted into a narrower list or the API is above the threshold and still wants remote reach) |
| `mcp_token_efficiency`, `mcp_surface_strategy` | `mcp.endpoint_tools`, `mcp.orchestration` | `endpoint_tools: hidden` + `orchestration: code` (Cloudflare pattern: ~70 raw endpoint tools collapse to `<api>_search` + `<api>_execute`; all endpoints still reachable via execute) |
| `mcp_tool_design` | `mcp.intents` | Define multi-step intent compositions for the workflows the API supports |
| `mcp_description_quality` | `mcp-descriptions.json` (override file at the CLI root) | Per-tool description overrides; thin spec-derived descriptions get richer text without spec edits |
-Recommended threshold: at >50 typed endpoints, default to recommending all four (`transport`, `endpoint_tools=hidden`, `orchestration=code`, `intents` for the headline workflows). Below 30, `transport=[stdio, http]` is the only zero-cost win. The full reference is `docs/SPEC-EXTENSIONS.md`.
+Recommended threshold: at >50 typed endpoints, default to recommending all four (`transport`, `endpoint_tools=hidden`, `orchestration=code`, `intents` for the headline workflows). Small APIs (<= `spec.DefaultRemoteTransportEndpointThreshold`) get the http transport by default already, so `mcp_remote_transport` lands at 10/10 without any spec edit — only call it out if the spec explicitly narrowed the list. The full reference is `docs/SPEC-EXTENSIONS.md`.
After editing the spec, regenerate (or `regen-merge` the changes into the published library) so the new `mcp:` block reaches templates. Cobratree-walked novel commands continue to surface as MCP tools either way; they don't need spec changes.
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index d45318a7..9f41eb8c 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2047,7 +2047,10 @@ schema and `info`-level placement option.
**Smaller-surface variants:**
-- Just want remote reach? `mcp.transport: [stdio, http]` alone is fine.
+- Just want remote reach? Small APIs (at or under
+ `spec.DefaultRemoteTransportEndpointThreshold` typed endpoints) get `[stdio, http]`
+ by default — no spec edit needed. Set `mcp.transport: [stdio, http]` explicitly only
+ when the API is above the threshold and still wants remote reach.
- Have 3–5 obvious multi-step workflows but <50 endpoints? Add `mcp.intents`
without code orchestration; leave `endpoint_tools` at default (visible).
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
index e13793e8..939aba9d 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-mcp/main.go
@@ -4,13 +4,25 @@
package main
import (
+ "flag"
"fmt"
"os"
+ "strings"
mcptools "cafe-bistro-pp-cli/internal/mcp"
"github.com/mark3labs/mcp-go/server"
)
+// 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(
"Café Bistro",
@@ -20,8 +32,36 @@ func main() {
mcptools.RegisterTools(s)
- if err := server.ServeStdio(s); err != nil {
- fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
- os.Exit(1)
+ 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, "cafe-bistro-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-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
index 272d8b45..616b69f1 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
@@ -4,13 +4,25 @@
package main
import (
+ "flag"
"fmt"
"os"
+ "strings"
"github.com/mark3labs/mcp-go/server"
mcptools "printing-press-golden-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(
"Printing Press Studio",
@@ -20,8 +32,36 @@ func main() {
mcptools.RegisterTools(s)
- if err := server.ServeStdio(s); err != nil {
- fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
- os.Exit(1)
+ 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, "printing-press-golden-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-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index cf1a6a8a..94d88284 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -22,17 +22,17 @@
"local_cache": 10,
"mcp_description_quality": 0,
"mcp_quality": 10,
- "mcp_remote_transport": 5,
+ "mcp_remote_transport": 10,
"mcp_surface_strategy": 0,
"mcp_token_efficiency": 7,
"mcp_tool_design": 5,
"output_modes": 10,
"path_validity": 0,
- "percentage": 82,
+ "percentage": 83,
"readme": 8,
"sync_correctness": 10,
"terminal_ux": 8,
- "total": 82,
+ "total": 83,
"type_fidelity": 3,
"vision": 8,
"workflows": 10
← f671b7ba feat(ci): mirror supply-chain hardening from printing-press-
·
back to Cli Printing Press
·
feat(cli): flag empty defaultSyncResources at dogfood time ( 042a78d4 →