cd /news/developer-tools/mcp-logging-what-i-wish-i-knew-befor… · home topics developer-tools article
[ARTICLE · art-38941] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

MCP Logging: What I Wish I Knew Before Deploying My Production MCP Server (3 Weeks of Production Pain)

A developer who built over 10 MCP servers over three months discovered that production MCP servers require different logging than regular REST APIs after spending 8 hours debugging random failures. The engineer found that standard SLF4J logging missed issues like reverse proxy size limits dropping connections before requests reached the application. The solution was a custom logging filter that captures every request before processing, logs request IDs, API key presence, and warns on slow requests exceeding 10 seconds.

read7 min views1 publishedJun 25, 2026

Honestly, I built 10+ MCP servers over the past 3 months, and I thought I had everything figured out. Authentication? Check. Error handling? Check. Rate limiting? Check. Deployment? Check.

Then my production server started failing randomly. Users would report "empty responses" or "timeout" but when I checked locally, everything worked fine. No errors in the console, nothing in the logs—just… nothing.

I spent 8 hours debugging over three days, and I learned the hard way: MCP servers need different logging than your regular REST API. In this post, I want to share what I got wrong, what fixed it, and the exact logging setup I use now that actually catches those weird MCP-specific failures.

Let me start with a confession: I used the same logging setup I use for regular Spring Boot REST APIs. SLF4J, INFO level for requests, errors go to error log. That works fine for most APIs. But MCP is different.

With MCP:

What was happening to me? Some requests were hitting size limits in my reverse proxy, the proxy would drop the connection, and I'd never see it in my logs because the request never reached my app. The client retries, same thing happens again. User gets frustrated, I sit there scratching my head because "it works on my machine".

So here's what I changed—everything.

First, I added a filter that logs every single request before it hits my controller. Even if the request never makes it through, I have a log entry. That sounds obvious, but I used to log after authentication. Wrong—if authentication fails silently or the filter chain breaks, you get nothing.

Here's the code I use now:

@Component
public class McpLoggingFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(McpLoggingFilter.class);

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, 
            HttpServletResponse response, 
            FilterChain filterChain
    ) throws ServletException, IOException {

        long startTime = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString();
        String apiKey = extractApiKey(request);

        // Log BEFORE processing—even if everything fails, we have this
        log.info("MCP_REQUEST_START | requestId={} | method={} | path={} | apiKeyPresent={} | remoteAddr={}", 
                requestId,
                request.getMethod(),
                request.getRequestURI(),
                apiKey != null,
                request.getRemoteAddr());

        try {
            wrapRequestLogging(request, response, filterChain, requestId, apiKey, startTime);
        } catch (Exception e) {
            log.error("MCP_REQUEST_ERROR | requestId={} | error={}", requestId, e.getMessage(), e);
            throw e;
        }
    }

    private void wrapRequestLogging(
            HttpServletRequest request, 
            HttpServletResponse response, 
            FilterChain filterChain, 
            String requestId, 
            String apiKey,
            long startTime
    ) throws IOException, ServletException {
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);

        filterChain.doFilter(wrappedRequest, wrappedResponse);

        long duration = System.currentTimeMillis() - startTime;
        int status = wrappedResponse.getStatus();
        int responseSize = wrappedResponse.getContentSize();

        // Log after processing—everything completed, capture size and status
        log.info("MCP_REQUEST_COMPLETE | requestId={} | status={} | durationMs={} | responseSize={} | apiKey={}",
                requestId, status, duration, responseSize, maskApiKey(apiKey));

        // Log WARN for slow requests—helps catch timeout issues before users report
        if (duration > 10000) {
            log.warn("MCP_REQUEST_SLOW | requestId={} | durationMs={} | this might cause client retry", 
                    requestId, duration);
        }

        wrappedResponse.copyBodyToResponse();
    }

    private String extractApiKey(HttpServletRequest request) {
        // Check all four locations (I learned this the hard way in my auth article)
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null) return apiKey;

        String auth = request.getHeader("Authorization");
        if (auth != null && auth.startsWith("Bearer ")) {
            return auth.substring(7);
        }

        apiKey = request.getParameter("api_key");
        if (apiKey != null) return apiKey;

        return request.getParameter("apiKey");
    }

    private String maskApiKey(String apiKey) {
        if (apiKey == null || apiKey.length() < 8) return null;
        // Show first 4 chars, mask the rest—good enough for debugging, still secure
        return apiKey.substring(0, 4) + "****";
    }
}

Key things I do differently here:

MCP really only has two endpoints you need to care about: tools/list

and tools/call

. Each has different failure modes, so I added structured logging specifically for them.

For tools/list

, I log how many tools are being returned:

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

    private static final Logger log = LoggerFactory.getLogger(McpController.class);

    private final List<McpTool> tools;

    // ... constructor

    @PostMapping("/tools/list")
    public ResponseEntity<McpListToolsResponse> listTools(@RequestAttribute String requestId) {
        long start = System.currentTimeMillis();
        int count = tools.size();

        log.info("MCP_TOOLS_LIST | requestId={} | toolCount={}", requestId, count);

        McpListToolsResponse response = McpListToolsResponse.builder()
                .tools(tools)
                .build();

        long duration = System.currentTimeMillis() - start;
        log.info("MCP_TOOLS_LIST_DONE | requestId={} | toolCount={} | durationMs={}", 
                requestId, count, duration);

        return ResponseEntity.ok(response);
    }

For tools/call

, this is where the real money is. You need to log which tool was called, what parameters came in, and what the response size was. AI clients love to send huge prompts, so this catches those:

    @PostMapping("/tools/call")
    public ResponseEntity<McpCallToolResponse> callTool(
            @RequestBody McpCallToolRequest request,
            @RequestAttribute String requestId
    ) {
        long start = System.currentTimeMillis();
        String toolName = request.getName();
        int paramsSize = new ObjectMapper().writeValueAsString(request.getArguments()).length();

        log.info("MCP_TOOL_CALL | requestId={} | tool={} | paramsSize={}", 
                requestId, toolName, paramsSize);

        // If parameters are huge, log a warning—something's probably wrong
        if (paramsSize > 100_000) {
            log.warn("MCP_TOOL_CALL_LARGE_PARAMS | requestId={} | tool={} | paramsSize={}KB", 
                    requestId, toolName, paramsSize / 1000);
        }

        try {
            McpToolResult result = toolExecutor.execute(toolName, request.getArguments());
            long duration = System.currentTimeMillis() - start;
            int resultSize = result.getContent() != null ? 
                    result.getContent().toString().length() : 0;

            log.info("MCP_TOOL_CALL_DONE | requestId={} | tool={} | durationMs={} | resultSize={} | isError={}",
                    requestId, toolName, duration, resultSize, result.isError());

            // Warn on huge responses—this is what got me!
            if (resultSize > 100_000) {
                log.warn("MCP_TOOL_CALL_LARGE_RESPONSE | requestId={} | tool={} | resultSize={}KB",
                        requestId, toolName, resultSize / 1000);
            }

            return ResponseEntity.ok(McpCallToolResponse.builder()
                    .content(result.getContent())
                    .isError(result.isError())
                    .build());
        } catch (Exception e) {
            log.error("MCP_TOOL_CALL_FAILED | requestId={} | tool={} | error={}", 
                    requestId, toolName, e.getMessage(), e);
            return ResponseEntity.internalServerError().build();
        }
    }
}

Here's the thing that caught my proxy issue: I was getting resultSize=12845

in my logs, but clients would get disconnected halfway through. That's when I realized my Nginx proxy had a proxy_buffer_size

setting that was too small for large MCP responses. It would buffer part of the response, hit the limit, and close the connection. My app thought it sent everything fine—no error in my original logs. With this logging, I immediately saw the pattern: only responses over 8KB would fail.

I tried logging full request/response bodies at first, and it was just too noisy. Most MCP requests are small, but when you get a big tool call with a lot of context, your logs blow up quickly.

Instead, I have a debug flag I can turn on per-API key. When a specific user is having problems, I can enable debug logging just for them without turning everything on:

@Component
public class DebugLoggingConfig {

    private final Set<String> debugApiKeyPrefixes = Set.of(
            // Add prefixes of API keys that need debug logging
            // "abcd" (matches abcd**** from our masked logs)
    );

    public boolean isDebugEnabled(String maskedApiKey) {
        if (maskedApiKey == null) return false;
        return debugApiKeyPrefixes.stream()
                .anyMatch(prefix -> maskedApiKey.startsWith(prefix));
    }
}

Then in your filter, when debug is enabled, log the full request body:

if (debugConfig.isDebugEnabled(maskedApiKey)) {
    String body = new String(wrappedRequest.getContentAsByteArray(), StandardCharsets.UTF_8);
    log.debug("MCP_DEBUG_REQUEST | requestId={} | body={}", requestId, body);
}

This keeps your logs clean for normal use, but when you need to debug, you have all the details. I can't tell you how many times this saved me hours—users would send me their request ID, I can pull up the full request and see exactly what went wrong.

Okay, so here's another mistake I made: I used DEBUG for most of this MCP logging, and kept my app at INFO level. That means all this useful stuff wasn't even in my production logs!

Now I use:

Before this change, I'd get user reports and check my logs—nothing. Because the useful stuff was at DEBUG. After this change, the answer is usually in the first five lines I look at.

Let me be honest—this isn't perfect. Here's what works and what doesn't:

Honestly, I'd start with this logging setup day one. I thought "it's just a couple endpoints, I don't need fancy logging". Wrong. MCP is still young, clients are evolving, proxies have weird defaults—you need visibility.

The three biggest surprises for me:

proxy_buffer_size

is 8k or 16k depending on your system. If your MCP tool returns a bigger response than that, boom—dead connection. And you'd never know from default app logging.Here's my quick checklist I go through before deploying any new MCP server now:

That's it. Eight simple checks that would have saved me 8 hours of debugging.

MCP changes how you think about a lot of things—logging is one of them. Your regular REST API logging works fine for regular APIs, but MCP has different failure modes that trip you up if you're not ready.

You don't need anything fancy. Just a few extra lines of logging in the right places catches 90% of the weird silent failures that drive you crazy.

The full Papers project with my MCP server implementation is on GitHub if you want to see the full working code: https://github.com/kevinten10/Papers

Have you built an MCP server in production? What weird logging issues have you run into? I'd love to hear your stories in the comments—maybe we can collect more of these hard-earned lessons.

── more in #developer-tools 4 stories · sorted by recency
── more on @mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/mcp-logging-what-i-w…] indexed:0 read:7min 2026-06-25 ·