Building a Model Context Protocol Client with Dart: A Comprehensive Guide

Learn how to implement a Model Context Protocol (MCP) client with Dart and communicate with various MCP servers through a step-by-step approach. Introduction As AI technologies advance, the need for standardized communication between large language models (LLMs) and local systems becomes increasingly important. The Model Context Protocol (MCP) addresses this need by providing a standardized way for AI models to interact with external environments. In this article, we'll explore how to implement an MCP client using Dart. This is the second part of our MCP series, following our previous article on Building a Model Context Protocol Server with Dart. The series will continue with future articles on integrating MCP with LLMs. What is an MCP Client? An MCP client is a component that communicates with MCP servers using the Model Context Protocol. It serves as a bridge between applications and MCP servers, enabling the use of tools, resources, and prompts provided by the server. Key functionalities of an MCP client include: Server connection management: Establishing and maintaining connections to MCP servers Server capability discovery: Querying available tools, resources, and prompts Tool invocation: Remotely calling functions registered on the server Resource access: Accessing data sources provided by the server Event handling: Processing events and notifications from the server The MCP client provides a clean, programmatic interface to these capabilities, making it easy to integrate them into applications. Project Setup Let's start by setting up a new Dart project and installing the necessary dependencies. # Create a new Dart project dart create mcp_client_example cd mcp_client_example Next, update the pubspec.yaml file to include the MCP client package: name: mcp_client_example description: Example of MCP client implementation version: 1.0.0 environment: sdk: ^3.7.2 dependencies: mcp_client: ^0.1.7 uuid: ^4.0.0 http: ^1.1.0 dev_dependencies: lints: ^5.0.0 test: ^1.24.0 Install the dependencies: dart pub get Initializing the Client Now let's create a basic MCP client. Create a file named mcp_client_example.dart in the bin directory: import 'dart:io'; import 'package:mcp_client/mcp_client.dart'; final Logger _logger = Logger.getLogger('mcp_client_example'); /// MCP client example application void main() async { // Set up logging _logger.setLevel(LogLevel.debug); // Create a log file final logFile = File('mcp_client_example.log'); final logSink = logFile.openWrite(); logToConsoleAndFile('Starting MCP client example...', _logger, logSink); try { // Create a client with specific capabilities final client = McpClient.createClient( name: 'Example MCP Client', version: '1.0.0', capabilities: ClientCapabilities( roots: true, rootsListChanged: true, sampling: true, ), ); logToConsoleAndFile('Client initialized successfully.', _logger, logSink); // Connect to a server (implemented in the next section) } catch (e) { logToConsoleAndFile('Error: $e', _logger, logSink); } finally { // Close the log file await logSink.flush(); await logSink.close(); } } /// Log to both console and file void logToConsoleAndFile(String message, Logger logger, IOSink logSink) { // Log to console logger.debug(message); // Log to file logSink.writeln(message); } The ClientCapabilities object specifies which features the client supports: roots: Support for root management rootsListChanged: Support for notifications when the root list changes sampling: Support for sampling (needed for LLM integration) Connecting to Servers MCP clients can connect to servers using two primary transport mechanisms: STDIO Transport STDIO (Standard Input/Output) transport communicates through standard input/output streams. This is useful for connecting to local MCP servers that run as separate processes. // Create an STDIO transport to connect to a filesystem MCP server final transport = await McpClient.createStdioTransport( command: 'npx', arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path], ); logToConsoleAndFile('STDIO transport mechanism created.', _logger, logSink); // Connect to the server await client.connect(transport); logToConsoleAndFile('Successfully connected to server!', _logger, logSink); This example connects to a Node.js-based filesystem MCP server. The command and arguments parameters specify how to launch the external process. SSE Transport SSE (Server-Sent Events) transport communicates over HTTP. This is suitable for connecting to web-based MCP servers. // Create an SSE transport final transport = await McpClient.createSseTransport( serverUrl: 'http://localhost:8080', headers: {'Authorization': 'Bearer your-token'}, ); // Conn

May 1, 2025 - 09:14
 0
Building a Model Context Protocol Client with Dart: A Comprehensive Guide

Learn how to implement a Model Context Protocol (MCP) client with Dart and communicate with various MCP servers through a step-by-step approach.

MCP Client Architecture

Introduction

As AI technologies advance, the need for standardized communication between large language models (LLMs) and local systems becomes increasingly important. The Model Context Protocol (MCP) addresses this need by providing a standardized way for AI models to interact with external environments.

In this article, we'll explore how to implement an MCP client using Dart. This is the second part of our MCP series, following our previous article on Building a Model Context Protocol Server with Dart. The series will continue with future articles on integrating MCP with LLMs.

What is an MCP Client?

An MCP client is a component that communicates with MCP servers using the Model Context Protocol. It serves as a bridge between applications and MCP servers, enabling the use of tools, resources, and prompts provided by the server.

Key functionalities of an MCP client include:

  1. Server connection management: Establishing and maintaining connections to MCP servers
  2. Server capability discovery: Querying available tools, resources, and prompts
  3. Tool invocation: Remotely calling functions registered on the server
  4. Resource access: Accessing data sources provided by the server
  5. Event handling: Processing events and notifications from the server

The MCP client provides a clean, programmatic interface to these capabilities, making it easy to integrate them into applications.

Project Setup

Let's start by setting up a new Dart project and installing the necessary dependencies.

# Create a new Dart project
dart create mcp_client_example
cd mcp_client_example

Next, update the pubspec.yaml file to include the MCP client package:

name: mcp_client_example
description: Example of MCP client implementation
version: 1.0.0

environment:
  sdk: ^3.7.2

dependencies:
  mcp_client: ^0.1.7
  uuid: ^4.0.0
  http: ^1.1.0

dev_dependencies:
  lints: ^5.0.0
  test: ^1.24.0

Install the dependencies:

dart pub get

Initializing the Client

Now let's create a basic MCP client. Create a file named mcp_client_example.dart in the bin directory:

import 'dart:io';
import 'package:mcp_client/mcp_client.dart';

final Logger _logger = Logger.getLogger('mcp_client_example');

/// MCP client example application
void main() async {
  // Set up logging
  _logger.setLevel(LogLevel.debug);

  // Create a log file
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();

  logToConsoleAndFile('Starting MCP client example...', _logger, logSink);

  try {
    // Create a client with specific capabilities
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );

    logToConsoleAndFile('Client initialized successfully.', _logger, logSink);

    // Connect to a server (implemented in the next section)

  } catch (e) {
    logToConsoleAndFile('Error: $e', _logger, logSink);
  } finally {
    // Close the log file
    await logSink.flush();
    await logSink.close();
  }
}

/// Log to both console and file
void logToConsoleAndFile(String message, Logger logger, IOSink logSink) {
  // Log to console
  logger.debug(message);

  // Log to file
  logSink.writeln(message);
}

The ClientCapabilities object specifies which features the client supports:

  • roots: Support for root management
  • rootsListChanged: Support for notifications when the root list changes
  • sampling: Support for sampling (needed for LLM integration)

Connecting to Servers

MCP clients can connect to servers using two primary transport mechanisms:

STDIO Transport

STDIO (Standard Input/Output) transport communicates through standard input/output streams. This is useful for connecting to local MCP servers that run as separate processes.

// Create an STDIO transport to connect to a filesystem MCP server
final transport = await McpClient.createStdioTransport(
  command: 'npx',
  arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path],
);

logToConsoleAndFile('STDIO transport mechanism created.', _logger, logSink);

// Connect to the server
await client.connect(transport);
logToConsoleAndFile('Successfully connected to server!', _logger, logSink);

This example connects to a Node.js-based filesystem MCP server. The command and arguments parameters specify how to launch the external process.

SSE Transport

SSE (Server-Sent Events) transport communicates over HTTP. This is suitable for connecting to web-based MCP servers.

// Create an SSE transport
final transport = await McpClient.createSseTransport(
  serverUrl: 'http://localhost:8080',
  headers: {'Authorization': 'Bearer your-token'},
);

// Connect with retry options
await client.connectWithRetry(
  transport,
  maxRetries: 3,
  delay: const Duration(seconds: 2),
);

The SSE transport requires a server URL and optionally headers. The connectWithRetry method attempts to reconnect automatically if the initial connection fails.

Registering Notification Handlers

After connecting to a server, we can register handlers for various notifications:

// Register notification handlers
client.onToolsListChanged(() {
  logToConsoleAndFile('Tools list has changed!', _logger, logSink);
});

client.onResourcesListChanged(() {
  logToConsoleAndFile('Resources list has changed!', _logger, logSink);
});

client.onResourceUpdated((uri) {
  logToConsoleAndFile('Resource has been updated: $uri', _logger, logSink);
});

client.onLogging((level, message, logger, data) {
  logToConsoleAndFile('Server log [$level]: $message', _logger, logSink);
});

These handlers will be called when the corresponding events occur on the server.

Working with Tools and Resources

Now let's explore how to use tools and resources provided by the server.

Listing and Calling Tools

try {
  // Get available tools
  final tools = await client.listTools();
  logToConsoleAndFile('\n--- Available Tools ---', _logger, logSink);

  if (tools.isEmpty) {
    logToConsoleAndFile('No tools available.', _logger, logSink);
  } else {
    for (final tool in tools) {
      logToConsoleAndFile('Tool: ${tool.name} - ${tool.description}', _logger, logSink);
    }
  }

  // Call a specific tool: list directory contents
  if (tools.any((tool) => tool.name == 'list_directory')) {
    logToConsoleAndFile('\n--- Listing Current Directory ---', _logger, logSink);

    final result = await client.callTool('list_directory', {
      'path': Directory.current.path
    });

    if (result.isError == true) {
      logToConsoleAndFile('Error: ${(result.content.first as TextContent).text}', _logger, logSink);
    } else {
      final contentText = (result.content.first as TextContent).text;
      logToConsoleAndFile('Directory contents:', _logger, logSink);
      logToConsoleAndFile(contentText, _logger, logSink);
    }
  }
} catch (e) {
  logToConsoleAndFile('Error working with tools: $e', _logger, logSink);
}

Listing and Reading Resources

try {
  // Get available resources
  final resources = await client.listResources();
  logToConsoleAndFile('\n--- Available Resources ---', _logger, logSink);

  if (resources.isEmpty) {
    logToConsoleAndFile('No resources available.', _logger, logSink);
  } else {
    for (final resource in resources) {
      logToConsoleAndFile('Resource: ${resource.name} (${resource.uri})', _logger, logSink);
    }

    // Read a file resource
    if (resources.any((resource) => resource.uri.startsWith('file:'))) {
      final readmeFile = 'README.md';
      if (await File(readmeFile).exists()) {
        logToConsoleAndFile('\n--- Reading README.md via resource ---', _logger, logSink);

        try {
          final fullPath = '${Directory.current.path}/$readmeFile';
          final resourceResult = await client.readResource('file://$fullPath');

          if (resourceResult.contents.isEmpty) {
            logToConsoleAndFile('Resource has no content.', _logger, logSink);
          } else {
            final content = resourceResult.contents.first.text ?? '';

            // Show partial content if too long
            if (content.length > 500) {
              logToConsoleAndFile('${content.substring(0, 500)}...\n(Content truncated)', _logger, logSink);
            } else {
              logToConsoleAndFile(content, _logger, logSink);
            }
          }
        } catch (e) {
          logToConsoleAndFile('Error reading resource: $e', _logger, logSink);
        }
      }
    }
  }
} catch (e) {
  logToConsoleAndFile('Resources functionality not supported: $e', _logger, logSink);
}

Practical Example: Filesystem Server Integration

Let's create a complete example that communicates with a filesystem MCP server. First, we need to install the server:

npm install -g @modelcontextprotocol/server-filesystem

Now, let's implement a client that interacts with this server:

import 'dart:io';
import 'dart:convert';
import 'package:mcp_client/mcp_client.dart';

/// MCP client example application
void main() async {
  final Logger _logger = Logger.getLogger('mcp_client_example');
  _logger.setLevel(LogLevel.debug);

  // Create a log file
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();

  logToConsoleAndFile('Starting MCP client example...', _logger, logSink);

  try {
    // Create a client
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );

    logToConsoleAndFile('Client initialized successfully.', _logger, logSink);

    // Connect to the filesystem MCP server via STDIO
    logToConsoleAndFile('Connecting to MCP filesystem server...', _logger, logSink);

    final transport = await McpClient.createStdioTransport(
      command: 'npx',
      arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path],
    );

    logToConsoleAndFile('STDIO transport mechanism created.', _logger, logSink);

    // Establish connection
    await client.connect(transport);
    logToConsoleAndFile('Successfully connected to server!', _logger, logSink);

    // Register notification handlers
    client.onToolsListChanged(() {
      logToConsoleAndFile('Tools list has changed!', _logger, logSink);
    });

    client.onResourcesListChanged(() {
      logToConsoleAndFile('Resources list has changed!', _logger, logSink);
    });

    client.onResourceUpdated((uri) {
      logToConsoleAndFile('Resource has been updated: $uri', _logger, logSink);
    });

    client.onLogging((level, message, logger, data) {
      logToConsoleAndFile('Server log [$level]: $message', _logger, logSink);
    });

    // Check server health (with error handling)
    try {
      final health = await client.healthCheck();
      logToConsoleAndFile('\n--- Server Health Status ---', _logger, logSink);
      logToConsoleAndFile('Server running: ${health.isRunning}', _logger, logSink);
      logToConsoleAndFile('Connected sessions: ${health.connectedSessions}', _logger, logSink);
      logToConsoleAndFile('Registered tools: ${health.registeredTools}', _logger, logSink);
      logToConsoleAndFile('Registered resources: ${health.registeredResources}', _logger, logSink);
      logToConsoleAndFile('Registered prompts: ${health.registeredPrompts}', _logger, logSink);
      logToConsoleAndFile('Uptime: ${health.uptime.inSeconds} seconds', _logger, logSink);
    } catch (e) {
      logToConsoleAndFile('Health check functionality not supported: $e', _logger, logSink);
    }

    // List available tools
    try {
      final tools = await client.listTools();
      logToConsoleAndFile('\n--- Available Tools ---', _logger, logSink);

      if (tools.isEmpty) {
        logToConsoleAndFile('No tools available.', _logger, logSink);
      } else {
        for (final tool in tools) {
          logToConsoleAndFile('Tool: ${tool.name} - ${tool.description}', _logger, logSink);
        }
      }

      // List directory contents
      if (tools.any((tool) => tool.name == 'list_directory')) {
        logToConsoleAndFile('\n--- Current Directory Contents ---', _logger, logSink);

        final result = await client.callTool('list_directory', {
          'path': Directory.current.path
        });

        if (result.isError == true) {
          logToConsoleAndFile('Error: ${(result.content.first as TextContent).text}', _logger, logSink);
        } else {
          final contentText = (result.content.first as TextContent).text;
          logToConsoleAndFile('Directory contents:', _logger, logSink);
          logToConsoleAndFile(contentText, _logger, logSink);
        }
      }

      // Get file information
      if (tools.any((tool) => tool.name == 'get_file_info')) {
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists()) {
          logToConsoleAndFile('\n--- README.md File Information ---', _logger, logSink);

          final infoResult = await client.callTool('get_file_info', {
            'path': '${Directory.current.path}/$readmeFile'
          });

          if (infoResult.isError == true) {
            logToConsoleAndFile('Error: ${(infoResult.content.first as TextContent).text}', _logger, logSink);
          } else {
            final infoText = (infoResult.content.first as TextContent).text;
            logToConsoleAndFile('File info:', _logger, logSink);
            logToConsoleAndFile(infoText, _logger, logSink);
          }
        }
      }

      // Read file contents
      if (tools.any((tool) => tool.name == 'read_file')) {
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists()) {
          logToConsoleAndFile('\n--- Reading README.md File ---', _logger, logSink);

          final readResult = await client.callTool('read_file', {
            'path': '${Directory.current.path}/$readmeFile'
          });

          if (readResult.isError == true) {
            logToConsoleAndFile('Error: ${(readResult.content.first as TextContent).text}', _logger, logSink);
          } else {
            final content = (readResult.content.first as TextContent).text;

            // Truncate if too long
            if (content.length > 500) {
              logToConsoleAndFile('${content.substring(0, 500)}...\n(Content truncated)', _logger, logSink);
            } else {
              logToConsoleAndFile(content, _logger, logSink);
            }
          }
        }
      }
    } catch (e) {
      logToConsoleAndFile('Error listing tools: $e', _logger, logSink);
    }

    // Check resources (with error handling)
    try {
      logToConsoleAndFile('\n--- Checking Resources ---', _logger, logSink);
      final resources = await client.listResources();

      if (resources.isEmpty) {
        logToConsoleAndFile('No resources available.', _logger, logSink);
      } else {
        for (final resource in resources) {
          logToConsoleAndFile('Resource: ${resource.name} (${resource.uri})', _logger, logSink);
        }

        // Try to read README.md as a resource
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists() && 
            resources.any((resource) => resource.uri.startsWith('file:'))) {
          logToConsoleAndFile('\n--- Reading README.md as Resource ---', _logger, logSink);

          try {
            final fullPath = '${Directory.current.path}/$readmeFile';
            final resourceResult = await client.readResource('file://$fullPath');

            if (resourceResult.contents.isEmpty) {
              logToConsoleAndFile('Resource has no content.', _logger, logSink);
            } else {
              final content = resourceResult.contents.first.text ?? '';

              // Truncate if too long
              if (content.length > 500) {
                logToConsoleAndFile('${content.substring(0, 500)}...\n(Content truncated)', _logger, logSink);
              } else {
                logToConsoleAndFile(content, _logger, logSink);
              }
            }
          } catch (e) {
            logToConsoleAndFile('Error reading resource: $e', _logger, logSink);
          }
        }
      }
    } catch (e) {
      logToConsoleAndFile('Resources functionality not supported: $e', _logger, logSink);
    }

    // Wait briefly then exit
    await Future.delayed(Duration(seconds: 2));
    logToConsoleAndFile('\nExample execution completed.', _logger, logSink);

    // Disconnect client
    client.disconnect();
    logToConsoleAndFile('Client disconnected.', _logger, logSink);

  } catch (e, stackTrace) {
    logToConsoleAndFile('Error: $e', _logger, logSink);
    logToConsoleAndFile('Stack trace: $stackTrace', _logger, logSink);
  } finally {
    // Close log file
    await logSink.flush();
    await logSink.close();
  }
}

/// Log to both console and file
void logToConsoleAndFile(String message, Logger logger, IOSink logSink) {
  // Log to console
  logger.debug(message);

  // Log to file
  logSink.writeln(message);
}

Running the Example

To run the example, execute:

dart run bin/mcp_client_example.dart

The output will be displayed in the console and also saved to mcp_client_example.log.

Error Handling and Troubleshooting

When working with MCP clients, you may encounter various errors. Here are some common issues and how to handle them:

Common Errors

  1. Method not found errors
   McpError (-32601): Method not found

This occurs when the client attempts to call a method that doesn't exist on the server. Always wrap method calls in try-catch blocks and check server capabilities first.

  1. Resources not supported
   McpError: Server does not support resources

Some servers only implement tools without resource capabilities. Handle this by checking capabilities or using try-catch blocks.

  1. Connection errors
   Failed to connect to transport

Ensure the server is running and the connection parameters are correct. For STDIO transport, verify the command and arguments.

Debugging Tips

  • Enable debug logging to see detailed communication between client and server.
  • Check server capabilities before using features.
  • Verify tool and resource names, as they can vary between different servers.
  • Isolate critical API calls in try-catch blocks.
// Example error handling pattern
try {
  // Potentially problematic code
  final result = await client.callTool('tool_name', { 'param': 'value' });
  // Process result
} catch (e) {
  if (e.toString().contains('Method not found')) {
    // Handle missing tool
    print('The tool "tool_name" is not available on this server');
  } else {
    // Handle other errors
    print('Error calling tool: $e');
  }
}

Conclusion

In this article, we've explored how to implement an MCP client using Dart. We've covered:

  1. Setting up a client and configuring capabilities
  2. Connecting to servers using different transport mechanisms (STDIO and SSE)
  3. Working with tools and resources
  4. Handling notifications and events
  5. Properly handling errors and connection issues

The MCP client allows applications to communicate with MCP servers, unlocking powerful capabilities like file system access, API integrations, and more. This enables AI models to interact with local systems in a standardized way.

In our next article, we'll explore integrating MCP with large language models (LLMs) using the mcp_llm package, showing how models like Claude can leverage local system capabilities through MCP.

Further Reading

If you found this tutorial helpful, please consider supporting the development of more free content through Patreon. Your support helps me create more high-quality developer tutorials and tools.

Support on Patreon

This article is part of a series on Model Context Protocol implementation. Stay tuned for future articles on MCP and LLM integration.

Tags: Dart, Model Context Protocol, API Integration, Client Development, AI Integration