[object Object]

← back to Cli Printing Press

feat(cli): mcp-audit subcommand + docs for the new MCP surface (#246)

38db061099b28c1d7dd307e0a3aedb83bb1601d1 · 2026-04-22 21:14:49 -0700 · Matt Van Horn

Adds printing-press mcp-audit, a diagnostic subcommand that walks every
CLI under ~/printing-press/library/ and reports transport, tool design,
endpoint count, and a one-line recommendation for each. Recommendations
map directly to the mcp: spec fields introduced in U1-U3 so the operator
can turn audit output straight into a spec change.

Output is a human-readable table by default; --json for machine-readable
output (suitable for emboss/regenerate loops). Exit 0 regardless of
findings — diagnostic, not gating.

Also documents the new spec surface in README.md, AGENTS.md glossary, and
docs/PIPELINE.md's review-phase gate description.

Plan: docs/plans/2026-04-22-003-feat-mcp-production-readiness-plan.md (U5)
Closes the plan — all 5 units shipped.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 38db061099b28c1d7dd307e0a3aedb83bb1601d1
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Wed Apr 22 21:14:49 2026 -0700

    feat(cli): mcp-audit subcommand + docs for the new MCP surface (#246)
    
    Adds printing-press mcp-audit, a diagnostic subcommand that walks every
    CLI under ~/printing-press/library/ and reports transport, tool design,
    endpoint count, and a one-line recommendation for each. Recommendations
    map directly to the mcp: spec fields introduced in U1-U3 so the operator
    can turn audit output straight into a spec change.
    
    Output is a human-readable table by default; --json for machine-readable
    output (suitable for emboss/regenerate loops). Exit 0 regardless of
    findings — diagnostic, not gating.
    
    Also documents the new spec surface in README.md, AGENTS.md glossary, and
    docs/PIPELINE.md's review-phase gate description.
    
    Plan: docs/plans/2026-04-22-003-feat-mcp-production-readiness-plan.md (U5)
    Closes the plan — all 5 units shipped.
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 AGENTS.md                      |   2 +
 README.md                      |  27 +++++
 docs/PIPELINE.md               |   2 +-
 internal/cli/mcp_audit.go      | 225 +++++++++++++++++++++++++++++++++++++++++
 internal/cli/mcp_audit_test.go | 133 ++++++++++++++++++++++++
 internal/cli/root.go           |   1 +
 6 files changed, 389 insertions(+), 1 deletion(-)

diff --git a/AGENTS.md b/AGENTS.md
index f999970f..ad7a628f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,6 +92,8 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
 | **scorecard** / **scoring** | Two-tier quality assessment with a 50/50 weighted composite. Tier 1: infrastructure (16 string-matching dimensions, raw max 160, normalized to 0-50). Tier 2: domain correctness (7 semantic dimensions, raw max 60 when live verify ran, normalized to 0-50). Total /100 with letter grades. Source of truth: `internal/pipeline/scorecard.go` (tier1Max / tier2Max). Subcommand: `printing-press scorecard`. |
 | **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. |
 | **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/README.md b/README.md
index 61a41194..13a265a4 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,33 @@ One spec  -->  printing-press generate  -->  <api>-pp-cli (cobra)  +  <api>-pp-m
 
 Every API that gets a CLI+MCP becomes instantly accessible to every AI coding tool. The printing press is the factory.
 
+### MCP spec surface (U1-U3)
+
+The generator ships three opt-in knobs on the spec's `mcp:` block, aligned with Anthropic's [2026-04-22 MCP production-agents guidance](https://www.anthropic.com/news/building-agents-that-reach-production-systems-with-mcp):
+
+```yaml
+mcp:
+  transport: [stdio, http]        # U1 — remote-capable for cloud-hosted agents; default [stdio]
+  addr: ":7777"                   # U1 — default bind for the http transport
+  orchestration: code             # U3 — "endpoint-mirror" (default), "intent", or "code"
+  endpoint_tools: hidden          # U2 — suppress raw endpoint tools when intents cover the surface
+  intents:                        # U2 — compose multi-step tools declaratively
+    - name: create_issue_from_thread
+      description: "Create an issue from a Slack thread."
+      params:
+        - { name: thread_id, type: string, required: true, description: "slack thread id" }
+      steps:
+        - endpoint: messages.get_thread
+          bind: { thread_id: "${input.thread_id}" }
+          capture: thread
+        - endpoint: issues.create
+          bind: { title: "${thread.subject}", description: "${thread.body}" }
+          capture: issue
+      returns: issue
+```
+
+Run `printing-press mcp-audit` after changes to see which library CLIs would benefit from the new surface.
+
 ## Domain Archetypes
 
 The profiler classifies every API into a domain archetype and auto-generates the right workflow + insight commands:
diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md
index 7a46b7c1..6db5e4f5 100644
--- a/docs/PIPELINE.md
+++ b/docs/PIPELINE.md
@@ -164,7 +164,7 @@ Outputs:
 Gates:
 - Dogfood: structural checks (path validity, auth, dead flags, wiring) pass at the configured tier
 - Verify: runtime behavioral checks against the real API or a mock server return PASS (or WARN after an auto-remediation pass)
-- Scorecard: overall grade clears the operator's configured threshold
+- Scorecard: overall grade clears the operator's configured threshold. Tier-1 dimensions include three MCP-shape dimensions (`mcp_remote_transport`, `mcp_tool_design`, `mcp_surface_strategy`) added in the April 2026 MCP production-readiness pass — opt-in via the `(score, bool)` pattern so CLIs without the relevant surface remain unscored.
 
 Artifacts:
 - `pipeline/dogfood-results.json`
diff --git a/internal/cli/mcp_audit.go b/internal/cli/mcp_audit.go
new file mode 100644
index 00000000..482ce56c
--- /dev/null
+++ b/internal/cli/mcp_audit.go
@@ -0,0 +1,225 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+// newMCPAuditCmd reports the MCP surface strategy for every CLI in the
+// local library, so the operator can see at a glance which printed CLIs
+// are ready for production agents and which would benefit from a
+// regenerate under the new spec surface (remote transport, intent tools,
+// code-orchestration).
+//
+// Diagnostic only — never exits non-zero for findings. Use the scorecard
+// (gated by the ship pipeline) when you need a gating signal.
+func newMCPAuditCmd() *cobra.Command {
+	var asJSON bool
+	var libraryPath string
+
+	cmd := &cobra.Command{
+		Use:   "mcp-audit",
+		Short: "Report MCP surface shape for every installed printed CLI",
+		Long: `Walks each CLI under ~/printing-press/library/<api>/ and reports the
+current MCP surface strategy — transport, tool design, whether the shape
+matches the API's size. Useful after a machine change to see which CLIs
+would benefit from a regenerate.
+
+Diagnostic only. Exit 0 regardless of findings.`,
+		Example: `  printing-press mcp-audit
+  printing-press mcp-audit --library ~/printing-press/library --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			path := libraryPath
+			if path == "" {
+				home, err := os.UserHomeDir()
+				if err != nil {
+					return fmt.Errorf("resolving home: %w", err)
+				}
+				path = filepath.Join(home, "printing-press", "library")
+			}
+			findings, err := runMCPAudit(path)
+			if err != nil {
+				return err
+			}
+			if asJSON {
+				data, err := json.MarshalIndent(findings, "", "  ")
+				if err != nil {
+					return fmt.Errorf("encoding findings: %w", err)
+				}
+				fmt.Fprintln(cmd.OutOrStdout(), string(data))
+				return nil
+			}
+			renderMCPAuditTable(cmd.OutOrStdout(), findings)
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVar(&libraryPath, "library", "", "library root (default ~/printing-press/library)")
+	cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON instead of a human-readable table")
+	return cmd
+}
+
+// MCPAuditFinding summarizes one CLI's MCP shape. Stable JSON field names
+// so downstream tools (ci scripts, the future emboss-audit loop) can
+// consume this without depending on go struct ordering.
+type MCPAuditFinding struct {
+	API        string `json:"api"`         // library directory basename
+	HasMCP     bool   `json:"has_mcp"`     // the CLI emits an MCP server
+	Transport  string `json:"transport"`   // "stdio", "http", "both", "unknown", "n/a"
+	ToolDesign string `json:"tool_design"` // "endpoint-mirror", "intent", "code-orch", "n/a"
+	EndpointCt int    `json:"endpoint_count"`
+	IntentCt   int    `json:"intent_count"`
+	Recommend  string `json:"recommend"` // short, actionable suggestion
+}
+
+// runMCPAudit walks every immediate subdirectory of libraryPath and reports
+// one finding per CLI. Missing MCP surfaces are reported, not skipped, so
+// the operator sees which CLIs lack one entirely.
+func runMCPAudit(libraryPath string) ([]MCPAuditFinding, error) {
+	entries, err := os.ReadDir(libraryPath)
+	if err != nil {
+		return nil, fmt.Errorf("reading library %s: %w", libraryPath, err)
+	}
+
+	var findings []MCPAuditFinding
+	for _, e := range entries {
+		if !e.IsDir() {
+			continue
+		}
+		findings = append(findings, auditLibraryCLI(filepath.Join(libraryPath, e.Name()), e.Name()))
+	}
+	sort.Slice(findings, func(i, j int) bool { return findings[i].API < findings[j].API })
+	return findings, nil
+}
+
+// auditLibraryCLI inspects a single library entry. It relies on string
+// heuristics over file contents rather than requiring a manifest file
+// because older printed CLIs predate the tools-manifest schema.
+func auditLibraryCLI(dir, api string) MCPAuditFinding {
+	f := MCPAuditFinding{API: api}
+
+	var mainPath string
+	cmdDir := filepath.Join(dir, "cmd")
+	cmdEntries, err := os.ReadDir(cmdDir)
+	if err == nil {
+		for _, ce := range cmdEntries {
+			if ce.IsDir() && strings.HasSuffix(ce.Name(), "-pp-mcp") {
+				mainPath = filepath.Join(cmdDir, ce.Name(), "main.go")
+				break
+			}
+		}
+	}
+	if mainPath == "" {
+		f.Transport = "n/a"
+		f.ToolDesign = "n/a"
+		f.Recommend = "no MCP surface — consider adding mcp: block to enable an MCP server"
+		return f
+	}
+	f.HasMCP = true
+
+	if data, err := os.ReadFile(mainPath); err == nil {
+		body := string(data)
+		hasStdio := strings.Contains(body, "server.ServeStdio")
+		hasHTTP := strings.Contains(body, "NewStreamableHTTPServer") || strings.Contains(body, "ServeStreamableHTTP")
+		switch {
+		case hasStdio && hasHTTP:
+			f.Transport = "both"
+		case hasHTTP:
+			f.Transport = "http"
+		case hasStdio:
+			f.Transport = "stdio"
+		default:
+			f.Transport = "unknown"
+		}
+	} else {
+		f.Transport = "unknown"
+	}
+
+	if data, err := os.ReadFile(filepath.Join(dir, "internal", "mcp", "tools.go")); err == nil {
+		f.EndpointCt = strings.Count(string(data), "mcplib.NewTool(")
+	}
+	intentsPresent := false
+	if data, err := os.ReadFile(filepath.Join(dir, "internal", "mcp", "intents.go")); err == nil {
+		intentsPresent = true
+		f.IntentCt = strings.Count(string(data), "mcplib.NewTool(")
+	}
+	codeOrch := false
+	if _, err := os.Stat(filepath.Join(dir, "internal", "mcp", "code_orch.go")); err == nil {
+		codeOrch = true
+	}
+
+	switch {
+	case codeOrch:
+		f.ToolDesign = "code-orch"
+	case intentsPresent:
+		f.ToolDesign = "intent"
+	default:
+		f.ToolDesign = "endpoint-mirror"
+	}
+
+	f.Recommend = recommendForFinding(f)
+	return f
+}
+
+// recommendForFinding produces a one-line action hint. The hints match the
+// spec surface introduced in U1-U3 so the operator can map directly from
+// audit output to the mcp: fields they should add.
+func recommendForFinding(f MCPAuditFinding) string {
+	if !f.HasMCP {
+		return "no MCP surface — add mcp: block to enable"
+	}
+	var hints []string
+	if f.Transport == "stdio" {
+		hints = append(hints, "set mcp.transport: [stdio, http] for cloud-agent reach")
+	}
+	switch f.ToolDesign {
+	case "endpoint-mirror":
+		if f.EndpointCt > 50 {
+			hints = append(hints, "set mcp.orchestration: code (endpoint count past threshold)")
+		} else if f.EndpointCt >= 10 {
+			hints = append(hints, "declare mcp.intents for common workflows")
+		}
+	case "intent":
+		if f.EndpointCt > 50 {
+			hints = append(hints, "consider mcp.orchestration: code for the full surface")
+		}
+	}
+	if len(hints) == 0 {
+		return "ok"
+	}
+	return strings.Join(hints, "; ")
+}
+
+// renderMCPAuditTable produces the human-readable table. Kept narrow on
+// purpose — operators run this interactively, and wide tables stop being
+// readable past ~80 columns. Recommend is the last column so long hints
+// wrap cleanly in terminals.
+func renderMCPAuditTable(w interface{ Write(p []byte) (int, error) }, findings []MCPAuditFinding) {
+	if len(findings) == 0 {
+		fmt.Fprintln(w, "(no CLIs found in library)")
+		return
+	}
+	header := fmt.Sprintf("%-20s  %-10s  %-14s  %6s  %6s  %s", "API", "Transport", "ToolDesign", "Epts", "Itnts", "Recommend")
+	fmt.Fprintln(w, header)
+	fmt.Fprintln(w, strings.Repeat("-", len(header)))
+	for _, f := range findings {
+		fmt.Fprintf(w, "%-20s  %-10s  %-14s  %6d  %6d  %s\n",
+			truncate(f.API, 20), f.Transport, f.ToolDesign, f.EndpointCt, f.IntentCt, f.Recommend)
+	}
+}
+
+func truncate(s string, n int) string {
+	if len(s) <= n {
+		return s
+	}
+	if n <= 1 {
+		return s[:n]
+	}
+	return s[:n-1] + "…"
+}
diff --git a/internal/cli/mcp_audit_test.go b/internal/cli/mcp_audit_test.go
new file mode 100644
index 00000000..00ed8f4d
--- /dev/null
+++ b/internal/cli/mcp_audit_test.go
@@ -0,0 +1,133 @@
+package cli
+
+import (
+	"bytes"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// mustWrite is the table-test friendly helper for this file. Keeps each
+// fixture construction compact since the audit reads multiple file paths
+// per CLI.
+func mustWrite(t *testing.T, root, rel, body string) {
+	t.Helper()
+	full := filepath.Join(root, rel)
+	require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
+	require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
+}
+
+// makeMCPTools returns a tools.go body with n endpoint-tool registrations.
+func makeMCPTools(n int) string {
+	var b strings.Builder
+	b.WriteString("package mcp\nfunc RegisterTools() {\n")
+	for i := 0; i < n; i++ {
+		b.WriteString("mcplib.NewTool(\"x\",)\n")
+	}
+	b.WriteString("}\n")
+	return b.String()
+}
+
+func TestRunMCPAuditNoMCPSurface(t *testing.T) {
+	lib := t.TempDir()
+	// CLI without cmd/*-pp-mcp — audit should report no MCP.
+	require.NoError(t, os.MkdirAll(filepath.Join(lib, "bare-cli"), 0o755))
+
+	findings, err := runMCPAudit(lib)
+	require.NoError(t, err)
+	require.Len(t, findings, 1)
+	assert.Equal(t, "bare-cli", findings[0].API)
+	assert.False(t, findings[0].HasMCP)
+	assert.Equal(t, "n/a", findings[0].Transport)
+	assert.Equal(t, "n/a", findings[0].ToolDesign)
+	assert.Contains(t, findings[0].Recommend, "mcp: block")
+}
+
+func TestRunMCPAuditStdioEndpointMirror(t *testing.T) {
+	lib := t.TempDir()
+	cli := filepath.Join(lib, "small-cli")
+	mustWrite(t, cli, "cmd/small-cli-pp-mcp/main.go", "package main\nfunc main() { server.ServeStdio(s) }\n")
+	mustWrite(t, cli, "internal/mcp/tools.go", makeMCPTools(6))
+
+	findings, err := runMCPAudit(lib)
+	require.NoError(t, err)
+	require.Len(t, findings, 1)
+	f := findings[0]
+	assert.True(t, f.HasMCP)
+	assert.Equal(t, "stdio", f.Transport)
+	assert.Equal(t, "endpoint-mirror", f.ToolDesign)
+	assert.Equal(t, 6, f.EndpointCt)
+	assert.Contains(t, f.Recommend, "[stdio, http]", "small stdio mirror still recommends remote")
+	assert.NotContains(t, f.Recommend, "intents", "6 endpoints is below intent recommendation threshold")
+}
+
+func TestRunMCPAuditLargeMirrorRecommendsCodeOrch(t *testing.T) {
+	lib := t.TempDir()
+	cli := filepath.Join(lib, "huge-cli")
+	// Both transports + endpoint mirror with many tools.
+	mustWrite(t, cli, "cmd/huge-cli-pp-mcp/main.go",
+		"package main\nfunc main() { server.ServeStdio(s); server.NewStreamableHTTPServer(s) }\n")
+	mustWrite(t, cli, "internal/mcp/tools.go", makeMCPTools(60))
+
+	findings, err := runMCPAudit(lib)
+	require.NoError(t, err)
+	require.Len(t, findings, 1)
+	f := findings[0]
+	assert.Equal(t, "both", f.Transport)
+	assert.Equal(t, "endpoint-mirror", f.ToolDesign)
+	assert.Equal(t, 60, f.EndpointCt)
+	assert.Contains(t, f.Recommend, "orchestration: code")
+	assert.NotContains(t, f.Recommend, "[stdio, http]", "transport is already both — no remote recommendation")
+}
+
+func TestRunMCPAuditCodeOrchSurface(t *testing.T) {
+	lib := t.TempDir()
+	cli := filepath.Join(lib, "cloudy-cli")
+	mustWrite(t, cli, "cmd/cloudy-cli-pp-mcp/main.go",
+		"package main\nfunc main() { server.ServeStdio(s); server.NewStreamableHTTPServer(s) }\n")
+	mustWrite(t, cli, "internal/mcp/tools.go", "package mcp\n")
+	mustWrite(t, cli, "internal/mcp/code_orch.go", "package mcp\n")
+
+	findings, err := runMCPAudit(lib)
+	require.NoError(t, err)
+	require.Len(t, findings, 1)
+	f := findings[0]
+	assert.Equal(t, "both", f.Transport)
+	assert.Equal(t, "code-orch", f.ToolDesign)
+	assert.Equal(t, "ok", f.Recommend, "fully-modernized CLI has nothing to recommend")
+}
+
+func TestRunMCPAuditJSONRoundtrip(t *testing.T) {
+	lib := t.TempDir()
+	mustWrite(t, lib, "alpha/cmd/alpha-pp-mcp/main.go", "package main\nfunc main() { server.ServeStdio(s) }\n")
+	mustWrite(t, lib, "alpha/internal/mcp/tools.go", makeMCPTools(3))
+	mustWrite(t, lib, "beta/cmd/beta-pp-mcp/main.go", "package main\nfunc main() { server.NewStreamableHTTPServer(s); server.ServeStdio(s) }\n")
+	mustWrite(t, lib, "beta/internal/mcp/tools.go", makeMCPTools(12))
+	mustWrite(t, lib, "beta/internal/mcp/intents.go", makeMCPTools(4))
+
+	cmd := newMCPAuditCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetArgs([]string{"--library", lib, "--json"})
+	require.NoError(t, cmd.Execute())
+
+	var findings []MCPAuditFinding
+	require.NoError(t, json.Unmarshal(out.Bytes(), &findings))
+	require.Len(t, findings, 2)
+	assert.Equal(t, "alpha", findings[0].API)
+	assert.Equal(t, "stdio", findings[0].Transport)
+	assert.Equal(t, "beta", findings[1].API)
+	assert.Equal(t, "both", findings[1].Transport)
+	assert.Equal(t, "intent", findings[1].ToolDesign)
+	assert.Equal(t, 4, findings[1].IntentCt)
+}
+
+func TestRunMCPAuditMissingLibraryErrors(t *testing.T) {
+	_, err := runMCPAudit(filepath.Join(t.TempDir(), "does-not-exist"))
+	assert.Error(t, err, "missing library path should surface as a clear error")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 71f7eb7c..a826153e 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -57,6 +57,7 @@ func Execute() error {
 	rootCmd.AddCommand(newPolishCmd())
 	rootCmd.AddCommand(newWorkflowVerifyCmd())
 	rootCmd.AddCommand(newLockCmd())
+	rootCmd.AddCommand(newMCPAuditCmd())
 
 	return rootCmd.Execute()
 }

← 2d07a021 feat(cli): scorecard dimensions for remote transport, tool d  ·  back to Cli Printing Press  ·  feat(cli): generate replayable website CLIs (#241) e741db80 →