Skip to content

TASK-07: Runtime OpenAPI Request and Response Validation #10

Description

@gaarutyunov

TASK-07: Runtime OpenAPI Request and Response Validation

Context

Implement runtime validation of upstream HTTP requests and responses against the OpenAPI schema using openapi3filter. This runs on every tool call, catching invalid inputs (from MCP clients) and unexpected upstream responses before they propagate. Operators can configure whether response validation failures are fatal errors or logged warnings.

No stubs.

Active development: No backward compatibility.

Prerequisites

  • TASK-06 (full transform engine, compiled transforms per tool)

Spec References

  • SPEC.md §10 JSON Transformation Engine (runtime section)
  • SPEC.md AC-24 (runtime request validation)
  • SPEC.md AC-25 (runtime response validation)

Config Changes

Add to UpstreamConfig:

type ValidationConfig struct {
    ValidateRequest           bool     `koanf:"validate_request"`            // default: true
    ValidateResponse          bool     `koanf:"validate_response"`           // default: true
    ResponseValidationFailure string   `koanf:"response_validation_failure"` // "warn"|"fail", default: "warn"
    SuccessStatus             []int    `koanf:"success_status"`              // default: [200,201,202,204]
    ErrorStatus               []int    `koanf:"error_status"`                // default: [400,401,403,404,422,429,500,502,503]
}

Add Validation ValidationConfig to UpstreamConfig.

Apply defaults in config.Load(): ValidateRequest=true, ValidateResponse=true, ResponseValidationFailure="warn", SuccessStatus=[200,201,202,204], ErrorStatus=[400,401,403,404,422,429,500,502,503].

Request Validation

Implement internal/openapi/validator.go:

// Validator holds a pre-built kin-openapi router for a single upstream spec.
type Validator struct {
    router routers.Router
    doc    *openapi3.T
}

func NewValidator(doc *openapi3.T, router routers.Router) *Validator

// ValidateRequest validates an outbound HTTP request against the OpenAPI spec.
// It uses NoopAuthenticationFunc so upstream auth schemes are not re-validated.
func (v *Validator) ValidateRequest(ctx context.Context, r *http.Request) error

// ValidateResponse validates an upstream HTTP response against the OpenAPI spec.
// The reqInput must be the RequestValidationInput from a prior ValidateRequest call.
func (v *Validator) ValidateResponse(ctx context.Context, reqInput *openapi3filter.RequestValidationInput, resp *http.Response, body []byte) error

ValidateRequest implementation:

route, pathParams, err := v.router.FindRoute(r)
if err != nil { return fmt.Errorf("route not found: %w", err) }

input := &openapi3filter.RequestValidationInput{
    Request:    r,
    PathParams: pathParams,
    Route:      route,
    Options: &openapi3filter.Options{
        AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
        MultiError:         true,
    },
}
return openapi3filter.ValidateRequest(ctx, input)

ValidateResponse implementation:

input := &openapi3filter.ResponseValidationInput{
    RequestValidationInput: reqInput,
    Status:                 resp.StatusCode,
    Header:                 resp.Header,
    Body:                   io.NopCloser(bytes.NewReader(body)),
    Options:                &openapi3filter.Options{MultiError: true},
}
return openapi3filter.ValidateResponse(ctx, input)

Integration into Tool Handler

Update internal/mcp/handler.go to integrate validation into the request lifecycle per SPEC.md §17.

After transform.RunRequest and before the upstream HTTP call:

  1. Build the *http.Request from the RequestEnvelope
  2. If validation.ValidateRequest == true, call v.ValidateRequest(ctx, req)
  3. If validation fails, return CallToolResult{IsError: true} with the validation error — do not call the upstream

After receiving the upstream response body:

  1. Determine if status is in success_status or error_status
  2. If in neither: return CallToolResult{IsError: true} with "unexpected HTTP {status}" — do not validate
  3. If in success_status and validation.ValidateResponse == true:
    • Call v.ValidateResponse(ctx, reqInput, resp, body)
    • If error and ResponseValidationFailure == "fail": return CallToolResult{IsError: true}
    • If error and ResponseValidationFailure == "warn": log slog.Warn("response validation failed", ...) and continue

Unit Tests

Create internal/openapi/validator_test.go (no build tag):

These tests use httptest to create a synthetic request/response — no containers needed.

  • Valid request passes validation
  • Request with missing required path param fails validation
  • Response matching the schema passes validation
  • Response with wrong type field fails validation with "fail" mode
  • Response with wrong type field logs warning with "warn" mode
  • Unrecognised HTTP status (not in success or error list) → IsError: true

Integration Test

Create tests/integration/runtime_validation_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: TestRequestValidationBlocksInvalidArgs

  • Write a spec declaring GET /pets/{petId} where petId is a required path parameter
  • Start WireMock as the upstream (register a stub for GET /pets/{petId})
  • Start the proxy container with the spec and config mounted, on a shared network with WireMock
  • Connect MCP client to the proxy
  • Call tools/call get_pet with arguments that omit the required petId
  • Assert:
    • The tool returns IsError: true
    • The error message mentions the missing required parameter
    • WireMock's request journal (/__admin/requests) shows zero requests (validation blocked the call before it hit the upstream)

Test: TestResponseValidationWarnMode

  • Write a spec declaring GET /pets with a 200 response schema: {pets: [{id: integer, name: string}]}
  • Configure the upstream with response_validation_failure: warn
  • Start WireMock returning {"pets": [{"id": "not-an-integer", "name": "Fido"}]} (wrong type for id)
  • Start the proxy container on a shared network with WireMock
  • Connect MCP client, call tools/call list_pets
  • Assert:
    • Tool call returns a successful result (not IsError)
    • The response data is returned to the client despite the schema mismatch

Test: TestResponseValidationFailMode

  • Same setup as TestResponseValidationWarnMode, but with response_validation_failure: fail
  • Connect MCP client, call tools/call list_pets
  • Assert:
    • Tool call returns IsError: true
    • Error message mentions the validation failure

Test: TestUnexpectedStatusReturnsError

  • Start WireMock returning HTTP 418 for GET /pets
  • Start the proxy container on a shared network with WireMock
  • Connect MCP client, call tools/call list_pets
  • Assert:
    • Tool call returns IsError: true
    • Error message contains "unexpected HTTP 418" or similar

Checks Before Committing

make check
make integration

Acceptance Criteria

  • AC-24.1: ValidateRequest called before every upstream HTTP request when validate_request: true
  • AC-24.2: Request validation failure returns IsError: true without calling upstream
  • AC-24.3: NoopAuthenticationFunc used in request validation options
  • AC-25.1: ValidateResponse called on success-path responses when validate_response: true
  • AC-25.2: Response validation failure returns IsError: true when mode is fail
  • AC-25.3: Response validation failure logs WARN and continues when mode is warn
  • AC-22.2: Upstream status not in success or error list returns IsError: true without validation
  • All unit tests pass
  • All integration tests pass

Definition of Done

make check exits 0. Integration tests pass. Invalid MCP inputs are rejected before hitting the upstream, and schema-violating responses are handled per configuration.

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