Building an MCP Client and Server from Scratch   with an Open LLM

Vikas Kumar Ojha profile

Vikas Kumar Ojha

MachineLearning@Avalara Ex-Samsung

Uploaded

If you're a sci-fi fan, you've probably wished at some point for a real-life Jarvis. While true AGI is still far off and what “AGI” even means is a debate for another day, deep learning has advanced dramatically, shifting from narrow, domain-specific systems to highly generalized models. Modern chatbots built with generative techniques feel remarkably human. Even more fascinating is the rise of the MCP (Model Context Protocol), which enables LLMs to take meaningful actions in the real world, making them exponentially more useful. ChatGPT was already impressive with its coding and writing abilities, but when paired with MCP-enabled applications like Cursor, it can genuinely feel like a capable teammate, provided it is used with care.

Most tutorials I've come across focus on building an MCP server and then rely on existing client orchestrators such as Claude Desktop, Cursor, or LLM Studio. Instead, I wanted to take a different approach: building the client orchestration layer from scratch. This not only gives me far more flexibility but also forces me to deeply understand and re-implement the orchestration mechanics that power MCP clients.

----

Understanding Model Context Protocol

Before we dive into implementation, it's better to understand how MCP works. Let's begin with the most fundamental question.

What is the Model Context Protocol (MCP)?

To simplify, MCP is a standard that a MCP client would use to interact with the MCP servers.

Before we dive into how it all works, let's first break down the core components involved. Once we understand these building blocks, it will become much easier to see how they come together to accomplish the task at hand.

MCP Server

An MCP server is a standalone component that receives requests from a client, typically in JSON or another supported format, performs an action, and returns a processed response.

Uploaded

I don't want to make this sound overly technical, so I put together a simple diagram to show the main idea. Basically, an MCP server is just a service that talks to your app or hits internal/external APIs, does whatever work you ask it to do, and then sends the results back to the MCP client.

MCP Orchestration

The MCP client, or orchestration layer, is a standalone component that runs its own server and can send requests to various MCP servers, whether they’re running locally or in an external environment. This is also the part the user directly interacts with. Classic examples include tools like Claude Desktop and Cursor.

Uploaded

If you've worked in the Generative AI or NLP space, you probably know that LLMs can't actually do things; they simply predict the next word based on the previous ones, a process called autoregressive modeling. So how does something like Cursor read a file, inspect its contents, or even edit it when you ask? That's where MCP orchestration comes in. We'll understand this better through the application we're building.

----

Calculator and converter MCP

To keep things simple and focus on the core concept, I chose to build basic MCP servers like a calculator and a converter. These servers are easier to implement, and since LLMs typically struggle with calculations, offloading such tasks to MCP servers makes sense.

Calculator MCP

Our calculator is a simple server that has functions/tools to do calculations like adding, subtracting, multiplying, etc. 

Converter MCP 

Our converter is simple too, and has tools to convert temperature.

MCP Client 

Our client is a server that hosts an open-source model called Gorilla-openfunctions that has been finetuned on toolchain calling datasets. You can find the huggingface repo here.

----

What does the flow look like?.

The capabilities or functions offered by an MCP server are usually called tools.

MCP tools discovery

Each MCP server has something called a toolchain registry. When an MCP client starts up, it sends a toolchain discovery request to all the MCP servers it can access. In response, each MCP server returns metadata about its tools - such as the tool or function name, the parameters it accepts, the output it returns, and what it actually does. With this information, the MCP client knows exactly which tool to call when it needs to perform a specific task.

Uploaded

Now MCP Client/LLM will be aware of the tools available at its disposal.

MCP tool calling

As discussed above, an LLM can only generate text - it can't directly call any tools. So if a user asks the MCP client to add two numbers, the orchestration layer prepares a prompt that includes the user's query along with a list of available tools. It also instructs the model to use a specific syntax, like <call_function>function_name</call_function> with the required parameters, whenever a tool needs to be invoked. Since the LLM is trained on tool-calling examples, it knows when to trigger a tool call and when not to. The orchestration layer then watches the model's output; if it detects a tool-call pattern, it executes the tool and feeds the tool's result back into the LLM on the next step.

Uploaded

The idea is pretty simple, but what happens when there are lots of tools or servers available? After all, an LLM has a limited context window.

I had the same question when I was building the MCP client. There are several ways to handle this, such as:

(i) Storing all tool metadata in a vector database and retrieving the most relevant ones using similarity search.

(ii) Getting summaries of tools from each MCP server and letting the MCP orchestration handle routing with the help of a smaller router model.

(iii) Storing keywords or intents for each tool and selecting the right one based on those.

These are just a few options, but I decided to go with the first approach.

Enough with theory, let's implement it in code.

----

Building the MCP servers

We'll be implementing our MCP servers using a Python library called ModelContextProtocol (mcp).

Now, I know what you might be thinking: "Didn't you say you were going to build everything from scratch?"

 You're right - and I actually did try building a fully custom version using FastAPI. It worked, but it was very basic. The overall flow was similar to what we discussed earlier, but I eventually decided to switch back to the MCP library for the server implementation for a few reasons:

(i) There are already plenty of MCP server implementations available online, and even the ones that run inside Docker or Claude Desktop give you a solid reference point.

(ii) There are far fewer MCP client or orchestration implementations - especially simple, well-explained ones - and that's where I want to focus.

(iii) I'm currently exploring some interesting ideas, and I want my setup to easily integrate with existing Docker-based MCP servers so I can build something useful for myself and the community.

If you still want the FastAPI version, I can share the code or even write a separate blog post about it. Just let me know in the comments.

With that out of the way, let's jump into the implementation.

Calculator MCP

          # Calculator MCP server.
import os
import logging
from typing import Any

from mcp.server.fastmcp import FastMCP

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mcp-server")

# Read host/port from environment (with defaults)
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))

mcp = FastMCP(host=HOST, port=PORT)

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

@mcp.tool()
def subtract(a: int, b: int) -> int:
    """Subtract two numbers."""
    return a - b

@mcp.tool()
def multiply(a: int, b: int) -> int:
    """Multiply two numbers."""
    return a * b

@mcp.tool()
def divide(a: float, b: float) -> float:
    """Divide two numbers. Returns a float. Raises error if dividing by zero."""
    if b == 0:
        raise ValueError("Division by zero is not allowed.")
    return a / b

if __name__ == "__main__":
    logger.info("Starting MCP server on %s:%d", HOST, PORT)
    # Running with SSE as transport because we will use same in client to interact.
    mcp.run("sse")     

In the above, we are using the FastMCP class from the mcp library. The @mcp.tool decorators are used to define tools in the MCP library. The MCP library uses the comments and parameter names and their types to generate the metadata, which is sent to the MCP Client when it requests tools discovery. So it's important to document the functions you are defining in the tool.

Converter MCP

          # Converter MCP server.py
import os
import logging
from typing import Any

from mcp.server.fastmcp import FastMCP

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mcp-server")

# Read host/port from environment (with defaults)
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))

mcp = FastMCP(host=HOST, port=PORT)

@mcp.tool()
def celsius_to_fahrenheit(celsius: float):
    """Convert temperature from Celsius to Fahrenheit."""
    return (celsius * 9 / 5) + 32

@mcp.tool()
def fahrenheit_to_celsius(fahrenheit: float):
    """Convert temperature from Fahrenheit to Celsius."""
    return (fahrenheit - 32) * 5 / 9

if __name__ == "__main__":
    logger.info("Starting MCP server on %s:%d", HOST, PORT)
    # FastMCP's .run() will start the server. transport "sse" is common for remote clients.
    mcp.run("sse")

Similarly, we've defined our converter MCP server as well. Just like before, we're using SSE as the transport mechanism. The available options are stdout, HTTP, and SSE, but I'm choosing SSE because it's efficient and can communicate with remote MCP servers easily. This flexibility will likely be useful for me for the project I am planning next. The ports are the same in both the calculator and the converter this I will explain later why I chose to do.

----

Building MCP Client Orchestration

In my opinion, this is the most important part of the entire blog - the implementation of the MCP client.

Our MCP client will be built using FastAPI because it delivers great performance, is easy to work with, and makes deployment straightforward if we ever need to scale or expose the client as a service. Plus, FastAPI is quite popular right now, so chances are most readers will already be familiar with it.

Some utility functions

          import asyncio
import json
import re
import ast
from typing import Dict, List, Optional, Union, Any,Tuple
import aiohttp
from urllib.parse import urljoin
from utils import *
import numpy as np
import copy

def build_tool_index(server_map: Dict[str, List[Dict]]) -> Dict[str, List[str]]:
    """
    Build an index mapping tool name -> list of server URLs that provide that tool.
    """
    index: Dict[str, List[str]] = {}
    for server_url, tools in server_map.items():
        for tool in tools:
            name = tool.get("name")
            if not name:
                continue
            index.setdefault(name, []).append(server_url)
    return index

def try_parse_function_call(text: str) -> Optional[Tuple[str, dict]]:
    """
    Robust function call extractor.
    Handles noisy model outputs like:
      <<<CALL_FUNCTION>>>subtract(a=2, b=3)
      <<function>> add(x=1,y=2)
      function_call: multiply(a=2)
    """
    if not text:
        return None

    txt = text.strip()

    # Flexible patterns
    patterns = [
        
        r"<+ *CALL_FUNCTION *>+\s*([A-Za-z0-9_]+)\s*\((.*)\)",
        
        r"<+ *function *>+\s*([A-Za-z0-9_]+)\s*\((.*)\)",
        
        r"CALL_FUNCTION\s*:?\s*([A-Za-z0-9_]+)\s*\((.*)\)",
        
        r"([A-Za-z0-9_]+)\s*\((.*)\)"
    ]

    for p in patterns:
        m = re.search(p, txt, flags=re.IGNORECASE | re.DOTALL)
        if not m:
            continue

        func_name = m.group(1)
        args_str = m.group(2).strip()
        args = {}

        # No args
        if args_str == "":
            return func_name, args

        # Split arguments by commas NOT inside quotes
        parts = [s.strip() for s in re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', args_str) if s.strip()]

        for part in parts:
            if "=" in part:
                k, v = part.split("=", 1)
                k = k.strip()
                v = v.strip()

                try:
                    parsed_v = ast.literal_eval(v)
                except Exception:
                    parsed_v = v.strip('"').strip("'")

                args[k] = parsed_v

            else:
                # positional args
                args.setdefault("_args", []).append(part)

        return func_name, args

    return None

def get_server_for_tool(
    tool_name: str,
    server_map: Dict[str, List[Dict]],
    *,
    first_only: bool = True,
    case_sensitive: bool = False
) -> Optional[Union[str, List[str]]]:
    """
    Return the server URL(s) that provide the tool `tool_name`.

    Args:
      tool_name: name of the tool to look up (e.g. "add").
      server_map: dict mapping server_url -> list of tool dicts (like your server_map_dict).
      first_only: if True (default) return the first matching URL (string).
                  if False return a list of all matching URLs (could be empty).
      case_sensitive: if False (default) match tool name case-insensitively.

    Returns:
      If first_only=True -> a string (server URL) or None if not found.
      If first_only=False -> list of matching URLs (empty list if none found).
    """
    # Build index (fast for repeated lookups)
    index = build_tool_index(server_map)

    if not case_sensitive:
        # build a case-insensitive mapping
        ci_index = {}
        for name, urls in index.items():
            ci_index[name.lower()] = ci_index.get(name.lower(), []) + urls
        matches = ci_index.get(tool_name.lower(), [])
    else:
        matches = index.get(tool_name, [])

    if first_only:
        return matches[0] if matches else None
    return matches

def sanitize_for_json(obj: Any) -> Any:
    """
    Recursively convert NumPy arrays/scalars and other common non-JSON types
    to JSON-native Python types.
    - numpy.ndarray -> list
    - numpy scalar (np.generic) -> native python via .item()
    - bytes/bytearray -> utf-8 str (or list of ints if decode fails)
    """
    # numpy array -> list
    if isinstance(obj, np.ndarray):
        return obj.tolist()

    # numpy scalar -> native Python (works across numpy versions)
    if isinstance(obj, np.generic):
        try:
            return obj.item()
        except Exception:
            # fallback: convert to Python scalar string
            return str(obj)

    # bytes -> try decode
    if isinstance(obj, (bytes, bytearray)):
        try:
            return obj.decode("utf-8")
        except Exception:
            return list(obj)

    # dict/list/tuple -> recurse
    if isinstance(obj, dict):
        return {sanitize_for_json(k): sanitize_for_json(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [sanitize_for_json(v) for v in obj]
    if isinstance(obj, tuple):
        return tuple(sanitize_for_json(v) for v in obj)

    # fallback: assume JSON-serializable already
    return obj

def get_prompt(user_query: str, functions: list = []) -> str:
    system = (
        "You are a LLM you can answer questions. If you are not sure about it and you believe you can use a tool then you can call it.\n"
        "If you need to call a tool, output EXACTLY the marker >>>CALL_FUNCTION<<< followed\n"
        "by a single JSON object with keys 'name' and 'arguments' on the next line.\n"
        "Example:\n"
        ">>>CALL_FUNCTION<<<\n"
        "{\"name\": \"get_current_weather\", \"arguments\": {\"location\": \"Boston, MA\", \"unit\": \"celsius\"}}\n"
        "If you do not need to call a function, just answer normally.\n",
        "Also don't give example how to call the function but give the exact marker defined above."
    )
    functions_string = json.dumps(functions, indent=2)
    return f"{system}\nAvailable Functions:\n{functions_string}\nUser: {user_query}\nAssistant:"

def _find_json_block(s: str):
    """
    Locate the first JSON object in the string by finding the first '{' and
    scanning forward until the braces are balanced. Handles quoted strings
    and escaped quotes inside those strings.
    """
    start = s.find('{')
    if start == -1:
        return None

    depth = 0
    in_string = None
    escape = False

    for i in range(start, len(s)):
        ch = s[i]

        if in_string is not None:
            # We're inside a quoted string
            if escape:
                # previous char was backslash, so this char is escaped
                escape = False
            elif ch == "\\":
                # start an escape for the next char
                escape = True
            elif ch == in_string:
                # closing quote
                in_string = None
            # otherwise remain inside the string
        else:
            # not inside a string
            if ch == '"' or ch == "'":
                in_string = ch
            elif ch == '{':
                depth += 1
            elif ch == '}':
                depth -= 1
                if depth == 0:
                    return s[start:i+1]

    return None

def sanitize_for_json(obj: Any) -> Any:
    """
    Recursively convert numpy arrays/scalars and basic non-JSON types to JSON-native types.
    """
    # numpy arrays -> lists
    if isinstance(obj, np.ndarray):
        return obj.tolist()
    # numpy scalars -> native Python types
    if isinstance(obj, (np.integer, np.int_)):
        return int(obj)
    if isinstance(obj, (np.floating, np.float64)):
        return float(obj)
    if isinstance(obj, (np.bool_)):
        return bool(obj)
    # bytes -> decode if possible
    if isinstance(obj, (bytes, bytearray)):
        try:
            return obj.decode("utf-8")
        except Exception:
            return list(obj)
    # dict/list/tuple -> recurse
    if isinstance(obj, dict):
        return {sanitize_for_json(k): sanitize_for_json(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [sanitize_for_json(v) for v in obj]
    if isinstance(obj, tuple):
        return tuple(sanitize_for_json(v) for v in obj)
    # fallback: leave as-is
    return obj

def _split_args_top_level(args_str: str):
    # split on commas that are not inside quotes or parentheses
    parts = re.split(r',(?=(?:[^\"\']*[\"\'][^\"\']*[\"\'])*[^\"\']*$)', args_str)
    # the regex above is a heuristic - trim parts
    return [p.strip() for p in parts if p.strip()]

async def call_mcp_sse(name: str, arguments: dict, url: str = "http://localhost:8000/sse",
                                 init_timeout: int = 10, call_timeout: int = 60):
    """
    MCP SSE flow with a properly populated `initialize` payload.
    Steps:
      1) GET /sse and wait for event: endpoint -> data: /messages/?session_id=...
      2) POST initialize (id=0) with required params {protocolVersion, capabilities, clientInfo}
         and wait for its JSON-RPC response on the SSE stream.
      3) POST tools/call (id=1)
      4) Read SSE until JSON-RPC response for id=1 which contains the tool result
    """

    rpc_initialize_id = 0
    rpc_call_id = 1

    # <-- IMPORTANT: supply required fields under params -->
    init_params = {
        "protocolVersion": "1.0",
        # Capabilities: adjust as needed for your client. Keep keys conservative.
        "capabilities": {
            "supportsToolCalls": True,
            "supportsStreaming": True,
            # add other flags your client supports if desired
        },
        "clientInfo": {
            "name": "simple-python-client",
            "version": "0.1",
            "description": "Debug client for MCP SSE calls"
        }
    }

    init_payload = {"jsonrpc": "2.0", "id": rpc_initialize_id, "method": "initialize", "params": init_params}
    call_payload = {"jsonrpc": "2.0", "id": rpc_call_id, "method": "tools/call", "params": {"name": name, "arguments": arguments}}

    async def _sse_lines(resp):
        while True:
            line = await resp.content.readline()
            if not line:
                break
            yield line.decode("utf-8").rstrip("\r\n")

    headers = {"Accept": "text/event-stream"}
    async with aiohttp.ClientSession() as session:
        # 1) Open SSE GET
        async with session.get(url, headers=headers, timeout=init_timeout) as resp:
            if resp.status != 200:
                body = await resp.text()
                raise RuntimeError(f"SSE GET failed: status={resp.status}, body={body}")

            # read until endpoint event
            buf_event = None
            buf_data_lines = []
            messages_path = None
            async for raw in _sse_lines(resp):
                s = raw
                if s == "":
                    if buf_event is not None:
                        data_str = "\n".join(buf_data_lines)
                        if buf_event == "endpoint":
                            messages_path = data_str.strip()
                            break
                    buf_event = None
                    buf_data_lines = []
                    continue
                if s.startswith("event:"):
                    buf_event = s[len("event:"):].strip()
                elif s.startswith("data:"):
                    buf_data_lines.append(s[len("data:"):].lstrip())
                else:
                    # ignore other lines
                    pass

            if not messages_path:
                raise RuntimeError("Did not receive endpoint event from SSE GET (no /messages path).")
            full_messages_url = urljoin(url, messages_path)

            # 2) POST initialize
            async with session.post(full_messages_url, json=init_payload, headers={"Content-Type": "application/json"}, timeout=10) as post_init:
                if post_init.status not in (200, 202):
                    txt = await post_init.text()
                    raise RuntimeError(f"POST initialize failed: status={post_init.status}, body={txt}")
                # server accepted initialize; wait for JSON-RPC response via SSE

            # wait for initialize response (id == rpc_initialize_id)
            init_deadline = asyncio.get_event_loop().time() + init_timeout
            buf_event = None
            buf_data_lines = []
            init_ok = False
            init_response = None
            async for raw in _sse_lines(resp):
                now = asyncio.get_event_loop().time()
                if now > init_deadline:
                    break
                s = raw
                if s == "":
                    if buf_event is not None:
                        data_str = "\n".join(buf_data_lines)
                        try:
                            parsed = json.loads(data_str)
                        except Exception:
                            parsed = None
                        if isinstance(parsed, dict) and parsed.get("id") == rpc_initialize_id:
                            init_response = parsed
                            init_ok = True
                            break
                    buf_event = None
                    buf_data_lines = []
                    continue
                if s.startswith("event:"):
                    buf_event = s[len("event:"):].strip()
                elif s.startswith("data:"):
                    buf_data_lines.append(s[len("data:"):].lstrip())

            if not init_ok:
                # dump a helpful error
                raise RuntimeError(f"Did not receive initialize response before timeout. Last init_response: {init_response}")

            # Optional: inspect the initialize result for diagnostics
            # print("Initialize response:", json.dumps(init_response, indent=2))

            # 3) POST tools/call
            async with session.post(full_messages_url, json=call_payload, headers={"Content-Type": "application/json"}, timeout=10) as call_resp:
                if call_resp.status not in (200, 202):
                    txt = await call_resp.text()
                    raise RuntimeError(f"POST tools/call failed: status={call_resp.status}, body={txt}")

            # 4) wait for tools/call response (id == rpc_call_id)
            call_deadline = asyncio.get_event_loop().time() + call_timeout
            buf_event = None
            buf_data_lines = []
            async for raw in _sse_lines(resp):
                now = asyncio.get_event_loop().time()
                if now > call_deadline:
                    break
                s = raw
                if s == "":
                    if buf_event is not None:
                        data_str = "\n".join(buf_data_lines)
                        try:
                            parsed = json.loads(data_str)
                        except Exception:
                            parsed = None
                        if isinstance(parsed, dict) and parsed.get("id") == rpc_call_id and "result" in parsed:
                            res = parsed["result"]
                            # try to extract text content
                            if isinstance(res, dict) and "content" in res:
                                texts = []
                                for item in res["content"]:
                                    if isinstance(item, dict) and item.get("type") == "text" and "text" in item:
                                        texts.append(item["text"])
                                return "\n".join(texts) if texts else json.dumps(res)
                            if isinstance(res, str):
                                return res
                            return json.dumps(res)
                    buf_event = None
                    buf_data_lines = []
                    continue
                if s.startswith("event:"):
                    buf_event = s[len("event:"):].strip()
                elif s.startswith("data:"):
                    buf_data_lines.append(s[len("data:"):].lstrip())

            raise RuntimeError("Timed out waiting for tools/call JSON-RPC response over SSE.")

These are some of the key utility functions used in our MCP client. Although most of them are well documented, I'll highlight the ones that matter the most.

The call_mcp_sse function sends requests to the MCP server using the SSE transport, triggers the appropriate tools, and returns their results. The get_prompt function prepares the prompt for our LLM, instructing it to generate a specific function-call signature when it decides a tool should be invoked. This prompt is something I arrived at through experimentation-it works well for now, but can be refined further in the future, possibly through more experimentation or by using frameworks like DsPy.

Finally, the get_server_for_tool function maps a tool name to the correct MCP server URL responsible for hosting it. This file is referred to as utils.py in the code.

RAG Tool retrieval module

As discussed in the Core Concepts section, LLMs operate within a fixed context window. To overcome this limitation, we use a RAG-based tool-metadata retrieval system that helps identify the tools most relevant to the current request.

          # mcp_tools_ret_utils.py (replacement functions)
import json
import os
from typing import Dict, List, Any

import numpy as np
from sentence_transformers import SentenceTransformer
import lancedb

from lancedb import connect

# Optional imports for typed vector schema fallback
try:
    from lancedb.pydantic import Vector, LanceModel  # newer versions
    HAVE_PYDANTIC = True
except Exception:
    HAVE_PYDANTIC = False

EMBED_MODEL_NAME = "all-MiniLM-L6-v2"  # change if you use another embedder

def _make_tool_text(tool: Dict[str, Any]) -> str:
    name = tool.get("name", "")
    desc = tool.get("description", "") or ""
    params = tool.get("parameters", None)
    try:
        params_str = json.dumps(params, ensure_ascii=False)
    except Exception:
        params_str = str(params)
    return f"{name}. {desc}. parameters: {params_str}"

def _ensure_json_schema(params: Any) -> Dict[str, Any]:
    if isinstance(params, dict):
        return params
    if isinstance(params, str):
        try:
            parsed = json.loads(params)
            if isinstance(parsed, dict):
                return parsed
        except Exception:
            pass
    return {"type": "object", "properties": {}}

def print_lancedb_debug(db):
    print("lancedb version:", getattr(lancedb, "__version__", "unknown"))
    print("db object methods:", [m for m in dir(db) if not m.startswith("_")])

def index_tools_to_lancedb(
    server_map_dict: Dict[str, List[Dict[str, Any]]],
    db_path: str = "./mcp_tools_lancedb",
    table_name: str = "mcp_tools",
    embedding_model_name: str = EMBED_MODEL_NAME,
    overwrite: bool = False,
) -> None:
    """
    Index discovered tools into LanceDB in a portable way that works across versions.
    The vector column will be stored under the key "embedding" (list[float]).
    """
    model = SentenceTransformer(embedding_model_name)

    rows = []
    for server_url, tools in server_map_dict.items():
        for i, tool in enumerate(tools):
            name = tool.get("name") or f"tool_{i}"
            doc_id = f"{server_url}::{name}::{i}"
            params = _ensure_json_schema(tool.get("parameters"))
            text = _make_tool_text({"name": name, "description": tool.get("description", ""), "parameters": params})
            rows.append(
                {
                    "id": doc_id,
                    "server_url": server_url,
                    "tool_name": name,
                    "description": tool.get("description", "") or "",
                    "parameters": params,
                    "text": text,
                    # embedding is added below
                }
            )

    if not rows:
        print("[index_tools_to_lancedb] no tools to index.")
        return

    texts = [r["text"] for r in rows]
    embeddings = model.encode(texts, show_progress_bar=False, convert_to_numpy=True).astype(np.float32)
    for r, emb in zip(rows, embeddings):
        # Convert to Python list so older lancedb versions accept it
        r["embedding"] = emb.tolist()

    os.makedirs(db_path, exist_ok=True)
    db = connect(db_path)

    # debug info (uncomment if you want to inspect)
    # print_lancedb_debug(db)

    # Try to remove existing table in a portable way
    if overwrite:
        # Some versions expose drop_table or delete_table
        if hasattr(db, "drop_table"):
            try:
                db.drop_table(table_name)
                print(f"[index_tools_to_lancedb] dropped existing table '{table_name}' (overwrite=True).")
            except Exception:
                pass
        elif hasattr(db, "delete_table"):
            try:
                db.delete_table(table_name)
                print(f"[index_tools_to_lancedb] deleted existing table '{table_name}' (overwrite=True).")
            except Exception:
                pass

    # Try several create_table signatures used across releases
    created = False
    create_exceptions = []
    try:
        # common simple form: create_table(name, data)
        db.create_table(table_name, rows)
        created = True
    except Exception as e1:
        create_exceptions.append(e1)
        try:
            # alternate: create_table(name=..., data=...)
            db.create_table(name=table_name, data=rows)
            created = True
        except Exception as e2:
            create_exceptions.append(e2)
            # fallback to explicit schema using pydantic Vector (if available)
            if HAVE_PYDANTIC:
                try:
                    # determine embedding dim from first row
                    dim = len(embeddings[0])
                    class ToolModel(LanceModel):
                        id: str
                        server_url: str
                        tool_name: str
                        description: str
                        parameters: dict
                        text: str
                        embedding: Vector(dim)
                    # create table with schema and mode overwrite to replace if exists
                    db.create_table(table_name, schema=ToolModel, mode="overwrite")
                    tbl = db.open_table(table_name)
                    # many versions expose add/insert or append, try common names
                    if hasattr(tbl, "add"):
                        tbl.add(rows)
                    elif hasattr(tbl, "insert"):
                        tbl.insert(rows)
                    elif hasattr(tbl, "append"):
                        tbl.append(rows)
                    else:
                        # as last resort, try db.create_table with data after schema create
                        try:
                            db.create_table(table_name, rows)
                        except Exception:
                            raise RuntimeError("Could not add rows to newly created typed table.")
                    created = True
                except Exception as e3:
                    create_exceptions.append(e3)

    if not created:
        # If we still didn't create, try opening existing table and inserting rows
        try:
            tbl = db.open_table(table_name)
            # Try common insertion methods
            if hasattr(tbl, "add"):
                tbl.add(rows)
            elif hasattr(tbl, "insert"):
                tbl.insert(rows)
            elif hasattr(tbl, "append"):
                tbl.append(rows)
            else:
                raise RuntimeError("Opened table exists but doesn't support add/insert/append.")
            created = True
            print(f"[index_tools_to_lancedb] appended {len(rows)} rows to existing table '{table_name}'.")
        except Exception as e_open:
            create_exceptions.append(e_open)
            # Give a helpful error message
            msg = "Failed to create or open LanceDB table. Errors:\n" + "\n".join(
                f"- {type(x).__name__}: {x}" for x in create_exceptions
            )
            raise RuntimeError(msg)

    print(f"[index_tools_to_lancedb] table '{table_name}' ready with {len(rows)} rows.")

def fetch_top_k_tools_formatted(
    query: str,
    db_path: str = "./mcp_tools_lancedb",
    table_name: str = "mcp_tools",
    k: int = 5,
    embedding_model_name: str = EMBED_MODEL_NAME
) -> List[Dict[str, Any]]:
    """
    Query LanceDB and return top-k tools in the requested schema.
    Uses the portable search pattern: table.search(vector).limit(k).to_pandas()
    """
    model = SentenceTransformer(embedding_model_name)
    q_emb = model.encode([query], convert_to_numpy=True)[0].astype(np.float32).tolist()

    db = connect(db_path)
    try:
        table = db.open_table(table_name)
    except Exception as e:
        raise RuntimeError(f"Could not open LanceDB table '{table_name}': {e}") from e

    # Portable search: call search(q_emb) then chain limit() and to_pandas() if available
    try:
        search_obj = table.search(q_emb)
        # chain limit if method exists
        if hasattr(search_obj, "limit"):
            search_obj = search_obj.limit(k)
        # prefer to_pandas when available
        if hasattr(search_obj, "to_pandas"):
            df = search_obj.to_pandas()
            hits = []
            for _, row in df.iterrows():
                params = _ensure_json_schema(row.get("parameters", None))
                entry = {"name": row.get("tool_name"), "description": row.get("description") or "", "parameters": params}
                
                entry["server_url"] = row.get("server_url")
                hits.append(entry)
            return hits
        else:
            # try execute() or to_list
            try:
                executed = search_obj.execute()
            except Exception:
                executed = list(search_obj)
            hits = []
            for hit in executed[:k]:
                params = _ensure_json_schema(hit.get("parameters", None))
                entry = {"name": hit.get("tool_name"), "description": hit.get("description") or "", "parameters": params}
                
                entry["server_url"] = hit.get("server_url")
                hits.append(entry)
            return hits
    except Exception as e:
        raise RuntimeError(f"Search failed on table '{table_name}': {e}") from e

In the above code, we have implemented a simple module using MiniLM as the embedding model. This module has functions to store our tools metadata in a vector database called lancedb. And retrieve the top K that are relevant to our query. This file is referred to as mcp_tools_ret_utils.py in the code.

----

Main MCP client code

Importing libraries

          import asyncio
import json
from typing import List, Dict, Any, Optional,Tuple
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from llama_cpp import Llama
from llama_index.tools.mcp import BasicMCPClient, McpToolSpec
from utils import  sanitize_for_json,get_prompt, call_mcp_sse,get_server_for_tool,try_parse_function_call
from mcp_tools_ret_utils import index_tools_to_lancedb, fetch_top_k_tools_formatted
import re
import os
        
----

Some variable declarations

          MCP_SERVER_URLS = os.environ.get('MCP_SERVER_URLS', '')
if MCP_SERVER_URLS:
    MCP_SERVER_URLS = MCP_SERVER_URLS.split(',')

MODEL_PATH = "gorilla-openfunctions-v2-GGUF/gorilla-openfunctions-v2-q4_K_M.gguf"

app = FastAPI(title="MCP")

# Globals populated at startup
server_map_dict: Dict[str, List[Dict[str, Any]]] = {}
llm: Optional[Llama] = None

class QueryRequest(BaseModel):
    query: str
    k: int = 4
        

Main code block

          async def discover_tools(server_urls: List[str]) -> Dict[str, List[Dict[str, Any]]]:
    """
    Discover tools on each MCP server URL and return a mapping:
      { server_url: [ {name, description, parameters}, ... ], ... }
    This is adapted from your provided snippet; it is defensive about missing metadata.
    """
    server_map_dict_local: Dict[str, List[Dict[str, Any]]] = {}

    for server_url in server_urls:
        functions: List[Dict[str, Any]] = []
        try:
            client = BasicMCPClient(server_url)
            tool_spec = McpToolSpec(client=client)

            # Wait for tool list asynchronously with a timeout
            tools = await asyncio.wait_for(tool_spec.to_tool_list_async(), timeout=10)

            for tool in tools:
                meta = getattr(tool, "_metadata", None)
                if meta is None:
                    # fallback: try to access tool metadata properties defensively
                    name = getattr(tool, "name", None) or getattr(tool, "fn", None) or "unknown"
                    description = getattr(tool, "description", "") or ""
                    params_raw = getattr(tool, "fn_schema_str", None)
                else:
                    name = getattr(meta, "name", None) or "unknown"
                    description = getattr(meta, "description", "") or ""
                    params_raw = getattr(meta, "fn_schema_str", None)

                # Try to parse parameters if it's a JSON string; otherwise keep as-is
                params = None
                try:
                    if isinstance(params_raw, str):
                        params = json.loads(params_raw)
                    else:
                        params = params_raw
                except Exception as e:
                    print(f"[discover_tools] Warning: failed to parse fn_schema_str for tool '{name}': {e}")
                    params = params_raw

                func_dict = {
                    "name": name,
                    "description": description,
                    "parameters": params,
                }
                functions.append(func_dict)

        except asyncio.TimeoutError:
            print(f"[discover_tools] Timeout while fetching tools from {server_url}")
        except Exception as e:
            print(f"[discover_tools] Error while contacting {server_url}: {e}")

        # Always set an entry (possibly empty) for this server_url
        server_map_dict_local[server_url] = functions

    return server_map_dict_local

@app.on_event("startup")
async def startup_event():
    """Discover tools and index them at startup. Also create the Llama instance once."""
    global server_map_dict, llm

    # Discover MCP tools (async)
    try:
        server_map_dict = await discover_tools(MCP_SERVER_URLS)
        print("Discovered tools:", {k: len(v) for k, v in server_map_dict.items()})
    except Exception as e:
        print("Warning: discover_tools failed at startup:", e)

    # Index tools into lancedb (safe to call even if server_map_dict is empty)
    try:
        index_tools_to_lancedb(server_map_dict, db_path="./mcp_tools_lancedb", overwrite=True)
        print("Indexed tools to lancedb")
    except Exception as e:
        print("Warning: indexing to lancedb failed:", e)

    # Instantiate Llama in a threadpool to avoid blocking the event loop
    loop = asyncio.get_event_loop()

    def _create_llm():
        print("Creating Llama model (this may take a while)...")
        return Llama(model_path=MODEL_PATH, n_threads=8, n_ctx=2048, n_gpu_layers=35)

    try:
        llm = await loop.run_in_executor(None, _create_llm)
        print("Llama model created")
    except Exception as e:
        print("Error creating Llama model:", e)
        llm = None
    print(f"server_map_dict={server_map_dict}")

async def _call_llm(prompt: str, max_tokens: int = 2048) -> str:
    """Run the synchronous llm(prompt) in a threadpool and return the text string."""
    global llm
    if llm is None:
        raise RuntimeError("LLM not initialized")

    loop = asyncio.get_event_loop()

    def _sync_call():
        out = llm(prompt, max_tokens=max_tokens, echo=False)
        if isinstance(out, dict):
            return out.get("choices", [{}])[0].get("text") or out.get("text") or str(out)
        return str(out)

    return await loop.run_in_executor(None, _sync_call)

@app.post("/query")
async def run_query(req: QueryRequest):
    """Main endpoint. Accepts JSON: {"query":"Add two numbers 3 and 4.", "k":2}

    Flow:
      - fetch top-k tools from lancedb using fetch_top_k_tools_formatted
      - build prompt, call Llama
      - detect function call; if found, call the MCP tool via SSE and optionally follow up
      - return JSON with result and any tool output
    """
    try:
        # Step 1: retrieve candidate tools from lancedb
        functions = fetch_top_k_tools_formatted(req.query, k=req.k)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error fetching tools from index: {e}")
    print(f"functions returned by discovery={functions}")
    # Step 2: prepare prompt
    #functions_no_internal = strip_internal_fields(functions, fields=("embedding",))
    safe_functions = sanitize_for_json(functions)
    prompt = get_prompt(req.query, safe_functions)

    # Step 3: call LLM
    try:
        raw_text = await _call_llm(prompt, max_tokens=2048)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error during LLM call: {e}")

    # Step 4: detect function call
    call = try_parse_function_call(raw_text)

    if call is None:
        # No function call detected - return raw LLM output
        return {
            "final_text": raw_text,
            "tool_called": None,
            "tool_result": None,
            "raw_model_output": raw_text,
            "error": "No valid function call detected in model output"
        }

    func_name, func_args = call

    # Step 5: execute MCP tool via SSE
    url_to_call=get_server_for_tool(func_name, server_map_dict)
    try:
        tool_result = await call_mcp_sse(func_name, func_args,url=url_to_call)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error calling MCP tool: {e}")

    # Step 6: Optional follow-up LLM call to compose final answer
    followup_prompt = (
        prompt
        + "\n\n(The tool call has been executed.)\n"
        + f"Tool name: {func_name}\nTool output: {tool_result}\n" +
        "Now the result need to be formatted in a human readable tone."
        + "Assistant:"
    )

    try:
        final_text = await _call_llm(followup_prompt, max_tokens=2048)
    except Exception as e:
        # If follow-up LLM fails, still return tool_result and raw_text
        print(f"Exception={str(e)}")
        return {
            "final_text": None,
            "tool_called": func_name,
            "tool_result": sanitize_for_json(tool_result),
            "raw_model_output": raw_text,
            "followup_error": str(e),
        }

    return {
        "final_text": final_text,
        "tool_called": func_name,
        "tool_result": sanitize_for_json(tool_result),
        "raw_model_output": raw_text,
    }

if __name__ == '__main__':
    import uvicorn

    # Run with: python fastapi_mcp_endpoint.py  OR: uvicorn fastapi_mcp_endpoint:app --reload
    uvicorn.run(app, host='0.0.0.0', port=3000, log_level='info')
        

There is a startup event declared called startup_event, which is called upon when the application is launched. This event calls the function called discover_tools, which would iteratively discover tools for the server in the server_urls URL list. The retrieved tools are stored in the dictionary server_map_dict. This is stored/indexed in our vector DB using the function index_tools_to_lancedb. The LLM is loaded next in a non-blocking manner using the asyncio threadpool. It might be useful in real-world applications where you don't want the loading to interfere with the server loading, as model loading is CPU-heavy. 

The run query function in the query endpoint is the most important in the entire blog. Again its well documented with steps to follow. At the crux, it does the following operations:

(i) Get the top tools suitable for the query using RAG from the vector DB.

(ii) Embed it in a prompt using get_prompt and pass to LLM.

(iii) Extract the function's signature if tool calling is needed.

(iv) Call the appropriate tool and get results.

(v) Reformat the result and present to the user, recall another tool if needed.

----

Packaging the MCP client and server as Docker

Nothing is stopping us from manually setting up the dependencies for the MCP server and MCP clients and running all three as separate scripts. That alone would make the MCP client–server interface work. However, containerizing the setup with Docker makes dependency management much easier, and it also simplifies migrating the entire system to another machine.

For example, I develop on an Ubuntu VM because my main system runs Windows 11. With Docker, I can take the same setup and run it with GPU support directly on Windows without any additional configuration.

So I wrote a helper script that takes the folder structure in my project and iterates over the MCP server folder, and generates Docker-Compose YAML for running the container in the required order. Please refer to my git repo in the references section for finding and running that script.

----

MCP in action

----

Further Improvements

1. The retrieval logic for tools metadata is very basic and can be further refined.

2. Prompts can be improved as discussed above.

3. Better inference pipelines like vLLM could be used for inference.

4. The model can further be finetuned to improve the result, although prompt optimization should be prioritized here.

----

References and Further Readings


----

If you liked this blog, please consider sharing it with your network, which would motivate me to write more blogs like this. If you have any questions, please feel free to drop them in the comments section. 

Thanks for taking the time to read the complete blog.

  
  



Show Comments