[object Object]

← back to Cli Printing Press

fix(cli): route resource base urls through mcp surfaces (#500)

2817bdc747b2655d34030bafa80a561866574ec1 · 2026-05-02 09:04:57 -0700 · Trevin Chow

* fix(cli): route resource base urls through mcp surfaces

* refactor(cli): simplify resource base url template helpers

Files touched

Diff

commit 2817bdc747b2655d34030bafa80a561866574ec1
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 2 09:04:57 2026 -0700

    fix(cli): route resource base urls through mcp surfaces (#500)
    
    * fix(cli): route resource base urls through mcp surfaces
    
    * refactor(cli): simplify resource base url template helpers
---
 internal/generator/generator.go                    | 57 +++++++++++++---
 internal/generator/generator_test.go               | 75 ++++++++++++++++++++++
 internal/generator/templates/mcp_code_orch.go.tmpl | 10 +--
 internal/generator/templates/mcp_intents.go.tmpl   |  2 +-
 internal/generator/templates/mcp_tools.go.tmpl     |  4 +-
 skills/printing-press/references/spec-format.md    | 12 ++--
 6 files changed, 140 insertions(+), 20 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 4832dcd5..13af54da 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -242,10 +242,12 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"humanName":   naming.HumanName,
 		"envPrefix":   naming.EnvPrefix,
 		"mcpToolName": naming.SnakeIdentifier,
-		"lookupEndpoint": func(resources map[string]spec.Resource, ref string) spec.Endpoint {
+		"lookupEndpoint": func(resources map[string]spec.Resource, ref string) templateEndpoint {
 			e, _ := lookupEndpointForTemplate(resources, ref)
 			return e
 		},
+		"effectiveEndpointPath":    effectiveEndpointPath,
+		"effectiveSubEndpointPath": effectiveSubEndpointPath,
 		"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
@@ -3191,32 +3193,69 @@ func graphqlFieldSelection(typeName string, types map[string]spec.TypeDef) []str
 	return fields
 }
 
+type templateEndpoint struct {
+	spec.Endpoint
+	EffectivePath string
+}
+
 // 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) {
+// step's HTTP method and effective path are known at generate time.
+func lookupEndpointForTemplate(resources map[string]spec.Resource, ref string) (templateEndpoint, bool) {
 	parts := strings.Split(ref, ".")
 	switch len(parts) {
 	case 2:
 		r, ok := resources[parts[0]]
 		if !ok {
-			return spec.Endpoint{}, false
+			return templateEndpoint{}, false
 		}
 		e, ok := r.Endpoints[parts[1]]
-		return e, ok
+		if !ok {
+			return templateEndpoint{}, false
+		}
+		return templateEndpoint{
+			Endpoint:      e,
+			EffectivePath: effectiveEndpointPath(r, e),
+		}, true
 	case 3:
 		r, ok := resources[parts[0]]
 		if !ok {
-			return spec.Endpoint{}, false
+			return templateEndpoint{}, false
 		}
 		sub, ok := r.SubResources[parts[1]]
 		if !ok {
-			return spec.Endpoint{}, false
+			return templateEndpoint{}, false
 		}
 		e, ok := sub.Endpoints[parts[2]]
-		return e, ok
+		if !ok {
+			return templateEndpoint{}, false
+		}
+		return templateEndpoint{
+			Endpoint:      e,
+			EffectivePath: effectiveSubEndpointPath(r, sub, e),
+		}, true
 	default:
-		return spec.Endpoint{}, false
+		return templateEndpoint{}, false
+	}
+}
+
+func effectiveEndpointPath(resource spec.Resource, endpoint spec.Endpoint) string {
+	return endpointPathWithBase(resource.BaseURL, endpoint.Path)
+}
+
+func effectiveSubEndpointPath(parent spec.Resource, sub spec.Resource, endpoint spec.Endpoint) string {
+	baseURL := sub.BaseURL
+	if baseURL == "" {
+		baseURL = parent.BaseURL
+	}
+	return endpointPathWithBase(baseURL, endpoint.Path)
+}
+
+func endpointPathWithBase(baseURL, path string) string {
+	baseURL = strings.TrimRight(baseURL, "/")
+	if baseURL == "" {
+		return path
 	}
+	return baseURL + path
 }
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index fd2b6fbe..1f95a704 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4972,6 +4972,18 @@ func TestGenerateResourceBaseURLOverrideRoutesToOverrideHost(t *testing.T) {
 	assert.Contains(t, string(clientGo), "if isAbsoluteURL(path) {",
 		"client.do() must branch on isAbsoluteURL")
 
+	// Typed MCP endpoint tools must use the same effective path as the CLI
+	// command. The cobratree walker skips pp:endpoint commands, so a raw
+	// endpoint-mirror tool with "/search" would route agents to the spec-level
+	// BaseURL instead of the resource override host.
+	mcpTools, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+	require.NoError(t, err)
+	mcpToolsStr := string(mcpTools)
+	assert.Contains(t, mcpToolsStr, `makeAPIHandler("GET", "https://geocoding-api.example.com/v1/search"`,
+		"geocoding MCP endpoint mirror must emit the absolute override URL")
+	assert.Contains(t, mcpToolsStr, `makeAPIHandler("GET", "/forecast"`,
+		"forecast MCP endpoint mirror must keep the relative path when its resource has no override")
+
 	// Must compile.
 	runGoCommand(t, outputDir, "mod", "tidy")
 	runGoCommand(t, outputDir, "build", "./...")
@@ -5128,6 +5140,69 @@ func TestGenerateResourceBaseURLWithEndpointTemplateVars(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+// TestGenerateResourceBaseURLOverrideRoutesAgentDispatchSurfaces covers MCP
+// surfaces that execute through generated registries instead of Cobra
+// commands. The CLI command templates already prepend Resource.BaseURL; these
+// registries must do the same or agents route overridden resources to the
+// wrong host.
+func TestGenerateResourceBaseURLOverrideRoutesAgentDispatchSurfaces(t *testing.T) {
+	t.Parallel()
+	apiSpec := minimalSpec("multihost-agent")
+	apiSpec.BaseURL = "https://api.example.com/v1"
+	apiSpec.Resources["geocoding"] = spec.Resource{
+		Description: "Geocoding lookup",
+		BaseURL:     "https://geocoding-api.example.com/v1",
+		Endpoints: map[string]spec.Endpoint{
+			"search": {
+				Method:      "GET",
+				Path:        "/search",
+				Description: "Search",
+				Params: []spec.Param{
+					{Name: "q", Type: "string", Required: true, Description: "query"},
+				},
+			},
+			"reverse": {Method: "GET", Path: "/reverse", Description: "Reverse"},
+		},
+	}
+	apiSpec.MCP = spec.MCPConfig{
+		Orchestration: "code",
+		Intents: []spec.Intent{
+			{
+				Name:        "geocode",
+				Description: "Geocode a query",
+				Params: []spec.IntentParam{
+					{Name: "q", Type: "string", Required: true, Description: "query"},
+				},
+				Steps: []spec.IntentStep{
+					{
+						Endpoint: "geocoding.search",
+						Bind:     map[string]string{"q": "${input.q}"},
+						Capture:  "results",
+					},
+				},
+				Returns: "results",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	codeOrch, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "code_orch.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(codeOrch), `Path:    "https://geocoding-api.example.com/v1/search"`,
+		"code-orchestration execute must use the effective resource override URL")
+
+	intents, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "intents.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(intents), `"geocoding.search": {method: "GET", path: "https://geocoding-api.example.com/v1/search"}`,
+		"intent dispatch must use the effective resource override URL")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	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
diff --git a/internal/generator/templates/mcp_code_orch.go.tmpl b/internal/generator/templates/mcp_code_orch.go.tmpl
index a879ac1f..59ea86df 100644
--- a/internal/generator/templates/mcp_code_orch.go.tmpl
+++ b/internal/generator/templates/mcp_code_orch.go.tmpl
@@ -67,24 +67,26 @@ type codeOrchEndpoint struct {
 var codeOrchEndpoints = []codeOrchEndpoint{
 {{- range $name, $resource := .Resources}}
 {{- range $eName, $endpoint := $resource.Endpoints}}
+{{- $path := effectiveEndpointPath $resource $endpoint}}
 	{
 		ID:      "{{$name}}.{{$eName}}",
 		Method:  "{{upper $endpoint.Method}}",
-		Path:    "{{$endpoint.Path}}",
+		Path:    "{{$path}}",
 		Summary: {{printf "%q" (oneline $endpoint.Description)}},
 		Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
-		keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $endpoint.Path}}),
+		keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
 	},
 {{- end}}
 {{- range $subName, $subResource := $resource.SubResources}}
 {{- range $eName, $endpoint := $subResource.Endpoints}}
+{{- $path := effectiveSubEndpointPath $resource $subResource $endpoint}}
 	{
 		ID:      "{{$name}}.{{$subName}}.{{$eName}}",
 		Method:  "{{upper $endpoint.Method}}",
-		Path:    "{{$endpoint.Path}}",
+		Path:    "{{$path}}",
 		Summary: {{printf "%q" (oneline $endpoint.Description)}},
 		Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
-		keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $endpoint.Path}}),
+		keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
 	},
 {{- end}}
 {{- end}}
diff --git a/internal/generator/templates/mcp_intents.go.tmpl b/internal/generator/templates/mcp_intents.go.tmpl
index 20cfd111..ff88ce53 100644
--- a/internal/generator/templates/mcp_intents.go.tmpl
+++ b/internal/generator/templates/mcp_intents.go.tmpl
@@ -211,7 +211,7 @@ 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}}},
+	{{printf "%q" .Endpoint}}: {method: {{printf "%q" (upper $ep.Method)}}, path: {{printf "%q" $ep.EffectivePath}}},
 {{- end}}
 {{- end}}
 }
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 48ebe917..06ddfb62 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -62,7 +62,7 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithOpenWorldHintAnnotation(true),
 {{- end}}
 		),
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 	)
 {{- end}}
 {{- range $subName, $subResource := $resource.SubResources}}
@@ -93,7 +93,7 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithOpenWorldHintAnnotation(true),
 {{- end}}
 		),
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 	)
 {{- end}}
 {{- end}}
diff --git a/skills/printing-press/references/spec-format.md b/skills/printing-press/references/spec-format.md
index 1a5ef042..13de2cf8 100644
--- a/skills/printing-press/references/spec-format.md
+++ b/skills/printing-press/references/spec-format.md
@@ -9,7 +9,7 @@ Use this format when no OpenAPI spec is available.
 name: my-api                     # string (REQUIRED) CLI binary prefix, e.g. "my-api"
 description: "My API CLI"       # string shown in command help
 version: "0.1.0"                # string baked into generated binary
-base_url: "https://api.example.com/v1" # string (REQUIRED) base API URL
+base_url: "https://api.example.com/v1" # string (REQUIRED) default base API URL
 
 auth:                             # object (AuthConfig)
   type: api_key                   # string: api_key | oauth2 | bearer_token | none
@@ -27,6 +27,7 @@ config:                           # object (ConfigSpec)
 resources:                        # map[string]Resource (REQUIRED: at least one key)
   users:                          # resource key becomes top-level command: <name>-cli users
     description: "Manage users"  # string resource help text
+    base_url: "https://directory.example.com/v1" # string optional override for this resource and inherited sub-resources
     endpoints:                    # map[string]Endpoint (REQUIRED: at least one key)
       list:                       # endpoint key becomes subcommand: users list
         method: GET               # string (REQUIRED) must be one of GET | POST | PUT | DELETE
@@ -74,7 +75,7 @@ types:                            # map[string]TypeDef named response/body model
 name: stytch # CLI binary prefix => stytch-cli
 description: "Stytch authentication API CLI" # Root help text and README summary
 version: "0.1.0" # Printed by `stytch-cli version`
-base_url: "https://api.stytch.com/v1" # Base URL all endpoint paths are joined against
+base_url: "https://api.stytch.com/v1" # Default base URL endpoint paths are joined against
 
 auth:
   type: api_key # Uses API key style auth
@@ -208,21 +209,24 @@ types:
 Validation in `spec.Validate()` enforces:
 
 - `name` is required
-- `base_url` is required
+- root `base_url` is required unless `base_path` is supplied
 - at least one `resources` entry is required
 - every resource must have at least one endpoint
 - every endpoint must have both `method` and `path`
+- `resources.<name>.base_url` is allowed for resources that live on another host; sub-resources inherit the parent override unless they set their own `base_url`
+- resource `base_url` overrides cannot be combined with `client_pattern: proxy-envelope`, because proxy-envelope clients POST every request to the root `base_url`
 
 ## 4. Common Mistakes
 
 These commonly cause generation/build failures or incorrect CLI behavior:
 
-- Missing required fields (`name`, `base_url`, resource endpoints, endpoint `method`, endpoint `path`)
+- Missing required fields (`name`, root `base_url`, resource endpoints, endpoint `method`, endpoint `path`)
 - Invalid `method` values (generator templates only handle `GET`, `POST`, `PUT`, `DELETE`)
 - Missing `path` on endpoints
 - Defining `body` params on `GET` endpoints (allowed in YAML, but ignored by GET command generation)
 - Forgetting `positional: true` for params used in `/{path_placeholders}`
 - Using parameter types outside supported scalar set: `string`, `int`, `bool`, `float`
+- Putting a website/feed host in the root `base_url` just to make one resource work; keep the root API host as the default and use a resource `base_url` override for the outlier surface
 
 ## 5. Type Mapping
 

← ec74c7a9 chore(main): release 3.4.3 (#499)  ·  back to Cli Printing Press  ·  fix(cli): strip build/ from publish staging (#502) 263be3b3 →