Dev.to AI 🤖 Ai 👁 0 📖 8 min read

You Said No MCP? When AI Agents Should Just Use the CLI (and When They Shouldn't)

Tuần này trên Hacker News có một bài viral với tiêu đề khá gắt: You said no MCP. Mình đọc xong thì gật gù, vì mấy tháng qua team mình cũng đi đúng con đường đó: hào hứng dựng một loạt MCP server cho agent, rồi lặng lẽ gỡ

Tuần này trên Hacker News có một bài viral với tiêu đề khá gắt: You said no MCP. Mình đọc xong thì gật gù, vì mấy tháng qua team mình cũng đi đúng con đường đó: hào hứng dựng một loạt MCP server cho agent, rồi lặng lẽ gỡ bớt gần một nửa. Lý do không phải vì MCP (Model Context Protocol) dở. Lý do là nhiều khi agent chỉ cần một cái CLI tử tế với output dễ parse, còn MCP lại là thêm một lớp process, schema và auth phải bảo trì.

Bài này mình chia sẻ cách mình quyết định giữa CLI thuần và MCP server khi cho agent (Claude Code, Codex CLI, Gemini CLI...) dùng tool nội bộ, kèm code thật để bạn áp dụng ngay.

Vấn đề thật: context window không miễn phí

Mỗi MCP server bạn kết nối đều đẩy toàn bộ tool definition (tên, description, JSON schema) vào context của model ngay từ đầu session. Kết nối 5 server, mỗi server 15 tool, là bạn đã đốt vài nghìn đến cả chục nghìn token trước khi agent làm việc gì. Model cũng dễ chọn sai tool hơn khi danh sách quá dài.

Trong khi đó, model hiện đại đã được train cực kỳ nhiều trên git, gh, kubectl, jq, psql. Chúng biết cách gọi --help để tự tìm hiểu tool mới. Tức là với CLI, chi phí context chỉ phát sinh khi thật sự cần.

flowchart LR
    A[Agent session bắt đầu] --> B{Cách expose tool}
    B -->|MCP| C[Load toàn bộ schema vào context]
    C --> D[Tốn token ngay từ đầu]
    B -->|CLI| E[Chỉ có shell tool]
    E --> F[Agent gọi --help khi cần]
    F --> G[Tốn token theo nhu cầu]

Nguyên tắc của mình: tool mặc định là CLI, MCP là ngoại lệ có lý do rõ ràng.

Viết CLI thân thiện với agent

Một CLI tốt cho agent khác CLI tốt cho người ở vài điểm: output phải ổn định, có chế độ JSON, lỗi phải rõ ràng qua exit code, và --help phải đủ để model tự học cách dùng. Dưới đây là ví dụ một tool nội bộ tra cứu đơn hàng, viết bằng Python 3.12 với argparse (không cần dependency):

#!/usr/bin/env python3
"""orders - tra cứu đơn hàng nội bộ.

Ví dụ:
  orders get 10234 --json
  orders list --status pending --limit 20 --json
"""
import argparse, json, sys
from db import fetch_order, fetch_orders  # module nội bộ của bạn

def out(data, as_json):
    if as_json:
        print(json.dumps(data, ensure_ascii=False, default=str))
    else:
        for row in data if isinstance(data, list) else [data]:
            print(f"{row['id']}\t{row['status']}\t{row['total']}")

def main():
    p = argparse.ArgumentParser(prog="orders", description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    g = sub.add_parser("get", help="Lấy 1 đơn theo ID")
    g.add_argument("order_id", type=int)
    g.add_argument("--json", action="store_true")

    l = sub.add_parser("list", help="Liệt kê đơn hàng")
    l.add_argument("--status", choices=["pending", "paid", "shipped"])
    l.add_argument("--limit", type=int, default=50)
    l.add_argument("--json", action="store_true")

    a = p.parse_args()
    try:
        if a.cmd == "get":
            order = fetch_order(a.order_id)
            if order is None:
                print(f"error: order {a.order_id} not found", file=sys.stderr)
                sys.exit(2)
            out(order, a.json)
        else:
            out(fetch_orders(a.status, a.limit), a.json)
    except ConnectionError as e:
        print(f"error: database unreachable: {e}", file=sys.stderr)
        sys.exit(3)

if __name__ == "__main__":
    main()

Mấy điểm đáng chú ý:

  • Docstring có ví dụ cụ thể: model đọc --help và copy pattern gần như ngay lập tức.
  • --limit có default: tránh việc agent vô tình dump 100k dòng vào context.
  • Exit code phân biệt (2 = not found, 3 = hạ tầng lỗi): agent biết nên thử lại hay dừng.
  • Lỗi ra stderr, dữ liệu ra stdout: pipe qua jq không bị vỡ.

Sau đó chỉ cần một dòng trong CLAUDE.md hoặc AGENTS.md của repo:

# Trong AGENTS.md
# - Tra cứu đơn hàng: dùng `orders --help`. Luôn thêm --json khi cần xử lý tiếp.

# Agent sẽ tự làm những việc kiểu này:
orders list --status pending --limit 100 --json \
  | jq '[.[] | select(.total > 5000000)] | length'

Đó là toàn bộ "integration". Không có server, không có protocol, và composable với cả hệ sinh thái Unix.

Khi nào MCP thật sự đáng tiền

CLI không giải quyết được tất cả. Mình chuyển sang MCP khi gặp một trong các tình huống sau:

  1. Agent không có shell: Claude Desktop, chat app, IDE plugin chạy trong sandbox. Không có shell thì CLI vô nghĩa.
  2. Cần auth tập trung: OAuth với SaaS (Google Drive, Notion, Jira). Bạn không muốn phát token cá nhân cho từng máy dev; một remote MCP server xử lý OAuth gọn hơn nhiều.
  3. State dài hạn: browser automation (Playwright MCP), debugger session, kết nối DB pool cần giữ sống giữa các lần gọi.
  4. Phân quyền chặt: bạn muốn agent chỉ được gọi đúng 3 thao tác, không phải cả shell với quyền rm -rf.
flowchart TD
    S[Cần cho agent dùng tool mới] --> Q1{Agent có shell?}
    Q1 -->|Không| M[Dùng MCP]
    Q1 -->|Có| Q2{Cần OAuth hoặc state dài hạn?}
    Q2 -->|Có| M
    Q2 -->|Không| Q3{Cần giới hạn quyền chặt?}
    Q3 -->|Có| M
    Q3 -->|Không| C[Viết CLI + ghi vào AGENTS.md]

Khi đã chọn MCP, hãy giữ nó nhỏ. Đây là phiên bản MCP của tool trên, dùng Python SDK chính thức (pip install "mcp[cli]", bản 1.x), với FastMCP:

from mcp.server.fastmcp import FastMCP
from db import fetch_order, fetch_orders

mcp = FastMCP("orders")

@mcp.tool()
def get_order(order_id: int) -> dict:
    """Lấy chi tiết 1 đơn hàng theo ID."""
    order = fetch_order(order_id)
    if order is None:
        raise ValueError(f"Order {order_id} not found")
    return order

@mcp.tool()
def list_orders(status: str | None = None, limit: int = 20) -> list[dict]:
    """Liệt kê đơn hàng. status: pending | paid | shipped. limit tối đa 100."""
    return fetch_orders(status, min(limit, 100))

if __name__ == "__main__":
    mcp.run()  # mặc định transport stdio

Đăng ký với Claude Code chỉ một lệnh: claude mcp add orders -- python3 /path/to/orders_mcp.py. Lưu ý mình chỉ expose 2 tool và clamp limit ngay trong server. Đừng auto-generate MCP từ toàn bộ OpenAPI spec 200 endpoint, đó là cách nhanh nhất để làm agent ngu đi.

Mẹo thực chiến: cùng một core, hai lớp vỏ

Cách mình làm hiện tại là tách business logic vào một module (db.py ở trên), rồi bọc bằng hai lớp mỏng: CLI cho agent có shell, MCP cho môi trường không có shell. Logic chỉ viết một lần, test một lần.

Vài thói quen khác đáng giữ:

  • Đo token thật: chạy /context trong Claude Code để xem MCP server nào đang ăn bao nhiêu context. Mình từng phát hiện một server GitHub chiếm hơn 10% context chỉ vì tool definitions.
  • Tắt MCP không dùng theo project: cấu hình MCP ở scope project (.mcp.json) thay vì global, để repo frontend không phải load server database.
  • Ưu tiên CLI chính chủ: đã có gh, aws, gcloud, kubectl thì đừng cài thêm MCP wrapper cho chúng. Model biết dùng các CLI này còn rành hơn đọc schema của bạn.
  • Output ngắn gọn: dù CLI hay MCP, đừng trả về nguyên object 200 field. Trả những gì agent cần để ra quyết định tiếp theo.

Kết luận

MCP là một chuẩn tốt, nhưng không phải mọi thứ đều cần trở thành MCP server. Với dev đang làm việc với coding agent mỗi ngày, đây là checklist mình đề xuất:

  1. Mặc định viết CLI: có --json, --help kèm ví dụ, exit code rõ ràng, --limit có default an toàn.
  2. Ghi cách dùng vào AGENTS.md/CLAUDE.md một dòng là đủ, để agent tự khám phá qua --help.
  3. Chỉ dùng MCP khi agent không có shell, cần OAuth/state dài hạn, hoặc cần giới hạn quyền chặt.
  4. Giữ MCP server nhỏ: vài tool được thiết kế cẩn thận tốt hơn hàng chục tool auto-generate.
  5. Tách core logic để CLI và MCP chỉ là hai lớp vỏ mỏng.
  6. Audit context định kỳ và gỡ những server không thực sự dùng tới.

Tuần này thử mở config agent của bạn ra, đếm xem có bao nhiêu MCP server đang load và cái nào có thể thay bằng một CLI 50 dòng. Mình đoán con số sẽ khiến bạn bất ngờ.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.