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:
- Build the
*http.Request from the RequestEnvelope
- If
validation.ValidateRequest == true, call v.ValidateRequest(ctx, req)
- If validation fails, return
CallToolResult{IsError: true} with the validation error — do not call the upstream
After receiving the upstream response body:
- Determine if status is in
success_status or error_status
- If in neither: return
CallToolResult{IsError: true} with "unexpected HTTP {status}" — do not validate
- 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
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.
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
Spec References
SPEC.md§10 JSON Transformation Engine (runtime section)SPEC.mdAC-24 (runtime request validation)SPEC.mdAC-25 (runtime response validation)Config Changes
Add to
UpstreamConfig:Add
Validation ValidationConfigtoUpstreamConfig.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:ValidateRequestimplementation:ValidateResponseimplementation:Integration into Tool Handler
Update
internal/mcp/handler.goto integrate validation into the request lifecycle per SPEC.md §17.After
transform.RunRequestand before the upstream HTTP call:*http.Requestfrom theRequestEnvelopevalidation.ValidateRequest == true, callv.ValidateRequest(ctx, req)CallToolResult{IsError: true}with the validation error — do not call the upstreamAfter receiving the upstream response body:
success_statusorerror_statusCallToolResult{IsError: true}with"unexpected HTTP {status}"— do not validatesuccess_statusandvalidation.ValidateResponse == true:v.ValidateResponse(ctx, reqInput, resp, body)ResponseValidationFailure == "fail": returnCallToolResult{IsError: true}ResponseValidationFailure == "warn": logslog.Warn("response validation failed", ...)and continueUnit Tests
Create
internal/openapi/validator_test.go(no build tag):These tests use
httptestto create a synthetic request/response — no containers needed.IsError: trueIntegration Test
Create
tests/integration/runtime_validation_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:
TestRequestValidationBlocksInvalidArgsGET /pets/{petId}wherepetIdis a required path parameterGET /pets/{petId})tools/call get_petwith arguments that omit the requiredpetIdIsError: true/__admin/requests) shows zero requests (validation blocked the call before it hit the upstream)Test:
TestResponseValidationWarnModeGET /petswith a 200 response schema:{pets: [{id: integer, name: string}]}response_validation_failure: warn{"pets": [{"id": "not-an-integer", "name": "Fido"}]}(wrong type forid)tools/call list_petsIsError)Test:
TestResponseValidationFailModeTestResponseValidationWarnMode, but withresponse_validation_failure: failtools/call list_petsIsError: trueTest:
TestUnexpectedStatusReturnsErrorGET /petstools/call list_petsIsError: true"unexpected HTTP 418"or similarChecks Before Committing
Acceptance Criteria
ValidateRequestcalled before every upstream HTTP request whenvalidate_request: trueIsError: truewithout calling upstreamNoopAuthenticationFuncused in request validation optionsValidateResponsecalled on success-path responses whenvalidate_response: trueIsError: truewhen mode isfailwarnIsError: truewithout validationDefinition of Done
make checkexits 0. Integration tests pass. Invalid MCP inputs are rejected before hitting the upstream, and schema-violating responses are handled per configuration.