SSD Nodes Learn
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-07-23

Cách chạy MCP server trên VPS cho AI agent

Hướng dẫn cài đặt MCP server qua stdio và HTTP trên VPS. Cách cấu hình systemd, TLS và Nginx để bảo mật endpoint khi kết nối với AI coding agent.

Những gì bạn sẽ xây dựng

Hai thiết lập MCP hoạt động trên một VPS. Đầu tiên là một server stdio — một công cụ filesystem hoặc database mà Claude Code khởi chạy như một child process và giao tiếp qua một pipe. Tiếp theo là một server remote HTTP chạy như một network service lâu dài đằng sau systemd và một nginx reverse proxy có TLS, có thể truy cập bởi bất kỳ MCP client nào mà bạn trỏ tới. Việc cài đặt cho cả hai đều rất nhẹ. Phần lớn hướng dẫn này tập trung vào hai vấn đề thực sự khó nhằn: giữ cho luồng JSON-RPC luôn sạch và không bao giờ để một endpoint công cụ không có xác thực (unauthenticated) trên internet công cộng.

MCP thực sự là gì

Model Context Protocol là một cách tiêu chuẩn để một AI client — Claude Code, Claude Desktop, Gemini CLI trên một VPS, hoặc script của riêng bạn — gọi các công cụ bên ngoài và đọc các tài nguyên bên ngoài. Bản thân model không chạy bất cứ thứ gì. Nó yêu cầu client, client nói chuyện qua JSON-RPC 2.0 với một MCP server, server chạy công cụ và trả kết quả về. Chỉ một protocol duy nhất, vì vậy một server bạn viết một lần có thể hoạt động với mọi client nói chuyện qua MCP.

Có hai loại transport, và toàn bộ phần còn lại của hướng dẫn này sẽ chia theo chúng:

  • stdio. Client khởi tạo server như một child process và trao đổi các tin nhắn JSON-RPC phân tách bằng dòng mới (newline-delimited) qua standard input và standard output của nó. Không mạng, không port, không auth — ranh giới tin cậy chính là bản thân process đó. Hầu hết mọi công cụ local đều được phân phối theo cách này.
  • Streamable HTTP (và phiên bản cũ hơn là HTTP+SSE). Server là một web service chạy lâu dài. Client kết nối qua HTTP và server có thể stream phản hồi về dưới dạng Server-Sent Events. Đây là cách bạn chia sẻ một server cho nhiều client, hoặc chạy một công cụ cần phải tồn tại vĩnh viễn trên máy.

Chọn stdio khi công cụ đó thuộc về một máy và một người dùng duy nhất. Chọn HTTP khi đó là một dịch vụ dùng chung.

Điều kiện tiên quyết và những lỗi thực tế

Giả định bạn có một Ubuntu 24.04 KVM VPS mới với quyền root hoặc sudo. Ngoài ra:

  • Một runtime mà server được viết bằng đó. Hầu hết các server tham chiếu đều dùng Node hoặc Python. Ubuntu 24.04 đi kèm Node 18, và một số package MCP hiện tại yêu cầu Node 20 hoặc mới hơn, vì vậy hãy cài đặt một bản LTS hiện tại từ NodeSource hoặc nvm thay vì tin tưởng vào apt. Python 3.12 đã có sẵn.
  • Một domain và DNS A record, nhưng chỉ dành cho remote HTTP server — TLS cần một tên miền phân giải về VPS này. Ví dụ stdio không cần DNS.
  • 512 MB RAM là đủ. Các MCP server là các process JSON-RPC rất nhẹ; chi phí bộ nhớ phụ thuộc vào những gì công cụ của bạn chạm vào (một database driver, một file cache), chứ không phải do protocol.
  • Spec còn mới và đang thay đổi. Bản sửa đổi ngày 26-03-2025 đã thay thế HTTP+SSE bằng Streamable HTTP và đánh dấu SSE là deprecated. SSE vẫn hoạt động và nhiều server vẫn dùng nó, vì vậy hãy coi bất kỳ sự cố định transport nào là thứ cần kiểm tra lại với release notes của server thay vì coi là chân lý.

Bước 1: kết nối một stdio server vào Claude Code

Bắt đầu với filesystem server — nó là hàng chính chủ, được bảo trì tích cực và không cần gì ngoài Node. Chỉ một lệnh dưới đây sẽ đăng ký nó với Claude Code và giới hạn phạm vi trong project hiện tại để nó nằm trong một file có thể commit:

cd /home/matt/projects/api
claude mcp add --scope project --transport stdio filesystem \
  -- npx -y @modelcontextprotocol/server-filesystem /home/matt/projects/api

Dấu phân cách -- rất quan trọng: mọi thứ sau nó là lệnh mà Claude Code sẽ chạy, không phải là một flag của Claude Code. Việc này sẽ ghi một .mcp.json tại project root:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/home/matt/projects/api"
      ]
    }
  }
}

Chưa có gì chạy cả. Khi bạn khởi động Claude Code trong thư mục này lần tới, agent sẽ đọc .mcp.json, khởi chạy npx -y @modelcontextprotocol/server-filesystem ... như một child process, và thực hiện MCP handshake qua stdin/stdout của process đó. Xác nhận nó đã nhận:

claude mcp list

Một server hoạt động tốt sẽ in ra lệnh của nó và một dấu tick xanh — filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected. Trong session, lệnh slash /mcp sẽ liệt kê các công cụ mà server cung cấp (read_file, write_file, list_directory), và agent hiện có thể gọi chúng tại các đường dẫn mà bạn đã cho phép. Một database tool cũng có cấu trúc tương tự — chỉ cần thay package và truyền một connection string làm argument cuối cùng — nhưng hãy kiểm tra repository của chính server để biết tên package hiện tại, vì server Postgres tham chiếu đã thay đổi nhiều lần.

Đây chính là mục đích của việc chạy agent trên máy: Claude Code session chạy trên VPS bên trong tmux, và các stdio server của nó chạy ngay cạnh nó với quyền truy cập trực tiếp vào các file project và các dịch vụ local, không tốn độ trễ mạng.

Bước 2: xây dựng một remote HTTP server

Một stdio server sẽ chết cùng với parent của nó. Khi bạn muốn một công cụ luôn chạy cho mọi client — một công cụ ops dùng chung, một database gateway, thứ mà cả laptop và CI của bạn đều gọi — bạn cần transport HTTP và một service thực thụ. Đây là một Python server tối giản sử dụng SDK chính thức, cung cấp một công cụ:

# /opt/mcp-ops/server.py
from mcp.server.fastmcp import FastMCP
import subprocess

mcp = FastMCP("ops-tools", host="127.0.0.1", port=8000)

@mcp.tool()
def disk_free() -> str:
    """Return `df -h` for the server."""
    out = subprocess.run(["df", "-h"], capture_output=True, text=True)
    return out.stdout

if __name__ == "__main__":
    # Serves Streamable HTTP at /mcp on 127.0.0.1:8000
    mcp.run(transport="streamable-http")

Lưu ý host="127.0.0.1". Server chỉ bind vào localhost — không có gì bên ngoài máy có thể kết nối trực tiếp, đây chính xác là điều bạn muốn trước khi có auth. Hãy cài đặt nó trong một virtualenv riêng để systemd có một đường dẫn interpreter ổn định:

sudo useradd --system --home /opt/mcp-ops --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp /opt/mcp-ops
sudo -H -u mcp python3 -m venv /opt/mcp-ops/.venv
sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install "mcp[cli]"

Bước 3: duy trì nó hoạt động với systemd

Một công cụ bị sập khi agent gọi tới thì còn tệ hơn là không có công cụ. Hãy viết /etc/systemd/system/mcp-ops.service:

[Unit]
Description=MCP ops-tools server
After=network.target

[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/mcp-ops
ExecStart=/opt/mcp-ops/.venv/bin/python /opt/mcp-ops/server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Đường dẫn tuyệt đối đến venv Python trong ExecStart là bắt buộc — hãy trỏ nó tới /usr/bin/python3 và process sẽ khởi chạy với ModuleNotFoundError: No module named 'mcp', vì system interpreter không hề biết đến pip install của bạn. Kích hoạt và kiểm tra:

sudo systemctl daemon-reload
sudo systemctl enable --now mcp-ops
sudo systemctl status mcp-ops
curl -si -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:8000/mcp

status nên đọc được active (running). curl sẽ trả về HTTP/1.1 400 Bad Request với một lỗi JSON-RPC trong body — request không mang theo session và không có payload JSON hợp lệ — và đó chính xác là những gì bạn muốn: nó chứng minh port có phản hồi và nói chuyện được protocol. Connection refused hoặc một phản hồi trống có nghĩa là process không bind đúng nơi bạn nghĩ; hãy đọc journalctl -u mcp-ops -n 50.

Bước 4: thêm TLS và một reverse proxy phía trước

Server đang lắng nghe trên localhost. Để truy cập nó từ bất cứ đâu, bạn sẽ kết thúc TLS tại nginx và proxy vào trong. Cài đặt nginx, lấy chứng chỉ với Certbot và Let's Encrypt trên nginx, sau đó viết location block. Phần quan trọng là vô hiệu hóa buffering, vì hành vi mặc định của nginx là giữ phản hồi cho đến khi nó hoàn tất, điều này sẽ làm treo luồng SSE mãi mãi:

server {
    listen 443 ssl;
    server_name mcp.example.com;

    # ssl_certificate lines managed by Certbot

    location /mcp {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;

        # The four lines that make SSE work through nginx:
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
        chunked_transfer_encoding off;
    }
}

Reload với sudo nginx -t && sudo systemctl reload nginx. Nếu bạn đã chạy một dàn container, cùng một công việc này sẽ được thực hiện cho bạn bởi Traefik reverse proxy với automatic TLS — nó cấp chứng chỉ và định tuyến theo hostname, và bạn chỉ cần thêm các label vào MCP container. Dù cách nào, reverse proxy hiện là thứ duy nhất nằm trên một port công cộng, và nó trỏ tới một service mà bạn chưa bảo mật. Hãy sửa lỗi đó trước khi bạn đăng ký URL ở bất kỳ đâu.

Bước 5: quy tắc bảo mật thống trị chủ đề này

Không bao giờ expose một MCP endpoint không có xác thực. Một MCP server không phải là một API chỉ đọc. Nó cấp quyền truy cập công cụ — vào file, database, và đôi khi là cả shell của bạn. Một /mcp mở trên internet công cộng là một kẻ lạ mặt có phạm vi truy cập tương đương với AI agent của bạn: chúng liệt kê các công cụ, sau đó gọi chúng. Hãy coi nó chính xác như một admin socket không có xác thực, vì bản chất nó là như vậy.

Ba lớp phòng thủ, theo thứ tự ưu tiên:

  1. Đừng công khai nó. Giữ server ở 127.0.0.1 và truy cập nó từ laptop bằng một SSH tunnel: ssh -L 8000:127.0.0.1:8000 matt@vps, sau đó trỏ client tới http://127.0.0.1:8000/mcp. Không có gì bị expose.
  2. Đặt nó trên một mạng riêng. Bind địa chỉ tunnel của một self-hosted WireGuard VPN và chỉ cho phép các peer VPN truy cập. Internet công cộng sẽ chỉ thấy một port đóng.
  3. Nếu bắt buộc phải công khai, hãy yêu cầu một token. Câu trả lời đúng đắn là luồng MCP OAuth mà HTTP transport hỗ trợ mặc định. Cách tối thiểu thực dụng là một shared bearer token được kiểm tra tại proxy — nhanh gọn, và nó chặn hoàn toàn các cuộc tấn công dò dẫm:
location /mcp {
    if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM") {
        return 401;
    }
    proxy_pass http://127.0.0.1:8000;
    # ...buffering-off block from above...
}

Tạo token bằng openssl rand -hex 32, và đừng bao giờ bind chính server tới 0.0.0.0 mà không có một trong các lớp bảo vệ này ở phía trước. Client sau đó sẽ gửi token dưới dạng một header. Trong Claude Code:

claude mcp add --scope project --transport http ops-tools https://mcp.example.com/mcp \
  --header 'Authorization: Bearer ${MCP_TOKEN}'

Thiết lập MCP_TOKEN trong shell của bạn để secret không bao giờ nằm ở dạng plaintext trong .mcp.json — Claude Code sẽ expand ${MCP_TOKEN} từ môi trường tại thời điểm đọc.

Bước 6: debug với MCP Inspector

Khi một server hoạt động sai, đừng đoán mò từ bên trong agent — hãy điều khiển nó trực tiếp bằng Inspector, client test chính thức dựa trên web. Đối với một stdio server, hãy đưa cho nó chính lệnh mà agent chạy:

npx @modelcontextprotocol/inspector \
  npx -y @modelcontextprotocol/server-filesystem /tmp

Nó sẽ khởi chạy một UI tại http://localhost:6274 (các phiên bản gần đây sẽ in ra một URL với chuỗi truy vấn MCP_PROXY_AUTH_TOKEN — hãy dùng chính xác link đó hoặc UI sẽ từ chối bạn) và một proxy tại 6277. Nhấn Connect, sau đó List Tools, rồi Call Tool với các argument thực tế. Nếu nó hoạt động trong Inspector nhưng thất bại trong agent, lỗi nằm ở config của client, không phải ở server. Đối với remote HTTP server, hãy chọn transport Streamable HTTP, nhập https://mcp.example.com/mcp, thêm header Authorization, và kết nối — đây là cách nhanh nhất để chứng minh auth và proxy đã đúng trước khi dùng đến agent.

Giữ các server luôn được cập nhật

MCP thay đổi rất nhanh, vì vậy hãy patch theo lịch trình. Các Node server được khởi chạy với npx -y sẽ lấy phiên bản mới nhất mỗi khi spawn, điều này tiện lợi nhưng không thể tái lập (non-reproducible); hãy pin phiên bản chính xác mà bạn đã test — đọc nó từ npm view @modelcontextprotocol/server-filesystem version và thêm nó vào tên package trong .mcp.json (@modelcontextprotocol/server-filesystem@<version>) — một khi server đã quan trọng, hãy cập nhật nó một cách có chủ đích. Các Python server dưới systemd được cập nhật bằng sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" theo sau là sudo systemctl restart mcp-ops. Hãy chú ý đến bản revision của spec mà SDK của bạn nhắm tới khi bạn nâng cấp — một bước nhảy qua ranh giới SSE-sang-Streamable-HTTP có thể thay đổi transport mà các client của bạn phải yêu cầu.

Các chế độ lỗi, cùng với các chuỗi ký tự bạn sẽ thấy

Agent báo server bị lỗi. claude mcp list in ra ✗ Failed to connect, và TUI báo MCP server 'filesystem' failed to start. Chạy claude --debug và bạn sẽ thường thấy Error: spawn npx ENOENT — lệnh không nằm trong PATH của agent. Runtime bị thiếu hoặc không nằm ở nơi agent tìm: Node chưa được cài đặt, npx vắng mặt, hoặc một venv Python được gọi bằng tên khơi khơi. Hãy sửa lệnh thành đường dẫn tuyệt đối hoặc cài đặt runtime, sau đó kết nối lại.

Một stdio server kết nối, sau đó ngắt ngay lập tức. Client log một lỗi JSON parse — đại loại như Unexpected token 'S', "Server sta"... is not valid JSON hoặc Failed to parse message. Nguyên nhân luôn giống nhau: server đã ghi một dòng log vào stdout. Trên stdio, stdout chính là kênh JSON-RPC, vì vậy bất kỳ văn bản thừa nào cũng làm hỏng luồng và khiến handshake chết. Trong Node, console.log sẽ đi vào stdout — hãy dùng console.error. Trong Python, một lệnh print() trần sẽ đi vào stdout — hãy viết log với logging được cấu hình tới sys.stderr, hoặc truyền file=sys.stderr. Quy tắc là tuyệt đối: trên stdio, chỉ có JSON-RPC trên stdout, mọi thứ dành cho con người phải nằm trên stderr.

Một remote server bị timeout hoặc đóng giữa chừng khi đang handshake. Client thất bại với MCP error -32000: Connection closed, hoặc Inspector treo ở trạng thái Connect và không bao giờ liệt kê được các tools. Đằng sau nginx, đây là do buffering: proxy giữ luồng SSE thay vì flush nó, khiến client phải đợi một phản hồi không bao giờ đến. Thêm proxy_buffering off; (và phần còn lại của block trong Bước 4) vào location. Xác nhận bằng curl -N đối với URL công khai — bạn sẽ thấy dữ liệu event đến dần dần, chứ không phải tất cả cùng một lúc ở cuối.

Auth bị từ chối. Client báo lỗi Error POSTing to endpoint (HTTP 401) hoặc nói thẳng là 401 Unauthorized. Có thể là do thiếu header, token sai, hoặc biến shell bị trống khi client đọc config — một cái bẫy phổ biến, vì ${MCP_TOKEN} sẽ expand thành rỗng nếu biến chưa được thiết lập và nginx sau đó sẽ thấy Bearer không có giá trị. Hãy echo biến đó, thêm lại header, và xác minh các byte chính xác khớp với token trong nginx if.

Service không khởi động được dưới systemd. journalctl -u mcp-ops cho thấy ModuleNotFoundError: No module named 'mcp'ExecStart đang trỏ vào system Python thay vì venv interpreter. Hoặc Address already in use — một process khác đang giữ port 8000; hãy tìm nó bằng sudo ss -ltnp | grep 8000.

FAQ

Chính xác thì MCP server là gì?

Nó là một chương trình cung cấp các công cụ và tài nguyên cho một AI client thông qua Model Context Protocol, sử dụng JSON-RPC 2.0. Bản thân model AI không bao giờ tự chạy công cụ — nó hỏi client, client gọi MCP server, và server thực thi và trả kết quả về. Vì protocol là tiêu chuẩn, một server có thể hoạt động với bất kỳ client tuân thủ nào, cho dù đó là Claude Code, Claude Desktop, hay Gemini CLI.

Sự khác biệt giữa stdio và HTTP transport là gì?

Một stdio server được client khởi chạy như một child process và giao tiếp qua stdin/stdout, vì vậy nó sống và chết cùng với một client trên một máy và không cần mạng hay auth. Một HTTP server là một network service chạy lâu dài mà nhiều client có thể truy cập cùng lúc, đó là lý do tại sao nó yêu cầu TLS và xác thực. Sử dụng stdio cho các công cụ local, dùng cho một người dùng; sử dụng HTTP (Streamable HTTP trên các server hiện tại) cho bất cứ thứ gì dùng chung hoặc cần duy trì lâu dài.

Làm thế nào để bảo mật một remote MCP server?

Hãy giả định nó cấp quyền truy cập công cụ vào file, database, hoặc shell của bạn, và đừng bao giờ để nó không có xác thực. Tốt nhất là giữ nó bind vào localhost và truy cập qua một SSH tunnel hoặc một VPN riêng; nếu bắt buộc phải công khai, hãy đặt nó đằng sau một reverse proxy thực thi một bearer token hoặc luồng MCP OAuth. Tạo token bằng openssl rand -hex 32 và đừng bao giờ bind server tới 0.0.0.0 mà không có một trong các lớp bảo vệ này ở phía trước.

Làm thế nào để debug một server không khởi động được?

Đầu tiên hãy kiểm tra claude mcp list✗ Failed to connect với spawn ... ENOENT có nghĩa là lệnh hoặc runtime bị thiếu, vì vậy hãy sửa đường dẫn hoặc cài đặt nó. Nếu nó kết nối rồi ngắt với lỗi JSON parse, server đang log ra stdout và làm hỏng luồng JSON-RPC; hãy chuyển toàn bộ log sang stderr. Đối với bất kỳ lỗi nào khác, hãy chạy chính xác lệnh đó dưới MCP Inspector, nó điều khiển server một cách độc lập để bạn có thể phân biệt lỗi do server hay lỗi do config của client.