[object Object]

← back to Cli Printing Press

feat(cli): declare intent-grouped MCP tools in the spec (#243)

85d4ace95a6dfcb94e030e1576776da295fa1e9b · 2026-04-22 20:57:05 -0700 · Matt Van Horn

A single create_issue_from_thread tool beats get_thread + parse_messages +
create_issue + link_attachment. Printed MCP servers were one-to-one endpoint
mirrors; agents had to stitch primitives. This adds a spec-declared
mcp.intents surface so higher-level operations can land as single MCP tools
whose handlers call the underlying endpoints in sequence.

Syntax: each intent declares name/description/params/steps/returns. Steps
reference spec endpoints by resource.endpoint path and bind params via
`${input.<name>}` or `${<capture>.<field>}`. Responses can be captured for
later steps, and returns names the capture that surfaces to the caller.
Validation covers endpoint resolution, binding references, capture ordering,
and returns reachability.

Also adds mcp.endpoint_tools: hidden to suppress raw per-endpoint tools
when intents fully cover the useful surface.

Plan: docs/plans/2026-04-22-003-feat-mcp-production-readiness-plan.md (U2)

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 85d4ace95a6dfcb94e030e1576776da295fa1e9b
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Wed Apr 22 20:57:05 2026 -0700

    feat(cli): declare intent-grouped MCP tools in the spec (#243)
    
    A single create_issue_from_thread tool beats get_thread + parse_messages +
    create_issue + link_attachment. Printed MCP servers were one-to-one endpoint
    mirrors; agents had to stitch primitives. This adds a spec-declared
    mcp.intents surface so higher-level operations can land as single MCP tools
    whose handlers call the underlying endpoints in sequence.
    
    Syntax: each intent declares name/description/params/steps/returns. Steps
    reference spec endpoints by resource.endpoint path and bind params via
    `${input.<name>}` or `${<capture>.<field>}`. Responses can be captured for
    later steps, and returns names the capture that surfaces to the caller.
    Validation covers endpoint resolution, binding references, capture ordering,
    and returns reachability.
    
    Also adds mcp.endpoint_tools: hidden to suppress raw per-endpoint tools
    when intents fully cover the useful surface.
    
    Plan: docs/plans/2026-04-22-003-feat-mcp-production-readiness-plan.md (U2)
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go                  |  39 ++++
 internal/generator/generator_test.go             | 103 +++++++++++
 internal/generator/templates/mcp_intents.go.tmpl | 218 +++++++++++++++++++++++
 internal/generator/templates/mcp_tools.go.tmpl   |   6 +
 internal/spec/spec.go                            | 182 ++++++++++++++++++-
 internal/spec/spec_test.go                       | 164 +++++++++++++++++
 6 files changed, 708 insertions(+), 4 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index ab21c921..247919cc 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -218,6 +218,10 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 			// "steam-web" → "Steam Web", "notion" → "Notion"
 			return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
 		},
+		"lookupEndpoint": func(resources map[string]spec.Resource, ref string) spec.Endpoint {
+			e, _ := lookupEndpointForTemplate(resources, ref)
+			return e
+		},
 		"enumLiteral": func(values []string) string {
 			// Render a string slice as a Go []string literal for template embedding.
 			// Example: ["asc","desc"] → `"asc", "desc"`. Returns empty string when
@@ -1171,6 +1175,11 @@ func (g *Generator) Generate() error {
 		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)
 		}
+		if len(g.Spec.MCP.Intents) > 0 {
+			if err := g.renderTemplate("mcp_intents.go.tmpl", filepath.Join("internal", "mcp", "intents.go"), mcpData); err != nil {
+				return fmt.Errorf("rendering MCP intents: %w", err)
+			}
+		}
 	}
 
 	// Generate api discovery command when promoted commands exist (lets users browse hidden interfaces)
@@ -2065,3 +2074,33 @@ func graphqlFieldSelection(typeName string, types map[string]spec.TypeDef) []str
 	}
 	return fields
 }
+
+// lookupEndpointForTemplate resolves a dotted "resource.endpoint" (or
+// "resource.sub_resource.endpoint") reference from the spec's resource map.
+// Templates use it when rendering intent handler dispatch tables so each
+// step's HTTP method and path are known at generate time.
+func lookupEndpointForTemplate(resources map[string]spec.Resource, ref string) (spec.Endpoint, bool) {
+	parts := strings.Split(ref, ".")
+	switch len(parts) {
+	case 2:
+		r, ok := resources[parts[0]]
+		if !ok {
+			return spec.Endpoint{}, false
+		}
+		e, ok := r.Endpoints[parts[1]]
+		return e, ok
+	case 3:
+		r, ok := resources[parts[0]]
+		if !ok {
+			return spec.Endpoint{}, false
+		}
+		sub, ok := r.SubResources[parts[1]]
+		if !ok {
+			return spec.Endpoint{}, false
+		}
+		e, ok := sub.Endpoints[parts[2]]
+		return e, ok
+	default:
+		return spec.Endpoint{}, false
+	}
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index aa960d7d..733a17d0 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2179,6 +2179,109 @@ func TestGenerateMCPMainRemoteOptIn(t *testing.T) {
 	}
 }
 
+// TestGenerateMCPIntentsEmittedWhenDeclared proves that a spec with mcp.intents
+// emits internal/mcp/intents.go, wires the intent handler into RegisterTools
+// via RegisterIntents, and keeps endpoint-mirror tools by default.
+func TestGenerateMCPIntentsEmittedWhenDeclared(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "loops.yaml"))
+	require.NoError(t, err)
+	apiSpec.MCP = spec.MCPConfig{
+		Intents: []spec.Intent{
+			{
+				Name:        "fetch_contact_then_noop",
+				Description: "Fetch a contact, then do nothing (integration fixture)",
+				Params: []spec.IntentParam{
+					{Name: "contact_id", Type: "string", Required: true, Description: "contact id"},
+				},
+				Steps: []spec.IntentStep{
+					{
+						Endpoint: "contacts.list",
+						Bind:     map[string]string{"limit": "${input.contact_id}"},
+						Capture:  "contacts",
+					},
+				},
+				Returns: "contacts",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	intentsPath := filepath.Join(outputDir, "internal", "mcp", "intents.go")
+	data, err := os.ReadFile(intentsPath)
+	require.NoError(t, err, "intents.go must be emitted when intents are declared")
+	body := string(data)
+
+	for _, want := range []string{
+		`func RegisterIntents(`,
+		`handleFetchContactThenNoop`,
+		`mcplib.NewTool("fetch_contact_then_noop"`,
+		`intentEndpoints = map[string]intentEndpointMeta{`,
+		`"contacts.list"`,
+		`func resolveIntentBinding(`,
+		`func callIntentEndpoint(`,
+	} {
+		assert.Contains(t, body, want, "intents.go missing expected snippet %q", want)
+	}
+
+	toolsPath := filepath.Join(outputDir, "internal", "mcp", "tools.go")
+	toolsData, err := os.ReadFile(toolsPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(toolsData), "RegisterIntents(s)",
+		"RegisterTools must wire in RegisterIntents when intents are declared")
+	assert.Contains(t, string(toolsData), `mcplib.NewTool("contacts_list"`,
+		"raw endpoint-mirror tools remain visible by default")
+
+	// End-to-end signal — the whole generated project must compile.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGenerateMCPEndpointToolsHiddenSuppressesEndpointTools proves that
+// endpoint_tools: hidden removes the raw per-endpoint MCP tools but keeps
+// the intent registration wired in. This is the surface agents see when the
+// intent declarations fully cover the useful operations.
+func TestGenerateMCPEndpointToolsHiddenSuppressesEndpointTools(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "loops.yaml"))
+	require.NoError(t, err)
+	apiSpec.MCP = spec.MCPConfig{
+		EndpointTools: "hidden",
+		Intents: []spec.Intent{
+			{
+				Name:        "noop_intent",
+				Description: "Fixture intent",
+				Steps: []spec.IntentStep{
+					{Endpoint: "contacts.list", Capture: "contacts"},
+				},
+				Returns: "contacts",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	toolsPath := filepath.Join(outputDir, "internal", "mcp", "tools.go")
+	data, err := os.ReadFile(toolsPath)
+	require.NoError(t, err)
+	body := string(data)
+
+	assert.NotContains(t, body, `mcplib.NewTool("contacts_list"`,
+		"raw endpoint tools must be hidden when endpoint_tools: hidden")
+	assert.Contains(t, body, "RegisterIntents(s)",
+		"intent registration must still be called when endpoint tools are hidden")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 // TestGenerateMCPMainRemoteRuntime is the runtime signal for U1. Building the
 // binary is necessary but not sufficient — we also want to catch the shape of
 // failures the build cannot see (e.g., a panic in defaultTransport or an
diff --git a/internal/generator/templates/mcp_intents.go.tmpl b/internal/generator/templates/mcp_intents.go.tmpl
new file mode 100644
index 00000000..20cfd111
--- /dev/null
+++ b/internal/generator/templates/mcp_intents.go.tmpl
@@ -0,0 +1,218 @@
+// 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 — intent handlers.
+//
+// Intents are higher-level MCP tools that compose multiple endpoint calls.
+// Rather than forcing an agent to stitch primitives like get_thread +
+// create_issue + link_attachment, a single create_issue_from_thread tool
+// accepts the top-level input, calls each endpoint in sequence, resolves
+// bindings between steps, and returns the final captured value.
+//
+// Each intent is emitted as a handler registered alongside (or in place of,
+// when MCP.EndpointTools == "hidden") the raw endpoint-mirror tools.
+
+package mcp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+	"{{modulePath}}/internal/client"
+)
+
+// RegisterIntents adds the spec-declared intent tools to the MCP server.
+// This is called from RegisterTools when MCP.Intents is non-empty.
+func RegisterIntents(s *server.MCPServer) {
+{{- range .MCP.Intents}}
+	s.AddTool(
+		mcplib.NewTool("{{.Name}}",
+			mcplib.WithDescription({{printf "%q" .Description}}),
+{{- range .Params}}
+{{- if eq .Type "integer"}}
+			mcplib.WithNumber("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" .Description}})),
+{{- else if eq .Type "boolean"}}
+			mcplib.WithBoolean("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" .Description}})),
+{{- else}}
+			mcplib.WithString("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" .Description}})),
+{{- end}}
+{{- end}}
+		),
+		handle{{pascal .Name}},
+	)
+{{- end}}
+}
+
+{{range .MCP.Intents}}
+// handle{{pascal .Name}} runs the {{.Name}} intent: {{.Description}}
+func handle{{pascal .Name}}(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	c, err := newMCPClient()
+	if err != nil {
+		return mcplib.NewToolResultError(err.Error()), nil
+	}
+
+	input := req.GetArguments()
+	scope := map[string]any{"input": input}
+
+{{range $i, $step := .Steps}}
+	// Step {{add $i 1}}: {{$step.Endpoint}}
+	{
+		params := map[string]any{}
+{{- range $paramName, $expr := $step.Bind}}
+		if v, ok := resolveIntentBinding(scope, {{printf "%q" $expr}}); ok {
+			params[{{printf "%q" $paramName}}] = v
+		}
+{{- end}}
+		resp, err := callIntentEndpoint(c, {{printf "%q" $step.Endpoint}}, params)
+		if err != nil {
+			return mcplib.NewToolResultError(fmt.Sprintf("step %d ({{$step.Endpoint}}) failed: %v", {{add $i 1}}, err)), nil
+		}
+{{- if $step.Capture}}
+		scope[{{printf "%q" $step.Capture}}] = resp
+{{- else}}
+		_ = resp
+{{- end}}
+	}
+{{end}}
+
+	returnKey := {{if .Returns}}{{printf "%q" .Returns}}{{else}}""{{end}}
+	if returnKey == "" {
+{{- $last := index .Steps (add (len .Steps) -1)}}
+{{- if $last.Capture}}
+		returnKey = {{printf "%q" $last.Capture}}
+{{- end}}
+	}
+
+	if returnKey == "" {
+		return mcplib.NewToolResultText(`{"status":"ok"}`), nil
+	}
+	out := scope[returnKey]
+	data, err := json.Marshal(out)
+	if err != nil {
+		return mcplib.NewToolResultError(fmt.Sprintf("encoding %s result: %v", returnKey, err)), nil
+	}
+	return mcplib.NewToolResultText(string(data)), nil
+}
+{{end}}
+
+{{- if .MCP.Intents}}
+// resolveIntentBinding evaluates a binding expression against the intent's
+// runtime scope. Supported shapes:
+//   - ${input.<field>...}   — walks the MCP request's input args
+//   - ${<capture>.<field>...} — walks a prior step's captured JSON response
+//
+// Anything else is treated as a string literal. The returned value is the
+// raw Go type (string / float64 / bool / map / slice) so the caller can
+// let json.Marshal produce the right JSON shape for the endpoint body.
+func resolveIntentBinding(scope map[string]any, expr string) (any, bool) {
+	if !strings.HasPrefix(expr, "${") || !strings.HasSuffix(expr, "}") {
+		return expr, true
+	}
+	path := strings.TrimSuffix(strings.TrimPrefix(expr, "${"), "}")
+	parts := strings.Split(path, ".")
+	if len(parts) < 2 {
+		return nil, false
+	}
+	cur, ok := scope[parts[0]]
+	if !ok {
+		return nil, false
+	}
+	for _, seg := range parts[1:] {
+		m, ok := cur.(map[string]any)
+		if !ok {
+			return nil, false
+		}
+		cur, ok = m[seg]
+		if !ok {
+			return nil, false
+		}
+	}
+	return cur, true
+}
+
+// callIntentEndpoint dispatches a single intent step against the shared HTTP
+// client. It uses the generator-emitted endpoint registry below to look up
+// the HTTP method, path template, and positional-param set so the intent
+// handler can stay generic across endpoints.
+func callIntentEndpoint(c *client.Client, ref string, params map[string]any) (any, error) {
+	ep, ok := intentEndpoints[ref]
+	if !ok {
+		return nil, fmt.Errorf("unknown endpoint %q", ref)
+	}
+
+	path := ep.path
+	query := map[string]string{}
+	for k, v := range params {
+		placeholder := "{" + k + "}"
+		if strings.Contains(path, placeholder) {
+			path = strings.ReplaceAll(path, placeholder, fmt.Sprintf("%v", v))
+			continue
+		}
+		query[k] = fmt.Sprintf("%v", v)
+	}
+
+	var data json.RawMessage
+	var err error
+	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 nil, fmt.Errorf("marshaling body: %w", mErr)
+		}
+		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 nil, fmt.Errorf("unsupported method %q", ep.method)
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	trimmed := strings.TrimSpace(string(data))
+	if trimmed == "" {
+		return map[string]any{}, nil
+	}
+	var out any
+	if err := json.Unmarshal(data, &out); err != nil {
+		// Non-JSON response is rare but allowed; surface the raw bytes so the
+		// intent can still echo them via Returns when useful.
+		return string(data), nil
+	}
+	return out, nil
+}
+
+// intentEndpointMeta captures the small slice of endpoint metadata the intent
+// dispatcher needs at runtime.
+type intentEndpointMeta struct {
+	method string
+	path   string
+}
+
+// intentEndpoints is the generator-emitted lookup table covering every
+// endpoint referenced by at least one intent step. Populating a full registry
+// for every endpoint would inflate binary size without value — intents that
+// reference a new endpoint must be declared in the spec, which keeps this
+// table in sync with actual usage.
+var intentEndpoints = map[string]intentEndpointMeta{
+{{- range .MCP.Intents}}
+{{- range .Steps}}
+{{- $ep := lookupEndpoint $.Resources .Endpoint}}
+	{{printf "%q" .Endpoint}}: {method: {{printf "%q" (upper $ep.Method)}}, path: {{printf "%q" $ep.Path}}},
+{{- end}}
+{{- end}}
+}
+{{- end}}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 16db7673..47b2936d 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -60,6 +60,7 @@ func sanitizeErrorBody(msg string) string {
 
 // RegisterTools registers all API operations as MCP tools.
 func RegisterTools(s *server.MCPServer) {
+{{- if ne .MCP.EndpointTools "hidden"}}
 {{- range $name, $resource := .Resources}}
 {{- range $eName, $endpoint := $resource.Endpoints}}
 	s.AddTool(
@@ -98,6 +99,11 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 {{- end}}
 {{- end}}
+{{- end}}
+{{- if .MCP.Intents}}
+	// Intent tools — higher-level compositions declared in the spec.
+	RegisterIntents(s)
+{{- end}}
 
 {{- if .VisionSet.Sync}}
 	// Sync tool — populates local database for offline search and sql queries
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 1fcf953c..4a56c5ef 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -153,8 +153,52 @@ type ShareConfig struct {
 // ["stdio"] for backward compatibility. 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.
-	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.
+	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.
+	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.
+}
+
+// Intent declares an MCP tool that composes multiple endpoint calls into a
+// single agent-facing operation. The generator emits one handler per intent
+// that resolves bindings, calls each endpoint in order against the CLI's
+// existing HTTP client, and returns the captured value named by Returns.
+//
+// Binding syntax — each value in a step's Bind map is a string expression:
+//   - `${input.<name>}`    resolves to the MCP request's input parameter
+//   - `${<capture>.<field>}` resolves to a field of a previous step's captured JSON response
+//   - anything else is used as a string literal
+//
+// Type coercion: all bound values are rendered as strings at runtime; JSON
+// bodies for POST/PUT/PATCH are built from the resolved map. The intent
+// surface intentionally does not support array indexing, conditional
+// branching, or looping in v1 — those escapes belong in U3's code-orchestration
+// pattern, not here.
+type Intent struct {
+	Name        string        `yaml:"name" json:"name"`                           // MCP tool name; snake_case, unique within the spec
+	Description string        `yaml:"description" json:"description"`             // agent-facing description; should name the *intent*, not the endpoints
+	Params      []IntentParam `yaml:"params,omitempty" json:"params,omitempty"`   // input parameters the intent tool exposes to MCP callers
+	Steps       []IntentStep  `yaml:"steps" json:"steps"`                         // ordered list of endpoint calls; at least one required
+	Returns     string        `yaml:"returns,omitempty" json:"returns,omitempty"` // capture name whose value is returned to the caller; defaults to the last step's capture when blank
+}
+
+// IntentParam mirrors a narrow slice of the endpoint Param type. Kept small by
+// design: intents are compositions, so parameter shapes should be simple
+// string/int/bool inputs that bind into step calls, not full nested bodies.
+type IntentParam struct {
+	Name        string `yaml:"name" json:"name"`
+	Type        string `yaml:"type" json:"type"` // one of: string, integer, boolean
+	Required    bool   `yaml:"required,omitempty" json:"required,omitempty"`
+	Description string `yaml:"description" json:"description"`
+}
+
+// IntentStep declares one endpoint call inside an intent. Endpoint references
+// are `resource.endpoint` or `resource.sub_resource.endpoint`; the validator
+// confirms the path resolves against APISpec.Resources at spec load.
+type IntentStep struct {
+	Endpoint string            `yaml:"endpoint" json:"endpoint"`                   // dotted path into the spec's resources, e.g., "messages.get_thread"
+	Bind     map[string]string `yaml:"bind,omitempty" json:"bind,omitempty"`       // map of endpoint param name -> binding expression
+	Capture  string            `yaml:"capture,omitempty" json:"capture,omitempty"` // name to bind this step's response under for subsequent steps / returns; must be unique within the intent
 }
 
 // EffectiveTransports returns the transports the generated binary should
@@ -411,7 +455,7 @@ func (s *APISpec) Validate() error {
 	if err := validateCacheShare(s.Cache, s.Share); err != nil {
 		return err
 	}
-	if err := validateMCP(s.MCP); err != nil {
+	if err := validateMCP(s.MCP, s.Resources); err != nil {
 		return err
 	}
 	for name, r := range s.Resources {
@@ -549,7 +593,7 @@ var addrLikeRe = regexp.MustCompile(`^[A-Za-z0-9.\-_]*:[0-9]+$`)
 // validateMCP enforces the Transport allowlist and normalizes the Addr shape.
 // An empty Transport is valid (default stdio); non-empty lists must contain
 // only entries from allowedMCPTransports, with no duplicates.
-func validateMCP(m MCPConfig) error {
+func validateMCP(m MCPConfig, resources map[string]Resource) error {
 	seen := make(map[string]struct{}, len(m.Transport))
 	for i, t := range m.Transport {
 		normalized := strings.ToLower(strings.TrimSpace(t))
@@ -572,9 +616,139 @@ func validateMCP(m MCPConfig) error {
 			return fmt.Errorf("mcp.addr %q is not a valid bind address (expect \":port\" or \"host:port\")", m.Addr)
 		}
 	}
+	if m.EndpointTools != "" && m.EndpointTools != "visible" && m.EndpointTools != "hidden" {
+		return fmt.Errorf("mcp.endpoint_tools: %q must be \"visible\" or \"hidden\"", m.EndpointTools)
+	}
+	return validateIntents(m.Intents, resources)
+}
+
+// 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_]*$`)
+
+// allowedIntentParamTypes matches the narrow type set IntentParam supports.
+// Intents compose endpoints; complex shapes belong in the endpoint bodies.
+var allowedIntentParamTypes = map[string]struct{}{
+	"string": {}, "integer": {}, "boolean": {},
+}
+
+// bindingExprRe matches either ${input.<name>} or ${<capture>.<field>} where
+// the path after the dot may contain additional dot-separated segments. The
+// generator's runtime resolver walks the path on a map[string]any, so deep
+// paths are supported even though the validator only peeks at the first
+// segment to verify the reference target exists.
+var bindingExprRe = regexp.MustCompile(`^\$\{([a-z][a-z0-9_]*)(\.[a-zA-Z0-9_]+)+\}$`)
+
+func validateIntents(intents []Intent, resources map[string]Resource) error {
+	seenNames := make(map[string]struct{}, len(intents))
+	for i, intent := range intents {
+		if intent.Name == "" {
+			return fmt.Errorf("mcp.intents[%d]: name is required", i)
+		}
+		if !intentNameRe.MatchString(intent.Name) {
+			return fmt.Errorf("mcp.intents[%d]: name %q must be snake_case (lowercase letters, digits, underscore)", i, intent.Name)
+		}
+		if _, dup := seenNames[intent.Name]; dup {
+			return fmt.Errorf("mcp.intents[%d]: name %q appears more than once", i, intent.Name)
+		}
+		seenNames[intent.Name] = struct{}{}
+		if intent.Description == "" {
+			return fmt.Errorf("mcp.intents[%d] (%s): description is required", i, intent.Name)
+		}
+		if len(intent.Steps) == 0 {
+			return fmt.Errorf("mcp.intents[%d] (%s): at least one step is required", i, intent.Name)
+		}
+		inputNames := make(map[string]struct{}, len(intent.Params))
+		for pi, p := range intent.Params {
+			if p.Name == "" {
+				return fmt.Errorf("mcp.intents[%d] (%s): params[%d].name is required", i, intent.Name, pi)
+			}
+			if _, ok := allowedIntentParamTypes[p.Type]; !ok {
+				return fmt.Errorf("mcp.intents[%d] (%s): params[%d] (%s): type %q must be one of string, integer, boolean", i, intent.Name, pi, p.Name, p.Type)
+			}
+			if _, dup := inputNames[p.Name]; dup {
+				return fmt.Errorf("mcp.intents[%d] (%s): params[%d]: name %q appears more than once", i, intent.Name, pi, p.Name)
+			}
+			inputNames[p.Name] = struct{}{}
+		}
+		captures := make(map[string]struct{}, len(intent.Steps))
+		for si, step := range intent.Steps {
+			if step.Endpoint == "" {
+				return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].endpoint is required", i, intent.Name, si)
+			}
+			if _, ok := lookupEndpoint(resources, step.Endpoint); !ok {
+				return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].endpoint %q does not resolve against the spec's resources", i, intent.Name, si, step.Endpoint)
+			}
+			for paramName, expr := range step.Bind {
+				if paramName == "" {
+					return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].bind: param name must not be empty", i, intent.Name, si)
+				}
+				if strings.HasPrefix(expr, "${") {
+					m := bindingExprRe.FindStringSubmatch(expr)
+					if m == nil {
+						return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].bind[%s]: %q is not a valid binding (expect ${input.<name>} or ${capture.<field>})", i, intent.Name, si, paramName, expr)
+					}
+					root := m[1]
+					if root == "input" {
+						fieldPath := strings.TrimPrefix(m[2], ".")
+						firstSeg := strings.SplitN(fieldPath, ".", 2)[0]
+						if _, ok := inputNames[firstSeg]; !ok {
+							return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].bind[%s]: %q references undeclared input %q", i, intent.Name, si, paramName, expr, firstSeg)
+						}
+					} else if _, ok := captures[root]; !ok {
+						return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].bind[%s]: %q references undeclared capture %q (captures must be defined in a prior step)", i, intent.Name, si, paramName, expr, root)
+					}
+				}
+			}
+			if step.Capture != "" {
+				if step.Capture == "input" {
+					return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].capture: %q is reserved for intent inputs", i, intent.Name, si, step.Capture)
+				}
+				if _, dup := captures[step.Capture]; dup {
+					return fmt.Errorf("mcp.intents[%d] (%s): steps[%d].capture %q appears more than once", i, intent.Name, si, step.Capture)
+				}
+				captures[step.Capture] = struct{}{}
+			}
+		}
+		if intent.Returns != "" {
+			if _, ok := captures[intent.Returns]; !ok {
+				return fmt.Errorf("mcp.intents[%d] (%s): returns %q does not match any step capture", i, intent.Name, intent.Returns)
+			}
+		}
+	}
 	return nil
 }
 
+// lookupEndpoint resolves a dotted endpoint reference (`resource.endpoint` or
+// `resource.sub_resource.endpoint`) against the spec's resource map. Returns
+// the endpoint and whether it was found. The generator uses the same lookup
+// to emit the right HTTP method and path at intent-handler emission time.
+func lookupEndpoint(resources map[string]Resource, ref string) (Endpoint, bool) {
+	parts := strings.Split(ref, ".")
+	switch len(parts) {
+	case 2:
+		r, ok := resources[parts[0]]
+		if !ok {
+			return Endpoint{}, false
+		}
+		e, ok := r.Endpoints[parts[1]]
+		return e, ok
+	case 3:
+		r, ok := resources[parts[0]]
+		if !ok {
+			return Endpoint{}, false
+		}
+		sub, ok := r.SubResources[parts[1]]
+		if !ok {
+			return Endpoint{}, false
+		}
+		e, ok := sub.Endpoints[parts[2]]
+		return e, ok
+	default:
+		return Endpoint{}, false
+	}
+}
+
 // CountMCPTools counts total endpoints and public (NoAuth) endpoints across
 // all resources and sub-resources.
 func (s *APISpec) CountMCPTools() (total, public int) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 89c1bd6e..55c406e1 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1065,6 +1065,170 @@ func TestMCPConfigValidation(t *testing.T) {
 	}
 }
 
+func TestMCPIntentsParse(t *testing.T) {
+	input := `
+name: demo
+base_url: http://x
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/demo/config.toml
+mcp:
+  endpoint_tools: hidden
+  intents:
+    - name: fetch_and_summarize
+      description: Fetch an item then summarize it
+      params:
+        - name: item_id
+          type: string
+          required: true
+          description: item identifier
+      steps:
+        - endpoint: items.get
+          bind:
+            id: ${input.item_id}
+          capture: item
+      returns: item
+resources:
+  items:
+    description: "Items"
+    endpoints:
+      get:
+        method: GET
+        path: /items/{id}
+        params:
+          - name: id
+            type: string
+            required: true
+            positional: true
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	require.NoError(t, s.Validate())
+	require.Len(t, s.MCP.Intents, 1)
+	assert.Equal(t, "fetch_and_summarize", s.MCP.Intents[0].Name)
+	assert.Equal(t, "hidden", s.MCP.EndpointTools)
+}
+
+func TestMCPIntentsValidation(t *testing.T) {
+	base := func(mcp MCPConfig) APISpec {
+		return APISpec{
+			Name:    "demo",
+			BaseURL: "http://x",
+			Resources: map[string]Resource{
+				"items": {
+					Endpoints: map[string]Endpoint{
+						"get":  {Method: "GET", Path: "/items/{id}"},
+						"list": {Method: "GET", Path: "/items"},
+					},
+				},
+			},
+			MCP: mcp,
+		}
+	}
+
+	ok := Intent{
+		Name:        "get_item",
+		Description: "Get an item",
+		Params:      []IntentParam{{Name: "id", Type: "string", Required: true, Description: "id"}},
+		Steps: []IntentStep{
+			{Endpoint: "items.get", Bind: map[string]string{"id": "${input.id}"}, Capture: "item"},
+		},
+		Returns: "item",
+	}
+
+	tests := []struct {
+		name    string
+		intents []Intent
+		tools   string
+		wantErr string
+	}{
+		{
+			name:    "unknown endpoint reference rejected",
+			intents: []Intent{{Name: "x", Description: "x", Steps: []IntentStep{{Endpoint: "items.delete"}}}},
+			wantErr: "does not resolve against the spec",
+		},
+		{
+			name: "undeclared input reference rejected",
+			intents: []Intent{{
+				Name:        "x",
+				Description: "x",
+				Steps:       []IntentStep{{Endpoint: "items.get", Bind: map[string]string{"id": "${input.missing}"}}},
+			}},
+			wantErr: "undeclared input",
+		},
+		{
+			name: "capture referenced before definition rejected",
+			intents: []Intent{{
+				Name:        "x",
+				Description: "x",
+				Steps: []IntentStep{
+					{Endpoint: "items.get", Bind: map[string]string{"id": "${first.id}"}, Capture: "second"},
+				},
+			}},
+			wantErr: "undeclared capture",
+		},
+		{
+			name: "duplicate intent name rejected",
+			intents: []Intent{
+				ok,
+				{Name: "get_item", Description: "dup", Steps: []IntentStep{{Endpoint: "items.get"}}},
+			},
+			wantErr: "appears more than once",
+		},
+		{
+			name: "bad intent param type rejected",
+			intents: []Intent{{
+				Name:        "x",
+				Description: "x",
+				Params:      []IntentParam{{Name: "id", Type: "object", Description: "bad"}},
+				Steps:       []IntentStep{{Endpoint: "items.get"}},
+			}},
+			wantErr: "must be one of string, integer, boolean",
+		},
+		{
+			name: "missing returns capture rejected",
+			intents: []Intent{{
+				Name:        "x",
+				Description: "x",
+				Steps:       []IntentStep{{Endpoint: "items.get", Capture: "item"}},
+				Returns:     "not_a_capture",
+			}},
+			wantErr: "returns \"not_a_capture\" does not match",
+		},
+		{
+			name: "malformed binding rejected",
+			intents: []Intent{{
+				Name:        "x",
+				Description: "x",
+				Params:      []IntentParam{{Name: "id", Type: "string", Description: "id"}},
+				Steps:       []IntentStep{{Endpoint: "items.get", Bind: map[string]string{"id": "${input}"}}},
+			}},
+			wantErr: "is not a valid binding",
+		},
+		{
+			name:    "bad endpoint_tools value rejected",
+			intents: []Intent{ok},
+			tools:   "maybe",
+			wantErr: "must be \"visible\" or \"hidden\"",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mcp := MCPConfig{Intents: tt.intents}
+			if tt.tools != "" {
+				mcp.EndpointTools = tt.tools
+			}
+			s := base(mcp)
+			err := s.Validate()
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
 func TestMCPConfigAcceptsValidShapes(t *testing.T) {
 	tests := []struct {
 		name string

← bce586bc feat(cli): add http streamable transport to generated MCP se  ·  back to Cli Printing Press  ·  feat(cli): code-orchestration thin surface for large-surface a4207be8 →