RB

$ cat blog/what-is-an-mcp-server.md

What Is an MCP Server? How It Works and How to Build One in TypeScript

Raj Bhensdadiya
what-is-an-mcp-server.md
Cover illustration for the article: What Is an MCP Server? How It Works and How to Build One in TypeScript

If you have been using tools like Claude Code, Cursor, Codex, or other coding agents, you have probably seen the term MCP server.

You might have also seen commands like:

Connect the GitHub MCP server.
Add the MongoDB MCP server.
Use the Stripe MCP server.

At first, MCP can sound more complicated than it actually is.

If you already understand how an HTTP server works, MCP is much easier to understand.

In this article, I will explain what an MCP server is, how it works, why it is useful, and why companies building SaaS or developer tools should care about it.

Then we will build a small MCP server in TypeScript that can create, read, search, update, and delete documents in MongoDB.

At the time of writing, the official TypeScript MCP SDK has a stable v2 release using @modelcontextprotocol/server. It replaces the older monolithic @modelcontextprotocol/sdk package.


Start With Something We Already Know: An HTTP Server

Before talking about MCP, think about a normal backend API.

Imagine we have an Express application.

It exposes these endpoints:

POST   /users
GET    /users/:id
PATCH  /users/:id
DELETE /users/:id

A frontend might create a user like this:

  1. Frontend
    , then
  2. POST /users
    , then
  3. HTTP Server
    , then
  4. Business Logic
    , then
  5. Database
    , then
  6. HTTP Response

The server exposes operations.

The client knows which operation it wants.

It sends the required input.

The server performs the action and returns a result.

For example:

POST /users

with:

{
  "name": "John",
  "email": "john@example.com"
}

The server validates the request, saves the user, and returns something like:

{
  "id": "123",
  "name": "John",
  "email": "john@example.com"
}

Nothing complicated here.

But now imagine an AI agent wants to use this API.

You tell Claude:

Create a customer called John with john@example.com.

How does Claude know:

Which API should I call?

What endpoint creates a user?

Is it POST /users or POST /customers?

What parameters does it require?

What authentication does it need?

What does the response look like?

You could give the model your API documentation and ask it to figure everything out.

That can work.

But MCP gives us a standard way to expose these capabilities directly to AI applications.


What Is MCP?

MCP stands for Model Context Protocol.

The simplest way I think about it is:

MCP is a standard way for AI applications to discover and use external tools and data.

An MCP server exposes capabilities.

An AI application such as Claude Code, Cursor, Codex, VS Code, or another MCP-compatible application can connect to it and use those capabilities. The official TypeScript SDK describes MCP servers as programs that expose tools, resources, and prompts to MCP hosts.

Think about the HTTP example again.

With a normal API:

  1. Application
    , then
  2. POST /users
    , then
  3. HTTP API
    , then
  4. Database

With MCP:

  1. Claude Code / Cursor / Codex
    , then
  2. MCP Client
    , then
  3. MCP Server
    , then
  4. API / Database

Instead of exposing something like:

POST /users

your MCP server might expose:

create_user

with a description and an input schema.

For example:

Tool: create_user

Description:
Create a new user in the application.

Input:
{
  name: string,
  email: string
}

Now the model knows that this capability exists.

It also knows what it does and what arguments it requires.

That is the important part.


MCP Does Not Replace Your API

This is something worth making clear.

You do not normally replace your REST API with MCP.

You can keep your existing architecture:

  1. Frontend
    , then
  2. REST API
    , then
  3. Application
    , then
  4. Database

Then add another interface:

  1. Claude Code
    , then
  2. MCP Server
    , then
  3. Existing API
    , then
  4. Application
    , then
  5. Database

Your MCP server can simply call the API or SDK you already have.

For example, imagine you own a project management SaaS.

Your existing API might have:

POST /projects
GET /projects
POST /tasks
PATCH /tasks/:id

Your MCP server could expose:

create_project
list_projects
create_task
update_task

The underlying business logic does not need to change.

The MCP server becomes another way to access it.

A useful mental model is:

API = interface for software

SDK = interface for developers

MCP = interface for AI agents

They can all exist together.


The MCP Architecture

There are three terms you will often see when reading about MCP:

Host
Client
Server

Let's break them down.

MCP Host

The host is the application where the AI model is running.

For example:

Claude Code
Cursor
Codex
VS Code

The user interacts with the host.

You might type:

Find all customers who signed up this week.

The host sends that request to the model.


MCP Client

The MCP client lives inside the host.

Its job is to communicate with MCP servers.

You usually do not have to think about this part when building a server.

The client handles things such as:

connecting to the server
discovering tools
calling tools
receiving results

MCP Server

This is what we are going to build.

The MCP server exposes capabilities.

For example:

create_customer
find_customer
create_invoice
search_documents
deploy_application

The complete flow looks like this:

  1. User
    , then
  2. Claude Code / Cursor / Codex
    , then
  3. LLM
    , then
  4. MCP Client
    , then
  5. MCP Server
    , then
  6. Your API / Database / Service

MongoDB describes the same general architecture: the host contains the user-facing AI application, clients manage connections, and servers expose capabilities such as tools.


What Can an MCP Server Expose?

An MCP server can expose several types of capabilities.

The three you will see most often are:

Tools
Resources
Prompts

Tools

Tools are actions the model can call.

For example:

create_customer
search_orders
send_email
deploy_project
query_database
create_invoice

Think about tools like functions.

A function has:

name
description
input
output

An MCP tool is very similar.

For this article, tools are what we care about most.


Resources

Resources expose information that an AI application can read.

For example:

documentation
database schemas
configuration
application logs
customer information
project files

If a tool is similar to a function, a resource is closer to readable data.


Prompts

MCP servers can also expose reusable prompt templates.

For example, a code-review MCP server could expose a prompt such as:

review_pull_request

with predefined instructions for how code reviews should be performed.

We will not use prompts in our MongoDB example, but it is useful to know that they exist.

The current TypeScript SDK supports tools, resources, and prompts directly.


How Does an AI Know Which MCP Tool to Call?

This is one of the most important parts of understanding MCP.

Imagine our MCP server exposes this tool:

create_document

with this definition:

Description:
Save a new document in MongoDB.

Input:
{
  title: string,
  content: string,
  tags?: string[]
}

Now the user says:

Save these notes about authentication.

Title: JWT Authentication
Content: JWT access tokens should expire after 15 minutes.

The model sees the available tools.

It sees:

create_document

and its description.

It also sees the expected input.

From that information, it can determine:

This tool is relevant to the user's request.

It can then create a tool call similar to:

{
  "title": "JWT Authentication",
  "content": "JWT access tokens should expire after 15 minutes."
}

The MCP client sends that call to our server.

Our server executes it.

The result comes back to the model.

The model then responds to the user.

The user never had to say:

Use create_document.

The model selected the tool itself.

The official MCP SDK walkthrough shows this same flow: the host gives the model the tool name, description, and schema; the model chooses a tool; the MCP client sends a tools/call; the server validates the arguments and runs the handler.


What Happens Behind the Scenes?

Let's walk through the entire request.

You type:

Save a document called "MCP Notes" with my notes about MCP.

The flow could look like this:

1. The AI application loads the available MCP tools.

2. It sees:

   create_document

3. The model decides create_document matches the request.

4. It generates the arguments.

5. The MCP client sends the request to the MCP server.

6. The MCP server validates the input.

7. The tool handler runs.

8. MongoDB inserts the document.

9. The MCP server returns the result.

10. The model receives it.

11. The model tells you the document was created.

Or visually:

  1. "Save this document"
    , then
  2. LLM
    , then
  3. create_document
    , then
  4. MCP Client
    , then
  5. MCP Server
    , then
  6. MongoDB insertOne()
    , then
  7. Tool result
    , then
  8. LLM
    , then
  9. "Document saved"

This is the basic MCP loop.


Why Not Just Give the AI Your API Documentation?

You can.

Sometimes that is completely fine.

You could give an agent your OpenAPI specification or documentation and let it determine which endpoint to call.

But compare the two approaches.

With a normal API:

  1. AI
    , then
  2. Read API documentation
    , then
  3. Understand authentication
    , then
  4. Find the correct endpoint
    , then
  5. Construct the HTTP request
    , then
  6. Interpret the response

With MCP:

  1. AI
    , then
  2. Discover available tools
    , then
  3. Select a tool
    , then
  4. Provide structured arguments
    , then
  5. Receive structured result

MCP gives the AI application a standard protocol for discovering and calling those capabilities.

Your MCP server can also hide details that the model should not care about.

For example, the model does not need to know that creating a customer internally requires:

POST /api/v2/accounts/:accountId/customers

Authorization: Bearer ...
X-Tenant-ID: ...

It can simply call:

create_customer

Your MCP server handles the rest.


Why MCP Is Interesting for SaaS and Developer Tools

This is where MCP becomes more interesting than just connecting your own database to Claude.

Imagine you own a SaaS product.

You already have:

Web application
REST API
SDK
CLI

Now your users are spending more time inside tools like:

Claude Code
Cursor
Codex
VS Code

Instead of asking users to constantly move between your dashboard and their AI coding tool, you can expose your product through MCP.

Your architecture could look like:

Your SaaS API sits in the middle. It serves the web app, the developer SDK and the CLI, and an MCP server in front of it serves Claude Code, Cursor and Codex.

You already built most of the hard parts.

You already have:

authentication
business logic
permissions
database access
rate limits
API endpoints

The MCP server can sit in front of those existing capabilities.


Example: An Analytics SaaS

Imagine you run an analytics product.

Your API has:

GET /projects
GET /events
GET /analytics
POST /reports

You could expose MCP tools like:

list_projects
search_events
get_analytics
generate_report

Now your customer could be working inside Claude Code and ask:

What was our checkout conversion rate over the last 30 days?

The agent could call:

get_analytics

using the customer's account.

Then the user could ask:

Compare it with the previous 30 days.

The agent can make another tool call.

Then:

Look at our checkout code and see if anything could explain the drop.

Now the agent has both:

  1. the user's code
    , plus
  2. analytics data from your SaaS

That is much more useful than forcing the user to open your dashboard, export a CSV, download it, and upload it to the AI.


MCP Can Make Your Product Part of a Larger Workflow

This is another reason MCP matters.

An agent can connect to multiple MCP servers.

Imagine a developer has:

GitHub MCP
MongoDB MCP
Your Analytics MCP
Monitoring MCP
Project Management MCP

Now the agent can potentially perform a workflow like:

Read the production error.

Check the affected database records.

Look at the related code.

Find the GitHub issue.

Prepare a fix.

Update the task.

Each service owns its own tools.

The agent connects them through the same protocol.

That is why MCP is useful for developer tools and SaaS products.

You are not building an integration for only one AI application.

You are exposing a standard interface that MCP-compatible hosts can understand.


MCP Server vs API vs SDK

Here is another way to compare them.

REST APISDKMCP Server
Main consumerSoftwareDeveloperAI application
InterfaceHTTP endpointsFunctions/classesTools/resources/prompts
InputHTTP requestFunction argumentsStructured tool arguments
DiscoveryDocs/OpenAPIDocs/typesMCP
Can wrap your existing APIN/AYesYes

I would not think about MCP as a replacement.

Think about it as one more interface into your product.


How Does an MCP Server Communicate With the Client?

Two transports are especially important.

stdio

stdio stands for standard input and standard output.

With a local MCP server, the AI application can start your MCP server as a child process.

Then they communicate through stdin and stdout.

  1. Claude Code
    , both ways,
  2. stdin / stdout
    , both ways,
  3. Node.js MCP Server
    , both ways,
  4. MongoDB

This is useful for local MCP servers.

For example:

Claude Code
Cursor
Codex
VS Code

can launch a local command that starts your server.

The current MCP TypeScript SDK provides serveStdio() for exactly this model. Requests arrive over stdin and protocol responses go over stdout.

There is one small but important rule.

Do not use:

console.log()

for debugging a stdio MCP server.

stdout is being used for MCP communication.

Logging random text there can break the protocol.

Use:

console.error()

instead.

The official SDK documentation specifically warns about this.


Streamable HTTP

You can also expose MCP remotely over HTTP.

For example:

  1. Claude CodeCursorCodexOther Agent
    , then
  2. Internet
    , then
  3. https://mcp.yourcompany.com
    , then
  4. MCP Server
    , then
  5. Your API

This is more interesting if you are building MCP support for a SaaS product.

Instead of asking every customer to run your server locally, you can host it yourself.

The user authenticates.

Your server determines:

who the user is
what organization they belong to
what they are allowed to access

and then executes tools using those permissions.

For this tutorial we will keep things simple and use stdio.


Let's Build an MCP Server

Now that we understand the idea, let's build one.

We are going to create a small MongoDB MCP server.

MongoDB already has an official MCP server with database and Atlas management tools. We are not trying to replace it. We are building a smaller version because it is a good example for understanding how MCP works.

Our server will manage simple documents.

A document will look like this:

{
  "_id": "...",
  "title": "MCP Notes",
  "content": "MCP allows AI applications to use external tools.",
  "tags": ["mcp", "ai"],
  "createdAt": "...",
  "updatedAt": "..."
}

We will expose these MCP tools:

create_document
get_document
list_documents
search_documents
update_document
delete_document

Our architecture will be:

  1. Claude Code / Cursor / Codex
    , then
  2. MCP Client
    , then
  3. Our TypeScript MCP Server
    , then
  4. MongoDB Node Driver
    , then
  5. MongoDB

Create the Project

Create a new directory:

mkdir mongodb-mcp
cd mongodb-mcp

Initialize the project:

npm init -y

Set the package to use ES modules:

npm pkg set type=module

Install the dependencies:

npm install @modelcontextprotocol/server mongodb zod dotenv
npm install -D typescript tsx @types/node

The current MCP v2 SDK is ESM-based, and its getting-started documentation uses Node.js 20 or later.

Create our source directory:

mkdir src

Our project will stay intentionally small:

mongodb-mcp/
│
├── src/
│   └── index.ts
│
├── .env
├── package.json
└── tsconfig.json

Add MongoDB Configuration

Create .env:

MONGODB_URI=mongodb://localhost:27017
MONGODB_DATABASE=mcp_demo

You could also use a MongoDB Atlas connection string.

Do not commit real production credentials.

Add .env to .gitignore:

node_modules
.env

Create the MCP Server

Create:

src/index.ts

First add our imports:

import "dotenv/config";

import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { MongoClient, ObjectId } from "mongodb";
import * as z from "zod/v4";

Now connect to MongoDB:

const mongoUri = process.env.MONGODB_URI;
const databaseName = process.env.MONGODB_DATABASE ?? "mcp_demo";

if (!mongoUri) {
  throw new Error("MONGODB_URI is required");
}

const mongoClient = new MongoClient(mongoUri);

await mongoClient.connect();

const database = mongoClient.db(databaseName);
const documents = database.collection("documents");

There is nothing MCP-specific here.

This is just normal MongoDB code.

Now let's create the server.

function createServer() {
  const server = new McpServer({
    name: "mongodb-documents",
    version: "1.0.0",
  });

  return server;
}

This is the object where we will register our tools.

The current SDK uses:

server.registerTool(...)

A tool normally has:

name
configuration
handler

The configuration can include its description and input schema.

The SDK exposes that schema to the client and validates arguments before the handler runs.


Our First Tool: create_document

Let's register our first tool inside createServer().

server.registerTool(
  "create_document",
  {
    description:
      "Create and save a new document in MongoDB. Use this when the user wants to save notes, text, or other document content.",
    inputSchema: z.object({
      title: z.string().min(1).describe("Title of the document"),
      content: z.string().min(1).describe("Main content of the document"),
      tags: z
        .array(z.string())
        .optional()
        .describe("Optional tags for the document"),
    }),
  },
  async ({ title, content, tags }) => {
    const now = new Date();

    const result = await documents.insertOne({
      title,
      content,
      tags: tags ?? [],
      createdAt: now,
      updatedAt: now,
    });

    return {
      content: [
        {
          type: "text",
          text: `Document created successfully. ID: ${result.insertedId.toString()}`,
        },
      ],
    };
  },
);

There are a few important things happening here.

First, the tool has a clear name:

create_document

Then we give the model a description:

Create and save a new document in MongoDB.

Then we define the input:

z.object({
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()).optional(),
})

This is not only TypeScript validation.

The MCP SDK uses the schema to describe the tool's expected arguments to the client.

That means the model understands that it should call something similar to:

{
  "title": "MCP Notes",
  "content": "MCP connects AI applications with external tools.",
  "tags": ["mcp", "ai"]
}

Then our handler runs:

await documents.insertOne(...)

Finally, we return the tool result.


Reading a Document

Now add:

get_document
server.registerTool(
  "get_document",
  {
    description: "Get a document from MongoDB using its document ID.",
    inputSchema: z.object({
      id: z.string().describe("MongoDB ObjectId of the document"),
    }),
  },
  async ({ id }) => {
    if (!ObjectId.isValid(id)) {
      return {
        content: [
          {
            type: "text",
            text: "Invalid document ID.",
          },
        ],
        isError: true,
      };
    }

    const document = await documents.findOne({
      _id: new ObjectId(id),
    });

    if (!document) {
      return {
        content: [
          {
            type: "text",
            text: "Document not found.",
          },
        ],
        isError: true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(document, null, 2),
        },
      ],
    };
  },
);

Now an agent can retrieve a specific document.

For example, the user might say:

Show me the document with ID 68d1...

The model can call:

get_document

with:

{
  "id": "68d1..."
}

List Documents

Next, let's give the agent a way to see recently saved documents.

server.registerTool(
  "list_documents",
  {
    description:
      "List recently created documents stored in MongoDB.",
    inputSchema: z.object({
      limit: z
        .number()
        .int()
        .min(1)
        .max(50)
        .default(10)
        .describe("Maximum number of documents to return"),
    }),
  },
  async ({ limit }) => {
    const results = await documents
      .find({})
      .sort({ createdAt: -1 })
      .limit(limit)
      .toArray();

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(results, null, 2),
        },
      ],
    };
  },
);

Notice that we limit the maximum number of results.

That is intentional.

We do not want an innocent tool call returning 200,000 MongoDB documents into the model's context.

MCP tools should have sensible boundaries.


Search Documents

Now let's build something more interesting.

The user should be able to ask:

Find everything I saved about authentication.

We'll expose:

search_documents

First create a small helper to safely escape text used inside a regular expression:

function escapeRegex(value: string) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Then register the tool:

server.registerTool(
  "search_documents",
  {
    description:
      "Search saved documents by title, content, or tags. Use this when the user is trying to find documents related to a topic.",
    inputSchema: z.object({
      query: z.string().min(1).describe("Text to search for"),
      limit: z
        .number()
        .int()
        .min(1)
        .max(50)
        .default(10),
    }),
  },
  async ({ query, limit }) => {
    const regex = new RegExp(escapeRegex(query), "i");

    const results = await documents
      .find({
        $or: [
          { title: regex },
          { content: regex },
          { tags: regex },
        ],
      })
      .limit(limit)
      .toArray();

    return {
      content: [
        {
          type: "text",
          text:
            results.length === 0
              ? "No matching documents found."
              : JSON.stringify(results, null, 2),
        },
      ],
    };
  },
);

Now imagine the user asks:

What notes do I have about JWT authentication?

The model sees:

search_documents

Search saved documents by title, content, or tags.

That description tells the model this tool is probably useful.

It might call:

{
  "query": "JWT authentication",
  "limit": 10
}

Our MCP server searches MongoDB and returns the matching documents.


Update a Document

Let's add:

update_document
server.registerTool(
  "update_document",
  {
    description:
      "Update the title, content, or tags of an existing document.",
    inputSchema: z.object({
      id: z.string().describe("MongoDB ObjectId of the document"),
      title: z.string().min(1).optional(),
      content: z.string().min(1).optional(),
      tags: z.array(z.string()).optional(),
    }),
  },
  async ({ id, title, content, tags }) => {
    if (!ObjectId.isValid(id)) {
      return {
        content: [
          {
            type: "text",
            text: "Invalid document ID.",
          },
        ],
        isError: true,
      };
    }

    const updates: Record<string, unknown> = {
      updatedAt: new Date(),
    };

    if (title !== undefined) {
      updates.title = title;
    }

    if (content !== undefined) {
      updates.content = content;
    }

    if (tags !== undefined) {
      updates.tags = tags;
    }

    const result = await documents.updateOne(
      {
        _id: new ObjectId(id),
      },
      {
        $set: updates,
      },
    );

    if (result.matchedCount === 0) {
      return {
        content: [
          {
            type: "text",
            text: "Document not found.",
          },
        ],
        isError: true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text: "Document updated successfully.",
        },
      ],
    };
  },
);

Now the agent can modify data instead of only reading it.

That is an important distinction.

MCP is not only a way of giving models context.

Tools can perform actions.


Delete a Document

Finally:

server.registerTool(
  "delete_document",
  {
    description:
      "Delete an existing document from MongoDB. Use this only when the user explicitly wants the document deleted.",
    inputSchema: z.object({
      id: z.string().describe("MongoDB ObjectId of the document"),
    }),
  },
  async ({ id }) => {
    if (!ObjectId.isValid(id)) {
      return {
        content: [
          {
            type: "text",
            text: "Invalid document ID.",
          },
        ],
        isError: true,
      };
    }

    const result = await documents.deleteOne({
      _id: new ObjectId(id),
    });

    if (result.deletedCount === 0) {
      return {
        content: [
          {
            type: "text",
            text: "Document not found.",
          },
        ],
        isError: true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text: "Document deleted successfully.",
        },
      ],
    };
  },
);

Notice the description:

Use this only when the user explicitly wants the document deleted.

Tool descriptions matter.

They are not only documentation for developers.

The model uses them when deciding how the tool should be used.


Start the MCP Server

At the end of src/index.ts, add:

void serveStdio(createServer);

console.error("MongoDB MCP server running on stdio");

The complete connection now looks like:

  1. MCP Host
    , then
  2. stdin / stdout
    , then
  3. MCP Server
    , then
  4. MongoDB

Run it:

npx tsx src/index.ts

You should see:

MongoDB MCP server running on stdio

and then nothing else.

That is expected.

The process is waiting for an MCP client to communicate with it.

The SDK documentation describes this exact behavior: a stdio server waits for the host to communicate through stdin/stdout.


Test It With MCP Inspector

Before connecting the server to an AI coding tool, you can test it directly.

The MCP project provides an Inspector.

Run:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

The Inspector can connect to your server and show the tools you registered.

You should see:

create_document
get_document
list_documents
search_documents
update_document
delete_document

You can manually call them and inspect their responses.

This is useful because it lets you verify your MCP server without involving an LLM yet. The official SDK also recommends the Inspector for testing stdio servers.


Connect It to Claude Code

From the project directory, you can register the stdio server with Claude Code:

claude mcp add mongodb-documents -- npx tsx src/index.ts

Then inside Claude Code you can use:

/mcp

to check the connection.

The current MCP SDK documentation uses the same claude mcp add <name> -- <command> pattern for local stdio servers.

Now try:

Save a document called "MCP Notes".

Content:
MCP servers expose tools and data to AI applications.

Tags:
mcp, typescript

Claude can discover:

create_document

and call it.

Then try:

Show me my latest documents.

It can use:

list_documents

Then:

Find my documents about MCP.

It can use:

search_documents

At no point did we need to tell Claude which MongoDB method to call.

Claude only knows about the interface we intentionally exposed.


Connect It to Cursor

Cursor can load local stdio MCP servers from an MCP configuration.

Create:

.cursor/mcp.json

and add:

{
  "mcpServers": {
    "mongodb-documents": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"]
    }
  }
}

The same MCP server now becomes available to Cursor.

Cursor's current MCP configuration supports local stdio servers using a command and optional arguments.

The important thing here is that we did not build a Cursor-specific server.

We built an MCP server.

Cursor is simply another MCP host.


Connect It to Codex

Codex also supports local stdio MCP servers.

Its configuration is stored in:

~/.codex/config.toml

or, for a project:

.codex/config.toml

A local configuration can look like:

[mcp_servers.mongodb-documents]
command = "npx"
args = ["tsx", "src/index.ts"]

If needed, you can also configure the working directory and environment settings.

Codex currently supports both stdio and Streamable HTTP MCP servers.

Again, our MCP server itself did not change.

Claude Code, Cursor and Codex all connect to the same MongoDB MCP Server, which talks to MongoDB.

That is the value of using a common protocol.


Why Tool Design Matters

Now that we have a working server, there is one design decision worth discussing.

We created tools like:

create_document
get_document
search_documents
update_document
delete_document

We could have created this instead:

execute_mongodb_query

and accepted any MongoDB query.

That would be much more powerful.

It would also be much more dangerous.

Imagine giving an agent unrestricted access to:

deleteMany({})
dropDatabase()
updateMany(...)

on your production database.

That is usually not what you want.

A better MCP design exposes the smallest useful capabilities.

Instead of:

execute_anything

prefer:

create_customer
get_customer
search_customers
update_customer

Your server should enforce the real boundaries.

Do not rely on a prompt like:

Please do not delete production data.

If the agent should not be able to perform an operation, do not expose the operation.


MCP Security Matters

This becomes even more important when you expose an MCP server for a real SaaS product.

Imagine you operate:

mcp.yourcompany.com

Your server must know:

Who is making this request?

Which organization do they belong to?

Which data can they access?

Which actions can they perform?

The architecture should look like:

  1. User
    , then
  2. AI Application
    , then
  3. MCP Server
    , then
  4. Authentication
    , then
  5. Authorization
    , then
  6. Your API
    , then
  7. Only that user's data

Not:

  1. User
    , then
  2. MCP Server
    , then
  3. Entire production database

A production MCP server should usually include the same protections you would expect from any production API:

authentication
authorization
tenant isolation
input validation
rate limiting
logging
auditing
timeouts
least privilege

Destructive tools deserve extra attention.

For example:

delete_customer
cancel_subscription
delete_project
send_payment

should have clear descriptions and strict permissions.


Building an MCP Server for an Existing SaaS

Our tutorial connected directly to MongoDB because it makes the architecture easy to understand.

For a real SaaS product, I would usually avoid having the MCP layer reimplement business logic.

Imagine your application already has:

POST /customers
GET /customers
PATCH /customers/:id

Your MCP tool might simply call that API:

server.registerTool(
  "create_customer",
  {
    description: "Create a customer in the current account.",
    inputSchema: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
  },
  async ({ name, email }) => {
    const response = await api.customers.create({
      name,
      email,
    });

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(response),
        },
      ],
    };
  },
);

Now your architecture becomes:

  1. Claude Code
    , then
  2. MCP Server
    , then
  3. Existing SDK
    , then
  4. Existing API
    , then
  5. Business Logic
    , then
  6. Database

This is usually cleaner.

Your API remains the source of truth.

Your MCP server becomes an adapter.


Local MCP vs Remote MCP for SaaS

For internal tools or development workflows, a local server can be enough.

  1. Developer Laptop
    , then
  2. Claude Code
    , then
  3. Local MCP Server
    , then
  4. Internal API

But if you are offering MCP as part of your SaaS, a remote server can make more sense.

  1. Customer's AI Tool
    , then
  2. Streamable HTTP
    , then
  3. mcp.yoursaas.com
    , then
  4. Authentication
    , then
  5. Your APIs

Now you control deployment and updates.

If you add a new tool, customers do not need to reinstall your local package.

You can expose:

list_projects
get_project
create_task
search_logs
get_usage

from one hosted MCP endpoint.

This is similar to what large developer platforms are increasingly doing.

MongoDB, for example, now offers both local MCP and an Atlas Managed MCP Server.


When Should You Build an MCP Server?

I would consider building an MCP server if you already have useful operations or data that users want to access from AI tools.

For example:

You have a developer API.

Your users use AI coding agents.

Your application contains useful technical context.

Your users repeatedly copy information from your SaaS into AI tools.

Your product performs actions that would be useful inside agent workflows.

Some obvious categories are:

Databases
Analytics
Monitoring
Infrastructure
Payments
CRM
Project management
Documentation
Cloud platforms
Developer tools

But not every product needs an MCP server.

If your application does not have meaningful data or actions that an agent needs, adding MCP just because it is popular does not provide much value.

Start from the workflow.

Ask:

What would my users want their AI agent to do with my product?

Then design the tools around those operations.


Designing Good MCP Tools

Good MCP tools are usually boring.

That is a good thing.

Prefer clear names:

create_invoice
get_invoice
list_invoices
cancel_invoice

over vague names:

manage_data
perform_action
execute_request

Descriptions should also explain when a tool should be used.

For example:

Search customers by name or email.
Use this when the user wants to find an existing customer.

is better than:

Customer search tool.

Input schemas should be strict.

Instead of:

z.any()

define exactly what you expect.

For example:

z.object({
  customerId: z.string(),
  status: z.enum(["active", "paused", "cancelled"]),
})

The smaller and clearer the contract is, the easier it is for both the model and your backend to handle correctly.


MCP Is Really an Interface Problem

Once you understand MCP, there is not much magic in it.

We already had:

HTTP APIs
SDKs
CLIs
Webhooks

MCP adds another interface.

The difference is who the interface is designed for.

With an API:

software decides which endpoint to call

With an SDK:

a developer decides which function to call

With MCP:

a model can discover available capabilities and decide which tool to call

The server still contains normal software.

Our MongoDB MCP server used:

TypeScript
Zod
MongoDB Node Driver
MongoDB

The MCP-specific part was mostly:

describe the tools
define their schemas
register handlers
connect the server to a transport

Everything behind those tools can be code you already have.


The Bigger Picture

Without a common protocol, every AI tool could require custom integrations.

You might end up with something like:

  • Claude
    , then
    custom GitHub integration
  • Cursor
    , then
    custom GitHub integration
  • Codex
    , then
    custom GitHub integration
  • Claude
    , then
    custom MongoDB integration
  • Cursor
    , then
    custom MongoDB integration
  • Codex
    , then
    custom MongoDB integration

With MCP, the idea becomes:

Claude, Cursor and Codex all speak MCP, and through that one protocol they reach the GitHub MCP server, the MongoDB MCP server and your SaaS MCP server.

Each product exposes its capabilities through a common protocol.

Each compatible AI application knows how to communicate with those servers.

That is the part of MCP that I find most useful.

Not that an AI can call a function.

Function calling already existed.

The useful part is having a standard interface between AI applications and external systems.


Final Thoughts

The easiest way to understand MCP is to compare it with the interfaces we already use.

  1. HTTP API
    , then
  2. Exposes endpoints to software
  1. SDK
    , then
  2. Exposes functions to developers
  1. MCP Server
    , then
  2. Exposes tools and context to AI applications

An MCP server can sit in front of:

your API
your SDK
your database
your internal tools
your infrastructure
your SaaS

The AI application discovers the tools, understands their schemas, calls them when needed, and receives the result.

In our example we built:

  1. Claude Code / Cursor / Codex
    , then
  2. MCP
    , then
  3. TypeScript MCP Server
    , then
  4. MongoDB

But the same idea works for almost anything.

If you already own a SaaS or developer tool with a useful API, you probably do not need to redesign your backend to support MCP.

You can add an MCP layer around the capabilities you already have:

  1. Claude Code / Cursor / Codex
    , then
  2. MCP
    , then
  3. Your MCP Server
    , then
  4. Existing API / SDK
    , then
  5. Your Product

And that is really what an MCP server is.

A standard interface that lets AI applications discover and use the things your software can already do.