教學:部署 Node.js MCP 伺服器到 Azure 容器應用

在本教學中,你將建立一個模型情境協定(MCP)伺服器,透過 Express 與 MCP TypeScript SDK 來暴露任務管理工具。 你要把伺服器部署到 Azure 容器應用程式,然後用 VS Code 從 GitHub Copilot Chat 連接。

在本教學課程中,您會:

  • 建立一個 Express 應用程式來暴露 MCP 工具
  • 用 GitHub Copilot 在本地測試 MCP 伺服器
  • 將應用程式容器化並部署至 Azure 容器應用程式
  • 將 GitHub Copilot 連接到已部署的 MCP 伺服器

先決條件

建立應用程式架構

在這個區段,你要用 Express 和 MCP TypeScript SDK 建立一個新的 Node.js 專案。

  1. 建立專案目錄並初始化:

    mkdir tasks-mcp-server && cd tasks-mcp-server
    npm init -y
    
  2. 安裝相依性:

    npm install @modelcontextprotocol/sdk express zod
    npm install -D typescript @types/node @types/express tsx
    
  3. 創建 tsconfig.json

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

    此配置針對 ES2022,並使用 Node.js 模組解析,將編譯檔案輸出到 dist/,且啟用嚴格的型別檢查。

  4. 更新 package.json 以啟用 ES 模組,並新增建置與啟動腳本。 新增或替換 typescripts 欄位:

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

    這很重要

    設定 "type": "module"。 MCP 伺服器程式碼使用頂層 await程式碼,僅支援於 ES 模組。

  5. 為記憶體內資料儲存建立 src/taskStore.ts

    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();
    

    介面定義TaskItem任務數據結構。 該 TaskStore 類別管理一個預先填充範例資料的記憶體陣列,並提供列出、查找、建立、切換及刪除任務的方法。 模組層級的單例匯出供 MCP 工具使用。

定義 MCP 工具

接著,你定義 MCP 伺服器,並用工具註冊來將任務儲存暴露給 AI 客戶端。

  1. 創建 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`);
    });
    

    重點︰

    • McpServer 在 TypeScript SDK 中定義了包含工具註冊的 MCP 伺服器。
    • StreamableHTTPServerTransport 處理 MCP 可串流 HTTP 協定。 設定設定讓 sessionIdGenerator: undefined 伺服器以無狀態模式運行。
    • 工具使用 Zod 架構來定義帶有描述的輸入參數。
    • 容器應用程式健全狀態探查需要獨立的/health端點。

在本機測試 MCP 伺服器

在部署到 Azure 之前,先透過本地運行 MCP 伺服器並從 GitHub Copilot 連接來確認它正常運作。

  1. 啟動開發伺服器:

    npx tsx src/index.ts
    
  2. 打開 VS Code,開啟 Copilot 聊天,並選擇 Agent 模式。

  3. 選擇 工具 按鈕,然後選擇 新增更多工具...>新增 MCP 伺服器

  4. 選取HTTP(HTTP 或 Server-Sent 事件)

  5. 輸入伺服器網址: http://localhost:3000/mcp

    備註

    本地開發伺服器預設使用 3000 埠。 容器化後,Dockerfile 會將環境變數設 PORT 為 8080,以匹配容器應用程式的目標埠。

  6. 輸入伺服器 ID: tasks-mcp

  7. 選取 [工作區設定]。

  8. 用一個提示來測試:「 顯示所有任務」

  9. 當 Copilot 請求工具呼叫確認時,選擇 繼續

你應該會看到 Copilot 從你的暫存中回傳任務列表。

小提示

試試其他提示,例如「建立任務以檢視 PR」、「標記任務 1 為完成」或「刪除任務 2」。

將應用程式容器化

把應用程式打包成 Docker 容器,這樣你可以在本地測試再部署到 Azure。

  1. 建立 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"]
    

    多階段建置在第一階段編譯 TypeScript,接著建立僅有執行時相依關係及編譯後 JavaScript 輸出的生產映像檔。 PORT環境變數設為 8080,以匹配容器應用程式的目標埠。

  2. 請在本地確認:

    docker build -t tasks-mcp-server .
    docker run -p 8080:8080 tasks-mcp-server
    

    確認: curl http://localhost:8080/health

部署至 Azure 容器應用程式

在你將應用程式容器化後,再用 Azure CLI 部署到 Azure 容器應用程式。 這個 az containerapp up 指令會在雲端建立容器映像,所以你不需要在機器上安裝 Docker。

  1. 設定環境變數:

    RESOURCE_GROUP="mcp-tutorial-rg"
    LOCATION="eastus"
    ENVIRONMENT_NAME="mcp-env"
    APP_NAME="tasks-mcp-server-node"
    
  2. 建立資源群組:

    az group create --name $RESOURCE_GROUP --location $LOCATION
    
  3. 建立容器應用程式環境:

    az containerapp env create \
        --name $ENVIRONMENT_NAME \
        --resource-group $RESOURCE_GROUP \
        --location $LOCATION
    
  4. 部署容器應用程式:

    az containerapp up \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --environment $ENVIRONMENT_NAME \
        --source . \
        --ingress external \
        --target-port 8080
    
  5. 設定 CORS 以允許 GitHub Copilot 請求:

    az containerapp ingress cors enable \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --allowed-origins "*" \
        --allowed-methods "GET,POST,DELETE,OPTIONS" \
        --allowed-headers "*"
    

    備註

    在生產環境中,將萬用字元 * 來源替換為特定的可信來源。 請參閱 容器應用程式上的安全 MCP 伺服器 以獲得指引。

  6. 驗證部署:

    APP_URL=$(az containerapp show \
        --name $APP_NAME \
        --resource-group $RESOURCE_GROUP \
        --query "properties.configuration.ingress.fqdn" -o tsv)
    
    curl https://$APP_URL/health
    

將 GitHub Copilot 連接到已部署的伺服器

現在 MCP 伺服器在 Azure 運行,設定 VS Code 將 GitHub Copilot 連接到已部署的端點。

  1. 在您的專案中,建立或更新 .vscode/mcp.json

    {
        "servers": {
            "tasks-mcp-server": {
                "type": "http",
                "url": "https://<your-app-fqdn>/mcp"
            }
        }
    }
    

    用部署輸出的 FQDN 替換 <your-app-fqdn>

  2. 在 VS Code 中,以客服模式開啟 Copilot 聊天。

  3. 如果伺服器沒有自動出現,請點選 工具 按鈕並確認 tasks-mcp-server 是否被列出。 如果需要,選擇 開始

  4. 「列出我所有任務」 的提示來測試,確認已部署的 MCP 伺服器有回應。

為互動使用設定縮放

預設情況下,Azure 容器應用程式可以擴展到零複本。 對於服務互動客戶端如 Copilot 的 MCP 伺服器,冷啟動會造成明顯延遲。 設定最小副本數量,以維持至少一個實例在運行:

az containerapp update \
    --name $APP_NAME \
    --resource-group $RESOURCE_GROUP \
    --min-replicas 1

安全性考慮

本教學使用未認證的 MCP 伺服器以簡化操作。 在正式環境中運行 MCP 伺服器前,請先檢視以下建議。 當由大型語言模型 (LLM) 技術支援的 Agent 呼叫您的 MCP 伺服器時,請注意提示注入攻擊。

  • 認證與授權:使用 Microsoft Entra ID 保護你的 MCP 伺服器。 請參閱 容器應用程式上的 Secure MCP 伺服器
  • 輸入驗證:Zod 架構提供型別安全,但會加入工具參數的商業規則驗證。 可以考慮像 zod-express-middleware 這類函式庫來進行請求層級驗證。
  • HTTPS:Azure 容器應用程式預設會強制執行 HTTPS,並自動使用 TLS 憑證。
  • 最低權限:只公開你使用情境所需的工具。 避免使用未經確認就進行破壞性操作的工具。
  • CORS:在執行環境中只允許受信任的網域作為來源。
  • 日誌與監控:記錄 MCP 工具的調用以進行稽核。 使用 Azure 監視器 和日誌分析。

清理資源

如果你不打算繼續使用這個應用程式,請刪除資源群組,移除你在教學中建立的所有資源:

az group delete --resource-group $RESOURCE_GROUP --yes --no-wait

下一個步驟