Building a Custom MCP Server with Node.js

Ever wished your AI assistant actually knew your stuff? Your files, your schedule, or even a custom data source? That's exactly what the Model Context Protocol (MCP) makes possible.

MCP is an open standard for connecting AI assistants like Claude to external data sources and tools. Think of it like a USB port for AI - a single, standardised connector that works with any compatible device. Build the server once, plug it into any MCP-compatible client.

In this guide, we'll build a custom MCP server from scratch using Node.js. The example is deliberately practical: a bin collection reminder system. Simple enough to grasp quickly. Complex enough to cover every core concept you'll need.

What is the Model Context Protocol?

MCP gives AI assistants a common language for talking to the outside world. Instead of every tool needing its own bespoke integration, MCP provides a shared standard.

Your MCP server exposes "tools" - functions that AI Assistants can call. The server does the work (reading files, querying a database, calling an API), then returns structured data. The AI Assistant handles the rest.

Right now, Claude Desktop, Visual Studio and VS Code (amongst others) support MCP out of the box. The ecosystem is growing fast.

The Problem We're Solving

To build a basic example (and experiment with real data in my life) - I wanted to create an MCP server that gave me the details of my Bin Collections. I wanted to be able to run a simple chat query and get a response. Ideally, this would save me time logging onto my local council's website and entering my information time and time again.

Here's the scenario. Garden waste goes out fortnightly on Fridays. Recycling is every Saturday. General refuse is also fortnightly on Fridays - but on different weeks. It's surprisingly easy to get wrong, especially when you factor in that holidays throw the schedule off.

So we'll build an MCP server that Claude can query to answer questions like "when are my bins next due out?" A simple use case that's genuinely useful and it teaches you the full pattern.

Setting Up the Project

Create a new Node.js project:

mkdir bin-collection-mcp
cd bin-collection-mcp
npm init -y

You only need one dependency - the official MCP SDK:

npm install @modelcontextprotocol/sdk

Update your package.json to use ES modules. Add "type": "module":

{
  "name": "bin-collection-mcp",
  "version": "1.0.0",
  "description": "MCP server for local bin collection day lookups",
  "type": "module",
  "main": "index.js",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0"
  }
}

The Data Structure

Before writing any server code, think about your data. We'll use a simple JSON file - bin-days.json - that stores collection dates:

{
  "collections": [
    {
      "date": "2026-05-29",
      "day": "Friday",
      "types": ["garden"],
      "notes": ""
    },
    {
      "date": "2026-05-30",
      "day": "Saturday",
      "types": ["recycling"],
      "notes": ""
    }
  ],
  "binTypes": {
    "general": {
      "colour": "black",
      "description": "General household refuse"
    },
    "garden": {
      "colour": "brown",
      "description": "Garden waste - grass, leaves, small branches"
    },
    "recycling": {
      "colour": "blue",
      "description": "Paper, card, plastics, tins, glass"
    }
  }
}

Each collection has a date, day name, an array of bin types, and optional notes. The binTypes object describes what each bin is for. Keep it readable - you'll be updating this file manually.

Building the MCP Server

Create index.js. We'll build it in steps.

Step 1 - Import Dependencies and Load Data

#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const BIN_DAYS_PATH = resolve(__dirname, "bin-days.json");

function loadBinDays() {
  const raw = readFileSync(BIN_DAYS_PATH, "utf-8");
  return JSON.parse(raw);
}

The shebang at the top makes the file directly executable. We're loading the JSON synchronously each time it's needed. Not the most efficient approach - but simple, and it means changes to your data file are picked up immediately.

Step 2 - Add Helper Functions

Here's the business logic before we wire up the server:

function getUpcomingCollections(data, daysAhead = 30) {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const cutoff = new Date(today);
  cutoff.setDate(cutoff.getDate() + daysAhead);

  return data.collections.filter((c) => {
    const date = new Date(c.date);
    return date >= today && date <= cutoff;
  });
}

function getNextCollection(data, binType) {
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  const upcoming = data.collections
    .filter((c) => {
      const date = new Date(c.date);
      if (date < today) return false;
      if (binType) return c.types.includes(binType.toLowerCase());
      return true;
    })
    .sort((a, b) => new Date(a.date) - new Date(b.date));

  return upcoming[0] || null;
}

getUpcomingCollections returns everything within a date window. getNextCollection finds the soonest one, with an optional filter by bin type. Clean, testable functions. Keep your business logic separate from your server wiring.

Step 3 - Initialise the Server

const server = new Server(
  { name: "bin-collection", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

The capabilities object tells clients what your server can do. Here we're saying: this server exposes tools. That's all your AI assistant needs to know upfront.

Step 4 - Define Your Tools

This is where MCP gets interesting. You're essentially writing a contract - here's what I can do, here's what you can ask me:

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_upcoming_collections",
      description:
        "Returns all bin collections scheduled in the next N days (default 30).",
      inputSchema: {
        type: "object",
        properties: {
          days_ahead: {
            type: "number",
            description: "How many days ahead to look (default: 30)",
          },
        },
      },
    },
    {
      name: "get_next_collection",
      description:
        "Returns the next upcoming bin collection, optionally filtered by bin type.",
      inputSchema: {
        type: "object",
        properties: {
          bin_type: {
            type: "string",
            description: "Filter by bin type: 'general', 'recycling', or 'garden'",
            enum: ["general", "recycling", "garden"],
          },
        },
      },
    },
    {
      name: "get_bin_types",
      description: "Returns details about each bin type.",
      inputSchema: {
        type: "object",
        properties: {},
      },
    },
  ],
}));

Your inputSchema uses JSON Schema to define parameters. The AI assistant reads this to understand what arguments to pass. Notice the enum on bin_type - that tells the AI assistant exactly which values are valid. Good schemas mean fewer errors.

Step 5 - Handle Tool Calls

When the AI assistant actually calls one of your tools, this handler runs:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  const data = loadBinDays();

  if (name === "get_upcoming_collections") {
    const daysAhead = args?.days_ahead ?? 30;
    const collections = getUpcomingCollections(data, daysAhead);

    if (collections.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No bin collections found in the next ${daysAhead} days.`,
          },
        ],
      };
    }

    const formatted = collections
      .map((c) => {
        const types = c.types.join(", ");
        const note = c.notes ? ` (${c.notes})` : "";
        return `• ${c.date} (${c.day}): ${types}${note}`;
      })
      .join("\n");

    return {
      content: [
        {
          type: "text",
          text: `Upcoming bin collections (next ${daysAhead} days):\n\n${formatted}`,
        },
      ],
    };
  }

  if (name === "get_next_collection") {
    const binType = args?.bin_type || null;
    const next = getNextCollection(data, binType);

    if (!next) {
      const label = binType ? `${binType} bin` : "any bin";
      return {
        content: [{ type: "text", text: `No upcoming collections found for ${label}.` }],
      };
    }

    const types = next.types.join(", ");
    const note = next.notes ? `\nNote: ${next.notes}` : "";
    const label = binType ? `Next ${binType} collection` : "Next collection";

    return {
      content: [
        {
          type: "text",
          text: `${label}: ${next.date} (${next.day})\nBins out: ${types}${note}`,
        },
      ],
    };
  }

  if (name === "get_bin_types") {
    const lines = Object.entries(data.binTypes)
      .map(([key, val]) => `• ${key} (${val.colour} bin): ${val.description}`)
      .join("\n");

    return {
      content: [{ type: "text", text: `Bin types:\n\n${lines}` }],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

Each tool returns a content array with text. You can return images or embedded resources too - but text handles most cases just fine.

Step 6 - Start the Server

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Bin collection MCP server running");

We're using stdio transport - the server talks via standard input/output. That's why logging goes to console.error. Stdout is reserved for MCP messages.

Testing It

Make the file executable, then run it:

chmod +x index.js
node index.js

You should see "Bin collection MCP server running" in the console. The server is now waiting for requests on stdin. You can also write unit tests against your helper functions directly - they're just plain JavaScript functions.

Connecting to Visual Studio Code

VS Code supports MCP servers through its settings. Open your settings.json (Cmd+Shift+P → "Open User Settings JSON") and add the following:

{
  "mcp": {
    "servers": {
      "bin-collection": {
        "type": "stdio",
        "command": "node",
        "args": ["/absolute/path/to/bin-collection-mcp/index.js"]
      }
    }
  }
}

Reload VS Code and your server should be picked up automatically. Then try asking Copilot Chat in agent mode:

  • "When are my bins next due out?"
  • "Show me all collections in the next 7 days"
  • "When is the next recycling collection?"

VS Code calls your tools automatically and gives you a natural language answer. It feels like magic the first time. ✨

The Pattern to Remember

MCP servers always follow the same structure. List your tools. Handle tool calls. Return content. That's it. Once you've got this pattern in your head, you can build servers for anything - your local filesystem, an internal API, a Raspberry Pi sensor, whatever you like.

I took this a step further and deployed this code to an Azure App Service and this made it available from anywhere. The full code for this project is on GitHub. Give it a try - and if you build something interesting with it, I'd love to hear about it.