MCP Client – Step by Step Guide to Building from Scratch

MCP client from scratch

In my previous guide, we explored building MCP servers from scratch. Today, we’ll explore MCP client development (based on a request by readers), the critical bridge between AI applications and external systems.

In this blog, we will build a custom CLI-based MCP chatbot client from scratch. It will have two sections.

  1. 1. MCP client with STDIO connecting to a custom local tool
  2. 2. MCP client with Composio SSE server for connecting with external services like Slack.

But before that, here’s a quick refresher on MCP!

Revisiting MCP

MCP stands for Model Context Protocol. It is a universal protocol developed by Anthropic to provide a standardised way to connect to tools, resources, local storage, databases and much more. Think of it as a type-C connector but for LLMS.

Here is a minor breakdown of the data flow in MCP:

  1. 1. The AI model sends a request (e.g., “fetch user profile data”).
  2. 2. The MCP client forwards the request to the appropriate MCP server.
  3. 3. The MCP server retrieves the required data from a database or API.
  4. 4. The MCP client sends the response back to the AI model.
  5. 5. The MCP client displays the output in the terminal / UI

MCP works on client-server architecture, meaning you can define your server and client. In this blog, our focus will be solely on the MCP Client.

But why build one?

Why Custom Client?

Proprietary clients are good and provide support for general use cases. So why opt for custom MCP Clients?

The reason for having a custom MCP Client is that it can help organisations to:

  • • Connect to internal or custom tools that aren’t supported by default.
  • • Provide real-time data and context to the AI for more accurate answers within internal tools.
  • • Stay vendor-agnostic to reduce future maintenance, i.e., switch seamlessly between multiple tools.

However, one should only opt for a custom MCP client when they require tailored integrations, enhanced security, or specific functionalities not addressed by standard solutions.

Having understood the necessity, let’s know how the MCP Client works.

How does the MCP Client work?

MCP client lifecycle can be summarised into three steps: Connect, Exchange, Terminate.

  • Connect: The client initiates the connection by sending its protocol version and capabilities.
  • Handshake: The server responds with its own, establishing a handshake.
  • Exchange: Once connected, the client and server can exchange messages through request-response or one-way notifications/alerts for data exchange.
  • Terminate: If something goes wrong, such as errors, lost networks, or if the client/ server connection is closed, the connection terminates.

To learn more, you can refer to this GitHub repo.

Theory would be useless without practice, so let’s build a custom MCP client from scratch!

Building a Custom MCP Client using Langchain & Gemini

MCP is built on a tool-calling and client-server structure, making it ideal for integrating with any LLM that supports Tool Calling.

So, for simplicity and open source, we will use Google Gemini Models as LLMS and Langchain MCP Adapters to abstract out the tool-calling format conversion.

The Client will be a terminal-based client that connects to any MCP Server, enables conversations, and executes the tool process.

Let’s begin by creating our workspace.

Workspace Setup

We create a parent directory, MCP, and three child directories: services, clients, and workspace.

  • Servers store all the MCP servers.
  • The client stores all clients.
  • & Workspace stores the results of the MCP client-server interaction, usually actions.

You can do it manually or open the terminal and execute all the commands one by one:

>mkdir mcp
> cd mcp
> mkdir servers clients workspace

Ensure your directory structure looks similar to the image below:

Next, create a virtual environment in MCP using:

# windows 
cd mcp
python -m venv .venv

# linux/mac
cd mcp
python -m venv --help
sudo apt-get install python3-venv #install venv - optional
python3 -m venv .venv

Now activate the environment with:

# activates env
.venv\\\\Scripts\\\\activate

# linux/mac
source .venv/bin/activate

Ensure you see (.venv) in front of the terminal cwd path.

Next, add all the required libraries:

pip install langchain langchain_mcp_adapters langgraph langchain_google_genai google-generativeai python-dotenv

You can also use the requirements.txt method to perform the same.

Navigate to the client’s folder and create a .env file

> cd clients
> copy con .env # windows
> touch .env # linux/macc

Now populate it with GOOGLE_API_KEY. To get one, go to Google AI Studio > Get API Key > Create API Key > Select Project > Create API Key in Existing Product.

A new pop-up will open with the API Key, copy it and paste it in place of your-api-key .

GOOGLE_API_KEY=your-api-key

Note: You may need to log in to Google and create a new project with AI API access to get one.

Finally, open the project in any IDE of your choice. I am using VS Code:

cd ..
code . 

Make sure after all steps the directory structure looks similar to the image👇

Now we are ready to code the client.

Add Client Code

In the client’s folder, create a new file called client.py and write the following code:

#!/usr/bin/env python
"""
MCP client that connects to an MCP server, loads tools, and runs a chat loop using Google Gemini LLM.
"""

import asyncio
import os
import sys
import json
from contextlib import AsyncExitStack
from typing import Optional, List

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.prebuilt import create_react_agent
from langchain_google_genai import ChatGoogleGenerativeAI

from dotenv import load_dotenv
load_dotenv()  # Load environment variables from .env (e.g., GOOGLE_API_KEY)

# Custom JSON encoder for objects with 'content' attribute
class CustomEncoder(json.JSONEncoder):
    def default(self, o):
        if hasattr(o, "content"):
            return {"type": o.__class__.__name__, "content": o.content}
        return super().default(o)

# Instantiate Google Gemini LLM with deterministic output and retry logic
llm = ChatGoogleGenerativeAI(
    model="gemini-1.5-pro",
    temperature=0,
    max_retries=2,
    google_api_key=os.getenv("GOOGLE_API_KEY")
)

# Require server script path as command-line argument
if len(sys.argv) < 2:
    print("Usage: python client_langchain_google_genai_bind_tools.py <path_to_server_script>")
    sys.exit(1)
server_script = sys.argv[1]

# Configure MCP server startup parameters
server_params = StdioServerParameters(
    command="python" if server_script.endswith(".py") else "node",
    args=[server_script],
)

# Global holder for the active MCP session (used by tool adapter)
mcp_client = None

# Main async function: connect, load tools, create agent, run chat loop
async def run_agent():
    global mcp_client
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            mcp_client = type("MCPClientHolder", (), {"session": session})()
            tools = await load_mcp_tools(session)
            agent = create_react_agent(llm, tools)
            print("MCP Client Started! Type 'quit' to exit.")
            while True:
                query = input("\\nQuery: ").strip()
                if query.lower() == "quit":
                    break
                # Send user query to agent and print formatted response
                response = await agent.ainvoke({"messages": query})
                try:
                    formatted = json.dumps(response, indent=2, cls=CustomEncoder)
                except Exception:
                    formatted = str(response)
                print("\\nResponse:")
                print(formatted)
    return

# Entry point: run the async agent loop
if __name__ == "__main__":
    asyncio.run(run_agent())

In a nutshell, the code does the following:

  • • Connects to an MCP server via a stdio connection.
  • • Loads the available MCP tools using the adapter function load_mcp_tools.
  • • Creates a React agent using LangGraph’s prebuilt agent (create_react_agent) with the LLM and tools.
  • • Runs an interactive asynchronous chat loop for processing user queries.

Now let’s add the MCP Server code for essential interaction.

Add Server Code

You can use the calculator MCP server we made in an earlier blog. Just copy and paste it into the server folder.

However, to demonstrate tool interaction, I will create a terminal mcp server that executes CLI commands in the terminal.

In the server folder, create a new file called client.py And write the following code:

# imports
import os
import subprocess
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("terminal")

DEFAULT_WORKSPACE = os.path.join(os.getcwd(), "workspace")

# define tool - llm will create code/ command and pass to this fn
@mcp.tool()
async def run_server_command(command: str) -> str:
    """
    Run a terminal command in workspace.
    Args:
        command: The shell command to run on windows

    Returns:
        The command output or an error message.
    """
    # try catch to handle exception
    try:
        result = subprocess.run(command, cwd= DEFAULT_WORKSPACE, shell=True, capture_output=True, text=True)
        return result.stdout or result.stderr
    except Exception as e:
        return str(e)

if __name__ == "__main__":
    mcp.run(transport="stdio")

Here is a breakdown of what the code does:

  • • It imports the necessary modules: os for file paths, subprocess for running shell commands, and FastMCP for defining an MCP instance.
  • • Creates an instance of FastMCP named “terminal”.
  • • Sets a default workspace directory the “workspace”. It will hold all the terminal operation results.
  • • Defines an asynchronous function run_server_command as a tool for the MCP server, which:
    • • Takes a shell command as input.
    • • Runs the command in the workspace directory.
    • • Returns the command’s output or any error message.
  • • Redefine the standard Python entry-point check to run the script directly and pass in mcp.run(transport = stdio) which starts the MCP server using standard input/output for communication.

Now that we have our server and client ready, let’s see it in action

Run The Code

Navigate / cd to the root folder mcp and run the following command:

# python <path-to-client> <path-to-server>
python "clients\\client.py" "servers\\terminal_server.py"

This will initiate a client-server MCP session. If everything goes fine, you will see a Query: message in the terminal.

Let’s try to create a folder test. Within it, create a file data.txt and populate it with the text “MCP Client Building is fun.” Here is a simple prompt to do it.

Create a folder called test, and inside it, create a file named file.txt. In the file, write “MCP Building Is Fun”

This will generate a response and format it using the custom encoder (focus on the last AI message):

And now, if you check the workspace folder, you will get a file with the given name and the instructed text:

Now let’s delete the test folder and file using the following prompt:

Delete the test folder.

I have provided no context, let’s see if it can remember our previous conversation

And here are the results (yes, it remembers):

And now, no file will be present if you check the workspace folder.

If you like a practical demo, here is one:

This means our client followed the prompt instructions perfectly, just like any agent, such as Copilot, Cursor, or Composer, but now it’s all local.

However, this doesn’t end here; you can even integrate and use the custom MCP client with a hosted MCP server and repositories like Composio-MCP, which makes it super easy.

Testing the MCP Client with the Composio SSE Server

Composio is the ultimate integration platform, empowering developers to seamlessly connect AI agents with external tools, 100+ managed MCP servers, and APIS with just a single line of code. This makes it ideal for any use case, including ours.

To test client compatibility with SSE, we will use the Slack MCP server from composio to send messages to a Slack channel.

But first, we need to add the MCP server!

Adding the MCP Server from Composio

Integrating with Composio MCP is incredibly simple and can be done in just five steps:

  1. 1. Visit the Composio MCP Directory page.
  2. 2. In this case, select/Search Tools you need, such as Slack.
  3. 3. Go to the Installation Steps section on the next page and hit Generate.
  4. 4. Copy the generated URL (private info). Make sure to read all the available functions in the Available Actions section.
  5. 5. Head to the terminal, paste the copied URL, and press Enter. Ensure the environment is activated.

The terminal now installs the tool and configures its mcp.json file of (cursor / claude desktop) with the hosted URL:

# mcp.json
{
  "mcpServers": {
    "reddit_composio": {
      "url": "your-private-url"
    }
  }
}

Now let’s create a see-based MCP client.

Creating SSE-Based MCP Client (Terminal usage)

Open the client folder, create a file called sse_client.py And paste the following code:

#!/usr/bin/env python
"""
client_sse.py

MCP client that connects to an SSE-based MCP server, loads tools, and runs a chat loop using Google Gemini LLM.
"""

import os
import sys
import json
import warnings
import logging
import asyncio
from typing import Optional, List

from mcp import ClientSession
from mcp.client.sse import sse_client

from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.prebuilt import create_react_agent
from langchain_google_genai import ChatGoogleGenerativeAI

from dotenv import load_dotenv
load_dotenv()  # Load environment variables from .env (e.g., GEMINI_API_KEY)

# Suppress specific warnings
warnings.filterwarnings('ignore')

# Custom JSON encoder for objects with 'content' attribute
class CustomEncoder(json.JSONEncoder):
    def default(self, o):
        if hasattr(o, "content"):
            return {"type": o.__class__.__name__, "content": o.content}
        return super().default(o)

# Instantiate Google Gemini LLM with deterministic output and retry logic
llm = ChatGoogleGenerativeAI(
    model="gemini-1.5-pro",
    temperature=0,
    max_retries=2,
    google_api_key=os.getenv("GEMINI_API_KEY")
)

# Require MCP server URL as command-line argument
if len(sys.argv) < 2:
    print("Usage: python client_sse.py <mcp_server_url>")
    sys.exit(1)
server_url = sys.argv[1]

# Global holder for the active MCP session (used by tool adapter)
mcp_client = None

# Main async function: connect, load tools, create agent, run chat loop
async def run_agent():
    global mcp_client
    async with sse_client(url=server_url) as streams:
        async with ClientSession(*streams) as session:
            await session.initialize()
            mcp_client = type("MCPClientHolder", (), {"session": session})()
            tools = await load_mcp_tools(session)
            agent = create_react_agent(llm, tools)
            print("MCP Client Started! Type 'quit' to exit.")
            while True:
                query = input("\\nQuery: ").strip()
                if query.lower() == "quit":
                    break
                # Send user query to agent and print formatted response
                response = await agent.ainvoke({"messages": query})
                try:
                    formatted = json.dumps(response, indent=2, cls=CustomEncoder)
                except Exception:
                    formatted = str(response)
                print("\\nResponse:")
                print(formatted)
    return

# Entry point: run the async agent loop
if __name__ == "__main__":
    asyncio.run(run_agent())

Here is a breakdown of what the code does:

  • • It imports necessary modules, including os, sys, asyncio, and components from MCP and LangChain, to manage communication, tool loading, and agent behaviour.
  • • Loads environment variables from a .env file using dotenv, which includes the Gemini API key.
  • • Suppresses warnings to avoid cluttered console output.
  • • Defines a custom JSON encoder to format responses containing an content attribute properly.
  • • Initialises the Gemini 1.5 Pro LLM with zero temperature (deterministic output) and a retry mechanism in case of failure.
  • • Checks if the MCP server URL is provided as a command-line argument and exits if missing.
  • • Creates a global holder mcp_client that will store the active session, allowing tools to access it.
  • • Provides SSE (Server-Sent Events) connection support by importing sse_client from mcp.client.sse.
  • • Inside the run_agent function:
    • • Connects to the MCP server using async with sse_client(url=server_url), which establishes a real-time SSE stream.
    • • Wraps the stream in a ClientSession class that initialises and manages the ongoing connection with the server.
    • • Loads available MCP tools using load_mcp_tools, making them accessible to the agent.
    • • Constructs a ReAct-style agent using create_react_agent the Gemini model and the loaded tools.
    • • Enters a chat loop where:
      • • It waits for user input.
      • • Sends the query to the agent.
      • • Formats and prints the response.
      • • Loops continuously until the user types quit.
  • • Finally, it runs the run_agent coroutine asyncio.run() When the script is executed directly.

Now that the client is wired with SSE connection support and real-time interaction, let’s test the custom MCP client.

Test the Client

Now, head to the terminal, ensure you are in clients the directory and run

#python sse_client.py redit_sse_server_url
python sse_client.py your-composio-server-url #use slack ones

You can fetch the SSE URL by going to mcp.json the file. Also, you may be prompted to authenticate the connection, so do it.

If everything goes fine, you will get the following output:

Now, check if Slack has an active connection; otherwise, ask it to initiate a connection. You will be prompted to authenticate your Slack account. Once done, you can start using the Slack MCP server.

You can see that the connection is initiated and established.

Now let’s send a message to the social channel. Ensure you are in clients the directory, then enter the following prompt in Query:

 Query : "send a message 'hii' to social channel"

Terminal Output: (ignore json schema warnings)

Result:

Social Channel Output

We have successfully sent the message, which means the client works as expected.

Congrats on making it so far. We have reached the end of the article. Here are some of my final thoughts.

Final Thoughts

If you have followed along, you have learnt the following:

  • • How to develop a powerful custom MCP client that can utilise STDIO transport for local usage / SSE transport for connecting with multiple local / hosted servers (URL-based)
  • • Learnt to use Slack with your custom client.

However,

Building production-grade MCP clients requires careful attention to protocol specifics, security, and error handling. It also requires servers that work with minimal effort. So, that’s what we’re building at Composio.

I am excited to see what use cases you all come up with. Please share them in the comments.

Leave a Reply

Your email address will not be published. Required fields are marked *

  • Pricing
  • Explore
  • Blog