Skip to main content

Command Palette

Search for a command to run...

How to Build MCP Client from Scratch with TypeScript and Groq

Updated
9 min read
How to Build MCP Client from Scratch with TypeScript and Groq
O
Full-stack Ai Engineer

We are going to build MCP Client to connect MCP Server(remote/local)

If you are new to building Model Context Protocol servers, I highly recommend checking out our foundational guide on How to Build Local & Remote MCP Servers before diving into client orchestration.

Architectural Overview

Before we dive into setting up our environment and writing the code, here is a high-level look at how our MCP Client will handle connections, process queries through the Groq API, and execute tools:

Understanding the MCP Client Architecture

Our MCP Client acts as a central orchestrator or router that sits between the user, the LLM provider (Groq API), and the data sources (MCP Servers). The entire lifecycle operates in four simple phases:

1. Server Initialization (connectToServer)

When the client starts, it establishes a communication channel with either a local server (via stdio) or a remote server (via StreamableHTTP). Once connected, the client downloads a list of all available tools that the server exposes and translates them into a format that the LLM understands.

2. The User Interface (chatLoop)

The client opens an interactive terminal interface using Node's readline module. It continuously waits for a user to type a query and hands it off to the query processor.

3. The AI Decision Layer (processQuery)

The client sends the user's question along with the downloaded list of tools to the Groq API. The LLM acts as the "brain"—it analyzes the prompt and decides if it can answer natively, or if it needs to call one of our tools to look up real-time information.

4. The Tool Execution Loop (mcp.callTool)

If the LLM decides it needs a tool, it returns the tool's name and arguments. The client catches this request, safely routes it down to the correct local or remote MCP server to run the actual code, grabs the raw data result, and sends it back to the LLM. The LLM then transforms that raw data into a friendly, human-readable answer for the user.

With this architecture in mind, let's start by setting up our project environment!

Setting Up Your Environment

  • Create and set up our project:

    # Create project directory
    mkdir mcp-client
    cd mcp-client
    
    # Initialize npm project
    npm init -y
    
    # Install dependencies
    npm install groq-sdk @modelcontextprotocol/sdk
    
    # Install dev dependencies
    npm install -D @types/node typescript
    
    # Create src dir
    mkdir src
    cd src
    
    #create index file
    touch index.ts
    
  • Update package.json

    {
      "type": "module",
      "scripts": {
        "build": "tsc",
        "start": "node --env-file=.env dist/app.js"
      }
    }
    
  • Create a tsconfig.json in the root of your project

    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "Node16",
        "moduleResolution": "Node16",
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true
      },
      "include": ["index.ts"],
      "exclude": ["node_modules"]
    }
    
  • Create .env file for setting up Groq API key from Groq Console

    GROQ_API_KEY = <your groq-api-key>
    

Creating the client

Create a src/index.ts file

The Imports and Environment Configuration

here necessary imports required for this code

// official client sdk for mcp to connect to server
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// official sdk for stdio transport for local mcp server
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// official sdk for streamableHttp transport for remote mcp server
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// type for transport
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport";
// offical groq client sdk to connect llm
import { Groq } from "groq-sdk/client.js";
// built-in Node.js module used here to create an interactive chat loop in the terminal
import readline from "readline/promises";

// read grow-api-key
const GROQ_API_KEY = process.env.GROQ_API_KEY;

// throw an error when api key is missing
if (!GROQ_API_KEY) {
  throw new Error("GROQ_API_KEY is not set");
}

MCPClient class Structure

  1. Private Properties To manage execution lifecycles safely, our MCPClient architecture encapsulates four private state properties:

    1. mcp - MCP Client connector

    2. groq - groq api connector

    3. transport - communication channel for both remote and local server

    4. tools - we fetch and store tools from mcp server

  2. Constructor: When a new instance of the MCPClient is initialized, the constructor automatically instantiates our core connection adapters for both Groq and the MCP SDK.

class MCPClient {
  private mcp: Client;
  private groq: Groq;
  private transport:
    | StdioClientTransport
    | StreamableHTTPClientTransport
    | null = null;
  private tools: Groq.Chat.Completions.ChatCompletionTool[] = [];

  constructor() {
    this.groq = new Groq({
      apiKey: GROQ_API_KEY,
    });
    this.mcp = new Client({ name: "mcp-client-cli", version: "1.0.0" });
  }

}

1. Start MCP Connection connectToServer()

connectToServer() method takes two parameters serverType which is local or remote and serverScriptPath depends on server type

  1. Client Transport:

    To connect MCP Client to server we need two client transport StdioClientTransport for local MCP server and StreamableHTTPClientTransport for remote MCP server

  2. Tool-list Storing:

    • by using this.mcp.listTools() we fetch all available tool from MCP server

    • And store this tools inside this.tools with with respective format that groq should understand this

async connectToServer(
    serverScriptPath: string,
    serverType: "local" | "remote",
  ) {
    try {
      if (serverType === "local") {
        const isJs = serverScriptPath.endsWith(".js");
        const isPy = serverScriptPath.endsWith(".py");
        if (!isJs && !isPy) {
          throw new Error("Server script must be a .js or .py file");
        }
        const command = isPy
          ? process.platform === "win32"
            ? "python"
            : "python3"
          : process.execPath;

        this.transport = new StdioClientTransport({
          command,
          args: [serverScriptPath],
        });
      } else if (serverType === "remote") {
        const url = new URL(serverScriptPath);
        this.transport = new StreamableHTTPClientTransport(url);
      }

      await this.mcp.connect(this.transport as Transport);

      const toolsResult = await this.mcp.listTools();
      
      this.tools = toolsResult.tools.map((tool) => {
        return {
          type: "function",
          function: {
            name: tool.name,
            description: tool.description as string,
            parameters: tool.inputSchema,
          },
        };
      });
      console.log(
        "Connected to server with tools:",
        this.tools.map((tool) => tool.function?.name),
      );
    } catch (e) {
      console.log("Failed to connect to MCP server: ", e);
      throw e;
    }
  }

2. Query processing processQuer()

Here we process query and handles the tool calls

  1. The Initial LLM Request

    • here we use messagearray with user query

    • along with message array we also attach this.tool to model about available tools we have.

    const messages: Groq.Chat.ChatCompletionMessageParam[] = [
      { role: "user", content: query },
    ];
    
    const response = await this.groq.chat.completions.create({
      model: "openai/gpt-oss-120b",
      messages,
      tools: this.tools, // Here are the tools we got from the MCP server!
    });
    
  2. Reading the AI's Decision

    • For standard informational prompts, the LLM bypasses tool execution entirely, returning a direct text evaluation via message.content.
    const finalText = [];
    const choise = response.choices[0];
    const assistantMessage = choise?.message;
    
    if (assistantMessage?.content) {
      finalText.push(assistantMessage.content);
    }
    
  3. The Tool Loop Execution

    • assistantMessage?.tool_calls exists if llm requires tool_calls.

    • It iterate over assistantMessage?.tool_calls grab toolName and toolArgs from function and pass it this.mcp.callTool which gives result of that tool

    • We store that tool result inside message array with role tool

    if (
          assistantMessage?.tool_calls &&
          assistantMessage.tool_calls.length > 0
        ) {
          messages.push(assistantMessage);
    
          for (const toolCalls of assistantMessage.tool_calls) {
            if (toolCalls.type !== "function") continue;
    
            const toolName = toolCalls.function.name;
    
            const toolArgs = JSON.parse(toolCalls.function.arguments);
    
            finalText.push(`[Calling tool \({toolName} with args \){toolArgs}]`);
    
            const result = await this.mcp.callTool({
              name: toolName,
              arguments: toolArgs,
            });
    
            messages.push({
              role: "tool",
              tool_call_id: toolCalls.id,
              content: JSON.stringify(result.content),
            });
          }
        }
    
  4. Generating the Final Answer

    • Now that the messages history contains the original question plus the actual data fetched by the tool, your code calls the AI a second time

    • AI look on row tool response and translate into human-friendly way

    const followupResponse = await this.groq.chat.completions.create({
      model: "openai/gpt-oss-120b",
      messages, // This now contains: User query -> AI Tool Request -> Tool Result
    });
    
    if (followupResponse.choices[0]?.message.content) {
      finalText.push(followupResponse.choices[0].message.content);
    }
    return finalText.join("\n");
    
  5. Complete code of processQuery method

    processQuery method code
     async processQuery(query: string) {
        const messages: Groq.Chat.ChatCompletionMessageParam[] = [
          {
            role: "user",
            content: query,
          },
        ];
    
    const response = await this.groq.chat.completions.create({
      model: "openai/gpt-oss-120b",
      messages,
      tools: this.tools,
    });
    

    const finalText = [];

    const choise = response.choices[0];

    const assistantMessage = choice?.message;

    if (assistantMessage?.content) {
    finalText.push(assistantMessage.content);
    }

    if (
    assistantMessage?.tool_calls &amp;&amp;
    assistantMessage.tool_calls.length &gt; 0
    ) {
    messages.push(assistantMessage);

    for (const toolCalls of assistantMessage.tool_calls) {
    if (toolCalls.type !== "function") continue;

    const toolName = toolCalls.function.name;
    
    

    const toolArgs = JSON.parse(toolCalls.function.arguments);

    finalText.push([Calling tool \({toolName} with args \){toolArgs}]);

    const result = await this.mcp.callTool({ name: toolName, arguments: toolArgs, });

    messages.push({ role: "tool", tool_call_id: toolCalls.id, content: JSON.stringify(result.content), });

    }

    const followupResponse = await this.groq.chat.completions.create({
    model: "openai/gpt-oss-120b",
    messages,
    });

    if (followupResponse.choices[0]?.message.content) {
    finalText.push(followupResponse.choices[0].message.content);
    }
    }
    return finalText.join("\n");

    }

3. Terminal chat interface chatLoop()

  • This method we created to send input user query to this.processQuery using node readline module.
 async chatLoop() {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    try {
      console.log("\nMCP Client Started!");
      console.log("Type your queries or 'quit' to exit.");

      while (true) {
        const message = await rl.question("\nQuery: ");
        if (message.toLowerCase() === "quit") {
          break;
        }
        const response = await this.processQuery(message);
        console.log("\n" + response);
      }
    } finally {
      rl.close();
    }
  }

4. Close MCP connection cleanup()

this method we used to close MCP Client connection

async cleanup() {
    await this.mcp.close();
 }

main execution logic

The Argument Guard

  • process.argv is array containing everything u typed in terminal and if u dont’t provide any path or url it gives an error.

Try / Catch execution

  • First we create instance of class MCPClient()

  • inside try block by using connectToServer() method we connect to MCP server with passing server type and their respective path/URL through cmd

  • Inside catch and finally block we close MCP connection using cleanup() method

async function main() {
  if (process.argv.length < 3) {
    console.log("Usage: node index.ts <path_to_server_script>");
    return;
  }
  const mcpClient = new MCPClient();
  try {
    await mcpClient.connectToServer(process.argv[2] as string, "remote");
    await mcpClient.chatLoop();
  } catch (e) {
    console.error("Error:", e);
    await mcpClient.cleanup();
    process.exit(1);
  } finally {
    await mcpClient.cleanup();
    process.exit(0);
  }
}

main();

Running the Client

To run your client with any MCP server:

# Build TypeScript
npm run build

# Run the client

#localmcp server
npm start path/to/build/index.js #node server
npm start path/to/server.py #python server

# Remote mcpserver
npm start https://remotemcp.com/mcp #remote server

Here is the screenshot of connecting remote MCP server

  1. Run the client - 1st it connect to server and gives all the tools
  1. Give input query

Resources

  1. MCP Official Document - Document

  2. MCP Client Complete Source Code - GitHub

Read more

  1. Gen AI Using JS - GitHub

More from this blog

Onkar K | Full-Stack AI Engineering

19 posts

Production-grade GenAI & multi-agent apps with Next.js & TypeScript. Explore deep architectures using LangGraph.js, LangChain.js, and backends via Hono, Express, & Node.js. Master advanced RAG with Qdrant, Pinecone, and Redis caching. Track execution with Langfuse and LangSmith. Zero fluff—just type-safe code, terminal logs, and robust deployments with Docker, Kafka, and Kubernetes for modern builders