From 310f5a3f85704e7badffad52e114e686cf5d156d Mon Sep 17 00:00:00 2001 From: linsamtw Date: Mon, 1 Jun 2026 13:31:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(list=5Fdatasets):=20=E6=94=B9=E8=AE=80?= =?UTF-8?q?=E5=85=A7=E5=BB=BA=20dataset=20=E7=9B=AE=E9=8C=84=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A3=9E=E6=8E=89=E7=9A=84=20/datalist=20?= =?UTF-8?q?=E5=91=BC=E5=8F=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:list_datasets 工具與 ChatGPT Action 都呼叫 /api/v4/datalist 想取得 「所有 dataset 名稱」,但該 endpoint 實際回傳的是國家清單 (["Canda","China","Euro","Japan","Taiwan","UK"]),並非 dataset。 FinMind 根本沒有「列出所有 dataset」的 API;/datalist 的真正用途是列出 某 dataset 底下的 data_id。此 bug 已隨 PyPI 0.0.2 上線:MCP 使用者呼叫 list_datasets 會拿到無意義的國家名單,Custom GPT 呼叫 listDatasets 也拿到 同樣結果而退回用知識庫猜測回答。 修正方向:dataset 總表本來就由 knowledge/datasets.md 提供(與 Custom GPT knowledge bundle 同一個 SSOT),list_datasets 改為讀取該檔,不再打 API。 變更檔案: - src/finmind_mcp/knowledge.py:新增 dataset_catalog(),解析 datasets.md 的 ## 分類 / ### dataset / Tier / 描述,回傳 90 筆有序記錄;datasets.md 缺檔時回 [](CI 無 knowledge/ 時可優雅降級)。 - src/finmind_mcp/tools.py:_list_datasets 改呼叫 knowledge.dataset_catalog() 並依分類輸出名稱+層級+說明;不再呼叫 client;更新工具描述與模組 docstring。 - src/finmind_mcp/client.py:移除 list_datasets()(壞掉的 /v4/datalist 呼叫)。 - chatgpt/openapi.yaml:移除 listDatasets operation、/v4/datalist path 與 DatasetList schema;只保留 queryDataset(GPT 用 knowledge bundle 認 dataset)。 - knowledge/instructions.md:移除「/datalist — 列出可用 dataset」錯誤敘述, 改註明 FinMind 無此 API、完整清單見 knowledge_bundle.md。 - docs/spec.md、docs/mcp-original-readme.md:同步修正工具↔endpoint 對照表。 測試: - tests/test_knowledge.py(新增):驗 dataset_catalog 解析 ≥80 筆、含必要欄位、 Tier 說明 legend 不產生記錄、TaiwanStockPrice 為 Free。 - tests/test_tools.py:list_datasets 測試改驗從知識庫讀取且 client.calls 為空; FakeClient 移除 list_datasets/list_result。 - tests/test_client.py:移除 test_list_datasets;test_token_from_env 改用 query_dataset 驗證 env token。 驗證:32 passed、smoke OK、build_instructions 7986/8000 chars、openapi 驗證通過。 Co-Authored-By: Claude Opus 4.8 (1M context) --- chatgpt/openapi.yaml | 25 ----------------------- docs/mcp-original-readme.md | 2 +- docs/spec.md | 5 +++-- knowledge/instructions.md | 3 ++- src/finmind_mcp/client.py | 6 ------ src/finmind_mcp/knowledge.py | 39 ++++++++++++++++++++++++++++++++++++ src/finmind_mcp/tools.py | 26 ++++++++++++++++-------- tests/test_client.py | 25 ++++++++--------------- tests/test_knowledge.py | 25 +++++++++++++++++++++++ tests/test_tools.py | 19 +++++++++--------- 10 files changed, 105 insertions(+), 70 deletions(-) create mode 100644 tests/test_knowledge.py diff --git a/chatgpt/openapi.yaml b/chatgpt/openapi.yaml index 210b2e5..ceeb370 100644 --- a/chatgpt/openapi.yaml +++ b/chatgpt/openapi.yaml @@ -71,22 +71,6 @@ paths: $ref: "#/components/responses/PaymentRequired" "5XX": $ref: "#/components/responses/UpstreamError" - /v4/datalist: - get: - operationId: listDatasets - summary: 列出可用 dataset - description: 回傳目前 token 可存取的 dataset 清單。 - responses: - "200": - description: 查詢成功 - content: - application/json: - schema: - $ref: "#/components/schemas/DatasetList" - "401": - $ref: "#/components/responses/Unauthorized" - "5XX": - $ref: "#/components/responses/UpstreamError" components: securitySchemes: bearerAuth: @@ -108,15 +92,6 @@ components: items: type: object additionalProperties: true - DatasetList: - type: object - properties: - status: - type: integer - data: - type: array - items: - type: string Error: type: object properties: diff --git a/docs/mcp-original-readme.md b/docs/mcp-original-readme.md index b4b8771..e645670 100644 --- a/docs/mcp-original-readme.md +++ b/docs/mcp-original-readme.md @@ -8,7 +8,7 @@ AI tools (Claude Desktop / Code, Gemini CLI, Cursor, Windsurf, Codex). | Tool | Endpoint | Required args | | --- | --- | --- | | `query_dataset` | `/api/v4/data` | `dataset` | -| `list_datasets` | `/api/v4/datalist` | — | +| `list_datasets` | bundled `knowledge/datasets.md`(無 API) | — | | `get_stock_info` | `/api/v4/data?dataset=TaiwanStockInfo` | — | | `query_trading_daily_report` | `/api/v4/taiwan_stock_trading_daily_report` | `data_id`, `date` | diff --git a/docs/spec.md b/docs/spec.md index 410ec7c..36d0fa1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -131,9 +131,10 @@ Custom GPT → markdown / Code Interpreter 畫圖 | Path | Method | 用途 | |---|---|---| | `/api/v4/data` | GET | 通用資料查詢 | -| `/api/v4/datalist` | GET | 列可用 dataset | | `/api/v4/taiwan_stock_trading_daily_report` | GET | dedicated endpoint dataset(如必要)| +> FinMind 無「列出所有 dataset」的 API;dataset 目錄由內建 `knowledge/datasets.md` 提供。 + Auth:Bearer token(HTTP `Authorization: Bearer ` header)。 完整 schema:`chatgpt/openapi.yaml`。 @@ -208,7 +209,7 @@ Claude.ai / Code / Desktop Gemini CLI / Cursor / Windsurf | Tool | 描述 | 對應 endpoint | |---|---|---| | `query_dataset` | 通用查詢,必填 `dataset` / `data_id` / `start_date` | `/api/v4/data` | -| `list_datasets` | 列可用 dataset 與層級 | `/api/v4/datalist` | +| `list_datasets` | 列可用 dataset 與層級 | 內建 `knowledge/datasets.md`(無 API) | | `query_trading_daily_report` | dedicated dataset,必填 `data_id` + 單日 `date` | `/api/v4/taiwan_stock_trading_daily_report` | | `get_stock_info` | 股票代號 ↔ 中文名查詢 | `/api/v4/data?dataset=TaiwanStockInfo` | diff --git a/knowledge/instructions.md b/knowledge/instructions.md index 6e44587..b860616 100644 --- a/knowledge/instructions.md +++ b/knowledge/instructions.md @@ -59,9 +59,10 @@ **主要 endpoint:** - `/data` — 通用資料查詢 -- `/datalist` — 列出可用 dataset - `/taiwan_stock_trading_daily_report` — 分點進出(dedicated,需 `data_id`+單日 `date`) +FinMind 沒有「列出所有 dataset」的 API;完整清單見 `knowledge_bundle.md`。 + **Token 傳遞方式:** - **ChatGPT(Custom GPT Action)**:以 `Authorization: Bearer ` header 傳遞,由 OpenAI Action 認證面板輸入。 - **MCP host(Claude、Cursor、Windsurf、Gemini CLI 等)**:透過環境變數 `FINMIND_TOKEN` 讀取,於 MCP server 設定檔指定。 diff --git a/src/finmind_mcp/client.py b/src/finmind_mcp/client.py index 87ffb72..83b59e8 100644 --- a/src/finmind_mcp/client.py +++ b/src/finmind_mcp/client.py @@ -70,12 +70,6 @@ async def query_dataset( ) return rows - async def list_datasets(self) -> list[str]: - """Call `/api/v4/datalist` and return the available dataset names.""" - data = await self._get_json("/v4/datalist", {}) - result = data.get("data", []) if isinstance(data, dict) else [] - return list(result) - async def query_trading_daily_report( self, data_id: str, diff --git a/src/finmind_mcp/knowledge.py b/src/finmind_mcp/knowledge.py index 0c5169e..30d7d3d 100644 --- a/src/finmind_mcp/knowledge.py +++ b/src/finmind_mcp/knowledge.py @@ -77,6 +77,45 @@ def read(uri: str) -> str: return path.read_text(encoding="utf-8") +def dataset_catalog() -> list[dict[str, str]]: + """Parse datasets.md into ordered {name, category, tier, desc} records. + + FinMind exposes no API endpoint that enumerates all datasets — `/datalist` + only lists the data_id values *within* a single dataset. So datasets.md is + the single source of truth for "what datasets exist", shared with the + Custom GPT knowledge bundle. Returns [] when datasets.md is absent (e.g. CI + without the knowledge/ tree), letting callers fall back gracefully. + """ + try: + content = read("finmind://datasets") + except FileNotFoundError: + return [] + records: list[dict[str, str]] = [] + category = "" + current: Optional[dict[str, str]] = None + for line in content.splitlines(): + if line.startswith("## "): + # Section header (e.g. "台股 - 技術面"); "Tier 說明" legend has no + # dataset entries so it never produces a record. + category = line[3:].strip() + current = None + elif line.startswith("### "): + current = { + "name": line[4:].strip(), + "category": category, + "tier": "", + "desc": "", + } + records.append(current) + elif current is not None: + stripped = line.strip() + if stripped.startswith("- **Tier:**"): + current["tier"] = stripped.split("**Tier:**", 1)[1].strip() + elif stripped.startswith("- **描述:**"): + current["desc"] = stripped.split("**描述:**", 1)[1].strip() + return records + + def load_errors() -> str: """Return errors.md contents for tools.py to format user-facing messages.""" try: diff --git a/src/finmind_mcp/tools.py b/src/finmind_mcp/tools.py index 44e09c2..83b2785 100644 --- a/src/finmind_mcp/tools.py +++ b/src/finmind_mcp/tools.py @@ -3,7 +3,7 @@ Four tools are exposed: - `query_dataset` — generic /api/v4/data query -- `list_datasets` — /api/v4/datalist +- `list_datasets` — bundled dataset catalog (knowledge/datasets.md) - `get_stock_info` — shorthand for TaiwanStockInfo - `query_trading_daily_report` — dedicated /api/v4/taiwan_stock_trading_daily_report @@ -79,8 +79,8 @@ def tool_definitions() -> list[Tool]: Tool( name="list_datasets", description=( - "列出 FinMind 目前可用的所有 dataset 名稱(呼叫 /api/v4/datalist)。" - "回傳 markdown 條列。" + "列出 FinMind 支援的所有 dataset(讀取內建知識庫,不需連線)。" + "依分類回傳 dataset 名稱、會員層級與說明的 markdown 條列。" ), inputSchema={"type": "object", "properties": {}}, ), @@ -169,12 +169,22 @@ async def _query_dataset(client: FinMindClient, args: dict[str, Any]) -> str: return _format_markdown_table(rows, title=dataset) -async def _list_datasets(client: FinMindClient, _args: dict[str, Any]) -> str: - names = await client.list_datasets() - if not names: +async def _list_datasets(_client: FinMindClient, _args: dict[str, Any]) -> str: + # FinMind has no "list all datasets" endpoint; read the bundled catalog + # (datasets.md) — the same SSOT as the Custom GPT knowledge bundle. + catalog = knowledge.dataset_catalog() + if not catalog: return "目前沒有可用的 dataset。" - body = "\n".join(f"- `{name}`" for name in names) - return f"### FinMind 可用 dataset(共 {len(names)} 個)\n\n{body}" + lines = [f"### FinMind 可用 dataset(共 {len(catalog)} 個)"] + current_cat: Optional[str] = None + for rec in catalog: + if rec["category"] != current_cat: + current_cat = rec["category"] + lines.append(f"\n**{current_cat}**") + tier = f"({rec['tier']})" if rec["tier"] else "" + desc = f" — {rec['desc']}" if rec["desc"] else "" + lines.append(f"- `{rec['name']}`{tier}{desc}") + return "\n".join(lines) async def _get_stock_info(client: FinMindClient, args: dict[str, Any]) -> str: diff --git a/tests/test_client.py b/tests/test_client.py index 1610c05..8af6c5c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -145,30 +145,21 @@ async def test_query_dataset_empty_raises_empty_error(): ) -@pytest.mark.asyncio -@respx.mock -async def test_list_datasets(): - respx.get("https://api.finmindtrade.com/api/v4/datalist").mock( - return_value=httpx.Response( - 200, json={"data": ["TaiwanStockPrice", "TaiwanStockInfo"]} - ) - ) - client = FinMindClient(token="t") - datasets = await client.list_datasets() - assert "TaiwanStockPrice" in datasets - assert "TaiwanStockInfo" in datasets - - @pytest.mark.asyncio @respx.mock async def test_token_from_env(monkeypatch): monkeypatch.setenv("FINMIND_TOKEN", "env-token") - respx.get("https://api.finmindtrade.com/api/v4/datalist").mock( - return_value=httpx.Response(200, json={"data": ["TaiwanStockPrice"]}) + respx.get("https://api.finmindtrade.com/api/v4/data").mock( + return_value=httpx.Response( + 200, json={"data": [{"stock_id": "2330", "close": 1000.0}]} + ) ) client = FinMindClient() # no token arg assert client.token == "env-token" - await client.list_datasets() + rows = await client.query_dataset( + dataset="TaiwanStockPrice", data_id="2330", start_date="2026-05-10" + ) + assert rows[0]["stock_id"] == "2330" def test_missing_token_raises_auth_error(monkeypatch): diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py new file mode 100644 index 0000000..843963b --- /dev/null +++ b/tests/test_knowledge.py @@ -0,0 +1,25 @@ +"""Tests for the knowledge-pack loader, focused on dataset_catalog parsing.""" + +from finmind_mcp import knowledge + + +def test_dataset_catalog_parses_datasets_md(): + catalog = knowledge.dataset_catalog() + # The shipped datasets.md lists ~90 datasets. + assert len(catalog) >= 80 + names = {rec["name"] for rec in catalog} + assert "TaiwanStockPrice" in names + assert "TaiwanStockInfo" in names + # Every record carries the expected keys. + for rec in catalog: + assert set(rec) == {"name", "category", "tier", "desc"} + assert rec["name"] + assert rec["category"] # each dataset sits under a "## " section + + +def test_dataset_catalog_captures_tier_and_category(): + by_name = {rec["name"]: rec for rec in knowledge.dataset_catalog()} + price = by_name["TaiwanStockPrice"] + assert "Free" in price["tier"] + # Legend section "Tier 說明" must never produce a dataset record. + assert "Tier 說明" not in {rec["category"] for rec in by_name.values()} diff --git a/tests/test_tools.py b/tests/test_tools.py index 4bc1895..8ecd2df 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -15,11 +15,10 @@ class FakeClient: """Test double for FinMindClient — records calls and yields canned responses.""" - def __init__(self, *, query_result=None, list_result=None, raise_exc=None, + def __init__(self, *, query_result=None, raise_exc=None, trading_result=None): self.token = "fake-token" self.query_result = query_result if query_result is not None else [] - self.list_result = list_result if list_result is not None else [] self.trading_result = trading_result if trading_result is not None else [] self.raise_exc = raise_exc self.calls: list[tuple] = [] @@ -30,12 +29,6 @@ async def query_dataset(self, dataset, data_id=None, start_date=None, end_date=N raise self.raise_exc return list(self.query_result) - async def list_datasets(self): - self.calls.append(("list_datasets",)) - if self.raise_exc is not None: - raise self.raise_exc - return list(self.list_result) - async def query_trading_daily_report(self, data_id, date): self.calls.append(( "query_trading_daily_report", @@ -127,11 +120,17 @@ async def test_dispatch_query_dataset_truncates_at_500(install_fake_client): @pytest.mark.asyncio -async def test_dispatch_list_datasets_markdown(install_fake_client): - install_fake_client(list_result=["TaiwanStockPrice", "TaiwanStockInfo"]) +async def test_dispatch_list_datasets_reads_knowledge_pack(install_fake_client): + # list_datasets must NOT hit the API (FinMind has no list-all endpoint); + # it reads the bundled datasets.md catalog instead. + client = install_fake_client() md = await tools.dispatch("list_datasets", {}) assert "TaiwanStockPrice" in md assert "TaiwanStockInfo" in md + # 90 datasets shipped in knowledge/datasets.md + assert "共" in md and "個" in md + # No client call was made. + assert client.calls == [] @pytest.mark.asyncio