Podcast Title

Author Name

0:00
0:00
Album Art

The Ultimate Guide to Building an MCP Server and Client

By 10xdev team August 16, 2025

The Model Context Protocol (MCP) is gaining incredible popularity. While everyone's talking about it, few are providing a deep enough dive into how it works and what it means for developers. In this article, we'll not only explain everything you need to know about MCP but also walk you through creating your very own MCP server and a custom client to interact with it.

This will be the complete crash course on everything you need to know about MCP.

What is MCP?

First, we need to understand what MCP is. MCP, or Model Context Protocol, is a protocol similar to a REST or GraphQL API. It defines a standard way for a client and a server to communicate, allowing them to send messages back and forth reliably.

A single MCP client can connect to numerous MCP servers. It's essentially a language that a client can use to communicate with a server, and that the server can use to send information back, enabling a wide range of powerful functionalities.

The Core Components of an MCP Server

An MCP server is primarily composed of four main components, with two being the most crucial:

  1. Tools: The most common component. They allow a client to execute code on the server.
  2. Resources: Represents a set of data, like files or database records.
  3. Prompts: Pre-defined prompts that a client can request from the server.
  4. Samplings: The reverse of a typical request, where the server asks the AI client to run a prompt and return information.

The MCP protocol itself is what facilitates communication between the client and these various server components.

Tools Explained

Tools are a way for a client to call code on the server. For instance, imagine an MCP server for a spreadsheet application. It could have a tool named create-spreadsheet. An AI chat client could then be instructed, "create a spreadsheet with this data." The AI, knowing about the MCP server's tool, would call it to perform the action. These tools can range from simple actions, like creating a file, to complex operations involving data computation and chart generation.

Resources Explained

A resource is much simpler: it's a set of data. This could be anything from a database, files on a filesystem, images, or records. In our spreadsheet example, resources could be the rows in a table, the spreadsheet files themselves, or even charts within those files. For a typical web application, resources often correspond to database records or user-uploaded files.

Prompts and Samplings

Prompts and samplings are less common but still important.

  • A prompt is a pre-made template on the server. A client can request this prompt, and the server sends back a well-formatted prompt for a specific task. This is a great way to create robust, reusable prompts that clients can interact with.
  • A sampling is the opposite. It's when the server needs information from the AI. The server sends a prompt to the client and says, "Can you run this prompt on your AI and send the result back to me?" It reverses the typical flow of communication.

Let's Build: The MCP Server

We don't have to start from scratch. The MCP team provides SDKs for various languages. We'll use the TypeScript SDK.

First, install the necessary package:

npm install @mcp/sdk

Our project will have a basic setup for a Node.js TypeScript project, including a tsconfig.json file and a package.json with scripts for building and running our server.

Server Setup

To begin, we create a new MCP server instance.

// server.ts
import { MCP_Server } from '@mcp/sdk/server';

const server = new MCP_Server({
  name: 'test-server',
  version: '1.0.0',
  capabilities: {
    resources: {},
    tools: {},
    prompts: {},
  },
});

We provide a name, version, and declare its capabilities. By passing empty objects, we're signaling that our server supports resources, tools, and prompts.

Transport Layer

Next, we need a transport protocol. The two main options are: * Standard I/O: For communication between processes on the same machine. * HTTP Streaming: For remote communication, like between two web applications.

Since we'll be running our client and server locally, we'll use Standard I/O.

// server.ts
import { StandardIOServerTransport } from '@mcp/sdk/server/stdio';

async function main() {
  const transport = new StandardIOServerTransport();
  await server.connect(transport);
  console.log('MCP Server Connected');
}

main();

This code establishes the server and connects it over the standard I/O transport. It's a functional server, but it doesn't do anything yet.

Creating Our First Tool

Let's create a tool to add a new user to a "database" (which will be a simple JSON file for this example).

First, we'll need zod for schema validation.

npm install zod

Now, let's define the tool.

// server.ts
import { z } from 'zod';

// ... (server setup)

server.tool(
  'create-user',
  {
    description: 'Create a new user in the database.',
    params: z.object({
      name: z.string(),
      email: z.string(),
      address: z.string(),
      phone: z.string(),
    }),
    annotations: {
      title: 'Create User',
      readOnly: false,
      destructive: false,
      idempotent: false,
      openWorld: true,
    },
  },
  async (params) => {
    try {
      const id = await createUser(params);
      return {
        content: [{ type: 'text', text: `User ${id} created successfully.` }],
      };
    } catch (e) {
      return {
        content: [{ type: 'text', text: 'Failed to save user.' }],
      };
    }
  }
);

// ... (main function)

Here's a breakdown: * We name the tool create-user. * The description helps the AI understand its purpose. * params defines the input schema using Zod. * annotations provide extra hints to the AI (e.g., this action is not readOnly and it modifies data). * The final function is the implementation. It calls a createUser helper and returns a success or failure message.

The createUser function would handle the logic of reading our users.json file, adding the new user, and writing the file back to disk.

Creating a Resource

Next, let's expose our user data as a resource. This will allow the AI to access the list of all users.

// server.ts

server.resource(
  'users',
  'users:all',
  {
    title: 'Users',
    description: 'Get all users data from the database.',
    mimeType: 'application/json',
  },
  async (uri) => {
    const users = await getAllUsers(); // Function to read users.json
    return {
      contents: [
        {
          uri: uri.href,
          text: JSON.stringify(users),
          mimeType: 'application/json',
        },
      ],
    };
  }
);

This code defines a resource named users with a unique URI users:all. When requested, it reads the user data and returns it as a JSON string.

Creating a Resource Template

What if we want to fetch a single user by their ID? For this, we use a resource template with a dynamic parameter.

// server.ts
import { ResourceTemplate } from '@mcp/sdk/server';

server.resource(
  'user-details',
  new ResourceTemplate('users/{userId}/profile', { list: undefined }),
  {
    title: 'User Details',
    description: "Get a user's details from the database.",
    mimeType: 'application/json',
  },
  async (uri, params) => {
    const userId = params.userId as string;
    const user = await getUserById(parseInt(userId));
    // ... return user data or an error
  }
);

This template defines a pattern users/{userId}/profile. The server can now handle requests for specific users, and the client can provide the userId.

Creating a Prompt

Prompts allow us to create reusable prompt templates on the server. Let's make one to generate a fake user.

// server.ts

server.prompt(
  'generate-fake-user',
  {
    description: 'Generate a fake user based on a given name.',
    params: z.object({ name: z.string() }),
  },
  (params) => {
    return [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: `Generate a fake user with the name ${params.name}. The user should have a realistic email address and phone number.`,
          },
        ],
      },
    ];
  }
);

When a client calls this prompt with a name, the server returns the structured message, which the client's AI can then execute.

Using Sampling

Sampling reverses the interaction: the server asks the client's AI for information. We can implement this inside a tool. Let's create a tool that generates a random user using the AI's creativity.

// server.ts

server.tool(
  'create-random-user',
  {
    description: 'Create a random user with fake data.',
    // ... annotations
  },
  async function () {
    const res = await this.server.request(
      'sampling/create-message',
      {
        messages: [
          {
            role: 'user',
            content: [
              {
                type: 'text',
                text: 'Generate fake user data (name, email, address, phone) as a JSON object.',
              },
            ],
          },
        ],
        max_tokens: 1024,
      },
      CreateMessageResultSchema
    );

    const fakeUserData = JSON.parse(res.content.text);
    const id = await createUser(fakeUserData);
    return {
      content: [{ type: 'text', text: `User ${id} created successfully.` }],
    };
  }
);

This tool sends a sampling/create-message request to the client. The client's AI will generate the fake user data, which is then sent back to the server. The server parses the data and calls our createUser tool to save it.

Building the MCP Client

Now that our server is complete, let's build a command-line client to interact with it.

Client Setup

The client setup is similar to the server's. We'll create a client instance and connect it using the StandardIOClientTransport, pointing it to the command that runs our server.

// client.ts
import { MCPClient } from '@mcp/sdk/client';
import { StandardIOClientTransport } from '@mcp/sdk/client/stdio';

const mcp = new MCPClient({
  name: 'test-client',
  version: '1.0.0',
  capabilities: {
    sampling: {},
  },
});

const transport = new StandardIOClientTransport({
  command: 'node',
  args: ['build/server.js'],
  stderr: 'ignore',
});

async function main() {
  await mcp.connect(transport);
  console.log('MCP Client Connected');
  // ... main application loop
}

main();

The Main Loop

Our client will be a simple CLI that lets the user choose an action: call a tool, read a resource, use a prompt, or send a general query to the AI.

Calling a Tool

To call a tool, the client first lists the available tools from the server, asks the user to pick one, prompts for the required arguments, and then calls mcp.callTool().

// Inside client's main loop
const toolName = await select({
  /* ... options to select a tool ... */
});
const tool = tools.find((t) => t.name === toolName);

if (tool) {
  // Logic to gather arguments from the user
  const args = await gatherArgsForTool(tool);
  const res = await mcp.callTool(tool.name, args);
  console.log(res.content[0].text);
}

Handling Queries with Tools

The real power comes from letting the AI decide which tools to use. We can pass our server's tools directly to an AI model.

// client.ts
import { generateText } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';

// ... inside handleQuery function
const { text, toolResults } = await generateText({
  model: google('models/gemini-pro'),
  prompt: query,
  tools: {
    /* ... format server tools for the AI SDK ... */
    'create-user': {
      description: 'Create a new user...',
      parameters: z.object({
        /* ... */
      }),
      execute: async (args) => {
        // When AI decides to use this tool, call our MCP server
        return await mcp.callTool('create-user', args);
      },
    },
  },
});

When the user enters a query like "Create a user named Jane Doe...", the AI model recognizes the intent, finds the matching create-user tool we provided, and calls its execute function. Our execute function then makes the actual mcp.callTool request to our server.

Handling Sampling on the Client

Finally, the client must handle sampling requests from the server. We set up a request handler for the create-message schema.

// client.ts

mcp.setRequestHandler(CreateMessageRequestSchema, async (request) => {
  // This handler is triggered by the server's sampling request
  const message = request.params.messages[0];

  // Use the AI to generate a response to the server's prompt
  const { text } = await generateText({
    model: google('models/gemini-pro'),
    prompt: message.content[0].text,
  });

  // Return the AI's response back to the server
  return {
    role: 'user',
    model: 'gemini-pro',
    stop_reason: 'end_turn',
    content: [{ type: 'text', text }],
  };
});

When our server's create-random-user tool sends its sampling request, this handler catches it, runs the prompt through its own AI, and sends the generated data back to the server, completing the cycle.

With that, you have a fully functional MCP server and client, capable of complex, tool-based interactions with an AI.

Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Recommended For You

Up Next