@dauthau/mcp-dauthau 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +339 -0
- package/README.md +171 -0
- package/dist/assets/mcp-instructions.md +46 -0
- package/dist/assets/mcp-prompts-tools.json +90 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +94 -0
- package/dist/diagnose.d.ts +8 -0
- package/dist/diagnose.js +105 -0
- package/dist/forward.d.ts +34 -0
- package/dist/forward.js +153 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +108 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +29 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +38 -0
- package/dist/sign.d.ts +22 -0
- package/dist/sign.js +38 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +62 -0
- package/package.json +51 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "search_tbmt",
|
|
4
|
+
"description": "Tìm Thông Báo Mời Thầu (TBMT) trong kho dữ liệu đấu thầu công Việt Nam qua Elasticsearch.\n\nDùng khi: người dùng hỏi về gói thầu, mời thầu, đấu thầu theo keyword / lĩnh vực / địa phương / thời gian / giá trị.\n\nTham số hay dùng nhất:\n- q: từ khóa tìm kiếm (tên dự án, loại hàng hóa, dịch vụ). Ví dụ: \"xây dựng trường học\", \"thiết bị y tế\".\n- sfrom / sto: khoảng ngày công bố, định dạng dd/mm/yyyy. Nên luôn truyền khi người dùng hỏi theo thời gian.\n- idprovince: mã tỉnh (101=Hà Nội, 201=TP.HCM, ...). Truyền dạng array [101].\n- field: lĩnh vực [1=Hàng hóa, 2=Xây lắp, 3=Tư vấn, 4=Phi tư vấn, 5=Hỗn hợp].\n- price_from / price_to: giá gói thầu tính bằng VND.\n- type_choose_id: hình thức (17=Đấu thầu rộng rãi, 20=Chỉ định thầu, ...).\n- page: trang kết quả (mỗi trang 20 bản ghi, mặc định 1).\n\nResponse: JSON với content là danh sách TBMT. Trường quan trọng trong mỗi TBMT: so_tbmt (số hiệu), ten_goi_thau (tên), gia_goi_thau (giá), bid_solicitor_id (ID bên mời thầu), han_nop_ho_so (hạn nộp)."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"name": "search_bidding_result",
|
|
8
|
+
"description": "Tìm Kết quả lựa chọn nhà thầu (KQLCNT) — danh sách gói thầu đã có quyết định trúng thầu — qua Elasticsearch.\n\nDùng khi: người dùng hỏi kết quả lựa chọn nhà thầu, ai trúng thầu, giá trúng thầu, kết quả đấu thầu theo keyword / lĩnh vực / địa phương / giá trị.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên dự án, MST nhà thầu, số TBMT, mã gói thầu). Ví dụ: \"xây dựng trường học\", \"0301234567\".\n- bidfieid: lĩnh vực dạng string array [\"HH\"=Hàng hóa, \"XL\"=Xây lắp, \"TV\"=Tư vấn, \"PTV\"=Phi tư vấn, \"HON_HOP\"=Hỗn hợp].\n- idprovincekq: mã tỉnh nơi thực hiện gói trúng thầu (101=Hà Nội, 701=TP.HCM, ...). Truyền dạng array [101].\n- win_price_from / win_price_to: khoảng giá trúng thầu (VND).\n- price_plan_from / price_plan_to: khoảng giá gói thầu kế hoạch (VND).\n- type_kqlcnt: 0=Tất cả, 1=Chỉ có liên kết KHLCNT, 2=Chỉ không liên kết KHLCNT.\n- sfrom / sto: khoảng ngày công bố KQLCNT, định dạng dd/mm/yyyy.\n- page: trang kết quả (mỗi trang 20 bản ghi, mặc định 1).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Cần mã tỉnh → gọi get_lookup(type=\"provinces\") trước.\n\nResponse: JSON với content là danh sách KQLCNT. Trường quan trọng: id, so_kqlcnt, nha_thau_trung (tên + MST nhà thầu trúng), gia_trung_thau (giá trúng), gia_goi_thau (giá kế hoạch), ten_goi_thau, so_tbmt."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "search_bidding_plans",
|
|
12
|
+
"description": "Tìm Kế hoạch lựa chọn nhà thầu (KHLCNT) — danh sách kế hoạch đã phê duyệt — qua Elasticsearch.\n\nDùng khi: người dùng hỏi kế hoạch lựa chọn nhà thầu, KHLCNT, kế hoạch đấu thầu theo keyword / lĩnh vực / địa phương / thời gian phê duyệt / giá gói thầu / tổng mức đầu tư.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên KHLCNT, tên chủ đầu tư, tên BMT). Ví dụ: \"xây dựng cầu\", \"thiết bị y tế\".\n- field_khlcnt: lĩnh vực [1=Hàng hóa, 2=Xây lắp, 3=Tư vấn, 4=Phi tư vấn, 5=Hỗn hợp].\n- idprovince_plan: mã tỉnh nơi thực hiện (101=Hà Nội, 701=TP.HCM, ...). Truyền dạng array [101].\n- price_plan_from / price_plan_to: giá gói thầu KHLCNT (VND).\n- invest_from / invest_to: tổng mức đầu tư dự án cha (VND).\n- sfrom / sto: khoảng ngày phê duyệt KHLCNT, định dạng dd/mm/yyyy.\n- solicitor_id: ID bên mời thầu cụ thể.\n- page: trang kết quả (mỗi trang 20 bản ghi, tối đa 100).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Cần mã tỉnh → gọi get_lookup(type=\"provinces\"). Cần ID bên mời thầu → lấy từ search_solicitors hoặc get_solicitor_profile.\n\nResponse: JSON với content là danh sách KHLCNT. Trường quan trọng: id, plan_code (mã KHLCNT), title (tên), addtime (ngày phê duyệt), investor (chủ đầu tư), solicitor_id / solicitor_title (bên mời thầu)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "search_kqmt",
|
|
16
|
+
"description": "Tìm Kết quả mở thầu (KQMT) — kết quả phiên mở thầu (chưa có quyết định trúng) — qua Elasticsearch.\n\nDùng khi: người dùng muốn biết danh sách nhà thầu dự thầu, số lượng nhà thầu, giá dự thầu, trạng thái mở thầu theo keyword / lĩnh vực / thời gian / giá trị.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên gói thầu, mã so_tbmt). Ví dụ: \"xây dựng trường học\".\n- type_view_open: trạng thái mở thầu (1=Hoàn thành mở thầu, 5=Hủy thầu, ...).\n- sl_nhathau: lọc/sắp xếp theo số lượng nhà thầu (2=Có ít nhất 1 NT, 3=Sort giảm, 4=Sort tăng).\n- field_kqmt: lĩnh vực [1=HH, 2=XL, 3=TV, 4=PTV, 5=Hỗn hợp].\n- price_plan_from / price_plan_to: giá gói thầu (VND).\n- sfrom / sto: khoảng ngày mở thầu, dd/mm/yyyy.\n- code: mã so_tbmt (nhiều mã phân tách bằng dấu phẩy).\n- page: trang (mỗi trang 20 bản ghi, mặc định 1).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Sau khi có kết quả, lấy id hoặc so_kqmt → gọi get_kqmt_detail để xem chi tiết.\n\nResponse: JSON với content là danh sách KQMT. Trường quan trọng: id, so_kqmt, so_tbmt, ten_goi_thau, trang_thai_mo_thau, so_luong_nha_thau, danh_sach_nha_thau, gia_goi_thau."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "search_projects",
|
|
20
|
+
"description": "Tìm Dự án đầu tư phát triển — dự án \"cha\" có nhiều KHLCNT/gói thầu — qua Elasticsearch.\n\nDùng khi: người dùng hỏi dự án đầu tư, dự án phát triển theo keyword / địa phương chủ đầu tư / tổng mức đầu tư / ODA / có KHLCNT hay chưa / thời gian.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên dự án, mã dự án). Ví dụ: \"cao tốc Bắc Nam\", \"xây dựng bệnh viện\".\n- devprovince: mã tỉnh chủ đầu tư. Truyền dạng array [701]. Dùng search_devprovince=1 cho tỉnh sau sáp nhập.\n- invest_from / invest_to: tổng mức đầu tư dự án (VND).\n- oda: 1=Có ODA, 2=Không ODA.\n- khlcnt: 1=Đã có KHLCNT, 2=Chưa có.\n- sfrom / sto: khoảng ngày đăng tải, dd/mm/yyyy.\n- page: trang (mỗi trang 20 bản ghi, tối đa 100).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Cần mã tỉnh → gọi get_lookup(type=\"provinces\"). Sau khi có kết quả, lấy id hoặc code → gọi get_project_detail để xem chi tiết dự án.\n\nResponse: JSON với content là danh sách dự án. Trường quan trọng: id, code (mã dự án), name (tên), date_post, solicitor_id, total_investment, oda, num_khlcnt, province_id."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "search_solicitors",
|
|
24
|
+
"description": "Tìm Bên mời thầu / Chủ đầu tư (procuring entity) trong kho dữ liệu đấu thầu qua Elasticsearch.\n\nDùng khi: người dùng hỏi thông tin bên mời thầu / chủ đầu tư theo tên, địa phương, số lượng hoạt động đấu thầu, thời gian phê duyệt / hoạt động cuối cùng.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên BMT, mã định danh). Ví dụ: \"Ban QLDA huyện X\", \"Sở Xây dựng\".\n- province_id_new: mã tỉnh sau sáp nhập (101=Hà Nội, 701=TP.HCM). Dùng get_lookup(type=\"provinces\") để tra mã.\n- num_tbmt: lọc BMT có ≥ N TBMT đã công bố.\n- order_by: 1=Mới→cũ, 2=Tổng DA, 3=Tổng KHLCNT, 4=Tổng TBMT, 5=Tổng KQMT, 6=Tổng KQLCNT.\n- sfrom_business / sto_business: khoảng ngày phê duyệt hồ sơ, dd/mm/yyyy.\n- page: trang (mỗi trang 20 bản ghi, mặc định 1).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Dùng tool này để tìm danh sách BMT; đã có ID cụ thể → gọi get_solicitor_profile. Cần mã tỉnh → gọi get_lookup(type=\"provinces\").\n\nResponse: JSON với content là danh sách BMT/chủ đầu tư. Trường quan trọng: id (dùng cho get_solicitor_profile), ten_bmt, ma_dinh_danh, dia_chi, thong_ke (total_project/plan/tbmt/kqmt/kqlcnt)."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "search_businesses",
|
|
28
|
+
"description": "Tìm Doanh nghiệp / Nhà thầu trong kho dữ liệu đấu thầu qua Elasticsearch.\n\nDùng khi: người dùng hỏi thông tin doanh nghiệp, nhà thầu theo tên/MST/ngành nghề/địa phương/loại hình/lĩnh vực kinh doanh/doanh số trúng thầu.\n\nTham số hay dùng nhất:\n- q: từ khóa (tên DN, MST, mã định danh, địa chỉ, SĐT). Ví dụ: \"0301234567\", \"xây dựng ABC\".\n- industry1: ngành VSIC cấp 1 (A-U). Ví dụ: \"F\"=Xây dựng.\n- province_id_new: mã tỉnh sau sáp nhập (101=Hà Nội, 701=TP.HCM). Dùng get_lookup(type=\"provinces\") tra mã.\n- businesstype: 5=Cổ phần, 4=TNHH, 11=TNHH 1TV, 3=Tư nhân, ...\n- lvkd: 1=HH, 2=XL, 3=TV, 4=PTV, 5=Hỗn hợp.\n- sort: sắp xếp (default/num_total_desc/num_result_desc/ability_point_desc/total_revenue/...). Xem mô tả đầy đủ trong jsonschema.\n- status: 0=Tất cả, 1=Đang hoạt động, 2=Tạm dừng, 3=Chấm dứt.\n- page: trang (mỗi trang 20 bản ghi, mặc định 1).\n\nCách lấy tham số: trực tiếp từ yêu cầu người dùng. Cần mã tỉnh → gọi get_lookup(type=\"provinces\"). Sau khi có kết quả, lấy id → gọi get_bidder_profile để xem hồ sơ năng lực chi tiết.\n\nResponse: JSON với content là danh sách doanh nghiệp/nhà thầu. Trường quan trọng: id, ten_dn (tên), mst (MST), loai_hinh, linh_vuc_kinh_doanh, dia_chi, thong_ke (total_tbmt/kqmt/kqlcnt, doanh_so_trung_thau, diem_nang_luc)."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "get_tbmt_detail",
|
|
32
|
+
"description": "Lấy thông tin chi tiết đầy đủ của 1 Thông Báo Mời Thầu cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem chi tiết 1 gói thầu (hạn nộp hồ sơ, yêu cầu kỹ thuật, file đính kèm, thông tin liên hệ...).\n\nCách lấy tham số:\n- so_tbmt: số TBMT dạng \"IB2600...\" — người dùng thường nói trực tiếp, hoặc lấy từ kết quả search_tbmt.\n- id: ID nội bộ số nguyên — lấy từ key của object content trong kết quả search_tbmt.\nChỉ cần truyền 1 trong 2, ưu tiên so_tbmt nếu người dùng đề cập rõ số TBMT.\n\nResponse: JSON chi tiết đầy đủ gồm hồ sơ mời thầu, bên mời thầu (bid_solicitor_id để gọi get_solicitor_profile), file đính kèm, lịch sử sửa đổi."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "get_result_detail",
|
|
36
|
+
"description": "Lấy thông tin chi tiết đầy đủ của 1 Kết quả lựa chọn nhà thầu (KQLCNT) cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem chi tiết 1 KQLCNT cụ thể — nhà thầu trúng, giá trúng, hàng hóa đấu thầu, nhà thầu trượt, thông tin gói thầu liên quan.\n\nTham số: id (số nguyên) hoặc code (mã KQLCNT dạng IBxxxxxxxxxx-YY). Chỉ cần truyền 1 trong 2, backend ưu tiên code nếu cả 2 có.\n\nCách lấy: id hoặc so_kqlcnt lấy từ kết quả search_bidding_result (trường id trong content, hoặc trường so_kqlcnt dùng làm code).\n\nResponse: JSON chi tiết KQLCNT gồm nhà thầu trúng (succ_business), nhà thầu trượt (fail_business), hàng hóa (hanghoa), giá trúng thầu, giá gói thầu, thông tin bên mời thầu."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "get_plan_detail",
|
|
40
|
+
"description": "Lấy thông tin chi tiết đầy đủ của 1 Kế hoạch lựa chọn nhà thầu (KHLCNT) cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem chi tiết 1 KHLCNT cụ thể — tên dự án, chủ đầu tư, tổng mức đầu tư, danh sách gói thầu con, nguồn vốn, hình thức LCNT từng gói.\n\nTham số: id (số nguyên) hoặc code (mã KHLCNT dạng PLxxxxxxxxxx-YY). Chỉ cần truyền 1 trong 2, backend ưu tiên code nếu cả 2 có.\n\nCách lấy: id lấy từ kết quả search_bidding_plans (trường id trong content). code = plan_code từ kết quả search.\n\nResponse: JSON chi tiết KHLCNT gồm thông tin dự án (project_name, investor, total_investment, isoda), phê duyệt (approval_org, approval_date, no_approval), và arr_contract (danh sách gói thầu con với giá, lĩnh vực, hình thức, phương thức LCNT)."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "get_kqmt_detail",
|
|
44
|
+
"description": "Lấy thông tin chi tiết đầy đủ của 1 Kết quả mở thầu (KQMT) cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem chi tiết 1 phiên mở thầu — danh sách nhà thầu dự thầu, giá dự thầu, giá gói thầu, trạng thái mở thầu, phương thức LCNT.\n\nTham số: id (số nguyên, ID KQMT) hoặc so_tbmt (mã TBMT dạng IBxxxxxxxxxx-YY). Chỉ cần truyền 1 trong 2.\n\nCách lấy: id hoặc so_tbmt lấy từ kết quả search_kqmt.\n\nResponse: JSON chi tiết KQMT gồm thông tin gói thầu (tên, giá, phương thức LCNT), thời điểm mở thầu, danh sách nhà thầu tham dự (tên, MST, giá dự thầu, giảm giá, bảo đảm, lý do trượt)."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "get_project_detail",
|
|
48
|
+
"description": "Lấy thông tin chi tiết đầy đủ của 1 dự án đầu tư phát triển cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem chi tiết 1 dự án đầu tư cụ thể — tên, chủ đầu tư, tổng mức đầu tư, quyết định phê duyệt, KHLCNT liên quan.\n\nTham số: id (số nguyên) hoặc code (mã dự án dạng PRxxxxxxxxxx-YY). Chỉ cần 1 trong 2, backend ưu tiên code.\n\nCách lấy: id lấy từ kết quả search_projects (trường id trong content). code = trường code từ kết quả search.\n\nResponse: JSON chi tiết dự án gồm thông tin đầu tư (investor, total_investment, invest_target, invest_scale), phê duyệt (book_number, accept_date, decision_agency), và other_plans (danh sách KHLCNT liên quan)."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "get_solicitor_profile",
|
|
52
|
+
"description": "Lấy hồ sơ Bên mời thầu / Chủ đầu tư theo ID (PHP API / MySQL).\n\nDùng khi: người dùng hỏi thông tin về cơ quan phát hành gói thầu (địa chỉ, lịch sử đấu thầu, số lượng gói đã đăng...).\n\nCách lấy tham số:\n- id: lấy từ trường bid_solicitor_id trong kết quả search_tbmt hoặc get_tbmt_detail.\nKhông gọi tool này nếu chưa có id — hãy gọi search_tbmt hoặc get_tbmt_detail trước để lấy id.\n\nResponse: JSON profile bên mời thầu gồm tên, địa chỉ, MST, lịch sử các gói thầu đã phát hành."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "get_bidder_profile",
|
|
56
|
+
"description": "Lấy hồ sơ chi tiết nhà thầu/doanh nghiệp theo ID (PHP API / MySQL).\n\nDùng khi: Người dùng cần xem hồ sơ chi tiết nhà thầu/doanh nghiệp (tên công ty, MST, ngành nghề, địa chỉ, vốn điều lệ, người đại diện).\n\nTham số hay dùng: id (bắt buộc — ID nhà thầu lấy từ kết quả search_businesses).\n\nCách lấy ID: Gọi search_businesses trước → lấy trường id từ content → truyền vào get_bidder_profile.\n\nResponse: JSON với content chứa thông tin chi tiết nhà thầu (companyname, code/MST, address, businesstype, industry_dkkd, thong_tin_nganh_nghe...)."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "get_lots_detail",
|
|
60
|
+
"description": "Lấy danh sách phân lô (lots) của 1 Thông Báo Mời Thầu cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem danh sách phân lô (lots) của 1 TBMT cụ thể — tên lô, giá lô, giá dự toán, bảo đảm dự thầu, thời gian thực hiện.\n\nTham số: id (bắt buộc — ID TBMT), page (trang, mặc định 1), limit (số bản ghi/trang, tối đa 50).\n\nCách lấy: id lấy từ get_tbmt_detail hoặc search_tbmt (trường id trong content).\n\nResponse: JSON với content là mảng phân lô (lotno, lotname, lotprice, lotestimate_price, lotguarantee_value, cperiod, cperiodunit) + phân trang (current_page, page_size, total, total_pages)."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "get_related_info",
|
|
64
|
+
"description": "Lấy danh sách ID bản ghi liên quan đến 1 TBMT cụ thể (PHP API / MySQL).\n\nDùng khi: sau khi có số TBMT (từ search_tbmt hoặc get_tbmt_detail), muốn lấy danh sách ID liên quan (KHLCNT/KQMT/KQLCNT/TBMT phiên bản khác/Dự án) để gọi tiếp các tool detail tương ứng.\n\nTham số: so_tbmt (bắt buộc — số TBMT dạng IBxxxxxxxxxx-YY hoặc 11 số + -YY).\n\nCách lấy: so_tbmt lấy từ kết quả search_tbmt hoặc get_tbmt_detail.\n\nResponse: JSON với content chứa tbmt_ids, kqmt_ids, kqlcnt_ids, khlcnt_ids, project_ids (tất cả mảng int). Dùng ID trong mảng này để gọi get_tbmt_detail/get_kqmt_detail/get_result_detail/get_plan_detail/get_project_detail."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "check_business_reg",
|
|
68
|
+
"description": "Kiểm tra MST doanh nghiệp có đăng ký trên hệ thống đấu thầu dauthau.info hay không (PHP API / MySQL).\n\nDùng khi: người dùng muốn kiểm tra 1 hoặc nhiều MST doanh nghiệp có tồn tại trên hệ thống đấu thầu không — phân biệt MST hợp lệ có dữ liệu, MST sai chuẩn, MST không có.\n\nTham số: mst (bắt buộc — chuỗi MST phân tách bằng dấu phẩy, tối đa 100 MST). Ví dụ: \"0301234567,0309876543\".\n\nCách lấy: MST lấy trực tiếp từ yêu cầu người dùng hoặc từ kết quả search_businesses/get_bidder_profile (trường code/mst).\n\nResponse: JSON với content chứa is_data (MST có trên hệ thống), error_data (MST sai chuẩn), not_data (MST không tồn tại trên hệ thống)."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "search_kqlcnt_by_mst",
|
|
72
|
+
"description": "Lấy danh sách Kết quả lựa chọn nhà thầu (KQLCNT) mà 1 doanh nghiệp cụ thể trúng thầu — tra theo MST (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem lịch sử trúng thầu của 1 doanh nghiệp cụ thể theo MST — liệt kê các gói thầu đã trúng, giá trúng, thông tin gói.\n\nTham số: mst (bắt buộc — MST doanh nghiệp, 10-14 ký tự). page (tùy chọn — trang, mỗi trang 20 bản ghi).\n\nCách lấy: MST lấy từ yêu cầu người dùng, hoặc từ kết quả search_businesses/get_bidder_profile (trường code/mst). LƯU Ý: nên gọi check_business_reg trước để đảm bảo MST đã đăng ký trên hệ thống.\n\nResponse: JSON với content là danh sách KQLCNT mà DN trúng. Trường quan trọng: id, bid_code (mã TBMT), title (tên gói), bid_price_number (giá gói), win_price_number (giá trúng), bidder_name, arr_bidder, result, total_goods (>0 thì gọi get_result_goods để xem hàng hóa)."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "list_craws_result",
|
|
76
|
+
"description": "Lấy danh sách KQLCNT mới crawl về — kết quả của các MST doanh nghiệp đã đăng ký qua check_business_reg (PHP API / MySQL).\n\nDùng khi: người dùng muốn lấy danh sách KQLCNT mới crawl về — kết quả của các MST đã đăng ký qua check_business_reg.\n\nTham số: last_id (truyền 0 lần đầu, sau đó truyền last_id từ response trước để phân trang — backend trả 20 bản ghi mỗi lần).\n\nCách lấy: gọi check_business_reg để đăng ký MST trước → backend đánh dấu KQLCNT mới chứa MST đó → gọi list_craws_result để lấy danh sách. Lặp lại với last_id từ response cho đến khi content rỗng.\n\nResponse: JSON content chứa id_craws, mst, id_result, bid_code, title. Trường last_id ở top-level dùng cho lần gọi tiếp theo."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "list_plan_subdivision",
|
|
80
|
+
"description": "Lấy danh sách các phần/lô (subdivision) trong 1 Kế hoạch lựa chọn nhà thầu (KHLCNT) cụ thể (PHP API / MySQL).\n\nDùng khi: cần lấy danh sách các phần/lô (subdivision) trong 1 KHLCNT — thông tin phân lô thuốc, vật tư, hàng hóa theo gói thầu.\n\nTham số hay dùng: id (bắt buộc — ID KHLCNT), page (trang, mặc định 1), limit (số bản ghi/trang, tối đa 50).\n\nCách lấy id: từ kết quả search_bidding_plans (trường id trong content) hoặc get_plan_detail (trường id).\n\nResponse: JSON content là mảng phần/lô gồm lotno, lotname, lotprice, bidno, medicine_code, tenthuoc, quantity + phân trang (current_page, page_size, total, total_pages)."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "get_result_goods",
|
|
84
|
+
"description": "Lấy danh sách hàng hoá trong 1 Kết quả lựa chọn nhà thầu (KQLCNT) cụ thể (PHP API / MySQL).\n\nDùng khi: người dùng muốn xem danh sách hàng hoá (goods) trong 1 KQLCNT — tên hàng hoá, khối lượng mời thầu, giá dự thầu, đơn vị tính, xuất xứ.\n\nTham số: mst (bắt buộc — MST nhà thầu trúng, 10-14 ký tự) + id (bắt buộc — ID KQLCNT lấy từ search_kqlcnt_by_mst hoặc get_result_detail).\n\nResponse: JSON content là mảng hàng hoá (goods_name, sign_product, number_bid, unit_cal, description, origin, capacity, bid_price, note). is_pay=true thì trừ điểm."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "get_lookup",
|
|
88
|
+
"description": "Tra cứu bảng mã tham chiếu dùng khi truyền tham số cho search_tbmt. Dữ liệu tĩnh — KHÔNG trừ điểm.\n\nDùng khi: cần mã số cụ thể để truyền vào search_tbmt mà không chắc mã là gì.\n\nTham số type (chọn 1):\n- \"provinces\" → danh sách mã tỉnh/thành (idprovince). Ví dụ: Hà Nội=101, TP.HCM=701, Bình Định=507.\n- \"type_choose\" → danh sách mã hình thức lựa chọn nhà thầu (type_choose_id). Ví dụ: Đấu thầu rộng rãi=17.\n- \"field\" → danh sách mã lĩnh vực (field). Ví dụ: Xây lắp=2, Hàng hóa=1, Tư vấn=3.\n- \"phanmucid\" → danh sách mã phân mục AI (phanmucid). Ví dụ: Dược/TBYT=261, Phần mềm CNTT=265.\n\nWorkflow: gọi get_lookup để lấy mã → dùng mã đó trong search_tbmt."
|
|
89
|
+
}
|
|
90
|
+
]
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đọc + validate env. Fail-fast với message rõ ràng qua stderr (vì stdout
|
|
3
|
+
* dành cho MCP JSON-RPC framing — KHÔNG được lẫn log vào).
|
|
4
|
+
*
|
|
5
|
+
* KHÔNG log giá trị apikey/apisecret/hashsecret theo .claude/rules/security.md §4
|
|
6
|
+
* (file rule của repo Go nhưng wrapper Node cũng áp dụng).
|
|
7
|
+
*/
|
|
8
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
9
|
+
export type HashAlgo = "md5" | "bcrypt";
|
|
10
|
+
export interface Config {
|
|
11
|
+
/** DauThau apikey của khách (giữ trên máy khách, KHÔNG forward lên gateway dưới dạng plaintext). */
|
|
12
|
+
apikey: string;
|
|
13
|
+
/** DauThau apisecret của khách — chỉ ở RAM wrapper, dùng để sign hashsecret mỗi request. */
|
|
14
|
+
apisecret: string;
|
|
15
|
+
/** URL gateway MCP do DauThau cấp khi đăng ký. */
|
|
16
|
+
gatewayUrl: string;
|
|
17
|
+
/** MCP API key do admin cấp — gửi qua header `X-MCP-API-Key`. */
|
|
18
|
+
gatewayKey: string;
|
|
19
|
+
/** Thuật toán sign hashsecret. Default `bcrypt` (khớp NukeViet PASSWORD_DEFAULT). */
|
|
20
|
+
hashAlgo: HashAlgo;
|
|
21
|
+
/** Log level wrapper → stderr. Mặc định `info`. */
|
|
22
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
23
|
+
/** Timeout request HTTPS lên gateway (ms). Default 30000. */
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
/** Số lần retry tối đa khi gateway trả 5xx. Default 3. 0 = không retry. */
|
|
26
|
+
retryMax: number;
|
|
27
|
+
/** Base delay (ms) cho exponential backoff. Default 200. Delay = base * 2^attempt. */
|
|
28
|
+
retryBaseMs: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đọc + validate env. Fail-fast với message rõ ràng qua stderr (vì stdout
|
|
3
|
+
* dành cho MCP JSON-RPC framing — KHÔNG được lẫn log vào).
|
|
4
|
+
*
|
|
5
|
+
* KHÔNG log giá trị apikey/apisecret/hashsecret theo .claude/rules/security.md §4
|
|
6
|
+
* (file rule của repo Go nhưng wrapper Node cũng áp dụng).
|
|
7
|
+
*/
|
|
8
|
+
const REQUIRED_ENV = [
|
|
9
|
+
"DAUTHAU_APIKEY",
|
|
10
|
+
"DAUTHAU_APISECRET",
|
|
11
|
+
"MCP_GATEWAY_URL",
|
|
12
|
+
"MCP_GATEWAY_KEY",
|
|
13
|
+
];
|
|
14
|
+
export function loadConfig(env = process.env) {
|
|
15
|
+
ensureNodeVersion();
|
|
16
|
+
const missing = REQUIRED_ENV.filter((k) => !env[k] || env[k].trim() === "");
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
throw new Error(`Thiếu env: ${missing.join(", ")}. ` +
|
|
19
|
+
`Xem mẫu .mcp.json trong README của repo.`);
|
|
20
|
+
}
|
|
21
|
+
const hashAlgoRaw = (env.DAUTHAU_HASH_ALGO ?? "bcrypt").toLowerCase();
|
|
22
|
+
if (hashAlgoRaw !== "md5" && hashAlgoRaw !== "bcrypt") {
|
|
23
|
+
throw new Error(`DAUTHAU_HASH_ALGO chỉ chấp nhận "md5" hoặc "bcrypt", got "${env.DAUTHAU_HASH_ALGO}"`);
|
|
24
|
+
}
|
|
25
|
+
const gatewayUrl = env.MCP_GATEWAY_URL.trim();
|
|
26
|
+
if (!gatewayUrl.startsWith("https://") && !gatewayUrl.startsWith("http://")) {
|
|
27
|
+
throw new Error(`MCP_GATEWAY_URL phải bắt đầu bằng https:// hoặc http://`);
|
|
28
|
+
}
|
|
29
|
+
validateGatewayUrl(gatewayUrl);
|
|
30
|
+
const logLevelRaw = (env.LOG_LEVEL ?? "info").toLowerCase();
|
|
31
|
+
const logLevel = ["debug", "info", "warn", "error"].includes(logLevelRaw)
|
|
32
|
+
? logLevelRaw
|
|
33
|
+
: "info";
|
|
34
|
+
const timeoutMs = Number.parseInt(env.MCP_GATEWAY_TIMEOUT_MS ?? "30000", 10);
|
|
35
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 120000) {
|
|
36
|
+
throw new Error(`MCP_GATEWAY_TIMEOUT_MS phải là số nguyên 1000-120000 ms, got "${env.MCP_GATEWAY_TIMEOUT_MS}"`);
|
|
37
|
+
}
|
|
38
|
+
const retryMax = Number.parseInt(env.MCP_GATEWAY_RETRY_MAX ?? "3", 10);
|
|
39
|
+
if (!Number.isFinite(retryMax) || retryMax < 0 || retryMax > 10) {
|
|
40
|
+
throw new Error(`MCP_GATEWAY_RETRY_MAX phải là số nguyên 0-10, got "${env.MCP_GATEWAY_RETRY_MAX}"`);
|
|
41
|
+
}
|
|
42
|
+
const retryBaseMs = Number.parseInt(env.MCP_GATEWAY_RETRY_BASE_MS ?? "200", 10);
|
|
43
|
+
if (!Number.isFinite(retryBaseMs) || retryBaseMs < 50 || retryBaseMs > 5000) {
|
|
44
|
+
throw new Error(`MCP_GATEWAY_RETRY_BASE_MS phải là số nguyên 50-5000, got "${env.MCP_GATEWAY_RETRY_BASE_MS}"`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
apikey: env.DAUTHAU_APIKEY.trim(),
|
|
48
|
+
apisecret: env.DAUTHAU_APISECRET.trim(),
|
|
49
|
+
gatewayUrl,
|
|
50
|
+
gatewayKey: env.MCP_GATEWAY_KEY.trim(),
|
|
51
|
+
hashAlgo: hashAlgoRaw,
|
|
52
|
+
logLevel,
|
|
53
|
+
timeoutMs,
|
|
54
|
+
retryMax,
|
|
55
|
+
retryBaseMs,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function ensureNodeVersion() {
|
|
59
|
+
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
|
|
60
|
+
if (!Number.isFinite(major) || major < 22) {
|
|
61
|
+
throw new Error(`Node.js >= 22 required, đang chạy ${process.version}. Tải LTS tại https://nodejs.org/`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Chặn SSRF: không cho MCP_GATEWAY_URL trỏ vào private IP / localhost / metadata endpoint. */
|
|
65
|
+
function validateGatewayUrl(raw) {
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error(`MCP_GATEWAY_URL không phải URL hợp lệ`);
|
|
72
|
+
}
|
|
73
|
+
const hostname = url.hostname.replace(/^\[/, "").replace(/\]$/, ""); // strip IPv6 brackets
|
|
74
|
+
// Blocklist: localhost, loopback, link-local, private ranges
|
|
75
|
+
const blocked = [
|
|
76
|
+
"localhost",
|
|
77
|
+
"127.0.0.1",
|
|
78
|
+
"::1",
|
|
79
|
+
"0.0.0.0",
|
|
80
|
+
"169.254.169.254", // AWS/GCP metadata
|
|
81
|
+
"metadata.google.internal",
|
|
82
|
+
];
|
|
83
|
+
if (blocked.includes(hostname)) {
|
|
84
|
+
throw new Error(`MCP_GATEWAY_URL không được trỏ tới ${hostname} (private/metadata endpoint)`);
|
|
85
|
+
}
|
|
86
|
+
// Chặn private IPv4 ranges: 10.x, 172.16-31.x, 192.168.x, 127.x
|
|
87
|
+
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.)/.test(hostname)) {
|
|
88
|
+
throw new Error(`MCP_GATEWAY_URL không được trỏ tới private IP range (${hostname})`);
|
|
89
|
+
}
|
|
90
|
+
// Chặn link-local IPv4
|
|
91
|
+
if (/^169\.254\./.test(hostname)) {
|
|
92
|
+
throw new Error(`MCP_GATEWAY_URL không được trỏ tới link-local (169.254.x.x)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic mode: chạy 3 check nhanh giúp user xác định lỗi cấu hình / mạng.
|
|
3
|
+
*
|
|
4
|
+
* Output user-friendly (✓ / ✗) ra stdout (OK vì mode này KHÔNG chạy MCP framing).
|
|
5
|
+
*/
|
|
6
|
+
import type { Config } from "./config.js";
|
|
7
|
+
/** Chạy diagnose và in kết quả ra stdout. Return true nếu tất cả pass. */
|
|
8
|
+
export declare function runDiagnose(cfg: Config): Promise<boolean>;
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic mode: chạy 3 check nhanh giúp user xác định lỗi cấu hình / mạng.
|
|
3
|
+
*
|
|
4
|
+
* Output user-friendly (✓ / ✗) ra stdout (OK vì mode này KHÔNG chạy MCP framing).
|
|
5
|
+
*/
|
|
6
|
+
import { resolve as dnsResolve } from "node:dns/promises";
|
|
7
|
+
import { signHashsecret, nowUnix } from "./sign.js";
|
|
8
|
+
/** Chạy diagnose và in kết quả ra stdout. Return true nếu tất cả pass. */
|
|
9
|
+
export async function runDiagnose(cfg) {
|
|
10
|
+
const write = (s) => process.stdout.write(s + "\n");
|
|
11
|
+
write(`@dauthau/mcp-dauthau — chẩn đoán kết nối`);
|
|
12
|
+
write(`Gateway: ${cfg.gatewayUrl}`);
|
|
13
|
+
write(`Algo: ${cfg.hashAlgo} | Timeout: ${cfg.timeoutMs}ms | Retry: ${cfg.retryMax}`);
|
|
14
|
+
write("---");
|
|
15
|
+
let allOk = true;
|
|
16
|
+
// Check 1: DNS resolve
|
|
17
|
+
const hostname = new URL(cfg.gatewayUrl).hostname;
|
|
18
|
+
const dnsOk = await checkDns(hostname);
|
|
19
|
+
if (dnsOk) {
|
|
20
|
+
write(`✓ DNS resolve ${hostname} → OK`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
write(`✗ DNS resolve ${hostname} → THẤT BẠI. Kiểm tra MCP_GATEWAY_URL hoặc kết nối mạng.`);
|
|
24
|
+
allOk = false;
|
|
25
|
+
}
|
|
26
|
+
// Check 2: HTTPS handshake (timeout 5s)
|
|
27
|
+
const handshakeOk = await checkHandshake(cfg.gatewayUrl);
|
|
28
|
+
if (handshakeOk) {
|
|
29
|
+
write(`✓ HTTPS handshake → OK`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
write(`✗ HTTPS handshake → THẤT BẠI. Gateway có thể đang bảo trì hoặc bị tường lửa chặn.`);
|
|
33
|
+
allOk = false;
|
|
34
|
+
}
|
|
35
|
+
// Check 3: POST tools/list
|
|
36
|
+
const toolsResult = await checkToolsList(cfg);
|
|
37
|
+
if (toolsResult.ok) {
|
|
38
|
+
write(`✓ POST tools/list → HTTP ${toolsResult.status} (${toolsResult.latencyMs}ms)`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
write(`✗ POST tools/list → ${toolsResult.error}. Kiểm tra API key / apisecret / clock.`);
|
|
42
|
+
allOk = false;
|
|
43
|
+
}
|
|
44
|
+
write("---");
|
|
45
|
+
if (allOk) {
|
|
46
|
+
write("Kết luận: tất cả check đều pass. Wrapper sẵn sàng.");
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
write("Kết luận: có lỗi. Sửa theo hướng dẫn trên rồi thử lại.");
|
|
50
|
+
}
|
|
51
|
+
return allOk;
|
|
52
|
+
}
|
|
53
|
+
async function checkDns(hostname) {
|
|
54
|
+
try {
|
|
55
|
+
await dnsResolve(hostname);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function checkHandshake(url) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
65
|
+
try {
|
|
66
|
+
await fetch(url, { method: "HEAD", signal: controller.signal });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function checkToolsList(cfg) {
|
|
77
|
+
const ts = nowUnix();
|
|
78
|
+
const hashsecret = await signHashsecret(cfg.hashAlgo, cfg.apisecret, ts);
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(cfg.gatewayUrl, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"User-Agent": `@dauthau/mcp-dauthau diagnose Node/${process.versions.node}`,
|
|
88
|
+
"X-MCP-API-Key": cfg.gatewayKey,
|
|
89
|
+
"X-Dauthau-Apikey": cfg.apikey,
|
|
90
|
+
"X-Dauthau-Hashsecret": hashsecret,
|
|
91
|
+
"X-Dauthau-Timestamp": ts.toString(),
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
const latencyMs = Date.now() - start;
|
|
97
|
+
return { ok: res.status === 200, status: res.status, latencyMs };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return { ok: false, error: err.message };
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward MCP request lên gateway HTTPS DauThau.
|
|
3
|
+
*
|
|
4
|
+
* Wrapper sign hashsecret local, gateway forward nguyên xuống backend API.
|
|
5
|
+
*
|
|
6
|
+
* 4 header bắt buộc:
|
|
7
|
+
* X-MCP-API-Key: gateway subscription
|
|
8
|
+
* X-Dauthau-Apikey: apikey của user
|
|
9
|
+
* X-Dauthau-Hashsecret: hashsecret đã sign per-request (md5 hex hoặc bcrypt $2y$)
|
|
10
|
+
* X-Dauthau-Timestamp: Unix seconds dùng khi sign — backend verify lại
|
|
11
|
+
*
|
|
12
|
+
* KHÔNG log apikey/apisecret/hashsecret/body — chỉ log latency + status (stderr).
|
|
13
|
+
*/
|
|
14
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
15
|
+
import type { Config } from "./config.js";
|
|
16
|
+
export interface ForwardResult {
|
|
17
|
+
status: number;
|
|
18
|
+
body: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface ForwardOptions {
|
|
21
|
+
/** Cho phép caller override timestamp (chỉ dùng cho test). Mặc định nowUnix(). */
|
|
22
|
+
timestamp?: number;
|
|
23
|
+
/** Cho phép caller cung cấp fetch mock (chỉ dùng cho test). */
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* forwardJsonRpc gửi 1 JSON-RPC envelope (đã được MCP SDK build sẵn) lên gateway.
|
|
28
|
+
* Retry exponential backoff nếu 5xx. KHÔNG retry cho 4xx (lỗi client).
|
|
29
|
+
*
|
|
30
|
+
* @param jsonRpcBody body JSON-RPC từ MCP SDK (initialize / tools/list / tools/call ...).
|
|
31
|
+
* @param cfg config đã load qua loadConfig().
|
|
32
|
+
* @param opts tuỳ chọn override cho test.
|
|
33
|
+
*/
|
|
34
|
+
export declare function forwardJsonRpc(jsonRpcBody: unknown, cfg: Config, opts?: ForwardOptions): Promise<ForwardResult>;
|
package/dist/forward.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward MCP request lên gateway HTTPS DauThau.
|
|
3
|
+
*
|
|
4
|
+
* Wrapper sign hashsecret local, gateway forward nguyên xuống backend API.
|
|
5
|
+
*
|
|
6
|
+
* 4 header bắt buộc:
|
|
7
|
+
* X-MCP-API-Key: gateway subscription
|
|
8
|
+
* X-Dauthau-Apikey: apikey của user
|
|
9
|
+
* X-Dauthau-Hashsecret: hashsecret đã sign per-request (md5 hex hoặc bcrypt $2y$)
|
|
10
|
+
* X-Dauthau-Timestamp: Unix seconds dùng khi sign — backend verify lại
|
|
11
|
+
*
|
|
12
|
+
* KHÔNG log apikey/apisecret/hashsecret/body — chỉ log latency + status (stderr).
|
|
13
|
+
*/
|
|
14
|
+
import { logInfo, logError, logWarn } from "./log.js";
|
|
15
|
+
import { signHashsecret, nowUnix } from "./sign.js";
|
|
16
|
+
/**
|
|
17
|
+
* forwardJsonRpc gửi 1 JSON-RPC envelope (đã được MCP SDK build sẵn) lên gateway.
|
|
18
|
+
* Retry exponential backoff nếu 5xx. KHÔNG retry cho 4xx (lỗi client).
|
|
19
|
+
*
|
|
20
|
+
* @param jsonRpcBody body JSON-RPC từ MCP SDK (initialize / tools/list / tools/call ...).
|
|
21
|
+
* @param cfg config đã load qua loadConfig().
|
|
22
|
+
* @param opts tuỳ chọn override cho test.
|
|
23
|
+
*/
|
|
24
|
+
export async function forwardJsonRpc(jsonRpcBody, cfg, opts = {}) {
|
|
25
|
+
const ts = opts.timestamp ?? nowUnix();
|
|
26
|
+
const hashsecret = await signHashsecret(cfg.hashAlgo, cfg.apisecret, ts);
|
|
27
|
+
// Serialize body TRƯỚC khi start timer — nếu circular reference sẽ throw sớm, không leak timer.
|
|
28
|
+
const serializedBody = JSON.stringify(jsonRpcBody);
|
|
29
|
+
const headers = {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
Accept: "application/json, text/event-stream",
|
|
32
|
+
"User-Agent": `@dauthau/mcp-dauthau Node/${process.versions.node}`,
|
|
33
|
+
"X-MCP-API-Key": cfg.gatewayKey,
|
|
34
|
+
"X-Dauthau-Apikey": cfg.apikey,
|
|
35
|
+
"X-Dauthau-Hashsecret": hashsecret,
|
|
36
|
+
"X-Dauthau-Timestamp": ts.toString(),
|
|
37
|
+
};
|
|
38
|
+
if (cfg.hashAlgo === "bcrypt") {
|
|
39
|
+
headers["X-Dauthau-Method"] = "password_verify";
|
|
40
|
+
}
|
|
41
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
42
|
+
let lastResult;
|
|
43
|
+
let lastError;
|
|
44
|
+
for (let attempt = 0; attempt <= cfg.retryMax; attempt++) {
|
|
45
|
+
// Delay exponential backoff trước retry (không delay lần đầu)
|
|
46
|
+
if (attempt > 0) {
|
|
47
|
+
const delayMs = cfg.retryBaseMs * Math.pow(2, attempt - 1);
|
|
48
|
+
logWarn("retry gateway", { attempt, delay_ms: delayMs, max: cfg.retryMax });
|
|
49
|
+
await sleep(delayMs);
|
|
50
|
+
}
|
|
51
|
+
const result = await doFetch(fetchImpl, cfg, headers, serializedBody);
|
|
52
|
+
if (result.type === "success") {
|
|
53
|
+
// 5xx → retry, 4xx/2xx → trả ngay (không retry lỗi client)
|
|
54
|
+
if (result.value.status >= 500 && attempt < cfg.retryMax) {
|
|
55
|
+
lastResult = result.value;
|
|
56
|
+
logWarn("gateway 5xx, sẽ retry", { status: result.value.status, attempt });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return result.value;
|
|
60
|
+
}
|
|
61
|
+
// Network error / timeout → retry
|
|
62
|
+
if (attempt < cfg.retryMax) {
|
|
63
|
+
lastError = result.error;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw result.error;
|
|
67
|
+
}
|
|
68
|
+
// Nếu hết retry vẫn 5xx → trả response cuối (để caller forward error code)
|
|
69
|
+
if (lastResult)
|
|
70
|
+
return lastResult;
|
|
71
|
+
throw lastError ?? new Error("gateway unreachable sau retry");
|
|
72
|
+
}
|
|
73
|
+
/** Thực hiện 1 lần POST lên gateway — tách riêng để retry loop gọn. */
|
|
74
|
+
async function doFetch(fetchImpl, cfg, headers, body) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl(cfg.gatewayUrl, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers,
|
|
82
|
+
body,
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
});
|
|
85
|
+
const latencyMs = Date.now() - start;
|
|
86
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
87
|
+
const validTypes = ["application/json", "text/event-stream", "text/plain"];
|
|
88
|
+
if (!validTypes.some((t) => contentType.includes(t))) {
|
|
89
|
+
logError("gateway content-type không hợp lệ", { content_type: contentType, status: res.status });
|
|
90
|
+
}
|
|
91
|
+
let responseBody;
|
|
92
|
+
if (contentType.includes("application/json")) {
|
|
93
|
+
responseBody = await res.json();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const txt = await res.text();
|
|
97
|
+
responseBody = txt ? safeParseJson(txt) : null;
|
|
98
|
+
}
|
|
99
|
+
logInfo("gateway response", { status: res.status, latency_ms: latencyMs, content_type: contentType });
|
|
100
|
+
// Clock skew warning: nếu gateway trả header Date hoặc lỗi timestamp → cảnh báo user
|
|
101
|
+
checkClockSkew(res, responseBody);
|
|
102
|
+
return { type: "success", value: { status: res.status, body: responseBody } };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const latencyMs = Date.now() - start;
|
|
106
|
+
if (err.name === "AbortError") {
|
|
107
|
+
logError("gateway timeout", { latency_ms: latencyMs, timeout_ms: cfg.timeoutMs });
|
|
108
|
+
return { type: "error", error: new Error(`gateway timeout sau ${cfg.timeoutMs}ms`) };
|
|
109
|
+
}
|
|
110
|
+
logError("gateway error", { latency_ms: latencyMs, err: err.message });
|
|
111
|
+
return { type: "error", error: err };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function sleep(ms) {
|
|
118
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
119
|
+
}
|
|
120
|
+
/** Cảnh báo nếu clock client lệch server > 30s. */
|
|
121
|
+
function checkClockSkew(res, body) {
|
|
122
|
+
// Cách 1: header Date từ server
|
|
123
|
+
const serverDate = res.headers.get("date");
|
|
124
|
+
if (serverDate) {
|
|
125
|
+
const serverTime = Math.floor(new Date(serverDate).getTime() / 1000);
|
|
126
|
+
const clientTime = Math.floor(Date.now() / 1000);
|
|
127
|
+
const skew = Math.abs(serverTime - clientTime);
|
|
128
|
+
if (skew > 30) {
|
|
129
|
+
logWarn("clock skew detected — đồng hồ máy lệch server", {
|
|
130
|
+
skew_seconds: skew,
|
|
131
|
+
hint: "Sync NTP: Windows → 'w32tm /resync', Linux/macOS → 'sudo sntp -sS time.google.com'",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Cách 2: lỗi timestamp từ gateway response body
|
|
137
|
+
if (body && typeof body === "object" && "message" in body) {
|
|
138
|
+
const msg = String(body.message).toLowerCase();
|
|
139
|
+
if (msg.includes("timestamp") && (msg.includes("expired") || msg.includes("invalid") || msg.includes("lệch"))) {
|
|
140
|
+
logWarn("gateway báo lỗi timestamp — có thể clock skew", {
|
|
141
|
+
hint: "Sync NTP: Windows → 'w32tm /resync', Linux/macOS → 'sudo sntp -sS time.google.com'",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function safeParseJson(s) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(s);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { raw: s };
|
|
152
|
+
}
|
|
153
|
+
}
|