Best Practices for Developing and Debugging MCP Servers for Use with ChatMCP

To successfully integrate with ChatMCP, MCP servers must implement strict JSON-RPC 2.0 compliance, expose mandatory methods (initialize, ping, tools/list), declare transport type (stdio, streamable, or sse) in assets/mcp_server.json, and utilize processStateStream and Dart's logging package for real-time debugging.

ChatMCP is a cross-platform AI client that communicates with MCP (Message-Calling-Protocol) servers via three transport layers. When developing and debugging MCP servers for use with ChatMCP, understanding the client implementation details in the daodao97/chatmcp repository ensures your server handles initialization, tool discovery, and graceful shutdown correctly across all supported transports.

Core Architectural Guidelines

All ChatMCP clients implement the abstract McpClient interface defined in lib/mcp/client/mcp_client_interface.dart. This uniform contract means your server must conform to specific protocols regardless of whether ChatMCP connects via STDIO, Streamable HTTP, or Server-Sent Events (SSE).

Enforce JSON-RPC 2.0 Schema Compliance

Every request must include an "id" field (string or number) and a "method" string. Responses must echo the same "id" and contain either "result" or "error". In lib/mcp/stdio/stdio_client.dart, lines 66-68 validate this contract during sendMessage; non-conforming payloads trigger a Failed to parse server output error, immediately terminating the connection.

Implement Mandatory RPC Methods

Your server must handle four critical methods to satisfy the ChatMCP client lifecycle:

  1. initialize – Return a JSON-RPC success response to complete the handshake. Optionally emit a notifications/initialized notification afterward, following the pattern in StdioClient.sendInitialize.
  2. ping – Echo back the incoming id with an empty result object. The SSEClient._handleMessage implementation (around lines 80-87) specifically expects this for liveness checks.
  3. tools/list – Return an array of tool descriptors, each containing name, description, and a JSON Schema parameters object. Names must be unique as the UI relies on this for deduplication.
  4. Tool execution methods – Handle params for any custom methods declared in your tool list, returning structured results or standard JSON-RPC error objects.

Configure Server Metadata Correctly

ChatMCP discovers servers through assets/mcp_server.json, parsed by McpServerProvider._loadServers. Each entry requires:

{
  "command": "path/to/executable",
  "args": [],
  "env": {},
  "type": "stdio|streamable|sse",
  "oauth": {}
}

The type field is mandatory and determines which client class (StdioClient, StreamableClient, or SSEClient) instantiates the connection. If your server requires authentication, implement either a /.well-known/oauth-authorization-server endpoint or /oauth/metadata endpoint; OAuthDiscoveryService in lib/utils/oauth_discovery.dart automatically probes these URLs via _tryWellKnownEndpoint, _tryDirectServerProbe, and _tryOAuthMetadataEndpoint.

Manage Process Lifecycle Gracefully

For STDIO transports, ensure your process responds to dispose() calls. The StdioClient implementation (lines 36-38) kills the process and closes streams to prevent zombie processes. For SSE, monitor the processStateStream broadcast stream; the client emits ProcessState.exited (lines 133-135 in sse_client.dart) when connections drop. Streamable implementations should reuse a single http.Client instance and close it in dispose(), supporting request cancellation via StreamableClient._abortController.

Debugging Techniques for MCP Development

Effective debugging relies on the observable state exposed by ChatMCP's client implementations rather than external proxies alone.

Monitor Process State Streams

All three clients expose a processStateStream broadcast stream. Subscribe to this in your test harness or UI:

client.processStateStream.listen((state) {
  if (state is ProcessStateError) {
    debugPrint('Connection error: ${state.error}');
  }
});

For STDIO, watch for transitions from ProcessState.starting to running and finally exited. For SSE, the stream reports ConnectionState.reconnecting during automatic retries.

Enable Verbose Client Logging

Set the logging level before initialization to capture internal client operations:

Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((r) => print('[${r.level.name}] ${r.message}'));

The client emits critical events such as Starting process, Failed to parse server output, and SSE connection error. Correlate these timestamps with your server logs to identify protocol violations.

Capture Raw JSON Traffic

For STDIO servers, the parsing logic in StdioClient reads lines from stdoutStream. Temporarily modify the client or wrap your server executable to dump raw lines before JSON parsing. For HTTP-based transports (Streamable/SSE), use a proxy like mitmproxy or inject an http.LoggingClient wrapper to inspect headers and bodies.

Verify OAuth Discovery

Test your authentication flow programmatically using the same service ChatMCP employs:

final result = await OAuthDiscoveryService.discoverOAuth('http://localhost:8080');
assert(result.requiresOAuth);
assert(result.authorizationUrl != null && result.tokenUrl != null);

Ensure at least one of the three discovery strategies returns a complete OAuthDiscoveryResult with valid authorization and token URLs.

Handle Crash Recovery

STDIO servers that crash emit ProcessState.exited; you can restart by calling client.initialize() again. SSE clients implement automatic reconnection via EventFlux with exponential back-off (configured in ReconnectConfig at lines 14-18 of sse_client.dart), defaulting to _maxReconnectAttempts of 5. Adjust this constant if your server requires longer recovery windows.

Implementation Examples

Building a Minimal STDIO Server in Python

The following Python script implements the required JSON-RPC interface for ChatMCP integration:

#!/usr/bin/env python3
import sys, json

def read():
    return json.loads(sys.stdin.readline())

def write(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()

def handle(message):
    if message["method"] == "initialize":
        return {"jsonrpc":"2.0","id":message["id"],"result":{}}
    if message["method"] == "ping":
        return {"jsonrpc":"2.0","id":message["id"],"result":{}}
    if message["method"] == "tools/list":
        tools = [{
            "name": "echo",
            "description": "Echoes the input text",
            "parameters": {
                "type": "object",
                "properties": {"text": {"type": "string"}}
            }
        }]
        return {"jsonrpc":"2.0","id":message["id"],"result":tools}
    raise Exception("unknown method")

while True:
    try:
        msg = read()
        resp = handle(msg)
    except Exception as e:
        resp = {"jsonrpc":"2.0","id":msg.get("id"),"error":{"code":-32603,"message":str(e)}}
    write(resp)

Register this in assets/mcp_server.json with "type": "stdio".

Integrating the Dart Client

When building Flutter widgets that interact with MCP servers, use the provider pattern to resolve configurations:

class McpDemo extends StatefulWidget {
  @override
  _McpDemoState createState() => _McpDemoState();
}

class _McpDemoState extends State<McpDemo> {
  late McpClient _client;

  @override
  void initState() {
    super.initState();
    final provider = McpServerProvider();
    final config = provider.clients['Echo']!.serverConfig;
    _client = StdioClient(serverConfig: config);
    
    _client.initialize().then((_) async {
      await _client.sendInitialize();
      final ping = await _client.sendPing();
      // Handle successful connection
    });
  }

  @override
  void dispose() async {
    await _client.dispose();
    super.dispose();
  }
}

Always call initialize() before sendInitialize(), and ensure dispose() runs during widget cleanup to release OS resources.

Configuring SSE Reconnection

For long-running SSE connections, adjust the reconnection behavior:

final client = SSEClient(serverConfig: sseConfig);
await client.initialize();

client.processStateStream.listen((state) {
  if (state is ProcessStateError) {
    // Implement custom UI feedback or logging
  }
});

The client automatically retries up to 5 times with exponential back-off. For servers requiring different behavior, modify the reconnectConfig parameter in SSEClient.

Summary

  • Implement strict JSON-RPC 2.0 compliance with valid id echoing and error objects to avoid parse failures in StdioClient (lines 66-68).
  • Declare transport type explicitly in assets/mcp_server.json (stdio, streamable, or sse) so McpServerProvider instantiates the correct client class.
  • Support mandatory methods: initialize, ping, and tools/list with proper response schemas.
  • Monitor processStateStream across all transport types to detect disconnections and crashes in real-time.
  • Leverage Logger.root for verbose logging of client internals, correlating with server-side logs during debugging.
  • Implement OAuth discovery endpoints if authentication is required, ensuring compatibility with OAuthDiscoveryService probing strategies.
  • Handle graceful shutdown by responding to process kills (STDIO) or closing HTTP clients (Streamable/SSE) to prevent resource leaks.

Frequently Asked Questions

What transport type should I use for my MCP server?

Choose STDIO for local executables like Python scripts or compiled binaries that run on the same machine as ChatMCP, as implemented in lib/mcp/stdio/stdio_client.dart. Use Streamable HTTP for remote stateless services that accept JSON-RPC over POST requests. Select SSE for long-running services requiring bidirectional streaming or server-pushed events, managed by lib/mcp/sse/sse_client.dart with automatic reconnection logic.

Why does ChatMCP fail to parse my server's output?

The client validates strict JSON-RPC 2.0 formatting. In StdioClient (lines 66-68), any line written to stdout that lacks a valid jsonrpc, id, and either result or error field triggers a Failed to parse server output error and terminates the connection. Ensure your server flushes output buffers immediately after each JSON object and does not print debug logs to stdout.

How do I debug authentication issues with my MCP server?

Verify OAuth discovery using the same logic as ChatMCP. Call OAuthDiscoveryService.discoverOAuth() with your server URL to test if your /.well-known/oauth-authorization-server or /oauth/metadata endpoints return valid authorization_url and token_url values. The service tries three fallback strategies, so ensure at least one endpoint returns a complete OAuthDiscoveryResult object.

Can I hot-reload my MCP server during development?

For STDIO servers, yes. Listen to ProcessState.exited events on processStateStream and automatically re-invoke client.initialize() to spawn a new process. For SSE, the client handles reconnection automatically with exponential back-off (default 5 attempts), but you can force a refresh by disposing and recreating the SSEClient instance, ensuring you call dispose() to clean up the underlying HTTP connection.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →