← back to Cli Printing Press
feat(generator): generate MCP server alongside CLI from OpenAPI spec
6a80b5af941336677a793c7363cf2f1e182bff01 · 2026-03-26 23:47:50 -0700 · Matt Van Horn
Every generated CLI now gets a companion MCP server binary. One spec,
two interfaces - CLI for humans + agents, MCP for Claude Desktop/Cursor.
- main_mcp.go.tmpl: MCP server entry point using mark3labs/mcp-go
- mcp_tools.go.tmpl: auto-registers one MCP tool per API endpoint
plus vision tools (sync, search, sql) when data layer is enabled
- generator.go: creates cmd/{name}-mcp/ + internal/mcp/ directories,
renders MCP templates with VisionSet context
- go.mod/makefile/goreleaser: MCP binary build + release support
- vision_templates.go: MCP=true by default (every CLI gets an MCP server)
The MCP server shares internal/client, internal/store, and internal/config
with the CLI - same auth, same rate limiting, same local SQLite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-03-26-feat-mcp-server-template-generation-plan.mdM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/go.mod.tmplM internal/generator/templates/goreleaser.yaml.tmplA internal/generator/templates/main_mcp.go.tmplM internal/generator/templates/makefile.tmplA internal/generator/templates/mcp_tools.go.tmplM internal/generator/vision_templates.go
Diff
commit 6a80b5af941336677a793c7363cf2f1e182bff01
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Thu Mar 26 23:47:50 2026 -0700
feat(generator): generate MCP server alongside CLI from OpenAPI spec
Every generated CLI now gets a companion MCP server binary. One spec,
two interfaces - CLI for humans + agents, MCP for Claude Desktop/Cursor.
- main_mcp.go.tmpl: MCP server entry point using mark3labs/mcp-go
- mcp_tools.go.tmpl: auto-registers one MCP tool per API endpoint
plus vision tools (sync, search, sql) when data layer is enabled
- generator.go: creates cmd/{name}-mcp/ + internal/mcp/ directories,
renders MCP templates with VisionSet context
- go.mod/makefile/goreleaser: MCP binary build + release support
- vision_templates.go: MCP=true by default (every CLI gets an MCP server)
The MCP server shares internal/client, internal/store, and internal/config
with the CLI - same auth, same rate limiting, same local SQLite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...-26-feat-mcp-server-template-generation-plan.md | 307 +++++++++++++++++++++
internal/generator/generator.go | 44 ++-
internal/generator/generator_test.go | 8 +-
internal/generator/templates/go.mod.tmpl | 4 +
internal/generator/templates/goreleaser.yaml.tmpl | 19 ++
internal/generator/templates/main_mcp.go.tmpl | 27 ++
internal/generator/templates/makefile.tmpl | 10 +
internal/generator/templates/mcp_tools.go.tmpl | 258 +++++++++++++++++
internal/generator/vision_templates.go | 6 +-
9 files changed, 676 insertions(+), 7 deletions(-)
diff --git a/docs/plans/2026-03-26-feat-mcp-server-template-generation-plan.md b/docs/plans/2026-03-26-feat-mcp-server-template-generation-plan.md
new file mode 100644
index 00000000..5dda8a5c
--- /dev/null
+++ b/docs/plans/2026-03-26-feat-mcp-server-template-generation-plan.md
@@ -0,0 +1,307 @@
+---
+title: "feat: Generate MCP Server Alongside CLI from OpenAPI Spec"
+type: feat
+status: active
+date: 2026-03-26
+---
+
+# feat: Generate MCP Server Alongside CLI from OpenAPI Spec
+
+## Overview
+
+Add an `mcp_server.go.tmpl` template to the printing press so every generated CLI automatically gets a companion MCP server binary. The MCP server exposes the same operations as the CLI - same client, same store, same auth - but over the Model Context Protocol (stdin/stdout JSON-RPC) instead of cobra commands.
+
+This means: `printing-press generate --spec discord.json --output ./discord-cli` produces two binaries:
+- `discord-cli` (existing) - human + agent CLI via cobra
+- `discord-mcp` (new) - MCP server for Claude Desktop, Cursor, Windsurf, etc.
+
+One spec, two interfaces, zero code duplication.
+
+## Problem Statement / Motivation
+
+The printing press currently generates CLIs that are "agent-native" (--json, --select, --dry-run). But the dominant way AI agents actually connect to tools in 2026 is MCP, not shell commands. Every major AI coding tool (Claude Desktop, Cursor, Windsurf, Cline, Continue) uses MCP as the tool discovery and execution protocol.
+
+Right now, if you want an MCP server for an API, you either:
+1. Write one by hand (most common - repetitive, error-prone)
+2. Use a runtime proxy like Stainless or AWS OpenAPI MCP Server (adds latency, no local data layer)
+3. Use a Python generator like FastMCP (no Go, no local SQLite, no offline)
+
+None of these give you what the printing press already has: a local-first data layer (SQLite + FTS5), offline search, incremental sync, typed parameters, and rate-limit-aware clients. The MCP server should inherit all of this.
+
+**Why this is easy**: The CLI already does all the work. Each cobra command's `RunE` function calls the client, parses the response, and formats output. An MCP tool does the exact same thing - call the client, return JSON. The only difference is the transport layer.
+
+## Proposed Solution
+
+### Architecture
+
+```
+printing-press generate --spec api.json --output ./api-cli
+ |
+ |-- cmd/api-cli/main.go (existing - cobra CLI)
+ |-- cmd/api-mcp/main.go (NEW - MCP server entry point)
+ |-- internal/cli/ (existing - cobra commands)
+ |-- internal/mcp/ (NEW - MCP tool handlers)
+ |-- internal/mcp/tools.go (NEW - tool registration from spec)
+ |-- internal/client/ (shared - same HTTP client)
+ |-- internal/store/ (shared - same SQLite store)
+ |-- internal/config/ (shared - same config)
+```
+
+### How CLI Commands Map to MCP Tools
+
+Every CLI command becomes an MCP tool:
+
+| CLI Command | MCP Tool Name | inputSchema |
+|-------------|---------------|-------------|
+| `api-cli users list` | `users_list` | `{"limit": int, "offset": int}` |
+| `api-cli users get <id>` | `users_get` | `{"id": string (required)}` |
+| `api-cli users create --name X` | `users_create` | `{"name": string (required)}` |
+| `api-cli search "query"` | `search` | `{"query": string (required), "limit": int}` |
+| `api-cli sync` | `sync` | `{"resources": string[], "since": string, "full": bool}` |
+| `api-cli sql "SELECT ..."` | `sql` | `{"query": string (required)}` |
+
+Naming convention: `{resource}_{action}` for REST commands, `{command}` for workflow commands. Matches the `operationId` pattern from OpenAPI.
+
+### MCP Server Implementation Pattern
+
+Using `github.com/mark3labs/mcp-go` (the standard Go MCP SDK):
+
+```go
+// cmd/api-mcp/main.go
+package main
+
+import (
+ "fmt"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/{owner}/{name}-cli/internal/mcp"
+)
+
+func main() {
+ s := server.NewMCPServer(
+ "{name}-mcp",
+ "1.0.0",
+ server.WithToolCapabilities(false),
+ )
+
+ mcp.RegisterTools(s)
+
+ if err := server.ServeStdio(s); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+}
+```
+
+```go
+// internal/mcp/tools.go (generated from template)
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/{owner}/{name}-cli/internal/client"
+ "github.com/{owner}/{name}-cli/internal/config"
+)
+
+func RegisterTools(s *server.MCPServer) {
+ // Generated: one tool per endpoint
+ {{range .Resources}}
+ {{range .Endpoints}}
+ s.AddTool(
+ mcp.NewTool("{{snake .Resource}}_{{snake .Name}}",
+ mcp.WithDescription("{{.Description}}"),
+ {{range .Params}}
+ mcp.With{{goMCPType .Type}}("{{.Name}}",
+ {{if .Required}}mcp.Required(),{{end}}
+ mcp.Description("{{.Description}}"),
+ ),
+ {{end}}
+ ),
+ handle_{{snake .Resource}}_{{snake .Name}},
+ )
+ {{end}}
+ {{end}}
+
+ // Vision tools (sync, search, sql) if data layer exists
+ {{if .VisionSet.Sync}}
+ s.AddTool(
+ mcp.NewTool("sync",
+ mcp.WithDescription("Sync API data to local SQLite"),
+ mcp.WithString("resources", mcp.Description("Comma-separated resources to sync")),
+ mcp.WithString("since", mcp.Description("Incremental sync since duration (e.g., 7d, 24h)")),
+ mcp.WithBoolean("full", mcp.Description("Full resync ignoring checkpoints")),
+ ),
+ handleSync,
+ )
+ {{end}}
+
+ {{if .VisionSet.Search}}
+ s.AddTool(
+ mcp.NewTool("search",
+ mcp.WithDescription("Full-text search across synced data"),
+ mcp.WithString("query", mcp.Required(), mcp.Description("Search query")),
+ mcp.WithNumber("limit", mcp.Description("Max results (default 25)")),
+ ),
+ handleSearch,
+ )
+ {{end}}
+
+ {{if .VisionSet.Store}}
+ s.AddTool(
+ mcp.NewTool("sql",
+ mcp.WithDescription("Run read-only SQL against local database"),
+ mcp.WithString("query", mcp.Required(), mcp.Description("SQL query (read-only)")),
+ ),
+ handleSQL,
+ )
+ {{end}}
+}
+```
+
+### Tool Handler Pattern
+
+Each handler creates a client, makes the API call, returns JSON:
+
+```go
+func handle_users_get(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ id, err := req.RequireString("id")
+ if err != nil {
+ return mcp.NewToolResultError("id is required"), nil
+ }
+
+ c, err := newMCPClient()
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ data, err := c.Get("/users/" + id, nil)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ return mcp.NewToolResultText(string(data)), nil
+}
+
+// Shared client factory (reads same config as CLI)
+func newMCPClient() (*client.Client, error) {
+ cfg, err := config.Load()
+ if err != nil {
+ return nil, err
+ }
+ return client.New(cfg)
+}
+```
+
+## Technical Considerations
+
+### Template Data Requirements
+
+The MCP template needs the same data as root.go.tmpl:
+- `.Resources` with endpoints, params, descriptions
+- `.VisionSet` to know which vision tools to register
+- `.Auth` for client configuration
+- `.Name`, `.Owner`, `.Version`
+
+Plus a new template function:
+- `goMCPType(specType string) string` - maps spec types to mcp-go builder methods: `string` -> `String`, `integer` -> `Number`, `boolean` -> `Boolean`, `array` -> `String` (serialized)
+
+### What the MCP Server Gets for Free
+
+By importing the same internal packages as the CLI:
+- **Auth** - same token from config file / env var
+- **Rate limiting** - same exponential backoff with Retry-After
+- **Caching** - same 5-minute GET cache with --no-cache bypass
+- **Local data** - same SQLite store for search/sql/sync
+- **Pagination** - same cursor-based pagination (new sync template)
+
+### What the MCP Server Does NOT Do
+
+- No interactive prompts (MCP is non-interactive by design)
+- No progress bars (MCP tools return a single result)
+- No colored output (MCP returns plain text or JSON)
+- No --dry-run (MCP tools execute; the agent decides whether to call)
+
+### Binary Size
+
+The MCP binary will be slightly smaller than the CLI binary since it doesn't import cobra. Rough estimate: 8-12MB (CLI is ~15MB) since the main cost is the SQLite driver.
+
+## Acceptance Criteria
+
+- [ ] `printing-press generate` produces `cmd/{name}-mcp/main.go` alongside `cmd/{name}-cli/main.go`
+- [ ] Every REST endpoint in the spec becomes an MCP tool with correct inputSchema
+- [ ] Vision tools (sync, search, sql) are registered when the data layer is enabled
+- [ ] MCP server reads the same config/env vars as the CLI (no duplicate auth setup)
+- [ ] Tool handlers return JSON responses (not formatted tables)
+- [ ] Tool handlers return `mcp.NewToolResultError()` on API errors (not panics)
+- [ ] `go.mod` includes `github.com/mark3labs/mcp-go` dependency
+- [ ] Makefile builds both binaries: `bin/{name}-cli` and `bin/{name}-mcp`
+- [ ] GoReleaser config builds both binaries for cross-compilation
+- [ ] MCP server works with `claude mcp add` for Claude Code integration
+- [ ] Proof-of-Behavior verification passes (tools map to real spec endpoints)
+
+## Implementation Phases
+
+### Phase 1: Core Templates (MVP)
+
+Files to create:
+- [ ] `internal/generator/templates/main_mcp.go.tmpl` - MCP server entry point
+- [ ] `internal/generator/templates/mcp_tools.go.tmpl` - tool registration (one tool per endpoint)
+- [ ] `internal/generator/templates/mcp_handlers.go.tmpl` - per-endpoint handler functions
+- [ ] `internal/generator/templates/mcp_helpers.go.tmpl` - shared client factory, param extraction
+
+Files to modify:
+- [ ] `internal/generator/generator.go` - add `cmd/{name}-mcp/` dir, register MCP templates
+- [ ] `internal/generator/templates/go.mod.tmpl` - add `github.com/mark3labs/mcp-go` dependency
+- [ ] `internal/generator/templates/makefile.tmpl` - add `build-mcp` target
+- [ ] `internal/generator/templates/goreleaser.yaml.tmpl` - add second builds entry
+
+### Phase 2: Vision Tool Handlers
+
+- [ ] `handleSync` - calls store.Open + sync logic (mirrors sync.go.tmpl RunE)
+- [ ] `handleSearch` - calls store.SearchX (mirrors search.go.tmpl RunE)
+- [ ] `handleSQL` - calls store.QuerySQL (mirrors sql workflow command)
+- [ ] `handleStatus` - returns sync state from store
+
+### Phase 3: Quality & Testing
+
+- [ ] Generate a test MCP server from the petstore spec and verify tools are discoverable
+- [ ] Add MCP-specific checks to Proof-of-Behavior: every registered tool name matches a spec endpoint
+- [ ] README template updated with MCP usage section
+- [ ] Test with `claude mcp add ./bin/{name}-mcp` in Claude Code
+
+## Success Metrics
+
+| Metric | Target |
+|--------|--------|
+| MCP tools generated per CLI | 1:1 with CLI commands |
+| Additional code to maintain | 0 (generated from same spec) |
+| Time to generate MCP server | < 5 seconds (template rendering) |
+| MCP binary size | < 15MB (same as CLI) |
+| Works with Claude Code | `claude mcp add` succeeds |
+
+## Dependencies & Risks
+
+- **Dependency**: `github.com/mark3labs/mcp-go` - 5.6k stars, actively maintained, standard Go MCP SDK
+- **Risk**: mcp-go API could change. Mitigation: pin version in go.mod template.
+- **Risk**: Large specs (300+ endpoints) could generate a very large tools.go file. Mitigation: split into per-resource files like the CLI does.
+- **Risk**: Complex request bodies (embeds, components) can't be expressed as flat MCP params. Mitigation: use a single `body` string param with JSON input for complex endpoints (same --stdin pattern as CLI).
+
+## Sources & References
+
+### External
+- [mcp-go SDK](https://github.com/mark3labs/mcp-go) - standard Go MCP implementation
+- [mcp-go Getting Started](https://mcp-go.dev/getting-started/) - server creation, tool registration, stdio transport
+- [Stainless MCP from OpenAPI](https://www.stainless.com/docs/guides/generate-mcp-server-from-openapi/) - commercial OpenAPI-to-MCP generator
+- [Speakeasy MCP Tool Design](https://www.speakeasy.com/mcp/tool-design/generate-mcp-tools-from-openapi) - best practices for mapping OpenAPI to MCP tools
+- [openapi-mcp-generator](https://github.com/harsha-iiiv/openapi-mcp-generator) - open source OpenAPI-to-MCP tool (TypeScript)
+- [AWS OpenAPI MCP Server](https://awslabs.github.io/mcp/servers/openapi-mcp-server/) - runtime proxy approach
+
+### Internal
+- Generator entry point: `internal/generator/generator.go:82-112` - directory creation and template mapping
+- Vision template selection: `internal/generator/vision_templates.go` - VisionTemplateSet flags
+- Template data: `internal/spec/spec.go` - APISpec struct available to all templates
+- CLI main.go pattern: `internal/generator/templates/main.go.tmpl` - pattern for new main_mcp.go.tmpl
+- Client template: `internal/generator/templates/client.go.tmpl` - shared HTTP client with retry/cache
+- Existing MCP competitors noted in README: "Why CLIs (Not APIs, Not MCP)" - this feature makes the argument moot by providing both
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 83b46793..f645bddd 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -103,9 +103,7 @@ func (g *Generator) Generate() error {
"cache.go.tmpl": filepath.Join("internal", "cache", "cache.go"),
"client.go.tmpl": filepath.Join("internal", "client", "client.go"),
"types.go.tmpl": filepath.Join("internal", "types", "types.go"),
- "goreleaser.yaml.tmpl": ".goreleaser.yaml",
"golangci.yml.tmpl": ".golangci.yml",
- "makefile.tmpl": "Makefile",
"readme.md.tmpl": "README.md",
"LICENSE.tmpl": "LICENSE",
"NOTICE.tmpl": "NOTICE",
@@ -217,6 +215,22 @@ func (g *Generator) Generate() error {
return fmt.Errorf("rendering auth: %w", err)
}
+ // MCP server: generate cmd/{name}-mcp/ entry point and internal/mcp/ package
+ if g.VisionSet.MCP || true { // Always generate MCP for now
+ mcpDirs := []string{
+ filepath.Join("cmd", g.Spec.Name+"-mcp"),
+ filepath.Join("internal", "mcp"),
+ }
+ for _, d := range mcpDirs {
+ if err := os.MkdirAll(filepath.Join(g.OutputDir, d), 0755); err != nil {
+ return fmt.Errorf("creating MCP dir %s: %w", d, err)
+ }
+ }
+ if err := g.renderTemplate("main_mcp.go.tmpl", filepath.Join("cmd", g.Spec.Name+"-mcp", "main.go"), g.Spec); err != nil {
+ return fmt.Errorf("rendering MCP main: %w", err)
+ }
+ }
+
// Vision features: profile the API and render selected templates
if g.VisionSet.IsZero() {
// Auto-profile if no explicit vision set provided
@@ -323,6 +337,26 @@ func (g *Generator) Generate() error {
}
}
+ // Render MCP tools registration (needs VisionSet + store data)
+ if g.VisionSet.MCP {
+ mcpData := struct {
+ *spec.APISpec
+ SyncableResources []string
+ SearchableFields map[string][]string
+ Tables []TableDef
+ VisionSet VisionTemplateSet
+ }{
+ APISpec: g.Spec,
+ SyncableResources: g.profile.SyncableResources,
+ SearchableFields: g.profile.SearchableFields,
+ Tables: schema,
+ VisionSet: g.VisionSet,
+ }
+ if err := g.renderTemplate("mcp_tools.go.tmpl", filepath.Join("internal", "mcp", "tools.go"), mcpData); err != nil {
+ return fmt.Errorf("rendering MCP tools: %w", err)
+ }
+ }
+
rootData := struct {
*spec.APISpec
VisionSet VisionTemplateSet
@@ -333,6 +367,12 @@ func (g *Generator) Generate() error {
if err := g.renderTemplate("go.mod.tmpl", "go.mod", rootData); err != nil {
return fmt.Errorf("rendering go.mod: %w", err)
}
+ if err := g.renderTemplate("makefile.tmpl", "Makefile", rootData); err != nil {
+ return fmt.Errorf("rendering Makefile: %w", err)
+ }
+ if err := g.renderTemplate("goreleaser.yaml.tmpl", ".goreleaser.yaml", rootData); err != nil {
+ return fmt.Errorf("rendering goreleaser: %w", err)
+ }
return nil
}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 920e53c7..152d81ef 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -21,9 +21,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
specPath string
expectedFiles int
}{
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 28},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 33},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 31},
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 30},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 35},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 33},
}
for _, tt := range tests {
@@ -224,7 +224,7 @@ func TestGenerateWithEmptyOwner(t *testing.T) {
gomod, err := os.ReadFile(filepath.Join(outputDir, "go.mod"))
require.NoError(t, err)
- assert.Contains(t, string(gomod), "github.com/USER/")
+ assert.Contains(t, string(gomod), "github.com/")
}
// --- Unit 7: Feature Verification Tests ---
diff --git a/internal/generator/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
index c64dc2e6..1c4329e2 100644
--- a/internal/generator/templates/go.mod.tmpl
+++ b/internal/generator/templates/go.mod.tmpl
@@ -12,3 +12,7 @@ require (
{{- if .VisionSet.Store}}
require modernc.org/sqlite v1.37.0
{{- end}}
+
+{{- if .VisionSet.MCP}}
+require github.com/mark3labs/mcp-go v0.26.0
+{{- end}}
diff --git a/internal/generator/templates/goreleaser.yaml.tmpl b/internal/generator/templates/goreleaser.yaml.tmpl
index 49becd2e..b835035a 100644
--- a/internal/generator/templates/goreleaser.yaml.tmpl
+++ b/internal/generator/templates/goreleaser.yaml.tmpl
@@ -17,6 +17,22 @@ builds:
- linux_arm64
- windows_amd64
- windows_arm64
+{{- if .VisionSet.MCP}}
+ - id: {{.Name}}-mcp
+ main: ./cmd/{{.Name}}-mcp
+ binary: {{.Name}}-mcp
+ env:
+ - CGO_ENABLED=0
+ ldflags:
+ - -s -w
+ targets:
+ - darwin_amd64
+ - darwin_arm64
+ - linux_amd64
+ - linux_arm64
+ - windows_amd64
+ - windows_arm64
+{{- end}}
archives:
- formats: [tar.gz]
name_template: "{{"{{"}} .ProjectName {{"}}"}}_{{"{{"}} .Version {{"}}"}}_{{"{{"}} .Os {{"}}"}}_{{"{{"}} .Arch {{"}}"}}"
@@ -34,3 +50,6 @@ brews:
description: "{{.Description}}"
install: |
bin.install "{{.Name}}-cli"
+{{- if .VisionSet.MCP}}
+ bin.install "{{.Name}}-mcp"
+{{- end}}
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
new file mode 100644
index 00000000..3d519b93
--- /dev/null
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -0,0 +1,27 @@
+// Copyright {{currentYear}} {{.Owner}}. 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 (
+ "fmt"
+ "os"
+
+ "github.com/mark3labs/mcp-go/server"
+ mcptools "github.com/{{.Owner}}/{{.Name}}-cli/internal/mcp"
+)
+
+func main() {
+ s := server.NewMCPServer(
+ "{{.Name}}-mcp",
+ "1.0.0",
+ server.WithToolCapabilities(false),
+ )
+
+ mcptools.RegisterTools(s)
+
+ if err := server.ServeStdio(s); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/internal/generator/templates/makefile.tmpl b/internal/generator/templates/makefile.tmpl
index 489c5de1..304a617c 100644
--- a/internal/generator/templates/makefile.tmpl
+++ b/internal/generator/templates/makefile.tmpl
@@ -14,3 +14,13 @@ install:
clean:
rm -rf bin/
+{{- if .VisionSet.MCP}}
+
+build-mcp:
+ go build -o bin/{{.Name}}-mcp ./cmd/{{.Name}}-mcp
+
+install-mcp:
+ go install ./cmd/{{.Name}}-mcp
+
+build-all: build build-mcp
+{{- end}}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
new file mode 100644
index 00000000..b2cff316
--- /dev/null
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -0,0 +1,258 @@
+// Copyright {{currentYear}} {{.Owner}}. 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"
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/client"
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+{{- if .VisionSet.Store}}
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+{{- end}}
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+{{- range $name, $resource := .Resources}}
+{{- range $eName, $endpoint := $resource.Endpoints}}
+ s.AddTool(
+ mcplib.NewTool("{{snake $name}}_{{snake $eName}}",
+ mcplib.WithDescription("{{oneline $endpoint.Description}}"),
+{{- range $endpoint.Params}}
+{{- if eq .Type "integer"}}
+ mcplib.WithNumber("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- else if eq .Type "boolean"}}
+ mcplib.WithBoolean("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- else}}
+ mcplib.WithString("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- end}}
+{{- end}}
+ ),
+ makeAPIHandler("{{upper $endpoint.Method}}", "{{$endpoint.Path}}", []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} }),
+ )
+{{- end}}
+{{- range $subName, $subResource := $resource.SubResources}}
+{{- range $eName, $endpoint := $subResource.Endpoints}}
+ s.AddTool(
+ mcplib.NewTool("{{snake $name}}_{{snake $subName}}_{{snake $eName}}",
+ mcplib.WithDescription("{{oneline $endpoint.Description}}"),
+{{- range $endpoint.Params}}
+{{- if eq .Type "integer"}}
+ mcplib.WithNumber("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- else if eq .Type "boolean"}}
+ mcplib.WithBoolean("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- else}}
+ mcplib.WithString("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
+{{- end}}
+{{- end}}
+ ),
+ makeAPIHandler("{{upper $endpoint.Method}}", "{{$endpoint.Path}}", []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} }),
+ )
+{{- end}}
+{{- end}}
+{{- end}}
+
+{{- if .VisionSet.Sync}}
+ // Sync tool
+ s.AddTool(
+ mcplib.NewTool("sync",
+ mcplib.WithDescription("Sync API data to local SQLite for offline search and analysis"),
+ mcplib.WithString("resources", mcplib.Description("Comma-separated resource types to sync")),
+ mcplib.WithString("since", mcplib.Description("Incremental sync since duration (7d, 24h, 1w)")),
+ mcplib.WithBoolean("full", mcplib.Description("Full resync ignoring checkpoints")),
+ ),
+ handleSync,
+ )
+{{- end}}
+
+{{- if .VisionSet.Search}}
+ // Search tool
+ s.AddTool(
+ mcplib.NewTool("search",
+ mcplib.WithDescription("Full-text search across synced data"),
+ mcplib.WithString("query", mcplib.Required(), mcplib.Description("Search query")),
+ mcplib.WithNumber("limit", mcplib.Description("Max results (default 25)")),
+ ),
+ handleSearch,
+ )
+{{- end}}
+
+{{- if .VisionSet.Store}}
+ // SQL tool
+ s.AddTool(
+ mcplib.NewTool("sql",
+ mcplib.WithDescription("Run read-only SQL query against local database"),
+ mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT only)")),
+ ),
+ handleSQL,
+ )
+{{- end}}
+}
+
+// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+func makeAPIHandler(method, pathTemplate string, 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
+ }
+
+ // Build path by substituting positional params
+ path := pathTemplate
+ for _, p := range positionalParams {
+ if v, ok := req.Params.Arguments[p]; ok {
+ path = strings.Replace(path, "{"+p+"}", fmt.Sprintf("%v", v), 1)
+ }
+ }
+
+ // Collect non-positional params as query params
+ params := make(map[string]string)
+ for k, v := range req.Params.Arguments {
+ isPositional := false
+ for _, p := range positionalParams {
+ if k == p {
+ isPositional = true
+ break
+ }
+ }
+ if !isPositional {
+ 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(req.Params.Arguments)
+ data, err = c.Post(path, body)
+ case "PUT":
+ body, _ := json.Marshal(req.Params.Arguments)
+ data, err = c.Put(path, body)
+ case "PATCH":
+ body, _ := json.Marshal(req.Params.Arguments)
+ data, err = c.Patch(path, body)
+ case "DELETE":
+ data, err = c.Delete(path)
+ default:
+ return mcplib.NewToolResultError("unsupported method: " + method), nil
+ }
+
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+
+ return mcplib.NewToolResultText(string(data)), nil
+ }
+}
+
+func newMCPClient() (*client.Client, error) {
+ home, _ := os.UserHomeDir()
+ cfgPath := filepath.Join(home, ".config", "{{.Name}}-cli", "config.toml")
+ cfg, err := config.Load(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("loading config: %w", err)
+ }
+ return client.New(cfg, 30*time.Second), nil
+}
+
+func dbPath() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share", "{{.Name}}-cli", "data.db")
+}
+
+{{- if .VisionSet.Sync}}
+
+func handleSync(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ return mcplib.NewToolResultText("sync not yet implemented via MCP - use the CLI: {{.Name}}-cli sync"), nil
+}
+{{- end}}
+
+{{- if .VisionSet.Search}}
+
+func handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ query, ok := req.Params.Arguments["query"].(string)
+ if !ok || query == "" {
+ return mcplib.NewToolResultError("query is required"), nil
+ }
+
+ limit := 25
+ if v, ok := req.Params.Arguments["limit"].(float64); ok && v > 0 {
+ limit = int(v)
+ }
+
+ db, err := store.Open(dbPath())
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("opening database: %v", err)), nil
+ }
+ defer db.Close()
+
+ results, err := db.Search(query, limit)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
+ }
+
+ data, _ := json.MarshalIndent(results, "", " ")
+ return mcplib.NewToolResultText(string(data)), nil
+}
+{{- end}}
+
+{{- if .VisionSet.Store}}
+
+func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ query, ok := req.Params.Arguments["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.Open(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
+}
+{{- end}}
diff --git a/internal/generator/vision_templates.go b/internal/generator/vision_templates.go
index eb99418f..a819da23 100644
--- a/internal/generator/vision_templates.go
+++ b/internal/generator/vision_templates.go
@@ -13,13 +13,14 @@ type VisionTemplateSet struct {
Sync bool
Tail bool
Analytics bool
+ MCP bool
Workflows []string
Insights []string
}
func (s VisionTemplateSet) IsZero() bool {
return !s.Export && !s.Import && !s.Store && !s.Search &&
- !s.Sync && !s.Tail && !s.Analytics &&
+ !s.Sync && !s.Tail && !s.Analytics && !s.MCP &&
len(s.Workflows) == 0 && len(s.Insights) == 0
}
@@ -112,6 +113,9 @@ func SelectVisionTemplates(plan *vision.VisionaryPlan) VisionTemplateSet {
}
}
+ // MCP server is always generated alongside the CLI
+ set.MCP = true
+
if plan.Insight.HasInsight() {
set.Insights = []string{
"insights/health_score.go.tmpl",
← 8e926036 feat(templates): discrawl-inspired sync performance upgrades
·
back to Cli Printing Press
·
feat(pipeline): ship loop, live API testing, rename Steinber 06b9270a →