TASK-05: Tool Naming, Conflict Detection, and InputSchema Derivation
Context
Implement the complete tool naming and InputSchema derivation logic from SPEC.md §8. This replaces the basic slugification from TASK-03 with the full rules: method-based verb prefixes, operationId support, x-mcp-tool-name override, description truncation, conflict detection across upstreams, and a complete InputSchema that faithfully reflects the OpenAPI parameter constraints.
No stubs. Every function must be fully implemented.
Active development: The GenerateTools function signature and GeneratedTool struct from TASK-03 may change. No backward compatibility.
Prerequisites
- TASK-04 (overlay pipeline,
x-mcp-enabled filtering, full spec loading)
Spec References
SPEC.md §8 Automatic Tool Generation (full section)
SPEC.md §9 Multi-Upstream Routing (conflict detection)
SPEC.md AC-06 (tool naming)
SPEC.md AC-07 (conflict detection)
SPEC.md AC-08 (auto request jq — InputSchema part only; jq generation is TASK-06)
SPEC.md AC-09 (InputSchema)
Config Changes
Add to NamingConfig:
type NamingConfig struct {
Separator string `koanf:"separator"`
MaxLength int `koanf:"max_length"`
ConflictResolution string `koanf:"conflict_resolution"` // error|first_wins|skip
DescriptionMaxLength int `koanf:"description_max_length"`
DescriptionTruncationSuffix string `koanf:"description_truncation_suffix"`
DefaultSlugRules SlugRulesConfig `koanf:"default_slug_rules"`
}
type SlugRulesConfig struct {
ReplaceSlashes bool `koanf:"replace_slashes"`
ReplaceBraces bool `koanf:"replace_braces"`
Lowercase bool `koanf:"lowercase"`
CollapseSeparators bool `koanf:"collapse_separators"`
}
Defaults: max_length=128, conflict_resolution=error, description_max_length=1024, description_truncation_suffix="...", all slug rules true.
Naming Implementation
Implement internal/openapi/naming.go. This file must contain all naming logic and must be fully unit-tested.
Slugify(method, path string, hasPathParams bool, rules SlugRulesConfig) string
Implements the full rules from SPEC.md §8:
- Determine method-based verb prefix:
GET with no path params → list_
GET with path params → get_
POST → create_
PUT → update_
DELETE → delete_
PATCH → patch_
- Strip the leading
/ from path
- If
rules.ReplaceBraces: remove { and }
- If
rules.ReplaceSlashes: replace / with _
- Replace any remaining non-alphanumeric, non-underscore characters with
_
- If
rules.Lowercase: convert to lowercase
- If
rules.CollapseSeparators: replace runs of _+ with single _
- Trim leading/trailing
_
- Prepend the verb prefix
ToolBaseName(op *openapi3.Operation, method, path string, hasPathParams bool, rules SlugRulesConfig) string
Returns the base name (without prefix) for a tool:
- If
op.Extensions["x-mcp-tool-name"] is set, use that value (extract the string from the yaml.Node or json.RawMessage extension)
- Else if
op.OperationID is non-empty, apply the same sanitisation rules from Slugify to it (without adding the method prefix)
- Else call
Slugify(method, path, hasPathParams, rules)
PrefixedName(baseName, prefix, separator string, maxLength int) string
Returns {prefix}{separator}{baseName}. Truncates baseName so that len(prefixedName) <= maxLength. Never truncates the prefix or separator.
TruncateDescription(desc string, maxLength int, suffix string) string
If maxLength == 0 or len(desc) <= maxLength, return desc. Otherwise return desc[:maxLength-len(suffix)] + suffix.
DetectConflicts(tools []PrefixedTool, resolution string) ([]PrefixedTool, error)
Where PrefixedTool is {PrefixedName string, OriginalPath string, OriginalMethod string}.
Implement all three conflict_resolution modes:
error: return an error listing all conflicting pairs
first_wins: keep first, drop duplicates, log each dropped tool via slog.Warn
skip: drop all tools involved in a conflict, log each via slog.Warn
Two upstreams sharing the same tool_prefix is always an error, regardless of conflict_resolution. Check for this before checking tool-level conflicts.
InputSchema Derivation
Implement internal/openapi/inputschema.go:
// DeriveInputSchema builds the MCP tool InputSchema from an OpenAPI operation.
// It includes path params, query params, header params, and request body properties.
func DeriveInputSchema(op *openapi3.Operation) *jsonschema.Schema
Where jsonschema.Schema is from the MCP SDK's schema type (or a plain map[string]any if the SDK uses that).
Rules:
- Path parameters → required fields; type and format from OpenAPI schema
- Query parameters → required only if
param.Required == true; optional otherwise
- Request body (
application/json) → merge all properties into top-level; mark required from the body schema's required list
- Header parameters → optional fields
- Preserve:
type, format, minimum, maximum, minLength, maxLength, enum, pattern, description from each parameter's schema
Unit Tests
Create internal/openapi/naming_test.go (no build tag — these are pure unit tests):
Test cases for Slugify:
| Method |
Path |
HasPathParams |
Expected base |
| GET |
/pets |
false |
list_pets |
| GET |
/pets/{petId} |
true |
get_pets_petId |
| POST |
/pets |
false |
create_pets |
| PUT |
/pets/{petId} |
true |
update_pets_petId |
| DELETE |
/pets/{petId} |
true |
delete_pets_petId |
| PATCH |
/pets/{petId} |
true |
patch_pets_petId |
| GET |
/stores/{storeId}/items |
true |
get_stores_storeId_items |
| GET |
/v2/users.list |
false |
list_v2_users_list |
Test DetectConflicts with all three modes and with the shared-prefix error case.
Test TruncateDescription edge cases: empty string, exactly at limit, one over limit, maxLength=0.
Integration Test
Create tests/integration/naming_test.go with build tag //go:build integration and package integration_test.
Tests do NOT import internal packages. The proxy runs as a Docker container (built from Dockerfile or pulled via PROXY_IMAGE env var). Use proxyContainerRequest() for the base container request. Test fixtures (WireMock) run as sibling containers on a shared Docker network. Assertions go through MCP protocol (tools/list, tools/call) and HTTP endpoints (/healthz, /readyz). Config and spec files are mounted into the proxy container via testcontainers.ContainerFile. See tests/integration/mvp_test.go for the full setup pattern.
Test: TestToolNamingEndToEnd
Spec has these operations:
GET /pets (no operationId, no x-mcp-tool-name)
GET /pets/{petId} (operationId: fetchPet)
POST /orders (x-mcp-tool-name set via overlay to place_order)
DELETE /orders/{orderId} (x-mcp-enabled: false via overlay)
Overlay (applied before tool generation):
- Sets
x-mcp-tool-name: place_order on POST /orders
- Sets
x-mcp-enabled: false on DELETE /orders/{orderId}
Setup:
- Write spec and overlay files, mount them into the proxy container
- Start WireMock as the upstream (register stubs for GET /pets, GET /pets/{petId}, POST /orders)
- Start the proxy container with config referencing the mounted spec, overlay, and WireMock as
base_url
- Connect MCP client to the proxy and call
tools/list
Expected tools from upstream with prefix shop:
shop__list_pets (GET /pets, default slug)
shop__fetchPet (GET /pets/{petId}, from operationId after sanitisation)
shop__place_order (POST /orders, from x-mcp-tool-name)
- No tool for DELETE (x-mcp-enabled: false)
Assert exactly these three tool names are returned.
Test: TestConflictDetectionFails
- Write a config with two upstreams sharing the same
tool_prefix
- Mount the config and spec files into the proxy container
- Start the proxy container -- assert it fails to start (container health check times out or container exits with non-zero code)
Test: TestInputSchemaPreservesConstraints
Spec has:
parameters:
- name: status
in: query
schema:
type: string
enum: [available, pending, sold]
description: "Filter by status"
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
Setup:
- Write spec file, mount into proxy container
- Start WireMock as the upstream
- Start proxy container, connect MCP client, call
tools/list
Assert that tools/list returns a tool whose InputSchema includes:
status field with enum: [available, pending, sold] and description
limit field with type: integer, minimum: 1, maximum: 100
Checks Before Committing
make check
make integration
Acceptance Criteria
Definition of Done
make check exits 0. Integration tests pass in CI. The proxy started via cmd/proxy/main.go exposes correctly named tools with complete InputSchemas when pointed at a real OpenAPI spec.
TASK-05: Tool Naming, Conflict Detection, and InputSchema Derivation
Context
Implement the complete tool naming and InputSchema derivation logic from SPEC.md §8. This replaces the basic slugification from TASK-03 with the full rules: method-based verb prefixes,
operationIdsupport,x-mcp-tool-nameoverride, description truncation, conflict detection across upstreams, and a completeInputSchemathat faithfully reflects the OpenAPI parameter constraints.No stubs. Every function must be fully implemented.
Active development: The
GenerateToolsfunction signature andGeneratedToolstruct from TASK-03 may change. No backward compatibility.Prerequisites
x-mcp-enabledfiltering, full spec loading)Spec References
SPEC.md§8 Automatic Tool Generation (full section)SPEC.md§9 Multi-Upstream Routing (conflict detection)SPEC.mdAC-06 (tool naming)SPEC.mdAC-07 (conflict detection)SPEC.mdAC-08 (auto request jq — InputSchema part only; jq generation is TASK-06)SPEC.mdAC-09 (InputSchema)Config Changes
Add to
NamingConfig:Defaults:
max_length=128,conflict_resolution=error,description_max_length=1024,description_truncation_suffix="...", all slug rulestrue.Naming Implementation
Implement
internal/openapi/naming.go. This file must contain all naming logic and must be fully unit-tested.Slugify(method, path string, hasPathParams bool, rules SlugRulesConfig) stringImplements the full rules from SPEC.md §8:
GETwith no path params →list_GETwith path params →get_POST→create_PUT→update_DELETE→delete_PATCH→patch_/from pathrules.ReplaceBraces: remove{and}rules.ReplaceSlashes: replace/with__rules.Lowercase: convert to lowercaserules.CollapseSeparators: replace runs of_+with single__ToolBaseName(op *openapi3.Operation, method, path string, hasPathParams bool, rules SlugRulesConfig) stringReturns the base name (without prefix) for a tool:
op.Extensions["x-mcp-tool-name"]is set, use that value (extract the string from theyaml.Nodeorjson.RawMessageextension)op.OperationIDis non-empty, apply the same sanitisation rules fromSlugifyto it (without adding the method prefix)Slugify(method, path, hasPathParams, rules)PrefixedName(baseName, prefix, separator string, maxLength int) stringReturns
{prefix}{separator}{baseName}. TruncatesbaseNameso thatlen(prefixedName) <= maxLength. Never truncates the prefix or separator.TruncateDescription(desc string, maxLength int, suffix string) stringIf
maxLength == 0orlen(desc) <= maxLength, returndesc. Otherwise returndesc[:maxLength-len(suffix)] + suffix.DetectConflicts(tools []PrefixedTool, resolution string) ([]PrefixedTool, error)Where
PrefixedToolis{PrefixedName string, OriginalPath string, OriginalMethod string}.Implement all three
conflict_resolutionmodes:error: return an error listing all conflicting pairsfirst_wins: keep first, drop duplicates, log each dropped tool viaslog.Warnskip: drop all tools involved in a conflict, log each viaslog.WarnTwo upstreams sharing the same
tool_prefixis always an error, regardless ofconflict_resolution. Check for this before checking tool-level conflicts.InputSchema Derivation
Implement
internal/openapi/inputschema.go:Where
jsonschema.Schemais from the MCP SDK's schema type (or a plainmap[string]anyif the SDK uses that).Rules:
param.Required == true; optional otherwiseapplication/json) → merge all properties into top-level; markrequiredfrom the body schema'srequiredlisttype,format,minimum,maximum,minLength,maxLength,enum,pattern,descriptionfrom each parameter's schemaUnit Tests
Create
internal/openapi/naming_test.go(no build tag — these are pure unit tests):Test cases for
Slugify:Test
DetectConflictswith all three modes and with the shared-prefix error case.Test
TruncateDescriptionedge cases: empty string, exactly at limit, one over limit,maxLength=0.Integration Test
Create
tests/integration/naming_test.gowith build tag//go:build integrationand packageintegration_test.Tests do NOT import internal packages. The proxy runs as a Docker container (built from Dockerfile or pulled via
PROXY_IMAGEenv var). UseproxyContainerRequest()for the base container request. Test fixtures (WireMock) run as sibling containers on a shared Docker network. Assertions go through MCP protocol (tools/list,tools/call) and HTTP endpoints (/healthz,/readyz). Config and spec files are mounted into the proxy container viatestcontainers.ContainerFile. Seetests/integration/mvp_test.gofor the full setup pattern.Test:
TestToolNamingEndToEndSpec has these operations:
GET /pets(no operationId, no x-mcp-tool-name)GET /pets/{petId}(operationId:fetchPet)POST /orders(x-mcp-tool-name set via overlay toplace_order)DELETE /orders/{orderId}(x-mcp-enabled: false via overlay)Overlay (applied before tool generation):
x-mcp-tool-name: place_orderonPOST /ordersx-mcp-enabled: falseonDELETE /orders/{orderId}Setup:
base_urltools/listExpected tools from upstream with prefix
shop:shop__list_pets(GET /pets, default slug)shop__fetchPet(GET /pets/{petId}, from operationId after sanitisation)shop__place_order(POST /orders, from x-mcp-tool-name)Assert exactly these three tool names are returned.
Test:
TestConflictDetectionFailstool_prefixTest:
TestInputSchemaPreservesConstraintsSpec has:
Setup:
tools/listAssert that
tools/listreturns a tool whose InputSchema includes:statusfield withenum: [available, pending, sold]anddescriptionlimitfield withtype: integer,minimum: 1,maximum: 100Checks Before Committing
Acceptance Criteria
x-mcp-enabled: falseoperations are skippedx-mcp-tool-nameis used as base name when presentoperationIdused as base whenx-mcp-tool-nameis absentnaming.max_lengthdescription_max_lengthwithdescription_truncation_suffixtool_prefixacross upstreams is always a fatal errornaming_test.gopassDefinition of Done
make checkexits 0. Integration tests pass in CI. The proxy started viacmd/proxy/main.goexposes correctly named tools with complete InputSchemas when pointed at a real OpenAPI spec.