datacore-a2a-mcp 0.1.0__tar.gz
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.
- datacore_a2a_mcp-0.1.0/.gitignore +11 -0
- datacore_a2a_mcp-0.1.0/LICENSE +21 -0
- datacore_a2a_mcp-0.1.0/PKG-INFO +159 -0
- datacore_a2a_mcp-0.1.0/PRIVACY.md +93 -0
- datacore_a2a_mcp-0.1.0/README.md +138 -0
- datacore_a2a_mcp-0.1.0/pyproject.toml +72 -0
- datacore_a2a_mcp-0.1.0/src/datacore_mcp/__init__.py +3 -0
- datacore_a2a_mcp-0.1.0/src/datacore_mcp/server.py +387 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 3TIT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: datacore-a2a-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server cho các skill dữ liệu DataCore: chuẩn hoá địa chỉ Việt Nam và tra cứu doanh nghiệp
|
|
5
|
+
Project-URL: Homepage, https://github.com/DataCore-VietNam/datacore-a2a-mcp
|
|
6
|
+
Project-URL: Documentation, https://github.com/DataCore-VietNam/datacore-a2a-mcp#readme
|
|
7
|
+
Project-URL: Privacy Policy, https://github.com/DataCore-VietNam/datacore-a2a-mcp/blob/master/PRIVACY.md
|
|
8
|
+
Project-URL: Issues, https://github.com/DataCore-VietNam/datacore-a2a-mcp/issues
|
|
9
|
+
Author-email: 3TIT <mcp-security@datacore.vn>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: a2a,address,company-data,mcp,model-context-protocol,vietnam
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# DataCore A2A — MCP server
|
|
23
|
+
|
|
24
|
+
<!-- mcp-name: vn.datacore/datacore-a2a -->
|
|
25
|
+
<!-- Marker dự phòng: registry tìm chuỗi con, nên hai dòng cùng tồn tại được.
|
|
26
|
+
Giữ đường lùi nếu xác minh tên miền không xong, khỏi phải phát hành
|
|
27
|
+
một version mới chỉ để sửa một dòng comment.
|
|
28
|
+
mcp-name: io.github.nambn3tit/datacore-a2a-mcp -->
|
|
29
|
+
|
|
30
|
+
Cho phép một MCP client (Claude Code, Claude Desktop, VS Code, Cursor…) dùng
|
|
31
|
+
các skill dữ liệu của DataCore như tool native: chuẩn hoá địa chỉ Việt Nam,
|
|
32
|
+
kiểm tra địa chỉ, tra cứu doanh nghiệp, sinh embedding.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
MCP client (stdio) ──► datacore-mcp ──HTTPS──► DataCore API
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Zero dependency, Python 3.9+.
|
|
39
|
+
|
|
40
|
+
## Cài đặt
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install datacore-a2a-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Cần có trước
|
|
47
|
+
|
|
48
|
+
Server này là **client**, không phải dịch vụ. Nó cần một endpoint DataCore và
|
|
49
|
+
một mandate token để gọi — liên hệ 3TIT để được cấp. Không có hai thứ đó thì
|
|
50
|
+
server khởi động rồi thoát ngay kèm thông báo thiếu biến nào.
|
|
51
|
+
|
|
52
|
+
## Cấu hình
|
|
53
|
+
|
|
54
|
+
| Biến | Bắt buộc | Ý nghĩa |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `DATACORE_A2A_URL` | có | Base URL của endpoint DataCore được cấp cho bạn, ví dụ `https://api.example.com` |
|
|
57
|
+
| `DATACORE_MANDATE` | có | AP2 mandate token do DataCore cấp — quyết định skill nào được gọi và hạn mức chi tiêu |
|
|
58
|
+
| `DATACORE_SKILLS` | không | Danh sách skill id cách nhau bằng phẩy để giới hạn tool, hoặc `*` để lấy hết. Bỏ trống dùng mặc định |
|
|
59
|
+
| `DATACORE_TIMEOUT` | không | Timeout mỗi request (giây), mặc định `30` |
|
|
60
|
+
|
|
61
|
+
Dùng đúng URL DataCore cấp kèm mandate. Một endpoint khác sẽ không nhận mandate
|
|
62
|
+
của bạn và trả `401`.
|
|
63
|
+
|
|
64
|
+
### Claude Code / Claude Desktop
|
|
65
|
+
|
|
66
|
+
Thêm vào `.mcp.json` (project) hoặc config của Claude Desktop:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"datacore-a2a": {
|
|
72
|
+
"command": "datacore-mcp",
|
|
73
|
+
"env": {
|
|
74
|
+
"DATACORE_A2A_URL": "https://api.example.com",
|
|
75
|
+
"DATACORE_MANDATE": "<mandate-token>"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Tool
|
|
83
|
+
|
|
84
|
+
Tool list được **sinh từ endpoint**, không hardcode ở đây — nên khi DataCore
|
|
85
|
+
thêm skill, restart server là thấy tool mới.
|
|
86
|
+
|
|
87
|
+
Mặc định expose 4 skill đã nối backend thật:
|
|
88
|
+
|
|
89
|
+
| Tool | Skill id | Việc nó làm |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `address_normalize` | `address.normalize` | Chuẩn hoá địa chỉ Việt Nam thành tỉnh / huyện / xã |
|
|
92
|
+
| `address_validate` | `address.validate` | Kiểm tra một địa chỉ có tồn tại theo dữ liệu hành chính |
|
|
93
|
+
| `company_search` | `company.search` | Tra cứu doanh nghiệp theo tên hoặc mã số thuế |
|
|
94
|
+
| `company_embeddings` | `company.embeddings` | Sinh vector embedding cho văn bản |
|
|
95
|
+
|
|
96
|
+
Mọi tool là **read-only** (`readOnlyHint: true`): không ghi, không xoá, không
|
|
97
|
+
tạo gì. Gọi lại cùng input cho cùng kết quả.
|
|
98
|
+
|
|
99
|
+
Ba skill khác của DataCore (`company.graph`, `company.financial-sankey`,
|
|
100
|
+
`company.news`) **cố ý không được expose**: chúng đang trả dữ liệu
|
|
101
|
+
mock/deterministic. Muốn thấy chúng để thử thì đặt `DATACORE_SKILLS="*"`, và
|
|
102
|
+
đừng tin kết quả.
|
|
103
|
+
|
|
104
|
+
## Ví dụ
|
|
105
|
+
|
|
106
|
+
Ba prompt dưới đây chạy qua ba tool khác nhau:
|
|
107
|
+
|
|
108
|
+
1. **Chuẩn hoá địa chỉ** — "Chuẩn hoá địa chỉ này thành tỉnh/huyện/xã: So 1 Ly
|
|
109
|
+
Thai To, Hoan Kiem, Ha Noi"
|
|
110
|
+
2. **Tra cứu doanh nghiệp** — "Tìm thông tin doanh nghiệp có mã số thuế
|
|
111
|
+
0100109106 và cho tôi biết trạng thái hoạt động"
|
|
112
|
+
3. **Embedding** — "Sinh embedding cho đoạn mô tả ngành nghề này rồi cho biết
|
|
113
|
+
vector có bao nhiêu chiều: bán buôn thiết bị điện tử"
|
|
114
|
+
|
|
115
|
+
## Mô hình bảo mật
|
|
116
|
+
|
|
117
|
+
Server này mang mandate token **nguyên dạng** ở header `X-AP2-Mandate` để phía
|
|
118
|
+
DataCore verify, và không tự khai bất kỳ header authorization nào khác. Một
|
|
119
|
+
client không được là nơi tự cấp quyền cho chính nó — nó chỉ chuyển tiếp thứ nó
|
|
120
|
+
được cấp, còn mọi quyết định cho phép hay từ chối đều nằm ở phía DataCore.
|
|
121
|
+
|
|
122
|
+
Mandate là **bearer credential**: ai giữ nó cũng gọi được đúng những skill trong
|
|
123
|
+
đó, tới hết hạn mức, cho tới khi hết hạn. Giữ nó như giữ mật khẩu — đừng commit
|
|
124
|
+
`.mcp.json` đã điền token, và nếu client của bạn hỗ trợ, đọc nó từ trình quản lý
|
|
125
|
+
secret thay vì để inline.
|
|
126
|
+
|
|
127
|
+
**Package này** không bao giờ ghi token ra log, kể cả một phần; nội dung `text`
|
|
128
|
+
bạn gửi cũng không. Đó là cam kết cho tiến trình chạy trên máy bạn — phía dịch
|
|
129
|
+
vụ là chuyện khác, và [PRIVACY.md](PRIVACY.md) nói thẳng nó lưu những gì.
|
|
130
|
+
|
|
131
|
+
## Xử lý sự cố
|
|
132
|
+
|
|
133
|
+
Log đi ra **stderr**, prefix `[datacore-mcp]`. stdout là kênh JSON-RPC.
|
|
134
|
+
|
|
135
|
+
| Triệu chứng | Nguyên nhân thường gặp |
|
|
136
|
+
|---|---|
|
|
137
|
+
| Server thoát ngay, `THIẾU biến môi trường bắt buộc` | Chưa set `DATACORE_A2A_URL` hoặc `DATACORE_MANDATE` |
|
|
138
|
+
| `401` mọi request | Mandate hết hạn, hoặc `DATACORE_A2A_URL` không phải endpoint đã cấp kèm mandate đó |
|
|
139
|
+
| `402` | Đã dùng hết spend cap của mandate |
|
|
140
|
+
| `403` | Skill nằm ngoài allowlist của mandate — xin mandate có skill đó |
|
|
141
|
+
| `502` | Backend của skill đó phía DataCore không phản hồi |
|
|
142
|
+
| Không thấy tool nào | `DATACORE_SKILLS` lọc hết, hoặc endpoint chưa đăng ký skill nào |
|
|
143
|
+
|
|
144
|
+
## Báo lỗi bảo mật
|
|
145
|
+
|
|
146
|
+
`mcp-security@datacore.vn`. Đừng mở issue công khai cho lỗi bảo mật.
|
|
147
|
+
|
|
148
|
+
Nghi mandate của mình bị lộ thì **báo ngay** — đừng đợi tới lúc nó hết hạn.
|
|
149
|
+
Chúng tôi cần biết để xử lý phía dịch vụ.
|
|
150
|
+
|
|
151
|
+
## Quyền riêng tư
|
|
152
|
+
|
|
153
|
+
Xem [PRIVACY.md](PRIVACY.md). Ngắn gọn: package này chạy trên máy bạn, không lưu
|
|
154
|
+
gì, không gửi đi đâu ngoài endpoint DataCore bạn tự cấu hình, và không bao giờ
|
|
155
|
+
ghi mandate token vào log.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT — xem [LICENSE](LICENSE).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Privacy Policy — datacore-a2a-mcp
|
|
2
|
+
|
|
3
|
+
Áp dụng cho package `datacore-a2a-mcp` (MCP server). Cập nhật 2026-08-24.
|
|
4
|
+
|
|
5
|
+
## Package này là gì, về mặt dữ liệu
|
|
6
|
+
|
|
7
|
+
`datacore-a2a-mcp` là một **client chạy trên máy bạn**. Nó không phải dịch vụ do
|
|
8
|
+
chúng tôi vận hành, không nhận dữ liệu về phía chúng tôi, và không có backend
|
|
9
|
+
riêng. Nó là một tiến trình cục bộ dịch giữa MCP client của bạn (Claude Code,
|
|
10
|
+
Claude Desktop, VS Code…) và endpoint DataCore mà **bạn** cấu hình.
|
|
11
|
+
|
|
12
|
+
## Dữ liệu nó xử lý
|
|
13
|
+
|
|
14
|
+
| Dữ liệu | Từ đâu | Đi đâu |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Nội dung `text` bạn (hoặc agent) đưa vào tool | MCP client | Gửi tới endpoint DataCore ở `DATACORE_A2A_URL` qua HTTPS |
|
|
17
|
+
| Skill id được gọi | Tên tool | Cùng request trên |
|
|
18
|
+
| Mandate token | `DATACORE_MANDATE` | Header `X-AP2-Mandate` của cùng request trên |
|
|
19
|
+
| Kết quả trả về | Endpoint DataCore | Trả lại MCP client |
|
|
20
|
+
|
|
21
|
+
Không có đích đến nào khác. Package **zero dependency** nên không có thư viện
|
|
22
|
+
bên thứ ba nào chạm vào dữ liệu, và không có telemetry, analytics, hay
|
|
23
|
+
crash-reporting nào.
|
|
24
|
+
|
|
25
|
+
## Nó lưu gì
|
|
26
|
+
|
|
27
|
+
**Không lưu gì cả.** Không file, không cache, không database, không state giữa
|
|
28
|
+
các lần chạy. Mọi thứ nằm trong bộ nhớ tiến trình và mất khi tiến trình dừng.
|
|
29
|
+
|
|
30
|
+
## Nó ghi log gì
|
|
31
|
+
|
|
32
|
+
Log đi ra **stderr**, nơi MCP client của bạn quyết định giữ hay bỏ:
|
|
33
|
+
|
|
34
|
+
- Lúc khởi động: base URL, timeout, danh sách skill được bật
|
|
35
|
+
- Lúc discovery: số tool tìm được và skill id của chúng
|
|
36
|
+
- Khi tool lỗi: câu lỗi đã được dịch, gồm mã HTTP và message của server
|
|
37
|
+
|
|
38
|
+
**Mandate token không bao giờ được ghi log, kể cả một phần của nó** — đây là
|
|
39
|
+
token cấp quyền chi tiêu, và stderr của MCP server thường được client ghi ra
|
|
40
|
+
file. Nội dung `text` bạn gửi cũng không được ghi log.
|
|
41
|
+
|
|
42
|
+
## Dữ liệu gửi tới DataCore thì sao
|
|
43
|
+
|
|
44
|
+
Mục trên nói về package — thứ chạy trên máy bạn. Mục này nói về **dịch vụ** ở
|
|
45
|
+
đầu kia `DATACORE_A2A_URL`. Hai thứ khác nhau, và cam kết của package không phủ
|
|
46
|
+
lên dịch vụ được, nên phần này viết riêng.
|
|
47
|
+
|
|
48
|
+
### Dịch vụ nhận gì
|
|
49
|
+
|
|
50
|
+
Mỗi lần gọi tool, dịch vụ nhận: nội dung `text` bạn gửi, skill id, mandate của
|
|
51
|
+
bạn (mandate id, client id, danh sách skill được phép, hạn mức chi tiêu, hạn sử
|
|
52
|
+
dụng), cùng những thứ mọi request HTTP đều mang — địa chỉ IP nguồn và header.
|
|
53
|
+
|
|
54
|
+
### `text` của bạn đi tới đâu
|
|
55
|
+
|
|
56
|
+
Gateway verify chữ ký mandate → orchestrator kiểm skill có nằm trong mandate
|
|
57
|
+
không và hạn mức còn không → chuyển cho worker agent của skill đó → worker gọi
|
|
58
|
+
API dữ liệu tương ứng: dữ liệu địa giới hành chính, dữ liệu doanh nghiệp, hoặc
|
|
59
|
+
mô hình embedding.
|
|
60
|
+
|
|
61
|
+
Các API dữ liệu đó là hệ thống riêng, không cùng một hệ thống với gateway. Nên
|
|
62
|
+
**nội dung `text` của bạn có rời khỏi phạm vi chúng tôi để tới bên cung cấp dữ
|
|
63
|
+
liệu** — đó là cách duy nhất để có câu trả lời cho truy vấn của bạn. Bên cung
|
|
64
|
+
cấp dữ liệu có chính sách riêng của họ.
|
|
65
|
+
|
|
66
|
+
### Dịch vụ lưu gì
|
|
67
|
+
|
|
68
|
+
| Thứ được lưu | Nội dung | Sống bao lâu |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Bộ đếm chi tiêu | Một con số cho mỗi mandate. Không có nội dung truy vấn | Tự xoá đúng lúc mandate hết hạn |
|
|
71
|
+
| Bản ghi tính phí | mandate id, client id, worker, skill id, số tiền, thời điểm. Không có nội dung truy vấn | Theo hệ thống tính phí |
|
|
72
|
+
| Log request ở gateway | **Body request — tức `text` của bạn — body response, header, query string, IP nguồn** | Theo chính sách log của hệ thống |
|
|
73
|
+
|
|
74
|
+
Dòng thứ ba là dòng đáng đọc kỹ: gateway ghi log ở mức đầy đủ, nên **truy vấn
|
|
75
|
+
và kết quả của bạn được lưu lại ở phía dịch vụ, không chỉ đi ngang qua**. Nếu
|
|
76
|
+
định gửi dữ liệu nhạy cảm qua tool này, hãy tính trên giả định đó. Package
|
|
77
|
+
không hứa thay cho phần này được — nó chỉ hứa được rằng bản thân nó không lưu
|
|
78
|
+
và không ghi gì (mục trên).
|
|
79
|
+
|
|
80
|
+
### Dùng để làm gì
|
|
81
|
+
|
|
82
|
+
Phục vụ chính request đó, cộng với vận hành và tính phí: đối soát khi có sự cố,
|
|
83
|
+
đếm chi tiêu theo mandate, xuất hoá đơn. Không bán cho ai, và không dùng nội
|
|
84
|
+
dung truy vấn của bạn để huấn luyện mô hình.
|
|
85
|
+
|
|
86
|
+
### Yêu cầu xoá, hoặc khiếu nại
|
|
87
|
+
|
|
88
|
+
`mcp-security@datacore.vn`. Nói rõ mandate id hoặc client id để chúng tôi tìm
|
|
89
|
+
đúng bản ghi — chúng tôi không tra được theo nội dung truy vấn.
|
|
90
|
+
|
|
91
|
+
## Liên hệ
|
|
92
|
+
|
|
93
|
+
Vấn đề bảo mật hoặc quyền riêng tư: `mcp-security@datacore.vn`
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# DataCore A2A — MCP server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: vn.datacore/datacore-a2a -->
|
|
4
|
+
<!-- Marker dự phòng: registry tìm chuỗi con, nên hai dòng cùng tồn tại được.
|
|
5
|
+
Giữ đường lùi nếu xác minh tên miền không xong, khỏi phải phát hành
|
|
6
|
+
một version mới chỉ để sửa một dòng comment.
|
|
7
|
+
mcp-name: io.github.nambn3tit/datacore-a2a-mcp -->
|
|
8
|
+
|
|
9
|
+
Cho phép một MCP client (Claude Code, Claude Desktop, VS Code, Cursor…) dùng
|
|
10
|
+
các skill dữ liệu của DataCore như tool native: chuẩn hoá địa chỉ Việt Nam,
|
|
11
|
+
kiểm tra địa chỉ, tra cứu doanh nghiệp, sinh embedding.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
MCP client (stdio) ──► datacore-mcp ──HTTPS──► DataCore API
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Zero dependency, Python 3.9+.
|
|
18
|
+
|
|
19
|
+
## Cài đặt
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install datacore-a2a-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Cần có trước
|
|
26
|
+
|
|
27
|
+
Server này là **client**, không phải dịch vụ. Nó cần một endpoint DataCore và
|
|
28
|
+
một mandate token để gọi — liên hệ 3TIT để được cấp. Không có hai thứ đó thì
|
|
29
|
+
server khởi động rồi thoát ngay kèm thông báo thiếu biến nào.
|
|
30
|
+
|
|
31
|
+
## Cấu hình
|
|
32
|
+
|
|
33
|
+
| Biến | Bắt buộc | Ý nghĩa |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `DATACORE_A2A_URL` | có | Base URL của endpoint DataCore được cấp cho bạn, ví dụ `https://api.example.com` |
|
|
36
|
+
| `DATACORE_MANDATE` | có | AP2 mandate token do DataCore cấp — quyết định skill nào được gọi và hạn mức chi tiêu |
|
|
37
|
+
| `DATACORE_SKILLS` | không | Danh sách skill id cách nhau bằng phẩy để giới hạn tool, hoặc `*` để lấy hết. Bỏ trống dùng mặc định |
|
|
38
|
+
| `DATACORE_TIMEOUT` | không | Timeout mỗi request (giây), mặc định `30` |
|
|
39
|
+
|
|
40
|
+
Dùng đúng URL DataCore cấp kèm mandate. Một endpoint khác sẽ không nhận mandate
|
|
41
|
+
của bạn và trả `401`.
|
|
42
|
+
|
|
43
|
+
### Claude Code / Claude Desktop
|
|
44
|
+
|
|
45
|
+
Thêm vào `.mcp.json` (project) hoặc config của Claude Desktop:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"datacore-a2a": {
|
|
51
|
+
"command": "datacore-mcp",
|
|
52
|
+
"env": {
|
|
53
|
+
"DATACORE_A2A_URL": "https://api.example.com",
|
|
54
|
+
"DATACORE_MANDATE": "<mandate-token>"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Tool
|
|
62
|
+
|
|
63
|
+
Tool list được **sinh từ endpoint**, không hardcode ở đây — nên khi DataCore
|
|
64
|
+
thêm skill, restart server là thấy tool mới.
|
|
65
|
+
|
|
66
|
+
Mặc định expose 4 skill đã nối backend thật:
|
|
67
|
+
|
|
68
|
+
| Tool | Skill id | Việc nó làm |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `address_normalize` | `address.normalize` | Chuẩn hoá địa chỉ Việt Nam thành tỉnh / huyện / xã |
|
|
71
|
+
| `address_validate` | `address.validate` | Kiểm tra một địa chỉ có tồn tại theo dữ liệu hành chính |
|
|
72
|
+
| `company_search` | `company.search` | Tra cứu doanh nghiệp theo tên hoặc mã số thuế |
|
|
73
|
+
| `company_embeddings` | `company.embeddings` | Sinh vector embedding cho văn bản |
|
|
74
|
+
|
|
75
|
+
Mọi tool là **read-only** (`readOnlyHint: true`): không ghi, không xoá, không
|
|
76
|
+
tạo gì. Gọi lại cùng input cho cùng kết quả.
|
|
77
|
+
|
|
78
|
+
Ba skill khác của DataCore (`company.graph`, `company.financial-sankey`,
|
|
79
|
+
`company.news`) **cố ý không được expose**: chúng đang trả dữ liệu
|
|
80
|
+
mock/deterministic. Muốn thấy chúng để thử thì đặt `DATACORE_SKILLS="*"`, và
|
|
81
|
+
đừng tin kết quả.
|
|
82
|
+
|
|
83
|
+
## Ví dụ
|
|
84
|
+
|
|
85
|
+
Ba prompt dưới đây chạy qua ba tool khác nhau:
|
|
86
|
+
|
|
87
|
+
1. **Chuẩn hoá địa chỉ** — "Chuẩn hoá địa chỉ này thành tỉnh/huyện/xã: So 1 Ly
|
|
88
|
+
Thai To, Hoan Kiem, Ha Noi"
|
|
89
|
+
2. **Tra cứu doanh nghiệp** — "Tìm thông tin doanh nghiệp có mã số thuế
|
|
90
|
+
0100109106 và cho tôi biết trạng thái hoạt động"
|
|
91
|
+
3. **Embedding** — "Sinh embedding cho đoạn mô tả ngành nghề này rồi cho biết
|
|
92
|
+
vector có bao nhiêu chiều: bán buôn thiết bị điện tử"
|
|
93
|
+
|
|
94
|
+
## Mô hình bảo mật
|
|
95
|
+
|
|
96
|
+
Server này mang mandate token **nguyên dạng** ở header `X-AP2-Mandate` để phía
|
|
97
|
+
DataCore verify, và không tự khai bất kỳ header authorization nào khác. Một
|
|
98
|
+
client không được là nơi tự cấp quyền cho chính nó — nó chỉ chuyển tiếp thứ nó
|
|
99
|
+
được cấp, còn mọi quyết định cho phép hay từ chối đều nằm ở phía DataCore.
|
|
100
|
+
|
|
101
|
+
Mandate là **bearer credential**: ai giữ nó cũng gọi được đúng những skill trong
|
|
102
|
+
đó, tới hết hạn mức, cho tới khi hết hạn. Giữ nó như giữ mật khẩu — đừng commit
|
|
103
|
+
`.mcp.json` đã điền token, và nếu client của bạn hỗ trợ, đọc nó từ trình quản lý
|
|
104
|
+
secret thay vì để inline.
|
|
105
|
+
|
|
106
|
+
**Package này** không bao giờ ghi token ra log, kể cả một phần; nội dung `text`
|
|
107
|
+
bạn gửi cũng không. Đó là cam kết cho tiến trình chạy trên máy bạn — phía dịch
|
|
108
|
+
vụ là chuyện khác, và [PRIVACY.md](PRIVACY.md) nói thẳng nó lưu những gì.
|
|
109
|
+
|
|
110
|
+
## Xử lý sự cố
|
|
111
|
+
|
|
112
|
+
Log đi ra **stderr**, prefix `[datacore-mcp]`. stdout là kênh JSON-RPC.
|
|
113
|
+
|
|
114
|
+
| Triệu chứng | Nguyên nhân thường gặp |
|
|
115
|
+
|---|---|
|
|
116
|
+
| Server thoát ngay, `THIẾU biến môi trường bắt buộc` | Chưa set `DATACORE_A2A_URL` hoặc `DATACORE_MANDATE` |
|
|
117
|
+
| `401` mọi request | Mandate hết hạn, hoặc `DATACORE_A2A_URL` không phải endpoint đã cấp kèm mandate đó |
|
|
118
|
+
| `402` | Đã dùng hết spend cap của mandate |
|
|
119
|
+
| `403` | Skill nằm ngoài allowlist của mandate — xin mandate có skill đó |
|
|
120
|
+
| `502` | Backend của skill đó phía DataCore không phản hồi |
|
|
121
|
+
| Không thấy tool nào | `DATACORE_SKILLS` lọc hết, hoặc endpoint chưa đăng ký skill nào |
|
|
122
|
+
|
|
123
|
+
## Báo lỗi bảo mật
|
|
124
|
+
|
|
125
|
+
`mcp-security@datacore.vn`. Đừng mở issue công khai cho lỗi bảo mật.
|
|
126
|
+
|
|
127
|
+
Nghi mandate của mình bị lộ thì **báo ngay** — đừng đợi tới lúc nó hết hạn.
|
|
128
|
+
Chúng tôi cần biết để xử lý phía dịch vụ.
|
|
129
|
+
|
|
130
|
+
## Quyền riêng tư
|
|
131
|
+
|
|
132
|
+
Xem [PRIVACY.md](PRIVACY.md). Ngắn gọn: package này chạy trên máy bạn, không lưu
|
|
133
|
+
gì, không gửi đi đâu ngoài endpoint DataCore bạn tự cấu hình, và không bao giờ
|
|
134
|
+
ghi mandate token vào log.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT — xem [LICENSE](LICENSE).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Tên xác nhận còn trống 2026-08-24 (pypi.org/pypi/datacore-a2a-mcp/json → 404).
|
|
7
|
+
# Tên trên PyPI là vĩnh viễn, không đổi được sau khi đã upload.
|
|
8
|
+
name = "datacore-a2a-mcp"
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "MCP server cho các skill dữ liệu DataCore: chuẩn hoá địa chỉ Việt Nam và tra cứu doanh nghiệp"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
|
|
14
|
+
# Cố ý rỗng. Zero dependency là tính năng, không phải thiếu sót: MCP stdio chỉ
|
|
15
|
+
# là JSON-RPC 2.0 phân cách bằng newline, nên `pip install` không kéo theo gì
|
|
16
|
+
# và cũng không có dependency nào để bị CVE.
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
keywords = ["mcp", "model-context-protocol", "a2a", "vietnam", "address", "company-data"]
|
|
20
|
+
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Environment :: Console",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# MIT: package này là một wrapper mỏng không chứa business logic, nên điều khoản
|
|
31
|
+
# patent của Apache-2.0 không mua thêm gì đáng kể, và MIT thì ngắn, quen mắt,
|
|
32
|
+
# không làm ai phải đọc kỹ trước khi cài. Đổi sang Apache-2.0 nếu muốn có
|
|
33
|
+
# patent grant tường minh — chỉ cần thay file LICENSE và dòng dưới.
|
|
34
|
+
#
|
|
35
|
+
# Dạng SPDX string này cần hatchling >= 1.27 (PEP 639). Build lỗi ở chỗ này thì
|
|
36
|
+
# đổi về: license = { file = "LICENSE" } và thêm lại classifier
|
|
37
|
+
# "License :: OSI Approved :: MIT License".
|
|
38
|
+
license = "MIT"
|
|
39
|
+
license-files = ["LICENSE"]
|
|
40
|
+
|
|
41
|
+
[[project.authors]]
|
|
42
|
+
name = "3TIT"
|
|
43
|
+
# ⚠️ Hộp thư này phải tồn tại thật trước khi publish — PyPI và registry đều hiện
|
|
44
|
+
# nó ra, và đây là đường người ta báo lỗi bảo mật. Alias vào đâu cũng được.
|
|
45
|
+
email = "mcp-security@datacore.vn"
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
datacore-mcp = "datacore_mcp.server:main"
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
Homepage = "https://github.com/DataCore-VietNam/datacore-a2a-mcp"
|
|
52
|
+
Documentation = "https://github.com/DataCore-VietNam/datacore-a2a-mcp#readme"
|
|
53
|
+
# PRIVACY.md trong repo, không phải một trang riêng — đủ cho Anthropic và không
|
|
54
|
+
# thêm một chỗ nữa để quên cập nhật. Phần nói về package đã đầy đủ; phần nói về
|
|
55
|
+
# dịch vụ DataCore còn một khối TODO phải điền trước khi submit Anthropic.
|
|
56
|
+
"Privacy Policy" = "https://github.com/DataCore-VietNam/datacore-a2a-mcp/blob/master/PRIVACY.md"
|
|
57
|
+
Issues = "https://github.com/DataCore-VietNam/datacore-a2a-mcp/issues"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.version]
|
|
60
|
+
path = "src/datacore_mcp/__init__.py"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/datacore_mcp"]
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.sdist]
|
|
66
|
+
# sdist mà PyPI phát ra chứa đúng những gì liệt kê ở đây. Giữ danh sách hẹp:
|
|
67
|
+
# mọi file lọt vào đây là file công khai.
|
|
68
|
+
#
|
|
69
|
+
# PRIVACY.md có trong danh sách vì người chỉ tải sdist vẫn phải đọc được nó.
|
|
70
|
+
# Runbook phát hành cố ý không nằm trong repo này — nó là tài liệu nội bộ, không
|
|
71
|
+
# phải tài liệu cho người dùng package.
|
|
72
|
+
include = ["src/datacore_mcp", "README.md", "PRIVACY.md", "pyproject.toml"]
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP server cho DataCore: mỗi skill dữ liệu thành một MCP tool.
|
|
4
|
+
|
|
5
|
+
MCP client (stdio) ──► server này ──HTTPS──► DataCore API
|
|
6
|
+
|
|
7
|
+
Mandate token được mang **nguyên dạng** ở header ``X-AP2-Mandate`` để phía
|
|
8
|
+
DataCore verify. Server này không bao giờ tự khai bất kỳ header authorization
|
|
9
|
+
nào khác: một client không được là nơi tự cấp quyền cho chính nó, nên nó chỉ
|
|
10
|
+
chuyển tiếp thứ nó được cấp.
|
|
11
|
+
|
|
12
|
+
Tool list sinh **từ endpoint** chứ không hardcode ở đây, nên DataCore thêm
|
|
13
|
+
skill là client thấy tool mới sau khi restart server này.
|
|
14
|
+
|
|
15
|
+
Zero dependency, Python 3.9+. MCP stdio chỉ là JSON-RPC 2.0 phân cách bằng
|
|
16
|
+
newline nên không cần SDK — và quan trọng hơn, người cài không phải kéo theo
|
|
17
|
+
cây dependency nào.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from . import __version__
|
|
30
|
+
except ImportError: # chạy trực tiếp file này, ngoài package
|
|
31
|
+
__version__ = "0.0.0+local"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------ cấu hình
|
|
35
|
+
|
|
36
|
+
#: Skill được expose ra tool. Cố ý là danh sách trắng tường minh chứ không
|
|
37
|
+
#: phải "mọi thứ endpoint trả về": ``company.graph``,
|
|
38
|
+
#: ``company.financial-sankey`` và ``company.news`` hiện trả dữ liệu
|
|
39
|
+
#: demo/deterministic, nên đưa chúng ra ngoài là mô tả dữ liệu giả như dữ liệu
|
|
40
|
+
#: thật — lý do bị registry từ chối phổ biến nhất. Chuyển sang đây khi từng
|
|
41
|
+
#: skill có dữ liệu thật.
|
|
42
|
+
DEFAULT_SKILLS = (
|
|
43
|
+
"address.normalize",
|
|
44
|
+
"address.validate",
|
|
45
|
+
"company.search",
|
|
46
|
+
"company.embeddings",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
PROTOCOL_FALLBACK = "2025-06-18"
|
|
50
|
+
|
|
51
|
+
# Tên tool MCP không nhận dấu chấm, mà skill id thì có ("address.normalize").
|
|
52
|
+
# Đổi sang '_' và giữ map ngược lại — cái gửi lên phải là id thật.
|
|
53
|
+
_TOOL_NAME_SAFE = re.compile(r"[^a-zA-Z0-9_-]")
|
|
54
|
+
|
|
55
|
+
_skill_by_tool_name = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def log(msg):
|
|
59
|
+
"""stderr, không phải stdout — stdout là kênh JSON-RPC, ghi gì vào đó là hỏng protocol."""
|
|
60
|
+
print(f"[datacore-mcp] {msg}", file=sys.stderr, flush=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require(name, what, example):
|
|
64
|
+
"""
|
|
65
|
+
Env bắt buộc, không có default. Bản trước default ``A2A_BASE_URL`` về một
|
|
66
|
+
IP LAN của người viết; ai cài cũng gặp timeout mà không hiểu vì sao. Thiếu
|
|
67
|
+
cấu hình là lỗi không tự phục hồi được, nên fail ngay và nói rõ thiếu gì.
|
|
68
|
+
"""
|
|
69
|
+
value = os.environ.get(name, "").strip()
|
|
70
|
+
if not value:
|
|
71
|
+
log(f"THIẾU biến môi trường bắt buộc {name} ({what}).")
|
|
72
|
+
log(f" ví dụ: {name}={example}")
|
|
73
|
+
log(" xem README để biết cách lấy mandate và URL endpoint.")
|
|
74
|
+
sys.exit(2)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config():
|
|
79
|
+
base_url = _require(
|
|
80
|
+
"DATACORE_A2A_URL",
|
|
81
|
+
"base URL của endpoint DataCore",
|
|
82
|
+
"https://api.example.com",
|
|
83
|
+
).rstrip("/")
|
|
84
|
+
mandate = _require(
|
|
85
|
+
"DATACORE_MANDATE",
|
|
86
|
+
"AP2 mandate token do DataCore cấp",
|
|
87
|
+
"<jws-token>",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
raw_skills = os.environ.get("DATACORE_SKILLS", "").strip()
|
|
91
|
+
if raw_skills == "*":
|
|
92
|
+
skills = None # None = không lọc, lấy hết những gì endpoint trả về
|
|
93
|
+
elif raw_skills:
|
|
94
|
+
skills = tuple(s.strip() for s in raw_skills.split(",") if s.strip())
|
|
95
|
+
else:
|
|
96
|
+
skills = DEFAULT_SKILLS
|
|
97
|
+
|
|
98
|
+
timeout = float(os.environ.get("DATACORE_TIMEOUT", "30"))
|
|
99
|
+
return base_url, mandate, skills, timeout
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
BASE_URL, MANDATE, SKILL_FILTER, TIMEOUT = "", "", DEFAULT_SKILLS, 30.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ HTTP
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class A2AError(Exception):
|
|
109
|
+
"""DataCore trả về HTTP lỗi. ``status`` là mã, ``detail`` là body."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, status, detail):
|
|
112
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
113
|
+
self.status = status
|
|
114
|
+
self.detail = detail
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
#: Mã status DataCore trả về, dịch sang câu người đọc hiểu được thay vì để
|
|
118
|
+
#: client tự đoán ý nghĩa của một con số như 402.
|
|
119
|
+
_STATUS_HINT = {
|
|
120
|
+
400: "request không hợp lệ, hoặc skill chưa được đăng ký",
|
|
121
|
+
401: (
|
|
122
|
+
"mandate thiếu hoặc không hợp lệ. Kiểm tra DATACORE_MANDATE còn hạn, và "
|
|
123
|
+
"kiểm tra DATACORE_A2A_URL đúng là endpoint DataCore đã được cấp"
|
|
124
|
+
),
|
|
125
|
+
402: "vượt spend cap của mandate — cần mandate mới hoặc cap cao hơn",
|
|
126
|
+
403: "skill nằm ngoài allowlist của mandate",
|
|
127
|
+
404: "không tìm thấy route — kiểm tra lại DATACORE_A2A_URL",
|
|
128
|
+
502: "backend của skill này không phản hồi",
|
|
129
|
+
503: "dịch vụ tạm thời không khả dụng",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _error_detail(exc):
|
|
134
|
+
"""
|
|
135
|
+
Lấy field ``error`` trong body lỗi. Body có thể không phải JSON (một proxy
|
|
136
|
+
chen vào và trả HTML chẳng hạn), nên phải chịu được cả trường hợp đó thay
|
|
137
|
+
vì ném tiếp một lỗi parse che mất lỗi gốc.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
body = exc.read().decode("utf-8", "replace")
|
|
141
|
+
except Exception:
|
|
142
|
+
return exc.reason or "không có chi tiết"
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(body)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return (body[:300] or exc.reason or "không có chi tiết").strip()
|
|
147
|
+
if isinstance(parsed, dict):
|
|
148
|
+
return parsed.get("error") or parsed.get("message") or json.dumps(parsed)[:300]
|
|
149
|
+
return body[:300]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def http_json(path, payload=None):
|
|
153
|
+
"""
|
|
154
|
+
Một chỗ duy nhất gắn ``X-AP2-Mandate``. Token đi cả trên discovery lẫn
|
|
155
|
+
invoke, vì cả hai đều là request được authorize — discovery không mang
|
|
156
|
+
mandate cũng bị từ chối 401.
|
|
157
|
+
"""
|
|
158
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
159
|
+
headers = {"Accept": "application/json", "X-AP2-Mandate": MANDATE}
|
|
160
|
+
if data is not None:
|
|
161
|
+
headers["Content-Type"] = "application/json"
|
|
162
|
+
|
|
163
|
+
req = urllib.request.Request(
|
|
164
|
+
f"{BASE_URL}{path}",
|
|
165
|
+
data=data,
|
|
166
|
+
headers=headers,
|
|
167
|
+
method="POST" if data is not None else "GET",
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
171
|
+
return json.loads(resp.read().decode())
|
|
172
|
+
except urllib.error.HTTPError as e:
|
|
173
|
+
raise A2AError(e.code, _error_detail(e)) from None
|
|
174
|
+
except urllib.error.URLError as e:
|
|
175
|
+
raise RuntimeError(f"không kết nối được tới {BASE_URL}: {e.reason}") from None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ discovery
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def tool_name_for(skill_id):
|
|
182
|
+
return _TOOL_NAME_SAFE.sub("_", skill_id)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def discover_tools():
|
|
186
|
+
"""
|
|
187
|
+
Sinh tool list từ endpoint discovery.
|
|
188
|
+
|
|
189
|
+
Endpoint trả kèm vài field mô tả hạ tầng phía DataCore; chúng bị bỏ đi có
|
|
190
|
+
chủ ý. Địa chỉ nội bộ và thông điệp lỗi hạ tầng không có việc gì phải đi
|
|
191
|
+
tới máy người dùng, và càng không thuộc về một tool description mà model
|
|
192
|
+
sẽ đọc.
|
|
193
|
+
"""
|
|
194
|
+
entries = http_json("/orchestrate/skills")
|
|
195
|
+
if not isinstance(entries, list):
|
|
196
|
+
raise RuntimeError(f"/orchestrate/skills trả về {type(entries).__name__}, cần một list")
|
|
197
|
+
|
|
198
|
+
_skill_by_tool_name.clear()
|
|
199
|
+
tools = []
|
|
200
|
+
skipped = []
|
|
201
|
+
|
|
202
|
+
for entry in entries:
|
|
203
|
+
skill_id = (entry or {}).get("id")
|
|
204
|
+
if not skill_id:
|
|
205
|
+
continue
|
|
206
|
+
if SKILL_FILTER is not None and skill_id not in SKILL_FILTER:
|
|
207
|
+
skipped.append(skill_id)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
name = tool_name_for(skill_id)
|
|
211
|
+
_skill_by_tool_name[name] = skill_id
|
|
212
|
+
|
|
213
|
+
# `name` của endpoint là mô tả ngắn của chính skill; nó là title hiển
|
|
214
|
+
# thị tốt hơn skill id. Rơi về id khi endpoint không trả được mô tả.
|
|
215
|
+
title = entry.get("name") or skill_id
|
|
216
|
+
description = entry.get("description") or f"DataCore skill {skill_id}"
|
|
217
|
+
examples = entry.get("examples") or []
|
|
218
|
+
if examples:
|
|
219
|
+
description += "\n\nVí dụ input: " + "; ".join(str(e) for e in examples[:3])
|
|
220
|
+
if entry.get("available") is False:
|
|
221
|
+
description += (
|
|
222
|
+
"\n\n(Agent giữ skill này đang không phản hồi. Skill id vẫn đúng "
|
|
223
|
+
"và gọi được khi agent trở lại.)"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
tools.append({
|
|
227
|
+
"name": name,
|
|
228
|
+
"title": title,
|
|
229
|
+
"description": description,
|
|
230
|
+
# Bốn hint đều là sự thật về skill, không phải khai cho đủ: mọi
|
|
231
|
+
# skill hiện tại là tra cứu thuần, không ghi gì, gọi lại cùng input
|
|
232
|
+
# cho cùng kết quả, và dữ liệu đến từ backend ngoài process này.
|
|
233
|
+
"annotations": {
|
|
234
|
+
"title": title,
|
|
235
|
+
"readOnlyHint": True,
|
|
236
|
+
"destructiveHint": False,
|
|
237
|
+
"idempotentHint": True,
|
|
238
|
+
"openWorldHint": True,
|
|
239
|
+
},
|
|
240
|
+
"inputSchema": {
|
|
241
|
+
"type": "object",
|
|
242
|
+
"properties": {
|
|
243
|
+
"text": {
|
|
244
|
+
"type": "string",
|
|
245
|
+
"description": "Input dạng free-text gửi cho skill.",
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"required": ["text"],
|
|
249
|
+
"additionalProperties": False,
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
log(f"{len(tools)} tool từ {BASE_URL}: {sorted(_skill_by_tool_name.values())}")
|
|
254
|
+
if skipped:
|
|
255
|
+
log(f"bỏ qua {len(skipped)} skill ngoài DATACORE_SKILLS: {sorted(skipped)}")
|
|
256
|
+
return tools
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def call_skill(skill_id, text):
|
|
260
|
+
"""Gọi một skill và trả về payload thô mà DataCore trả lại."""
|
|
261
|
+
body = http_json("/orchestrate", {"skill": skill_id, "text": text})
|
|
262
|
+
|
|
263
|
+
payload = (body or {}).get("payload")
|
|
264
|
+
state = (body or {}).get("state")
|
|
265
|
+
if not payload:
|
|
266
|
+
raise RuntimeError(f"DataCore trả state={state} nhưng không có payload")
|
|
267
|
+
return payload
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------ MCP
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _tool_error(exc):
|
|
274
|
+
"""Câu lỗi cho Claude đọc: mã status → nguyên nhân, kèm chi tiết của server."""
|
|
275
|
+
if isinstance(exc, A2AError):
|
|
276
|
+
hint = _STATUS_HINT.get(exc.status, "lỗi từ DataCore")
|
|
277
|
+
return f"{hint} (HTTP {exc.status}): {exc.detail}"
|
|
278
|
+
return str(exc)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def handle(method, params):
|
|
282
|
+
"""
|
|
283
|
+
Trả về ``(result, error)``. Chỉ implement những method client thực sự gọi
|
|
284
|
+
— initialize / tools/list / tools/call — cộng ping. Method khác trả -32601.
|
|
285
|
+
"""
|
|
286
|
+
if method == "initialize":
|
|
287
|
+
requested = params.get("protocolVersion")
|
|
288
|
+
return {
|
|
289
|
+
# Echo lại version client xin nếu là string: server không dùng gì
|
|
290
|
+
# đặc thù theo version, nên khớp client là lựa chọn an toàn nhất.
|
|
291
|
+
"protocolVersion": requested if isinstance(requested, str) else PROTOCOL_FALLBACK,
|
|
292
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
293
|
+
"serverInfo": {"name": "datacore-a2a", "version": __version__},
|
|
294
|
+
}, None
|
|
295
|
+
|
|
296
|
+
if method == "ping":
|
|
297
|
+
return {}, None
|
|
298
|
+
|
|
299
|
+
if method == "tools/list":
|
|
300
|
+
try:
|
|
301
|
+
return {"tools": discover_tools()}, None
|
|
302
|
+
except Exception as e:
|
|
303
|
+
# Không với tới endpoint được là lỗi cấu hình, không phải lỗi
|
|
304
|
+
# protocol — trả error để client hiện lý do thật.
|
|
305
|
+
return None, {"code": -32603, "message": f"Discovery thất bại: {_tool_error(e)}"}
|
|
306
|
+
|
|
307
|
+
if method == "tools/call":
|
|
308
|
+
name = params.get("name")
|
|
309
|
+
args = params.get("arguments") or {}
|
|
310
|
+
text = args.get("text")
|
|
311
|
+
|
|
312
|
+
if not _skill_by_tool_name:
|
|
313
|
+
try:
|
|
314
|
+
discover_tools()
|
|
315
|
+
except Exception as e:
|
|
316
|
+
return None, {"code": -32603, "message": f"Discovery thất bại: {_tool_error(e)}"}
|
|
317
|
+
|
|
318
|
+
skill_id = _skill_by_tool_name.get(name)
|
|
319
|
+
if not skill_id:
|
|
320
|
+
return None, {"code": -32602, "message": f"Tool không tồn tại: {name}"}
|
|
321
|
+
if not isinstance(text, str) or not text.strip():
|
|
322
|
+
return None, {"code": -32602, "message": "Thiếu tham số 'text'"}
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
return {"content": [{"type": "text", "text": call_skill(skill_id, text)}]}, None
|
|
326
|
+
except Exception as e:
|
|
327
|
+
# isError=true để Claude thấy tool thất bại và tự điều chỉnh, thay
|
|
328
|
+
# vì tưởng chuỗi lỗi là kết quả hợp lệ.
|
|
329
|
+
message = _tool_error(e)
|
|
330
|
+
log(f"tool {name} lỗi: {message}")
|
|
331
|
+
return {"content": [{"type": "text", "text": message}], "isError": True}, None
|
|
332
|
+
|
|
333
|
+
return None, {"code": -32601, "message": f"Method không hỗ trợ: {method}"}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def serve():
|
|
337
|
+
for line in sys.stdin:
|
|
338
|
+
line = line.strip()
|
|
339
|
+
if not line:
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
request = json.loads(line)
|
|
343
|
+
except json.JSONDecodeError as e:
|
|
344
|
+
log(f"bỏ qua dòng không phải JSON: {e}")
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
req_id = request.get("id")
|
|
348
|
+
method = request.get("method")
|
|
349
|
+
|
|
350
|
+
# Notification (không có id) — theo spec KHÔNG được trả lời.
|
|
351
|
+
# notifications/initialized rơi vào đây.
|
|
352
|
+
if req_id is None:
|
|
353
|
+
log(f"notification: {method}")
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
result, error = handle(method, request.get("params") or {})
|
|
358
|
+
except Exception as e:
|
|
359
|
+
result, error = None, {"code": -32603, "message": f"Lỗi nội bộ: {e}"}
|
|
360
|
+
|
|
361
|
+
response = {"jsonrpc": "2.0", "id": req_id}
|
|
362
|
+
if error:
|
|
363
|
+
response["error"] = error
|
|
364
|
+
else:
|
|
365
|
+
response["result"] = result
|
|
366
|
+
|
|
367
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
368
|
+
sys.stdout.flush()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main():
|
|
372
|
+
global BASE_URL, MANDATE, SKILL_FILTER, TIMEOUT
|
|
373
|
+
BASE_URL, MANDATE, SKILL_FILTER, TIMEOUT = load_config()
|
|
374
|
+
|
|
375
|
+
# Không log MANDATE, kể cả một phần của nó: đây là token cấp quyền chi
|
|
376
|
+
# tiêu, và stderr của MCP server thường được client ghi ra file log.
|
|
377
|
+
scope = "tất cả skill của endpoint" if SKILL_FILTER is None else ", ".join(SKILL_FILTER)
|
|
378
|
+
log(f"start v{__version__} | base={BASE_URL} | timeout={TIMEOUT}s | skill={scope}")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
serve()
|
|
382
|
+
except (KeyboardInterrupt, BrokenPipeError):
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
main()
|