Skip to content

TASK-05: Tool Naming, Conflict Detection, and InputSchema Derivation #8

Description

@gaarutyunov

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:

  1. Determine method-based verb prefix:
    • GET with no path params → list_
    • GET with path params → get_
    • POSTcreate_
    • PUTupdate_
    • DELETEdelete_
    • PATCHpatch_
  2. Strip the leading / from path
  3. If rules.ReplaceBraces: remove { and }
  4. If rules.ReplaceSlashes: replace / with _
  5. Replace any remaining non-alphanumeric, non-underscore characters with _
  6. If rules.Lowercase: convert to lowercase
  7. If rules.CollapseSeparators: replace runs of _+ with single _
  8. Trim leading/trailing _
  9. 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

  • AC-06.1: One tool generated per HTTP operation in the post-overlay spec
  • AC-06.2: x-mcp-enabled: false operations are skipped
  • AC-06.3: x-mcp-tool-name is used as base name when present
  • AC-06.4: Default slug rules applied correctly per the table in SPEC.md §8
  • AC-06.5: operationId used as base when x-mcp-tool-name is absent
  • AC-06.6: Prefix + separator prepended to every tool name
  • AC-06.7: Total name length does not exceed naming.max_length
  • AC-06.8: Descriptions truncated to description_max_length with description_truncation_suffix
  • AC-07.1–AC-07.4: Conflict detection works for all three resolution modes
  • AC-07.5: Shared tool_prefix across upstreams is always a fatal error
  • AC-09.1–AC-09.2: InputSchema includes all parameter types with constraints preserved
  • All unit tests in naming_test.go pass
  • All integration tests pass

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions