Back to blog
September 28, 2026•51 views

How to Build an MCP Server for Business Automation: A Node.js Step-by-Step Guide

MCP server tutorial nodejsmodel context protocol serverai automation mcpcustom mcp toolsNext.jsNode.jsTypeScriptAI automation for business
How to Build an MCP Server for Business Automation: A Node.js Step-by-Step Guide

Most "AI automation" projects I get asked to build have the same failure point. The model is fine. The reasoning is fine. What breaks is the last mile: the AI can't actually touch the CRM, the invoice table, or the internal admin panel without someone pasting data back and forth.

That last mile is exactly what the Model Context Protocol (MCP) aims to solve, and it's why I've spent a lot of the last year writing MCP servers for clients instead of yet another Zapier chain. In this MCP server tutorial for Node.js, I'll walk you through building a working server from scratch, in TypeScript, with tools that do something useful for a business rather than a toy weather lookup.

By the end you'll have a server that exposes custom MCP tools, runs over stdio, connects to Claude Desktop (or any MCP client), and is structured well enough that you can drop it into production without rewriting it next month.

What an MCP Server Actually Is (And Why Node.js)

The Model Context Protocol is an open standard, originally introduced by Anthropic, that lets an AI client talk to external systems through a uniform interface. Instead of every AI product inventing its own plugin format, you write one server and any MCP-compatible client can use it.

Think of it as a USB-C port for AI tools. The client speaks the protocol, your server speaks the protocol, and neither one needs to know anything about the other's internals.

An MCP server can expose three kinds of things:

  • Tools: functions the model can call (create an invoice, search orders, send a Slack message)
  • Resources: read-only data the model can pull in (a config file, a customer record)
  • Prompts: reusable prompt templates the client can offer to users

For business automation, tools are where most of the value lives.

Why Node.js for MCP

The official reference implementations lean Python, and many tutorials you'll find are Python too. That's a gap worth exploiting if your stack is already JavaScript.

Reasons I build MCP servers in Node.js:

  • Your existing backend is probably Node. Sharing types, validation, and auth logic between your API and your MCP server saves weeks.
  • The official TypeScript SDK is mature and the types genuinely help when you're defining tool schemas.
  • One runtime for your Next.js app, your workers, and your MCP server means one deployment pipeline.
  • If you're already using Claude Code or Cursor, npm-based MCP servers install in seconds.

If your team is Python-first, build in Python. But if you're a Next.js shop, staying in TypeScript is the right call. If you're still weighing runtimes for the rest of your stack, this breakdown of WordPress vs Next.js in 2026 is worth a read before you commit.

Prerequisites and Project Setup

You'll need:

  • Node.js 18 or newer (a current LTS release such as Node.js 20 or 22 is recommended)
  • npm or pnpm
  • A TypeScript-friendly editor
  • An MCP client for testing (Claude Desktop is the easiest)

Let's scaffold the project.

mkdir ops-mcp-server && cd ops-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init

Your tsconfig.json should target modern Node and output ESM or CommonJS consistently. I usually go with:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Add scripts to package.json:

{
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  }
}

Now create src/index.ts. This is the skeleton every MCP server shares.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "ops-mcp-server",
  version: "1.0.0",
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("ops-mcp-server running on stdio");
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Two things worth flagging now, because they bite everyone once:

  1. Never write logs to stdout. Stdio transport uses stdout for protocol messages. A stray console.log corrupts the stream and the client silently fails to connect. Use console.error for everything.
  2. The server does nothing yet. That's expected. We add tools next.

Defining Your First Custom MCP Tool

Here's where the business value starts. I'll build a tool that looks up an order status, the kind of thing a support team asks an AI assistant a hundred times a day.

The SDK gives you server.tool() with a name, a description, a Zod schema, and a handler.

import { z } from "zod";

server.tool(
  "get_order_status",
  "Look up the current status of a customer order by its order ID.",
  {
    orderId: z.string().describe("The internal order ID, e.g. ORD-10234"),
  },
  async ({ orderId }) => {
    const order = await fetchOrderFromDb(orderId);

    if (!order) {
      return {
        content: [
          { type: "text", text: `No order found for ${orderId}.` },
        ],
        isError: true,
      };
    }

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            {
              orderId: order.id,
              status: order.status,
              customer: order.customerName,
              updatedAt: order.updatedAt,
            },
            null,
            2
          ),
        },
      ],
    };
  }
);

A few opinions from doing this in production:

  • Descriptions matter more than names. The model picks tools based on the description text. "Look up the current status of a customer order by its order ID" beats "get order" every time.
  • Use .describe() on every field. Zod schemas become JSON Schema, and those descriptions are what the model reads to fill arguments correctly.
  • Return structured text, not prose. Dumping JSON means the model can reason over fields. A paragraph of English forces it to re-parse.
  • Set isError: true on failures. It tells the client the call failed without throwing, so the model can recover or ask the user.

Adding a Write Tool (With a Guardrail)

Read tools are safe. Write tools need care. Here's a tool that creates an invoice, with a dry-run flag so the model can preview before committing.

server.tool(
  "create_invoice",
  "Create a draft invoice for a customer. Use dryRun to preview without saving.",
  {
    customerId: z.string().describe("Internal customer ID"),
    amount: z.number().positive().describe("Invoice amount in USD"),
    description: z.string().describe("Line item description"),
    dryRun: z
      .boolean()
      .optional()
      .describe("If true, preview the invoice without writing to the database"),
  },
  async ({ customerId, amount, description, dryRun }) => {
    const payload = { customerId, amount, description, status: "draft" };

    if (dryRun) {
      return {
        content: [
          { type: "text", text: `DRY RUN:\n${JSON.stringify(payload, null, 2)}` },
        ],
      };
    }

    const invoice = await createInvoiceInDb(payload);

    return {
      content: [
        { type: "text", text: `Created draft invoice ${invoice.id}.` },
      ],
    };
  }
);

That dryRun pattern has saved more than one client from an AI-generated invoice with a missing zero. It costs you four lines of code.

Handling Auth, Config, and Secrets

Hard-coding API keys is how tutorials become incidents. Pull everything from environment variables and validate at startup.

import { z } from "zod";

const Env = z.object({
  DATABASE_URL: z.string().url(),
  CRM_API_KEY: z.string().min(1),
  SLACK_WEBHOOK_URL: z.string().url().optional(),
});

export const env = Env.parse(process.env);

If a variable is missing, the server exits with a clear error instead of failing weirdly on the first tool call.

For multi-tenant setups (an agency running one MCP server for several clients), pass the tenant ID through an environment variable or a per-connection header rather than trusting the model to supply it. Never let the model choose which customer's data it reads.

Testing Your MCP Server Before You Connect a Client

You have three layers of testing, and skipping the first two wastes hours.

Layer 1: Unit test the tool logic. Keep the database and API calls in separate modules so you can test them with plain functions.

Layer 2: Test the protocol handshake. The SDK ships an in-memory transport you can use to call tools directly without a client.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

const [clientTransport, serverTransport] =
  InMemoryTransport.createLinkedPair();

await server.connect(serverTransport);

const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);

const result = await client.callTool({
  name: "get_order_status",
  arguments: { orderId: "ORD-10234" },
});

console.log(result.content);

Layer 3: Manual test in a real client. Add the server to Claude Desktop's config file.

{
  "mcpServers": {
    "ops": {
      "command": "node",
      "args": ["/absolute/path/to/ops-mcp-server/dist/index.js"],
      "env": {
        "DATABASE_URL": "postgres://...",
        "CRM_API_KEY": "..."
      }
    }
  }
}

Restart the client and ask it something that should trigger the tool. If nothing happens, check your server's stderr output first. Nine times out of ten it's a stdout log or a bad path.

Real Business Automation Patterns That Work

Once the plumbing is done, the interesting question is what to automate. Here's what I've actually shipped and what I'd skip.

Automation Tools Exposed Typical Payoff My Take
Support triage get_order_status, search_tickets, draft_reply High Best first project. Read-only, low risk, obvious time savings.
Sales reporting query_revenue, list_top_customers Medium Great for founders who hate dashboards.
Invoice drafting create_invoice, list_unpaid, send_reminder High Add dry-run. Always.
Content publishing create_draft, schedule_post Medium Watch for double-publishing.
Data cleanup find_duplicates, merge_records Low Do this in a script, not an AI tool.
Full autonomous ops everything Risky Don't. Keep a human in the loop on writes.

The pattern I keep coming back to: read tools are cheap and safe, write tools need guardrails, and anything destructive should require explicit human confirmation in the client. If you want more workflow ideas beyond the table above, these 7 AI automation workflows for small business map neatly onto MCP tools.

Deploying and Scaling an MCP Server

For local use, stdio is all you need. For team-wide access, you'll want HTTP.

The SDK supports a streamable HTTP transport, which lets you run the server as a normal web service behind your load balancer. A few deployment notes from production:

  • Containerize it. A small Node image, node dist/index.js, health check on a /health route. Nothing exotic.
  • Keep it stateless where possible. Session state belongs in your database, not in memory, if you're running more than one replica.
  • Log to stderr or a log aggregator, never stdout.
  • Rate limit per tenant. An AI loop can call a tool far more often than a human would.
  • Version your tools. Renaming a tool breaks every client that references it. Add new tools, deprecate old ones, remove them a cycle later.

On Kubernetes, this is a boring Deployment with a couple of replicas and a readiness probe. The interesting work is the auth layer, not the orchestration.

Common Mistakes I See in Every MCP Server Tutorial

These come up so often I keep a list.

  1. console.log in the tool handler. Breaks stdio. Use console.error.
  2. Vague tool descriptions. The model guesses wrong and you blame the AI.
  3. Returning unstructured prose. Return JSON, let the model format it.
  4. No error handling. A thrown exception kills the tool call with no useful message.
  5. Exposing too many tools. Past a certain number, the model starts picking the wrong one. Split into multiple servers by domain.
  6. Trusting model-supplied IDs for tenant data. Validate against the session, not the arguments.

If you fix only one of these, fix the first one. It accounts for most "my server won't connect" support messages I get. The same discipline applies when you're securing AI agents in production, most incidents trace back to a missing guardrail, not a clever attack.

When to Build vs When to Buy

Not every business needs a custom MCP server. Here's my honest filter.

Build a custom server when:

  • You have proprietary data or internal systems no vendor can reach.
  • You need tight control over auth and audit logs.
  • Your workflows are specific enough that generic connectors don't fit.

Buy or use existing servers when:

  • You just need GitHub, Slack, or Google Drive access. Official and community servers exist.
  • You're prototyping and want to test the concept before investing.
  • Your team has no Node or Python capacity to maintain it.

For most founders I talk to in Dhaka, Sydney, or Austin, the answer is a hybrid: use off-the-shelf servers for commodity integrations, build one custom server for the thing that makes your business different. If you're weighing whether to staff this in-house, the real numbers in this cost of hiring a web developer across Bangladesh, Australia, and the USA are a useful reality check.

FAQ

Q: Do I need TypeScript, or can I write an MCP server in plain JavaScript?

A: Plain JavaScript works fine. The SDK ships with types either way. That said, tool argument schemas are where TypeScript pays for itself, because a typo in a Zod field name becomes a compile error instead of a silent model failure. For anything beyond a weekend prototype, I'd use TypeScript.

Q: Can one MCP server connect to multiple clients at once?

A: With stdio, no. Each client spawns its own process. If you need shared access across a team, run the server over streamable HTTP behind a load balancer. That's also the setup you want if you're deploying to Kubernetes or AWS ECS.

Q: How many tools should a single MCP server expose?

A: There's no hard limit, but I keep it under roughly 15 and group by domain. Beyond that, models tend to pick the wrong tool because the descriptions blur together. If you have 40 tools, split them into four servers (support, finance, content, ops) rather than one giant one.

Q: Is MCP only for Claude, or does it work with other AI clients?

A: It's an open protocol, and support is spreading across clients and IDEs. I test primarily against Claude because it's the most mature, but the same server works in any MCP-compatible client without code changes. That portability is the main reason I recommend MCP over vendor-specific plugin formats.

Where to Go From Here

The server we built is small on purpose. Two tools, clean structure, proper config, testable. From here you'd add the tools your business actually needs, wire up auth, containerize it, and deploy. If your MCP server is going to sit next to a Next.js app, the patterns in this guide to Next.js Server Actions will keep your mutations and tool handlers consistent.

If you're a developer, the fastest way to learn is to pick one annoying internal workflow and turn it into a tool this week. Read-only first. Ship it. Then add writes with dry-run.

If you're a founder reading this and thinking "we need this but nobody on my team writes Node," that's the kind of work I do most days. I've built MCP servers, AI automation pipelines, and Next.js platforms for clients across Bangladesh, Australia, the USA, and beyond. If you want a hand scoping one, reach out and we'll figure out whether a custom MCP server is actually the right answer for your workflow, or whether a simpler integration will do the job.

Frequently asked questions

Do I need TypeScript, or can I write an MCP server in plain JavaScript?

Plain JavaScript works fine. The SDK ships with types either way. That said, tool argument schemas are where TypeScript pays for itself, because a typo in a Zod field name becomes a compile error instead of a silent model failure. For anything beyond a weekend prototype, I'd use TypeScript.

Can one MCP server connect to multiple clients at once?

With stdio, no. Each client spawns its own process. If you need shared access across a team, run the server over streamable HTTP behind a load balancer. That's also the setup you want if you're deploying to Kubernetes or AWS ECS.

How many tools should a single MCP server expose?

There's no hard limit, but I keep it under roughly 15 and group by domain. Beyond that, models tend to pick the wrong tool because the descriptions blur together. If you have 40 tools, split them into four servers (support, finance, content, ops) rather than one giant one.

Is MCP only for Claude, or does it work with other AI clients?

It's an open protocol, and support is spreading across clients and IDEs. I test primarily against Claude because it's the most mature, but the same server works in any MCP-compatible client without code changes. That portability is the main reason I recommend MCP over vendor-specific plugin formats.

Share this post

Native Web Share is available on mobile — that share sheet includes Instagram and any other app you have installed.

Let's Connect

Get In Touch

Let's build something premium together.

Contact info

Follow me
Chat on WhatsApp