MCP tool trả về success nhưng chatbot vẫn không biết đã tạo giao dịch: bài học từ lỗi text-only
Tôi đang xây một project cá nhân để quản lý tài chính (Personal Finance Management - PFM). Thay vì mở ứng dụng, chọn biểu mẫu rồi nhập từng trường, tôi muốn người dùng chỉ cần nhắn cho chatbot bằng ngôn ngữ tự nhiên, chẳng hạn: “đổ xăng 50.000đ lúc 17:30, trừ vào ví tiền mặt”. Chatbot sẽ hiểu yêu cầu và gọi các công cụ của hệ thống để ghi nhận giao dịch.
Để chatbot có thể làm việc với ứng dụng, tôi expose các chức năng qua MCP (Model Context Protocol). Có thể hiểu MCP là lớp hợp đồng giữa mô hình ngôn ngữ và backend: server công bố các tool như lấy danh sách tài khoản, lấy danh mục chi tiêu, tạo giao dịch và xác nhận giao dịch; chatbot chọn tool phù hợp rồi truyền dữ liệu theo schema.
Vì sao tôi không tạo giao dịch ngay lập tức?
Ghi dữ liệu tài chính là một thao tác có hậu quả thật. Mô hình có thể hiểu sai số tiền, ngày, tài khoản hoặc danh mục. Vì vậy tôi không cho lệnh create ghi thẳng vào bảng giao dịch canonical ngay từ bước đầu.
Thay vào đó, hệ thống dùng một lớp trung gian tên là Capture:
Proposal là nội dung chatbot đề xuất tạo, ví dụ một khoản chi 50.000đ cho danh mục Di chuyển.
Capture là bản ghi bền vững giữ proposal cùng trạng thái, revision và thông tin cần để tiếp tục xử lý.
Canonical record là giao dịch tài chính chính thức, chỉ xuất hiện sau khi người dùng xác nhận.
Luồng tôi thiết kế ban đầu như sau:
Chatbot gọi MCP để lấy UUID của tài khoản và danh mục.
Chatbot gọi tool tạo giao dịch. Backend mới chỉ staging proposal vào một Capture ở trạng thái chờ xác nhận.
Tool trả về
captureId,revisionvàproposalIdđể chatbot biết chính xác proposal nào đang chờ.Chatbot trình bày lại số tiền, thời gian, tài khoản và danh mục cho người dùng xác nhận.
Sau khi người dùng đồng ý, chatbot gọi
capture_confirm.Backend commit Capture, tạo giao dịch canonical và trả về
transactionUuid.
Với thiết kế này, việc chatbot hiểu sai không lập tức làm bẩn dữ liệu tài chính. Capture cũng giúp workflow có thể resume sau khi mất kết nối, retry an toàn và kiểm tra lại lịch sử xác nhận.
Nhìn trên sơ đồ và trong unit test, luồng này khá chặt chẽ. Tool trả đúng schema, backend tạo Capture, database lưu đủ proposal và API confirm hoạt động. Tôi từng nghĩ phần khó nhất đã xong.
Nhưng khi nối MCP server vào chatbot và chạy bằng câu lệnh thật, một vấn đề khác mới lộ ra: dữ liệu đúng vẫn nằm trong kết quả tool, nhưng chatbot không nhìn thấy đủ dữ liệu để tiếp tục. Nó không lấy được UUID danh mục, không có proposalId để confirm, và đôi khi còn tuyên bố “đã tạo giao dịch” khi hệ thống mới chỉ staging Capture.
Lúc đó tôi mới nhận ra lỗi không nằm ở thuật toán tạo Capture hay database. Lỗi nằm ở contract output giữa MCP server, host và mô hình—cụ thể là cách tôi xem nhẹ đường tương thích text-only.
Triệu chứng thực tế
Luồng đầu tiên là lấy danh mục. Tool pfm_list_categories trả đủ UUID và tên trong structuredContent, nhưng phần text chỉ ghi kiểu:
4 categories
Ở client tôi đang dùng, chatbot chỉ nhìn thấy dòng text đó. Vì vậy nó biết có bốn danh mục nhưng không thể dùng UUID để tạo giao dịch.
Luồng thứ hai khó thấy hơn. Với thiết kế durable write, lệnh tạo giao dịch trước tiên sinh một Capture ở trạng thái chờ duyệt. Tool trả success: true vì thao tác staging thành công, nhưng điều đó không có nghĩa bản ghi tài chính canonical đã được tạo. Chatbot không thấy đủ captureId, revision và proposalId, nên bắt đầu suy đoán. Có lúc nó đoán proposalId = captureId; có lúc lại nhìn thấy giao dịch trong giao diện rồi kết luận hệ thống tự động confirm.
Cả hai suy đoán đều nguy hiểm.
Nguyên nhân: MCP có hai kênh output nhưng client không phải lúc nào cũng dùng cả hai
Một tool MCP có thể trả kết quả có cấu trúc trong structuredContent và nội dung tương thích rộng hơn trong content, thường là một block text. Về lý thuyết, host hiện đại nên đọc dữ liệu có cấu trúc. Trong thực tế, proxy, portal, connector hoặc lớp hiển thị có thể chỉ chuyển tiếp text, rút gọn structured data, hay chỉ đưa phần dễ đọc vào context của chatbot.
Vì vậy một output như sau nhìn có vẻ đúng nhưng chưa đủ bền:
{
"structuredContent": {
"success": true,
"items": [{ "uuid": "...", "name": "Di chuyển" }]
},
"content": [{ "type": "text", "text": "1 category" }]
}
Nếu host là text-only, UUID đã biến mất trước khi mô hình có cơ hội đọc nó.
Cách sửa thực dụng của tôi là giữ structuredContent làm contract máy đọc, đồng thời serialize cùng dữ liệu quan trọng sang text:
{"success":true,"items":[{"uuid":"...","name":"Di chuyển"}],"nextCursor":null}
Text có thể thêm một câu diễn giải, nhưng không được bỏ các identifier và trạng thái mà bước tiếp theo cần dùng.
Success của thao tác không đồng nghĩa giao dịch đã được tạo
Đây là phần dễ làm chatbot trả lời sai nhất. Durable write của tôi có ba trạng thái kết quả:
| confirmationStatus | Ý nghĩa | Chatbot được phép nói gì? | |---|---|---| | pending_confirmation | Proposal đã được staging | “Đang chờ xác nhận”, không được nói “đã tạo” | | committed | Đã tạo bản ghi canonical | Chỉ nói “đã tạo thành công” khi có UUID tài nguyên | | dismissed | Người dùng từ chối | “Đã hủy, không tạo giao dịch” |
Điểm quan trọng là success: true ở trạng thái pending chỉ có nghĩa lệnh staging chạy thành công. Nó không phải bằng chứng của một giao dịch đã commit.
Tôi chốt rule cho chatbot rất rõ:
Chỉ xác nhận giao dịch đã tạo khi
confirmationStatus === "committed"và cótransactionUuid. Với khoản thu thì cầnincomeUuid.
Nếu kết quả là pending, tool tạo phải trả ngay bộ dữ liệu dùng cho bước confirm:
{
"success": true,
"confirmationStatus": "pending_confirmation",
"captureId": "...",
"revision": 1,
"proposalId": "...",
"replayed": false
}
Không bắt chatbot gọi thêm một tool status chỉ để tìm identifier mà chính tool create vừa tạo ra. Tool status vẫn cần thiết cho retry, resume session và đối soát, nhưng không nên vá một output create thiếu dữ liệu.
Thiết kế output để chatbot không phải đoán
Sau sự cố này, tôi dùng bốn nguyên tắc.
1. Text là lớp tương thích, không phải lời tóm tắt làm mất dữ liệu
Những field phục vụ bước tiếp theo phải xuất hiện ở cả structured data lẫn text: UUID, status, revision, cursor và reason code. Với payload nhỏ, serialize JSON vào text là cách đơn giản và ít nhập nhằng nhất.
2. Dùng câu phủ định rõ ở trạng thái chưa hoàn tất
Text pending của tôi viết thẳng: Transaction is staged but NOT created yet. Cụm “confirmation pending” trước đây vẫn có thể bị mô hình diễn giải thành “đã tạo và đang đồng bộ”. Câu phủ định giúp giảm khoảng trống suy luận.
3. Kết quả committed phải mang bằng chứng
Tool confirm không chỉ trả Capture committed. Nó trả status, proposal và link tới resource canonical. Chatbot có thể trích UUID giao dịch từ link và thông báo thành công dựa trên dữ liệu, không dựa trên phỏng đoán.
4. Description phải mô tả state machine, không chỉ mô tả hành động
Mô tả “Creates a transaction” quá rộng. Description tốt hơn nói rõ điều kiện hoàn tất:
Treat it as created only when confirmationStatus is committed
and transactionUuid is present. When pending_confirmation,
ask for explicit confirmation, then call capture_confirm with
captureId, revision and proposalId.
Description không thay thế output contract, nhưng nó là lớp bảo vệ thứ hai khi model lập kế hoạch gọi tool.
Permission popup không phải business confirmation
Một chi tiết gây nhầm lẫn khác là popup của host kiểu “ChatGPT/Claude wants to use Confirm”. Popup này thường là quyền cho phép gọi tool. Nó không tự động đồng nghĩa người dùng đã xác nhận nội dung nghiệp vụ như số tiền, danh mục và ngày giao dịch.
Nếu ứng dụng yêu cầu explicit confirmation, hãy phân biệt:
Tool permission: host có được gọi tool hay không.
Business confirmation: người dùng có đồng ý commit proposal cụ thể hay không.
Với client hỗ trợ MCP elicitation, tool create có thể hỏi business confirmation trong cùng lượt và commit ngay khi người dùng accept. Với client không hỗ trợ, create trả pending; chatbot hỏi lại người dùng rồi gọi capture_confirm. Hai nhánh phải cùng hội tụ về một điều kiện thành công: trạng thái committed và có resource UUID.
Test lỗi text-only như một client thật
Unit test chỉ kiểm tra structuredContent sẽ bỏ sót đúng lỗi tôi đã gặp. Regression test cần gọi tool qua MCP client và kiểm tra cả hai kênh:
pfm_list_categories: text chứa UUID và tên danh mục.pfm_create_transactionpending: text chứa captureId, revision, proposalId và câu “NOT created yet”.capture_list_pending: text chứa chi tiết proposal, không chỉ số lượng.capture_get_status: text chứa proposals và links.capture_confirm: text nói rõ canonical record đã tạo và chứa resource UUID.
Ngoài test in-memory, tôi còn gọi thẳng endpoint MCP production qua loopback sau khi restart service. Cách này bắt được các lỗi build cũ, service chưa restart, proxy rút gọn output và cấu hình feature flag khác môi trường test.
Checklist khi MCP tool trả success nhưng chatbot vẫn hỏi lại
Xem raw tool result, không chỉ nhìn câu trả lời cuối của chatbot.
So sánh
structuredContentvà block text trongcontent.Kiểm tra UUID và field cho bước tiếp theo có nằm trong text không.
Phân biệt staging success với committed success.
Không cho model tự suy ra
proposalId, revision hoặc resource UUID.Viết điều kiện “được phép báo thành công” vào tool description.
Test bằng client/proxy thật đang dùng ở production.
Restart đúng service sau khi build; cấu hình bật tool không thể sửa một binary cũ.
FAQ ngắn
Vì sao structuredContent có dữ liệu mà chatbot vẫn không thấy?
Vì host hoặc lớp tích hợp có thể chỉ đưa text content vào context của mô hình. Hãy coi text là kênh tương thích bắt buộc và lặp lại dữ liệu quan trọng ở đó.
Có nên chỉ trả JSON trong text không?
Với tool backend và payload nhỏ, JSON đầy đủ là lựa chọn chắc chắn. Có thể thêm câu diễn giải phía trước để model hiểu semantics, nhưng đừng thay JSON bằng một câu tóm tắt làm mất identifier.
Khi nào được nói giao dịch đã tạo thành công?
Chỉ khi kết quả đã committed và có UUID của bản ghi canonical, ví dụ transactionUuid hoặc link resource tương ứng. success: true cùng pending_confirmation chưa đủ.
Có cần gọi capture_get_status ngay sau create không?
Không nếu create pending đã trả đủ captureId, revision và proposalId. Status hữu ích khi resume, retry hoặc kiểm tra một Capture từ phiên trước.
Kết luận
Lỗi này khiến tôi thay đổi cách nhìn về MCP tool output: output không chỉ cần đúng schema, mà còn phải sống sót qua host, proxy và cách mô hình thực sự nhận context.
Một contract tốt không bắt chatbot đoán. Nó nói rõ việc gì đã xảy ra, việc gì chưa xảy ra, identifier nào phải dùng tiếp và bằng chứng nào cho phép tuyên bố thành công. Nếu hệ thống có workflow nhiều bước, hãy thiết kế output như một state machine có thể kiểm chứng—và luôn test cả đường text-only.