← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/mcp_code_orch.go.tmplM internal/generator/templates/mcp_intents.go.tmplM internal/generator/templates/mcp_tools.go.tmplM skills/printing-press/references/spec-format.md
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 →