Understanding MCP Through Raw STDIO Communication
- July 17, 2025
- 233 Unique Views
- 9 min read
- Client → Server: Initialization Request
- Server → Client: Initialization Response
- The Message Type Hierarchy
- 1. Server Startup
- 2. Client Connects (via process spawn)
- 3. Message Exchange Begins
- 4. Error Handling Without Exceptions
- 1. Record Types for Protocol Messages
- 2. Builder Pattern for Complex Responses
- 3. Enum-Based Method Routing
- 1. The Protocol is Simple
- 2. Debugging is Straightforward
- 3. Portability is Maximized
- 4. Understanding is Complete
- 5. Performance is Transparent
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 toSystem.out
without buffering frameworks - Thread-safe operations using
AtomicBoolean
andCopyOnWriteArrayList
- 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:
- Building the Transport Layer - Create a robust STDIO communication foundation
- Understanding the Protocol - Implement JSON-RPC message handling
- Protocol Handshake & Routing - Build initialization and message dispatch
- Implementing Capabilities - Add Resources, Tools, and Prompts
- 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
Don’t Forget to Share This Post!
Comments (0)
No comments yet. Be the first.