Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
In this tutorial, you build a Model Context Protocol (MCP) server that exposes task-management tools by using Express and the MCP TypeScript SDK. You deploy the server to Azure Container Apps and connect to it from GitHub Copilot Chat in VS Code.
In this tutorial, you:
- Create an Express app that exposes MCP tools
- Test the MCP server locally with GitHub Copilot
- Containerize and deploy the app to Azure Container Apps
- Connect GitHub Copilot to the deployed MCP server
Prerequisites
- An Azure account with an active subscription. Create one for free.
- Azure CLI version 2.62.0 or later.
- Node.js 20 LTS or later.
- Visual Studio Code with the GitHub Copilot extension.
- Docker Desktop (optional - only needed to test the container locally).
Create the app scaffold
In this section, you create a new Node.js project with Express and the MCP TypeScript SDK.
Create the project directory and initialize it:
mkdir tasks-mcp-server && cd tasks-mcp-server npm init -yInstall dependencies:
npm install @modelcontextprotocol/sdk express zod npm install -D typescript @types/node @types/express tsxCreate
tsconfig.json:{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "declaration": true }, "include": ["src/**/*"] }This configuration targets ES2022 with Node.js module resolution, outputs compiled files to
dist/, and enables strict type checking.Update
package.jsonto enable ES modules and add build and start scripts. Add or replace thetypeandscriptsfields:{ "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsx watch src/index.ts" } }Important
Set
"type": "module". The MCP server code uses top-levelawait, which is only supported in ES modules.Create
src/taskStore.tsfor the in-memory data store:export interface TaskItem { id: number; title: string; description: string; isComplete: boolean; createdAt: string; } class TaskStore { private tasks: TaskItem[] = [ { id: 1, title: "Buy groceries", description: "Milk, eggs, bread", isComplete: false, createdAt: new Date().toISOString(), }, { id: 2, title: "Write docs", description: "Draft the MCP tutorial", isComplete: true, createdAt: new Date(Date.now() - 86400000).toISOString(), }, ]; private nextId = 3; getAll(): TaskItem[] { return [...this.tasks]; } getById(id: number): TaskItem | undefined { return this.tasks.find((t) => t.id === id); } create(title: string, description: string): TaskItem { const task: TaskItem = { id: this.nextId++, title, description, isComplete: false, createdAt: new Date().toISOString(), }; this.tasks.push(task); return task; } toggleComplete(id: number): TaskItem | undefined { const task = this.tasks.find((t) => t.id === id); if (!task) return undefined; task.isComplete = !task.isComplete; return task; } delete(id: number): boolean { const index = this.tasks.findIndex((t) => t.id === id); if (index < 0) return false; this.tasks.splice(index, 1); return true; } } export const store = new TaskStore();The
TaskIteminterface defines the task data shape. TheTaskStoreclass manages an in-memory array prepopulated with sample data and provides methods to list, find, create, toggle, and delete tasks. A module-level singleton is exported for use by the MCP tools.
Define the MCP tools
Next, you define the MCP server with tool registrations that expose the task store to AI clients.
Create
src/index.ts:import express, { Request, Response } from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; import { store } from "./taskStore.js"; const app = express(); app.use(express.json()); // Health endpoint for Container Apps probes app.get("/health", (_req: Request, res: Response) => { res.json({ status: "healthy" }); }); // Create the MCP server const mcpServer = new McpServer({ name: "TasksMCP", version: "1.0.0", }); // Register tools mcpServer.tool("list_tasks", "List all tasks with their ID, title, description, and completion status.", {}, async () => { return { content: [{ type: "text", text: JSON.stringify(store.getAll(), null, 2) }], }; }); mcpServer.tool( "get_task", "Get a single task by its numeric ID.", { task_id: z.number().describe("The numeric ID of the task to retrieve") }, async ({ task_id }) => { const task = store.getById(task_id); return { content: [ { type: "text", text: task ? JSON.stringify(task, null, 2) : `Task with ID ${task_id} not found.`, }, ], }; } ); mcpServer.tool( "create_task", "Create a new task with the given title and description. Returns the created task.", { title: z.string().describe("A short title for the task"), description: z.string().describe("A detailed description of what the task involves"), }, async ({ title, description }) => { const task = store.create(title, description); return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }], }; } ); mcpServer.tool( "toggle_task_complete", "Toggle a task's completion status between complete and incomplete.", { task_id: z.number().describe("The numeric ID of the task to toggle") }, async ({ task_id }) => { const task = store.toggleComplete(task_id); const msg = task ? `Task ${task.id} is now ${task.isComplete ? "complete" : "incomplete"}.` : `Task with ID ${task_id} not found.`; return { content: [{ type: "text", text: msg }] }; } ); mcpServer.tool( "delete_task", "Delete a task by its numeric ID.", { task_id: z.number().describe("The numeric ID of the task to delete") }, async ({ task_id }) => { const deleted = store.delete(task_id); const msg = deleted ? `Task ${task_id} deleted.` : `Task with ID ${task_id} not found.`; return { content: [{ type: "text", text: msg }] }; } ); // Mount the MCP streamable HTTP transport const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); app.post("/mcp", async (req: Request, res: Response) => { await transport.handleRequest(req, res, req.body); }); app.get("/mcp", async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); app.delete("/mcp", async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); // Connect the transport to the MCP server await mcpServer.connect(transport); // Start the Express server const PORT = parseInt(process.env.PORT || "3000", 10); app.listen(PORT, () => { console.log(`MCP server running on http://localhost:${PORT}/mcp`); });Key points:
McpServerfrom the TypeScript SDK defines the MCP server with tool registrations.StreamableHTTPServerTransporthandles the MCP streamable HTTP protocol. SettingsessionIdGenerator: undefinedruns the server in stateless mode.- Tools use Zod schemas to define input parameters with descriptions.
- A separate
/healthendpoint is required for Container Apps health probes.
Test the MCP server locally
Before deploying to Azure, verify the MCP server works by running it locally and connecting from GitHub Copilot.
Start the development server:
npx tsx src/index.tsOpen VS Code, open Copilot Chat, and select Agent mode.
Select the Tools button, and then select Add More Tools... > Add MCP Server.
Select HTTP (HTTP or Server-Sent Events).
Enter the server URL:
http://localhost:3000/mcpNote
The local development server defaults to port 3000. When containerized, the Dockerfile sets the
PORTenvironment variable to 8080 to match the Container Apps target port.Enter a server ID:
tasks-mcpSelect Workspace Settings.
Test with a prompt: "Show me all tasks"
Select Continue when Copilot requests tool invocation confirmation.
You should see Copilot return the list of tasks from your in-memory store.
Tip
Try other prompts like "Create a task to review the PR", "Mark task 1 as complete", or "Delete task 2".
Containerize the application
Package the application as a Docker container so you can test it locally before deploying to Azure.
Create a
Dockerfile:FROM node:20-slim AS build WORKDIR /app COPY package*.json . RUN npm ci COPY tsconfig.json . COPY src/ src/ RUN npm run build FROM node:20-slim WORKDIR /app COPY package*.json . RUN npm ci --omit=dev COPY --from=build /app/dist ./dist ENV PORT=8080 EXPOSE 8080 CMD ["node", "dist/index.js"]The multi-stage build compiles TypeScript in the first stage, then creates a production image with only runtime dependencies and the compiled JavaScript output. The
PORTenvironment variable is set to 8080 to match the Container Apps target port.Verify locally:
docker build -t tasks-mcp-server . docker run -p 8080:8080 tasks-mcp-serverConfirm:
curl http://localhost:8080/health
Deploy to Azure Container Apps
After you containerize the application, deploy it to Azure Container Apps by using Azure CLI. The az containerapp up command builds the container image in the cloud, so you don't need Docker on your machine for this step.
Set environment variables:
RESOURCE_GROUP="mcp-tutorial-rg" LOCATION="eastus" ENVIRONMENT_NAME="mcp-env" APP_NAME="tasks-mcp-server-node"Create a resource group:
az group create --name $RESOURCE_GROUP --location $LOCATIONCreate a Container Apps environment:
az containerapp env create \ --name $ENVIRONMENT_NAME \ --resource-group $RESOURCE_GROUP \ --location $LOCATIONDeploy the container app:
az containerapp up \ --name $APP_NAME \ --resource-group $RESOURCE_GROUP \ --environment $ENVIRONMENT_NAME \ --source . \ --ingress external \ --target-port 8080Configure CORS to allow GitHub Copilot requests:
az containerapp ingress cors enable \ --name $APP_NAME \ --resource-group $RESOURCE_GROUP \ --allowed-origins "*" \ --allowed-methods "GET,POST,DELETE,OPTIONS" \ --allowed-headers "*"Note
For production, replace the wildcard
*origins with specific trusted origins. See Secure MCP servers on Container Apps for guidance.Verify the deployment:
APP_URL=$(az containerapp show \ --name $APP_NAME \ --resource-group $RESOURCE_GROUP \ --query "properties.configuration.ingress.fqdn" -o tsv) curl https://$APP_URL/health
Connect GitHub Copilot to the deployed server
Now that the MCP server is running in Azure, configure VS Code to connect GitHub Copilot to the deployed endpoint.
In your project, create or update
.vscode/mcp.json:{ "servers": { "tasks-mcp-server": { "type": "http", "url": "https://<your-app-fqdn>/mcp" } } }Replace
<your-app-fqdn>with the FQDN from the deployment output.In VS Code, open Copilot Chat in Agent mode.
If the server doesn't appear automatically, select the Tools button and verify
tasks-mcp-serveris listed. Select Start if needed.Test with a prompt like "List all my tasks" to confirm the deployed MCP server responds.
Configure scaling for interactive use
By default, Azure Container Apps can scale to zero replicas. For MCP servers that serve interactive clients like Copilot, cold starts cause noticeable delays. Set a minimum replica count to keep at least one instance running:
az containerapp update \
--name $APP_NAME \
--resource-group $RESOURCE_GROUP \
--min-replicas 1
Security considerations
This tutorial uses an unauthenticated MCP server for simplicity. Before running an MCP server in production, review the following recommendations. When an agent powered by large language models (LLMs) calls your MCP server, be aware of prompt injection attacks.
- Authentication and authorization: Secure your MCP server by using Microsoft Entra ID. See Secure MCP servers on Container Apps.
- Input validation: Zod schemas provide type safety, but add business-rule validation for tool parameters. Consider libraries like zod-express-middleware for request-level validation.
- HTTPS: Azure Container Apps enforces HTTPS by default with automatic TLS certificates.
- Least privilege: Expose only the tools your use case requires. Avoid tools that perform destructive operations without confirmation.
- CORS: Restrict allowed origins to trusted domains in production.
- Logging and monitoring: Log MCP tool invocations for auditing. Use Azure Monitor and Log Analytics.
Clean up resources
If you don't plan to continue using this application, delete the resource group to remove all the resources you created in this tutorial:
az group delete --resource-group $RESOURCE_GROUP --yes --no-wait