MCP series: Je eerste MCP-server bouwen

Deze blogpost is de eerste van een reeks over het bouwen van je MCP-server, het hosten ervan op Azure en tot slot het implementeren ervan in een M365 Copilot-agent.

  • Deel 1 — Je eerste MCP-server bouwen
  • Deel 2 — De MCP-server hosten op Azure (Azure Container Apps)
  • Deel 3 — De MCP-server verbinden met een Microsoft 365 Copilot-agent

Wat is het Model Context Protocol?

Het Model Context Protocol (MCP) is een open standaard, oorspronkelijk geïntroduceerd door Anthropic en nu breed geadopteerd, die definieert hoe AI-modellen communiceren met externe tools en gegevensbronnen. Zie het als een universele adapter: in plaats van dat elke AI-assistent een op maat gemaakte integratie nodig heeft voor elke API of service, biedt MCP één enkel, consistent contract.

Voor Microsoft 365 Copilot specifiek betekent MCP-ondersteuning dat je elke mogelijkheid kunt blootstellen — een database bevragen, een REST API aanroepen, vanuit SharePoint lezen, bedrijfslogica uitvoeren — als een set tools die Copilot namens de gebruiker kan ontdekken en aanroepen.

Een MCP-server stelt drie primitieven bloot:

  • Tools — functies die Copilot kan aanroepen (bijv. getProjectStatus, createTicket)
  • Resources — gegevensbronnen die het model kan lezen (bijv. bestanden, databaserecords)
  • Prompts — herbruikbare promptsjablonen die het model kan gebruiken

Voor de meeste Copilot-agentscenario’s zijn tools waar je je op zult richten.

Vereisten

  • Node.js 20+
  • Azure CLI geïnstalleerd en ingelogd
  • Een Microsoft 365-tenant met Copilot-licenties
  • Docker Desktop (voor deel 2)
  • Basiskennis van REST APIs

Het project scaffolden

We schrijven de server in gewoon JavaScript, geen TypeScript-compilatiestap vereist, wat het eenvoudig houdt en prima werkt met de MCP Inspector voor lokaal testen.

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod express

Update package.json om ESM-modules te gebruiken:

{
  "type": "module",
  "scripts": {
    "dev": "node server.js",
    "start": "node server.js"
  }
}

De server schrijven

Maak server.js aan in de projectroot. De belangrijkste ontwerpbeslissing hier is een enkel bestand voor beide transporten — STDIO voor lokale ontwikkeling en de MCP Inspector, HTTP voor Azure en Copilot. Eén omgevingsvariabele bepaalt welke wordt gestart:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import express from "express";

// Gegevens (vervang dit door je echte gegevensbron)
const projects = {
  "PRJ-001": { name: "Intranet Redesign",  status: "In Progress", owner: "Arne" },
  "PRJ-002": { name: "Copilot Rollout",    status: "Planning",    owner: "Simon"   },
  "PRJ-003": { name: "Purview Governance", status: "Completed",   owner: "Nico"  },
};

// MCP-server en tools (gedeeld tussen beide transporten)
const server = new McpServer({ name: "project-status-server", version: "1.0.0" });

server.tool(
  "getProjectStatus",
  "Retrieves the current status and owner of a project by its ID.",
  { projectId: z.string().describe("The project ID, e.g. PRJ-001") },
  async ({ projectId }) => {
    const project = projects[projectId];
    if (!project) return { content: [{ type: "text", text: `Project ${projectId} not found.` }], isError: true };
    return { content: [{ type: "text", text: `**${project.name}** (${projectId})\nStatus: ${project.status}\nOwner: ${project.owner}` }] };
  }
);

server.tool(
  "updateProjectStatus",
  "Updates the status of a project.",
  {
    projectId: z.string().describe("The project ID"),
    status: z.enum(["Planning", "In Progress", "On Hold", "Completed"]).describe("The new status"),
  },
  async ({ projectId, status }) => {
    if (!projects[projectId]) return { content: [{ type: "text", text: `Project ${projectId} not found.` }], isError: true };
    projects[projectId].status = status;
    return { content: [{ type: "text", text: `Updated ${projectId} to "${status}".` }] };
  }
);

server.tool(
  "listProjects",
  "Returns a list of all projects with their current status.",
  {},
  async () => {
    const list = Object.entries(projects)
      .map(([id, p]) => `- **${id}** - ${p.name} (${p.status}, owner: ${p.owner})`)
      .join("\n");
    return { content: [{ type: "text", text: list }] };
  }
);

// Transport-schakelaar
if (process.env.TRANSPORT === "stdio") {
  // STDIO - voor lokale ontwikkeling en MCP Inspector
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running via STDIO");
} else {
  // HTTP - voor Azure en Copilot
  const app = express();
  app.use(express.json());

  app.get("/health", (_req, res) => res.json({ status: "ok" }));

  app.post("/mcp", async (req, res) => {
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    res.on("finish", () => transport.close());
  });

  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => console.error(`MCP server running via HTTP on http://localhost:${PORT}`));
}

Een paar dingen zijn het benadrukken waard:

  • Één bestand, twee transporten. De tools worden eenmalig gedefinieerd en gedeeld. Het transport wordt bij het opstarten gekozen via process.env.TRANSPORT.
  • console.error overal. In STDIO-modus is stdout gereserveerd voor JSON-RPC-berichten. Elke console.log corrumpeert het protocol en veroorzaakt cryptische JSON-parseerfouten in de Inspector. Gebruik altijd console.error voor logging.
  • Waarom Express? Het StreamableHTTPServerTransport van de MCP SDK verwacht een vooraf geparseerde aanvraagbody. Express verwerkt dit standaard netjes.

Start de server in HTTP-modus:

node server.js

De server testen

Optie A - curl

Het MCP-transport gebruikt Server-Sent Events (SSE), dus er zijn een paar dingen die je goed moet zetten:

  • Geef -N (geen buffering) mee, anders blijft curl stil
  • Voeg Accept: application/json, text/event-stream toe, zonder dit krijg je een 406 Not Acceptable
curl -N -X POST http://localhost:3000/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}"

Een succesvolle reactie ziet er als volgt uit:

Optie B - MCP Inspector (aanbevolen)

De MCP Inspector geeft je een goede browser-UI om tools interactief te bekijken en aan te roepen.

npx @modelcontextprotocol/inspector

Configureer de verbinding in de Inspector-UI:

  • Transport Type: STDIO
  • Command: node
  • Arguments: server.js
  • Working directory: pad naar je projectmap
  • Environment Variables: TRANSPORT = stdio

Klik op Connect en je ziet alle drie de tools klaarstaan.

Caution

De Inspector spawnt je server zelf via STDIO, start de server dus niet afzonderlijk voordat je verbinding maakt. Zorg er ook voor dat TRANSPORT=stdio is ingesteld in de omgevingsvariabelen, anders start de server in HTTP-modus en kan de Inspector er niet mee communiceren.

Een succesvolle reactie: