Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Understanding MCP Through Raw STDIO Communication

  • July 17, 2025
  • 233 Unique Views
  • 9 min read
Table of Contents
Deep Dive into the Model Context ProtocolUnderstanding MCP Through Raw STDIO CommunicationWhy STDIO? The Power of Universal CommunicationUnderstanding the JSON-RPC Message FlowBidirectional Communication: Beyond Request-ResponseThe Complete STDIO Loop: Putting It All TogetherDebugging STDIO CommunicationPrompts: Bridging Human Intent and Tool ExecutionAdvanced Features: Notification HandlingServer Lifecycle ManagementKey Architectural PatternsWhy Building Without Frameworks MattersConclusion: The Elegance of STDIO-Based ProtocolsLearn By Building: The Agent MCP Workshop

Deep Dive into the Model Context Protocol

Ever wondered how AI assistants like Claude actually communicate with external tools and services? While most tutorials focus on using pre-built SDKs and frameworks, this article takes a different approach—we'll dissect a production MCP server built from scratch using only Java's standard libraries and raw STDIO communication.

By stripping away all the abstractions and implementing the Model Context Protocol directly, we'll uncover the surprisingly elegant mechanics that enable AI systems to discover, understand, and execute tools.

Whether you're building AI integrations, debugging mysterious protocol errors, or simply curious about what really happens when an AI "uses a tool," this deep dive will transform JSON-RPC messages flowing through stdin/stdout from abstract concepts into concrete, debuggable reality.

No frameworks, no magic—just the protocol in its purest form.

Want to build this yourself? This article is based on code from the Agent MCP Workshop, an instructor-led workshop that guides you through building a complete MCP server from scratch. The workshop includes hands-on exercises, detailed explanations, and practical examples that complement the concepts discussed in this article.

Understanding MCP Through Raw STDIO Communication

The Model Context Protocol (MCP) represents a paradigm shift in how AI systems interact with external tools and data sources. This article dives deep into the protocol's STDIO-based communication layer, examining a real-world Java implementation built without frameworks to better understand how the protocol and communication actually work at the fundamental level.

By implementing MCP from scratch using only standard Java libraries, we gain invaluable insights into the protocol's inner workings—insights often hidden by higher-level abstractions and frameworks. This bare-metal approach reveals the elegant simplicity underlying MCP's powerful capabilities.

Why STDIO? The Power of Universal Communication

Before diving into the implementation, it's crucial to understand why MCP chose STDIO (Standard Input/Output) as its primary transport mechanism. STDIO provides:

  • Universal compatibility: Every programming language can read from stdin and write to stdout
  • Process isolation: Natural security boundaries between the AI client and tool server
  • Simplicity: No network configuration, firewall rules, or authentication complexity
  • Debugging ease: Messages can be logged, inspected, and replayed

This implementation demonstrates these principles by building everything from scratch—no MCP SDK, no framework dependencies, just pure Java interacting with STDIO streams.

At the heart of any MCP implementation lies a robust transport layer. The Java implementation demonstrates a clean separation of concerns through its I/O handler architecture:

public class IOHandlerImpl implements IOHandler {
    private final static LogFile logger = LogFileWriter.getInstance();
    private final PrintWriter writer;
    private final List<Consumer<String>> lineListeners;
    private final AtomicBoolean running;
    private final Gson gson = new Gson();

    @Override
    public void emit(Object message) {
        String text = gson.toJson(message);
        logger.log("[API][SENT]: " + text);
        writer.println(text);
        writer.flush();
    }

    @Override
    public void startInputReader() {
        if (running.get()) {
            return; // Already running
        }

        try (Scanner scanner = new Scanner(System.in)) {
            running.set(true);
            while (running.get()) {
                if (!scanner.hasNextLine()) {
                    running.set(false);
                    break;
                }
                String line = scanner.nextLine();
                logger.log("[API][RECEIVED]" + line);
                publishLine(line);
            }
        }
    }
}

This implementation showcases several critical design decisions:

  • Direct STDIO access: Reading from System.in and writing to System.out without buffering frameworks
  • Thread-safe operations using AtomicBoolean and CopyOnWriteArrayList
  • Event-driven architecture with listener patterns for incoming messages
  • Line-based protocol: Each JSON-RPC message is a complete line, enabling simple parsing
  • Comprehensive logging that writes to files (never stdout!) for debugging

The beauty of this approach is its transparency. When the server emits a message:

public void emit(Object message) {
    String text = gson.toJson(message);
    logger.log("[API][SENT]: " + text);  // Log to file, not stdout!
    writer.println(text);  // Send to stdout
    writer.flush();        // Ensure immediate delivery
}

The message goes directly to stdout as a single line of JSON. No framing, no length prefixes, no binary protocols—just newline-delimited JSON that any tool can read and debug.

Understanding the JSON-RPC Message Flow

MCP uses JSON-RPC 2.0 over STDIO, which means every message is a self-contained JSON object on a single line. Let's trace through an actual message exchange to see how this works:

Client → Server: Initialization Request

{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"0.1.0","capabilities":{"roots":{},"sampling":{}},"clientInfo":{"name":"mcp-inspector","version":"0.1.0"}},"id":0}

This single line contains everything needed for initialization. The server reads it from stdin using:

String line = scanner.nextLine();
logger.log("[API][RECEIVED]" + line);
publishLine(line);  // Notify the router

Server → Client: Initialization Response

{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"0.1.0","capabilities":{"tools":{},"prompts":{},"resources":{}},"serverInfo":{"name":"agent-mcp-workshop","version":"0.0.1"}}}

The server writes this response directly to stdout. No HTTP headers, no WebSocket frames—just a line of JSON followed by a newline character.

The Message Type Hierarchy

The implementation uses Java records to model the JSON-RPC message types:

public record JsonRpcRequest(
    String jsonrpc,
    Long id,
    String method,
    Object params
) {}

public record JsonRpcNotification(
    String jsonrpc,
    String method,
    Object params
) {}  // Note: No id field for notifications!

public record JsonRpcResponse(
    String jsonrpc,
    Long id,
    Object result
) {}

public record JsonRpcErrorResponse(
    String jsonrpc,
    Long id,
    JsonRpcError error
) {}

This type system directly maps to the JSON-RPC 2.0 specification, making the protocol implementation clear and type-safe.

Every MCP session begins with a crucial initialization handshake. The implementation demonstrates how servers advertise their capabilities:

case INITIALIZE -> {
    InitializeParams initializeParams = deserializer.deserializeParams(
        message, InitializeParams.class
    );
    ClientCapabilities clientCapabilities = initializeParams.capabilities();
    if (clientCapabilities.roots() != null) {
        hasRoots = true;
    }
    if (clientCapabilities.sampling() != null) {
        hasSampling = true;
    }
    InitializeResultBuilder builder = InitializeResultBuilder
            .builder()
            .withProtocolVersion(initializeParams.protocolVersion())
            .withDefaultCapabilities()
            .withDefaultServerInfo();
    success(message.id(), builder.build());
}

The builder pattern used here is particularly elegant:

public InitializeResultBuilder withDefaultCapabilities() {
    Capability capabilityTrue = new Capability();
    this.capabilities = new ServerCapabilities(
        capabilityTrue,  // tools
        capabilityTrue,  // prompts
        new Capability(false, false)  // resources
    );
    return this;
}

This approach allows servers to clearly declare what they support, enabling intelligent capability negotiation between clients and servers.

Bidirectional Communication: Beyond Request-Response

One of MCP's powerful features is true bidirectional communication. The server can send requests to the client, not just respond to them. This implementation demonstrates this with the roots feature:

case NOTIFICATIONS_INITIALIZED -> {
    // Server initiates a request to the client!
    if (hasRoots) {
        io.emit(rootsRequest);
    }
}

The rootsRequest is a server-initiated message:

private static final JsonRpcRequest rootsRequest = new JsonRpcRequest(
    JSON_RPC_VERSION, 
    ROOTS_REQUEST_ID,  // Negative ID to avoid conflicts
    "roots/list", 
    null
);

When the client responds, the server processes it just like any other response:

private void process(JsonRpcResponse message) {
    if (ROOTS_REQUEST_ID.equals(message.id())) {
        RootsResponse rootsResponse = deserializer.deserializeResult(
            message, RootsResponse.class
        );
        roots.clear();
        for (Root root : rootsResponse.roots()) {
            roots.add(root.uri());
        }
    }
}

This bidirectional flow over STDIO demonstrates that MCP isn't limited to simple request-response patterns—it's a full-duplex protocol where both parties can initiate communication.

The routing mechanism demonstrates how MCP servers handle different message types:

public void route(String message) {
    if (message == null || message.isEmpty()) {
        return;
    }
    Object object = deserializer.deserialize(message);
    switch (object) {
        case JsonRpcRequest request -> process(request);
        case JsonRpcNotification notification -> process(notification);
        case JsonRpcResponse successResponse -> process(successResponse);
        case JsonRpcErrorResponse errorResponse -> process(errorResponse);
        default -> logger.log("Unknown message type: " + object);
    }
}

This pattern matching approach (using Java's modern switch expressions) creates a clean, extensible routing system. Each message type has its own processing logic:

private void process(JsonRpcRequest message) {
    UniqueKeys uniqueKey = UniqueKeys.fromValue(message.method());
    switch (uniqueKey) {
        case INITIALIZE -> { /* ... */ }
        case PROMPTS_LIST -> { /* ... */ }
        case PROMPTS_GET -> { /* ... */ }
        case TOOLS_LIST -> { /* ... */ }
        case TOOLS_CALL -> { /* ... */ }
        case RESOURCES_LIST -> { /* ... */ }
        case RESOURCES_READ -> { /* ... */ }
        case PING -> { /* ... */ }
        default -> logger.log("Unhandled RpcRequest method: " + uniqueKey);
    }
}

The Complete STDIO Loop: Putting It All Together

Let's trace through a complete interaction to see how STDIO communication enables MCP:

1. Server Startup

public void start() {
    // Register the router to handle incoming lines
    this.io.addLineListener(router::route);

    // Start reading from stdin in a separate thread
    this.io.startInputReader();

    // Keep the main thread alive
    keepRunning();
}

2. Client Connects (via process spawn)

The client spawns the server process and connects to its stdin/stdout:

java -jar agent-mcp-workshop-0.0.1.jar

3. Message Exchange Begins

→ [stdin]  {"jsonrpc":"2.0","method":"initialize","params":{...},"id":0}
← [stdout] {"jsonrpc":"2.0","id":0,"result":{...}}
→ [stdin]  {"jsonrpc":"2.0","method":"notifications/initialized"}
← [stdout] {"jsonrpc":"2.0","method":"roots/list","id":-1000}
→ [stdin]  {"jsonrpc":"2.0","id":-1000,"result":{"roots":[...]}}

Each arrow represents a complete line written to stdin or stdout. The server never writes partial messages or multiple messages on one line—maintaining the protocol's simplicity.

4. Error Handling Without Exceptions

Since STDIO doesn't have error channels like HTTP status codes, errors are part of the protocol:

success(message.id(), ToolCallResultBuilder
    .builder()
    .addTextContent("Tool not found: " + toolCallParams.name())
    .asError()
    .build());

This creates a valid response with an error flag, keeping the STDIO stream clean and the protocol predictable.

The keyword search tool demonstrates how to create self-describing, executable functionality:

public class KeyWordSearch implements Tool {
    @Override
    public String name() {
        return "key_word_search";
    }

    @Override
    public String description() {
        return "Searches for a specified keyword across all files in a project. " +
               "Returns the total count of matches and the absolute file paths " +
               "of the files containing the keyword.";
    }

    @Override
    public InputSchema schema() {
        InputSchemaBuilder builder = InputSchemaBuilder
                .builder()
                .withType("object")
                .addProperty(PropertySchemaBuilder
                        .builder()
                        .withKey("keyword")
                        .withType("string")
                        .withDescription("the keyword to search for in a file")
                        .required());
        if (this.roots == null || this.roots.isEmpty()) {
            builder.addProperty(PropertySchemaBuilder
                    .builder()
                    .withKey("root_directory")
                    .withType("string")
                    .withDescription("The absolute path to the root directory")
                    .required());
        }
        return builder.build();
    }
}

The schema definition is particularly important—it enables AI clients to understand exactly how to invoke the tool. The actual implementation showcases robust file handling:

public ToolCallResult call(ToolCallParams toolCallParams) {
    String keyword = toolCallParams.arguments().get("keyword");
    ToolCallResultBuilder builder = ToolCallResultBuilder.builder();

    if (roots.isEmpty()) {
        builder.addTextContent("No root directories specified for search.");
        builder.asError();
    } else {
        List<ContentItem> contentItems = searchKeywordInDirectories(roots, keyword);
        builder.withContent(contentItems);
    }
    return builder.build();
}

Debugging STDIO Communication

One of the advantages of building MCP without frameworks is the ability to debug at the protocol level. The implementation includes comprehensive logging:

logger.log("[API][RECEIVED]" + line);  // Every incoming message
logger.log("[API][SENT]: " + text);     // Every outgoing message

This creates a complete trace of the STDIO communication:

[2024-01-15 10:23:45] [API][RECEIVED]{"jsonrpc":"2.0","method":"tools/list","id":5}
[2024-01-15 10:23:45] [API][SENT]: {"jsonrpc":"2.0","id":5,"result":{"tools":[{"name":"key_word_search","description":"Searches for a specified keyword...","inputSchema":{...}}]}}
[2024-01-15 10:23:46] [API][RECEIVED]{"jsonrpc":"2.0","method":"tools/call","params":{"name":"key_word_search","arguments":{"keyword":"TODO"}},"id":6}
[2024-01-15 10:23:47] [API][SENT]: {"jsonrpc":"2.0","id":6,"result":{"content":[{"text":"/src/main/java/Server.java, keyword_count=3","type":"text"}]}}

This trace can be replayed for testing, analyzed for performance, or used to debug protocol issues—something much harder with framework-heavy implementations.

case RESOURCES_LIST -> {
    ResourcesListResultBuilder builder = ResourcesListResultBuilder
            .builder()
            .withResources(JavadocResources.loadAllHtmlResourcesFromFolder(
                "javadoc/com/workshop/mcp/spec"
            ))
            .withNextCursor("pageNext");
    success(message.id(), builder.build());
}

The JavadocResources class shows sophisticated resource handling:

public static String readResourceContent(String resourcePath) throws IOException {
    try (InputStream inputStream = JavadocResources.class.getClassLoader()
                                                         .getResourceAsStream(resourcePath)) {
        if (inputStream == null) {
            throw new IOException("Resource not found: " + resourcePath);
        }

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(inputStream))) {
            return reader.lines().collect(Collectors.joining("\n"));
        }
    }
}

This approach allows servers to bundle and serve documentation, configurations, or any other static content directly from the JAR file—all transmitted as JSON over STDIO. When a client requests a resource:

→ {"jsonrpc":"2.0","method":"resources/read","params":{"uri":"javadoc/com/workshop/mcp/spec/Tool.html"},"id":10}
← {"jsonrpc":"2.0","id":10,"result":{"contents":[{"uri":"javadoc/com/workshop/mcp/spec/Tool.html","mimeType":"text/html","text":"<!DOCTYPE HTML>..."}]}}

The entire HTML document is embedded in the JSON response, properly escaped and transmitted as a single line. This demonstrates STDIO's flexibility—it can handle everything from simple method calls to large content transfers.

Prompts: Bridging Human Intent and Tool Execution

The prompt system creates user-friendly interfaces for tools:

case PROMPTS_LIST -> {
    KeyWordSearch keyWordSearch = new KeyWordSearch(this.roots);
    PromptsListResultBuilder builder = PromptsListResultBuilder
            .builder()
            .withPrompt("search_keyword",
                        "Creates a prompt, to search for a word using the " + 
                        keyWordSearch.name() + " tool.")
            .withPromptArgument("keyword", "The word to search for", true)
            .withNextCursor("nextPage");
    success(message.id(), builder.build());
}

When retrieving a prompt, the server can provide intelligent guidance:

case PROMPTS_GET -> {
    PromptsGetParams params = deserializer.deserializeParams(
        message, PromptsGetParams.class
    );
    PromptsGetResultBuilder builder = PromptsGetResultBuilder
            .builder()
            .withDescription("keyword")
            .addTextMessage("user", KEY_WORD_MESSAGE, params.arguments());
    success(message.id(), builder.build());
}

Advanced Features: Notification Handling

The implementation includes sophisticated notification handling:

private void process(JsonRpcNotification message) {
    UniqueKeys uniqueKey = UniqueKeys.fromValue(message.method());
    switch (uniqueKey) {
        case NOTIFICATIONS_INITIALIZED -> {
            if (hasRoots) {
                io.emit(rootsRequest);
            }
        }
        case NOTIFICATIONS_ROOTS_LIST_CHANGED -> {
            io.emit(rootsRequest);
        }
        case NOTIFICATION_CANCELLED -> {
            NotificationCancelledParams params = deserializer.deserializeParams(
                message, NotificationCancelledParams.class
            );
            logger.log("Notification cancelled reason " + params.reason());
        }
        default -> logger.log("Unhandled notification method: " + uniqueKey);
    }
}

This shows how servers can react to client-side events and maintain synchronized state.

Server Lifecycle Management

The Server class demonstrates robust lifecycle management:

public class Server {
    private final AtomicBoolean isShuttingDown = new AtomicBoolean(false);
    private final CountDownLatch shutdownLatch;

    public void start() {
        try {
            this.io.addLineListener(router::route);

            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                if (isShuttingDown.compareAndSet(false, true)) {
                    stop();
                    logger.close();
                    shutdownLatch.countDown();
                }
            }));

            this.io.startInputReader();
            keepRunning();
        } catch (Exception e) {
            logger.log("Error in main method", e);
            stop();
            System.exit(0);
        }
    }
}

The use of shutdown hooks and countdown latches ensures graceful termination even in complex scenarios.

Key Architectural Patterns

1. Record Types for Protocol Messages

public record JsonRpcRequest(
    String jsonrpc,
    Long id,
    String method,
    Object params
) {}

Java records provide immutable, self-documenting protocol structures.

2. Builder Pattern for Complex Responses

InitializeResult result = InitializeResultBuilder.builder()
    .withProtocolVersion("1.0")
    .withServerInfo("my-server", "1.0.0")
    .withDefaultCapabilities()
    .build();

Builders ensure valid, complete responses while maintaining readability.

3. Enum-Based Method Routing

public enum UniqueKeys {
    INITIALIZE("initialize"),
    NOTIFICATIONS_INITIALIZED("notifications/initialized"),
    PROMPTS_LIST("prompts/list"),
    // ... more methods

    public static UniqueKeys fromValue(String value) {
        for (UniqueKeys key : UniqueKeys.values()) {
            if (key.value.equalsIgnoreCase(value)) {
                return key;
            }
        }
        return NOT_FOUND;
    }
}

This approach provides type-safe method handling with built-in validation.

Why Building Without Frameworks Matters

This implementation deliberately avoids MCP SDKs or frameworks to reveal important insights:

1. The Protocol is Simple

At its core, MCP is just JSON-RPC 2.0 over newline-delimited streams. No magic, no hidden complexity—just structured messages over STDIO.

2. Debugging is Straightforward

Without framework abstractions, every message is visible and every routing decision is explicit. Problems can be traced directly to protocol-level issues.

3. Portability is Maximized

This implementation could be ported to any language that can read stdin and write stdout—no framework dependencies to worry about.

4. Understanding is Complete

By implementing from scratch, developers gain deep understanding of:

  • How capability negotiation works
  • Why message IDs matter
  • How bidirectional communication flows
  • What makes MCP transport-agnostic

5. Performance is Transparent

Without framework overhead, the performance characteristics are clear:

  • Message parsing time = JSON deserialization
  • Routing overhead = switch statement
  • I/O latency = STDIO buffering

Conclusion: The Elegance of STDIO-Based Protocols

This deep dive into a framework-free MCP implementation reveals the elegant simplicity at the protocol's heart. By using STDIO as the transport layer, MCP achieves:

  • Universal compatibility: Any language that can read and write text can implement MCP
  • Process isolation: Natural security boundaries without complex authentication
  • Debugging transparency: Every message is visible and reproducible
  • Deployment simplicity: No ports, no certificates, no network configuration

The implementation demonstrates that building AI-integrated tools doesn't require complex frameworks or abstractions. At its core, MCP is about structured communication—JSON messages flowing over STDIO streams, enabling AI systems to discover and use tools in a standardized way.

By understanding these fundamentals through a bare-metal implementation, developers gain the knowledge to:

  • Build MCP servers in any language or environment
  • Debug protocol issues at the message level
  • Optimize performance by understanding the actual costs
  • Extend the protocol while maintaining compatibility

As AI continues to evolve, the ability to create tools that AI can understand and use becomes increasingly valuable. This implementation shows that such integration doesn't require magic—just careful attention to protocol details and disciplined STDIO handling.

The Model Context Protocol's choice of STDIO as its primary transport isn't just a technical decision—it's a philosophical one. It says that AI-tool integration should be simple, debuggable, and accessible to everyone. By building without frameworks, we see this philosophy in action: powerful capabilities emerging from simple, well-designed primitives.

Whether you're building the next generation of AI tools or simply understanding how AI systems communicate, the lessons from this STDIO-based implementation provide a solid foundation for creating robust, interoperable systems that bridge the gap between human intentions and machine capabilities.


Learn By Building: The Agent MCP Workshop

If you found this deep dive valuable and want to build your own MCP server from the ground up, check out the Agent MCP Workshop on GitHub. This instructor-led workshop takes you through five progressive lessons:

  1. Building the Transport Layer - Create a robust STDIO communication foundation
  2. Understanding the Protocol - Implement JSON-RPC message handling
  3. Protocol Handshake & Routing - Build initialization and message dispatch
  4. Implementing Capabilities - Add Resources, Tools, and Prompts
  5. Agent Integration - Connect your MCP server with AI agents

The workshop provides hands-on experience with the exact code examined in this article, along with exercises, debugging techniques, and best practices for production deployment. Whether you're learning solo or in a group setting, the workshop materials offer a structured path to MCP mastery.

Start building your own AI-integrated tools today: github.com/David-Parry/agent-mcp-workshop

Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Comments (0)

Highlight your code snippets using [code lang="language name"] shortcode. Just insert your code between opening and closing tag: [code lang="java"] code [/code]. Or specify another language.

No comments yet. Be the first.

Subscribe to foojay updates:

https://foojay.io/feed/
Copied to the clipboard