How to Build MCP Client from Scratch with TypeScript and Groq

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.tsUpdate
package.json{ "type": "module", "scripts": { "build": "tsc", "start": "node --env-file=.env dist/app.js" } }Create a
tsconfig.jsonin 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
.envfile for setting up Groq API key from Groq ConsoleGROQ_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
Private Properties To manage execution lifecycles safely, our
MCPClientarchitecture encapsulates four private state properties:mcp- MCP Client connectorgroq- groq api connectortransport- communication channel for both remote and local servertools- we fetch and store tools from mcp server
Constructor: When a new instance of the
MCPClientis 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
Client Transport:
To connect MCP Client to server we need two client transport
StdioClientTransportfor local MCP server andStreamableHTTPClientTransportfor remote MCP serverTool-list Storing:
by using
this.mcp.listTools()we fetch all available tool from MCP serverAnd store this tools inside
this.toolswith 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
The Initial LLM Request
here we use
messagearray with user queryalong with message array we also attach
this.toolto 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! });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); }- For standard informational prompts, the LLM bypasses tool execution entirely, returning a direct text evaluation via
The Tool Loop Execution
assistantMessage?.tool_callsexists if llm requirestool_calls.It iterate over
assistantMessage?.tool_callsgrabtoolNameandtoolArgsfrom function and pass itthis.mcp.callToolwhich gives result of that toolWe 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), }); } }Generating the Final Answer
Now that the
messageshistory contains the original question plus the actual data fetched by the tool, your code calls the AI a second timeAI 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");Complete code of
processQuerymethodprocessQuerymethod codeasync 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 &&
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), });
}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.processQueryusing 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.argvis 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 cmdInside 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
- Run the client - 1st it connect to server and gives all the tools
- Give input query
Resources
Read more
- Gen AI Using JS - GitHub


![Token Based Auth System [state-less]](/_next/image?url=https%3A%2F%2Fcloudmate-test.s3.us-east-1.amazonaws.com%2Fuploads%2Fcovers%2F662e9149ea7b8adaf16495b0%2Ff4e28b41-8d8d-42bd-8b0c-e3b86fbebda5.png&w=3840&q=75)
![Session Based Auth System [state-full]](/_next/image?url=https%3A%2F%2Fcloudmate-test.s3.us-east-1.amazonaws.com%2Fuploads%2Fcovers%2F662e9149ea7b8adaf16495b0%2Fe59a4233-21ac-418e-8af0-9057d2e04cdf.png&w=3840&q=75)