Skip to content

Establishing MCP Communication

Build an MCP Server in Node.js or .NET and connect it to VS Code Copilot

This workshop is available in two languages. Use the tabs on each code block to switch between the Node.js and .NET implementation. The MCP protocol, the VS Code client configuration, and the workflow are identical — only the SDK and language change.

Project Setup

Step 1 - Create your project folder

bash
mkdir devcon-workshop
cd devcon-workshop

Step 2 - Initialise the project

bash
npm init -y
bash
dotnet new web -n DevconWorkshop -o .

Step 3 - Configure the project

json
{
  "name": "devcon-workshop",
  "version": "1.0.0",
  "type": "module"
}
xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.3.0" />
  </ItemGroup>

</Project>

Node.js — Why "type": "module"? The MCP SDK uses ES module syntax (import/export). Without this flag Node.js will reject it. If your package.json already contains "type": "commonjs" or any other value, replace that line, don't add a second "type" entry.

.NET — Why Microsoft.NET.Sdk.Web? The MCP server runs on top of ASP.NET Core's HTTP pipeline. The Web SDK gives us WebApplication.CreateBuilder and the routing infrastructure needed to expose the /mcp endpoint.

Step 4 - Install dependencies

bash
npm install @modelcontextprotocol/sdk@latest zod@latest
bash
dotnet add package ModelContextProtocol.AspNetCore --version 1.3.0
PackageLinkPurpose
@modelcontextprotocol/sdk@modelcontextprotocol/sdkAnthropic's official MCP SDK - handles the full protocol
zodzodSchema validation for tool inputs
ModelContextProtocol.AspNetCoreModelContextProtocol.AspNetCoreOfficial MCP SDK for ASP.NET Core - protocol + transport

⚠️ Node.js — Zod compatibility: If you encounter errors with import { z } from "zod", your environment may need import { z } from "zod/v4" instead. This can happen when Zod 4+ is installed. Check the Zod docs for migration guidance.

Your project folder should now look like this:

text
devcon-workshop/
├── node_modules/
├── package.json
└── package-lock.json
text
devcon-workshop/
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── DevconWorkshop.csproj
└── Program.cs

Build the MCP Server

We start by creating the server. The server is the core of an MCP system. It is responsible for exposing the tools that clients can call, receiving incoming requests, executing the right tool handler, and sending the response back. Everything flows through it.

In Node.js, create a new file called server.js. In .NET, rename the scaffolded Program.cs to Server.cs (or delete it and create a fresh Server.cs).

text
devcon-workshop/
├── node_modules/
├── package.json
├── package-lock.json
└── server.js         ← new
text
devcon-workshop/
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── DevconWorkshop.csproj
└── Server.cs         ← new

The Imports / Usings - What Each One Does

Why Streamable HTTP? MCP supports three transports: stdio (subprocess-based, used by Claude Desktop), SSE (Server-Sent Events, legacy), and Streamable HTTP (the current standard). We use Streamable HTTP because it works over the network, supports streaming responses, and is the recommended transport for HTTP-first architectures.

javascript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import http from "node:http";
csharp
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;

Node.js — what each import does

ImportWhat it does
McpServerCreates and manages your MCP server instance. Handles capability negotiation and tool routing automatically.
StreamableHTTPServerTransportWires the MCP server to HTTP. Manages sessions, handles POST requests from clients, and supports Server-Sent Events (SSE) for streaming responses back.
z (Zod)Defines and validates the input schema for each tool. Ensures the client sends the right types before your tool function runs.
httpNode.js built-in HTTP module. Creates the server that listens for incoming requests and routes them to the MCP transport.

.NET — what each using does

UsingWhat it does
ModelContextProtocol.ServerBrings in AddMcpServer, WithHttpTransport, [McpServerTool], and [McpServerToolType] — the full MCP SDK surface.
System.ComponentModelProvides [Description] so each tool and parameter can carry the metadata LLMs use to pick the right tool.
System.Text.Json + SerializationUsed to register JsonStringEnumConverter so enums are serialised as snake_case strings on the wire, not integers.

Section 1 - Create the MCP Server

javascript
function createServer() {
  const server = new McpServer({
    name: "devcon-workshop-server",
    version: "1.0.0",
  });

  // ... register tools here ...

  return server;
}
csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<JsonSerializerOptions>(o =>
    o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)));

builder.Services
    .AddMcpServer(o =>
    {
        o.ServerInfo = new() { Name = "devcon-workshop-server", Version = "1.0.0" };
    })
    .WithHttpTransport(options => options.Stateless = true)
    .WithTools<MathTools>()
    .WithTools<GreetingTools>();
  • The server takes a name and version — these are advertised to any client that connects during the handshake.
  • Node.js: the server is wrapped in a factory function so that each incoming HTTP request gets its own fresh instance. The MCP SDK only allows one active transport per server instance, so without this the server would crash on the second request.
  • .NET: WithHttpTransport(options => options.Stateless = true) plays the same role — each request gets its own session, no shared transport state.
  • .NET: JsonStringEnumConverter with SnakeCaseLower means an enum value like Language.English is sent as "english" on the wire, matching the schema clients expect.

Section 2 - Register Tools

We'll register two tools to demonstrate the pattern:

  • add: takes two numbers (a and b) and returns their sum. A minimal example that shows the basic structure without any business logic in the way.
  • greet: takes a name and a language (english, french, or spanish) and returns a personalised greeting in the chosen language. Demonstrates how to constrain a string input to a fixed set of allowed values.
javascript
// Tool 1: add two numbers
server.registerTool(
  "add",
  {
    description: "Adds two numbers together",
    inputSchema: {
      a: z.number().describe("First number"),
      b: z.number().describe("Second number"),
    },
  },
  async ({ a, b }) => ({
    content: [{ type: "text", text: `Result: ${a + b}` }],
  }),
);

// Tool 2: greet someone
server.registerTool(
  "greet",
  {
    description: "Returns a greeting in the chosen language",
    inputSchema: {
      name: z.string().describe("Name of the person to greet"),
      language: z.enum(["english", "french", "spanish"]).describe("Language"),
    },
  },
  async ({ name, language }) => {
    const greetings = {
      english: `Hello, ${name}! Welcome to the DevCon MCP Workshop.`,
      french: `Bonjour, ${name} ! Bienvenue au Workshop MCP DevCon.`,
      spanish: `¡Hola, ${name}! Bienvenido al Workshop MCP de DevCon.`,
    };
    return {
      content: [{ type: "text", text: greetings[language] }],
    };
  },
);
csharp
// ---------------------------------------------------------------------------
// Tool 1: add
// ---------------------------------------------------------------------------
public class MathTools
{
    [McpServerTool(Name = "add"), Description("Adds two numbers together")]
    public static string Add(
        [Description("First number")] double a,
        [Description("Second number")] double b)
        => $"Result: {a + b}";
}

public enum Language
{
    English,
    French,
    Spanish,
}

// ---------------------------------------------------------------------------
// Tool 2: greet
// ---------------------------------------------------------------------------
[McpServerToolType]
public class GreetingTools
{
    [McpServerTool(Name = "greet"), Description("Returns a greeting in the chosen language")]
    public static string Greet(
        [Description("Name of the person to greet")] string name,
        [Description("Language")] Language language)
        => language switch
        {
            Language.English => $"Hello, {name}! Welcome to the DevCon MCP Workshop.",
            Language.French => $"Bonjour, {name} ! Bienvenue au Workshop MCP DevCon.",
            Language.Spanish => $"¡Hola, {name}! Bienvenido al Workshop MCP de DevCon.",
            _ => throw new ArgumentOutOfRangeException(nameof(language)),
        };
}

Node.js — server.registerTool() takes three arguments:

  1. Name - the identifier clients use to call this tool
  2. Config object - contains description (used by LLMs to decide which tool to call) and inputSchema (Zod object defining and validating each input)
  3. Handler - the async function that runs when the tool is called; must return a content array

.NET — attribute-driven registration:

  1. [McpServerToolType] marks a class as containing MCP tools.
  2. [McpServerTool(Name = "...")] on a static method registers it as a tool with that name.
  3. [Description("...")] on the method and each parameter provides the metadata LLMs use to choose and call the tool — equivalent to Node.js's description and .describe().
  4. Enums like Language are how .NET constrains a string to a fixed set of values — the SDK turns them into a JSON schema enum automatically.

Section 3 - Create the HTTP Server and Start Listening

javascript
const PORT = 3000;

const httpServer = http.createServer(async (req, res) => {
  if (req.url !== "/mcp") {
    res.writeHead(404).end("Not found");
    return;
  }

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  res.on("close", () => transport.close());

  const server = createServer();
  await server.connect(transport);
  await transport.handleRequest(req, res);
});

httpServer.listen(PORT, () => {
  console.log(`MCP server running at http://localhost:${PORT}/mcp`);
});
csharp
var app = builder.Build();
app.Urls.Add("http://localhost:3000");
app.MapMcp("/mcp");

Console.WriteLine("MCP server running at http://localhost:3000/mcp");

app.Run();

Node.js

  • sessionIdGenerator: undefined - sets the server to stateless mode. Each request is self-contained.
  • A fresh StreamableHTTPServerTransport is created per request.
  • res.on("close", () => transport.close()) ensures the transport is cleaned up when the HTTP connection closes.
  • transport.handleRequest(req, res) does all MCP message parsing, routing, and response writing, no manual body parsing needed.

.NET

  • app.MapMcp("/mcp") wires the MCP transport onto the ASP.NET Core routing pipeline at the /mcp path. The SDK handles message parsing, tool dispatch, and SSE streaming for you.
  • app.Urls.Add("http://localhost:3000") binds the server to port 3000 — matching the port the VS Code client expects.
  • Stateless = true (set in Section 1) is the equivalent of Node's sessionIdGenerator: undefined.

View complete server.js in Source Code → · View complete Server.cs in Source Code →

Connect VS Code as Your MCP Client

VS Code with GitHub Copilot can act as an MCP client. It connects to your running server, discovers its tools, and lets you call them through natural language. All you need to do is tell VS Code where to find the server.

Terminology: In MCP, the client (VS Code, Cursor, Claude Desktop) connects to an MCP server and calls its tools. Some documentation uses "host" to refer to the application that embeds the client. In practice the terms are used interchangeably.

Create a .vscode folder in your project and add mcp.json. This file is identical for both Node.js and .NET — the client only cares about the URL, not what's behind it.

text
devcon-workshop/
├── .vscode/
│   └── mcp.json      ← new
├── node_modules/
├── package.json
├── package-lock.json
└── server.js
text
devcon-workshop/
├── .vscode/
│   └── mcp.json      ← new
├── Properties/
│   └── launchSettings.json
├── appsettings.Development.json
├── appsettings.json
├── DevconWorkshop.csproj
└── Server.cs
json
{
  "servers": {
    "devcon-workshop": {
      "type": "http",
      "url": "http://localhost:3000/mcp"
    }
  }
}
FieldWhat it does
serversA map of named MCP servers VS Code should know about
devcon-workshopThe display name shown in VS Code's MCP server list
type: httpTells VS Code to connect over HTTP
urlThe endpoint VS Code sends MCP requests to, must match where your server is listening

Run It

Step 1 - Start the server

bash
node server.js
bash
dotnet run

Expected output:

MCP server running at http://localhost:3000/mcp

Step 2 - Connect VS Code

Open the Command Palette (Ctrl+Shift+P on Windows/Linux, Cmd+Shift+P on Mac, or View → Command Palette) and run MCP: List Servers. You should see devcon-workshop listed with a Start button. Click it, VS Code will connect to your running server.

Step 3 - Verify tool calls through Copilot Chat

Open Copilot Chat (Ctrl+Alt+I on Windows/Linux, Cmd+Ctrl+I on Mac, or click the Copilot icon in the Activity Bar) and switch to Agent mode by clicking the mode selector next to the model name. MCP tools are only available in Agent mode, the other modes don't support external tool calls.

To reference your MCP tools explicitly, use the # prefix:

Use `#add` to add 12 and 30
Use `#greet` to greet Nabil in Spanish

You will see a tool call indicator appear in the chat. Copilot shows which tool it invoked and the result before writing its final answer. That confirms your server is live and the MCP connection is working.

Challenge

Add a third tool called estimate_cost that takes:

  • material (enum: "concrete", "steel", "timber", "glass") - the building material
  • volume (number) - volume in cubic metres

And returns a cost estimate using these hardcoded rates:

MaterialRate per m³
concrete$150
steel$950
timber$400
glass$1,200

Expected output for estimate_cost({ material: "concrete", volume: 25 }): Cost estimate: 25 m³ of concrete at $150/m³ = $3,750

Then ask VS Code Copilot:

Estimate the cost of 25 cubic metres of concrete.

Tip: After adding or modifying tools, you need to restart both the server and the MCP connection in VS Code.

  • Node.js: Ctrl+C then node server.js
  • .NET: Ctrl+C then dotnet run

You can restart the connection by opening .vscode/mcp.json and clicking the Restart button that appears inline above each server entry, or via the Command Palette: MCP: List Servers → Restart.

View complete solution server.js (adds estimate_cost) → · View complete solution Server.cs (adds estimate_cost) →

Quick Reference

Command / ActionWhat it does
node server.js (Node.js)Starts the MCP server on port 3000
dotnet run (.NET)Starts the MCP server on port 3000
VS Code MCP: List Servers → StartConnects VS Code Copilot to the running server
Copilot Chat → Agent modeCalls tools via natural language
server.registerTool(name, config, handler)Registers a new tool on the server (Node.js)
[McpServerTool] + .WithTools<T>()Registers a new tool on the server (.NET)

Looking for a higher-level API? This workshop uses the official MCP SDK directly so you understand every layer. If you want less boilerplate, check out FastMCP (Python) or FastMCP (TypeScript) — they wrap the same protocol in ~20-25 lines instead of ~70.

DevCon MCP Workshop