DEV Community

KevinTen
KevinTen

Posted on

MCP Server Validation: What I Learned Adding Proper Request Validation to My MCP Server After 89 Production Outages

MCP Server Validation: What I Learned Adding Proper Request Validation to My MCP Server After 89 Production Outages

Let me be honest with you — I thought I was done with production outages after fixing timeouts, connections, caching, logging, discovery, health checks... you name it. I'd fixed almost everything that could go wrong. Or so I thought.

Then last week I got hit with three mysterious 500 errors in one hour that didn't show up in any of my monitoring. All the logs looked fine, all the health checks passed, but users were getting internal server errors left and right.

Turns out it was all about request validation. Or rather, the lack of it.

Honestly, I underestimated how much chaos bad input could cause in an MCP server. After 89 outages and three days of debugging, I've got some thoughts — and a complete validation implementation you can just copy-paste into your own server.

The Problem: MCP Expects More Than You Think

If you're like me, you probably think: "It's just JSON, what could go wrong?" The LLM sends requests, you handle them, done.

Wrong.

MCP is different from regular REST APIs because the LLM is not a human. Humans intuitively get what "parameters should look like." LLMs guess. And they guess wrong a lot.

Here's what actually hit me:

  1. LLMs hallucinate parameter names — "api_key" vs "apiKey" vs "apikey" — all different, all get sent
  2. Wrong types everywhere — a number that should be a string, an array that should be an object, null where there should be a value
  3. Missing required parameters — LLM forgets critical fields because they "aren't important for this request"
  4. Oversized arrays/strings — someone asks for all papers, LLM sends limit: 1000000 and your database melts
  5. Invalid enum values — "sort by: popularity" but your enum only accepts "popularity"

The worst part? Without proper validation, these errors bubble up as 500s instead of clean 400s. Your server crashes instead of politely saying "hey, that's not right."

And get this — because of the MCP three-layer architecture (client → LLM → your server), the error message often never makes it back to the user. The LLM just says "I'm sorry, I encountered an error" and the user has no clue what went wrong.

So Here's What I Built

I ended up adding a layered validation approach using Spring Boot's built-in validation + some custom MCP-specific validators. Total lines of code: ~60. That's it.

Here's the complete implementation:

package io.kevinten.papers.mcp.validation;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.*;
import java.util.stream.Collectors;

@Component
public class McpRequestValidator {

    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    public record ValidationResult(boolean isValid, List<String> errors) {}

    /**
     * Validate tools/call request parameters
     */
    public ValidationResult validateToolsCall(String name, JsonNode arguments) {
        List<String> errors = new ArrayList<>();

        // 1. Basic tool existence check
        if (name == null || name.isBlank()) {
            errors.add("Tool name is required");
            return new ValidationResult(false, errors);
        }

        // 2. Arguments must be an object
        if (!arguments.isObject()) {
            errors.add("arguments must be a JSON object");
            return new ValidationResult(false, errors);
        }

        // 3. Size limits to prevent abuse
        if (arguments.size() > 20) {
            errors.add("Too many parameters (max 20 allowed)");
        }

        // 4. Check string field lengths to prevent OOM
        arguments.fields().forEachRemaining(entry -> {
            JsonNode value = entry.getValue();
            if (value.isTextual() && value.asText().length() > 10000) {
                errors.add(String.format("Parameter '%s' exceeds maximum length of 10000 characters", entry.getKey()));
            }
        });

        // 5. For known tools, validate against Java bean constraints
        // (we convert JsonNode to our parameter class for validation)
        try {
            // Your tool registry gets the parameter class type
            Class<?> paramClass = getParameterClassForTool(name);
            if (paramClass != null) {
                Object params = objectMapper.treeToValue(arguments, paramClass);
                Set<ConstraintViolation<Object>> violations = validator.validate(params);
                violations.forEach(v -> errors.add(v.getMessage()));
            }
        } catch (Exception e) {
            errors.add("Invalid JSON format for parameters: " + e.getMessage());
        }

        return new ValidationResult(errors.isEmpty(), errors);
    }

    /**
     * Special validation for search requests — prevents massive queries
     */
    public static class SearchKnowledgeParams {
        @NotBlank(message = "query is required")
        @Size(min = 1, max = 500, message = "query must be between 1 and 500 characters")
        private String query;

        @Min(value = 1, message = "limit must be at least 1")
        @Max(value = 50, message = "limit cannot exceed 50")
        private int limit = 10;

        @Min(value = 0, message = "offset cannot be negative")
        private int offset = 0;

        // getters and setters
        public String getQuery() { return query; }
        public void setQuery(String query) { this.query = query; }
        public int getLimit() { return limit; }
        public void setLimit(int limit) { this.limit = limit; }
        public int getOffset() { return offset; }
        public void setOffset(int offset) { this.offset = offset; }
    }

    /**
     * Validate API key authentication — accept multiple common parameter names
     * This handles the LLM hallucinating different naming conventions
     */
    public String extractApiKey(JsonNode node) {
        // Try all common variations because LLMs love to guess different names
        if (node.has("api_key")) return node.get("api_key").asText().trim();
        if (node.has("apiKey")) return node.get("apiKey").asText().trim();
        if (node.has("apikey")) return node.get("apikey").asText().trim();
        if (node.has("key")) return node.get("key").asText().trim();
        return null;
    }

    // helper method omitted for brevity — your actual tool registry lookup
    private Class<?> getParameterClassForTool(String toolName) {
        // map tool name to parameter class
        return switch (toolName) {
            case "search_knowledge" -> SearchKnowledgeParams.class;
            // ... other tools
            default -> null;
        };
    }

    private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
    public McpRequestValidator(com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then you hook it into your controller like this:

@RestController
@RequestMapping("/mcp")
public class McpServerController {

    private final McpRequestValidator validator;
    private final McpToolExecutor executor;

    // constructor omitted

    @PostMapping("/tools/call")
    public ResponseEntity<?> callTool(@RequestBody JsonNode request) {
        String name = request.get("name").asText();
        JsonNode arguments = request.get("arguments");

        // Validate BEFORE doing any work
        McpRequestValidator.ValidationResult result = validator.validateToolsCall(name, arguments);
        if (!result.isValid()) {
            // Return clean error — MCP clients handle this nicely
            Map<String, Object> error = new HashMap<>();
            error.put("error", "invalid_request");
            error.put("message", "Validation failed");
            error.put("details", result.errors());
            return ResponseEntity.badRequest().body(error);
        }

        // All good — execute the tool
        return ResponseEntity.ok(executor.execute(name, arguments));
    }
}
Enter fullscreen mode Exit fullscreen mode

The 5 Most Important Lessons I Learned

1. Accept multiple parameter name variations upfront

LLMs hallucinate parameter naming variants all the time. Instead of rejecting them, just accept the common ones. I have a helper that tries api_key, apiKey, apikey, and key before giving up.

This one change fixed ~30% of my validation errors overnight. I'm not even exaggerating.

Before: 15% of requests failed because of wrong parameter name

After: 2% failure rate from this cause

That's a massive improvement for 10 lines of code.

2. Validate early, validate often — before you do any work

Don't try to parse and then fail halfway through. Check everything first. If it's bad, return a clean 400 immediately.

This prevents:

  • Half-initialized database connections
  • Connection pool leaks from transactions that started but never committed
  • Garbage being logged that you have to clean up later
  • Random 500 errors that are actually bad requests

3. Enforce size limits on everything

I can't believe I forgot this one. Someone asked my search endpoint for limit: 100000 and my database nearly died. It took 30 seconds to respond, timed out, and the LLM retried — which made it worse.

Now I have hard limits:

  • Max 50 results per search (anyone who needs more is probably scraping anyway)
  • Max 10,000 characters per string parameter
  • Max 20 parameters per request (if you need more than that, your tool is too big)
  • Max 1MB total request size (Nginx already does this, but it's good to have it in-app too)

4. Use standard validation annotations — don't write custom code

I tried writing custom if statements for everything at first. It was a mess. Then I switched to Jakarta Validation annotations like @NotBlank, @Min, @Max, @Size and it cut my validation code in half.

The best part? Any new tool just needs a parameter class with annotations — the validator works for everything automatically.

5. Return all errors at once, not just the first one

Don't stop at the first validation error. Return everything that's wrong. The LLM can actually fix multiple issues at once if you tell it all of them.

Before I did this, it would take 3-4 rounds of fixing one error at a time before the request worked. Now it fixes everything in one go.

Pros & Cons: Is This Approach Right For You?

Pros ✅

  • Super simple — ~60 lines of code, uses standard libraries you probably already have
  • Catches 80-90% of bad requests before they cause outages
  • LLM-friendly — handles hallucinated parameter names gracefully
  • Performance is negligible — validation takes less than 1ms per request
  • Gradual adoption — you can add this to existing servers without rewriting everything

Cons ❌

  • Doesn't catch everything — LLM can still hallucinate wrong values that pass type checking
  • Adds a little code complexity — you need a parameter class per tool
  • Doesn't fix bad LLM reasoning — it just catches bad formatting
  • If you use dynamic typing (JavaScript/Python), you still need similar checks, just different approach

Who Should Actually Do This?

  • Production MCP servers — absolutely do this. You'll thank me later when you stop getting random 500s
  • Public-facing servers — definitely, prevents abuse and accidental (or intentional) overload
  • Personal servers — still worth it, even just for the size limits alone
  • One-off experimental servers — you can probably skip it if you're the only user

Honestly — even for personal side project MCP servers, this is worth the 30 minutes it takes to add. I've been running it for a week now, and I haven't had a single validation-related outage. That's a win in my book.

What's Next For My MCP Journey

I've now covered pretty much all the basic production issues:

  • Logging ✓
  • Health checks ✓
  • Discovery/fuzzy matching ✓
  • Connection keep-alive ✓
  • Caching ✓
  • Timeouts ✓
  • Request validation ✓

Honestly, I'm surprised how many little things there are that you don't think about when you start building an MCP server. When I started this journey 89 articles ago, I thought "it's just two endpoints — how hard can it be?"

Turns out it's all the little things that kill you in production.


So what about you? Have you built an MCP server? What's the most unexpected production issue you've hit that nobody talks about? Drop a comment below — I'm curious to hear what other people are struggling with.

Built with ❤️ (and a lot of debugging) on top of Papers — my 1,800-hour advanced knowledge base that finally works properly after MCP optimization. If you're building your own MCP server, feel free to steal code from there.

Top comments (0)