DEV Community

MCP Dev Studio
MCP Dev Studio

Posted on

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Install the dependencies:

dart pub get 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

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); 
Enter fullscreen mode Exit fullscreen mode

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), ); 
Enter fullscreen mode Exit fullscreen mode

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); }); 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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); } 
Enter fullscreen mode Exit fullscreen mode

Running the Example

To run the example, execute:

dart run bin/mcp_client_example.dart 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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'); } } 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)