Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions chatgpt/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-original-readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
5 changes: 3 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` header)。

完整 schema:`chatgpt/openapi.yaml`。
Expand Down Expand Up @@ -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` |

Expand Down
3 changes: 2 additions & 1 deletion knowledge/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` header 傳遞,由 OpenAI Action 認證面板輸入。
- **MCP host(Claude、Cursor、Windsurf、Gemini CLI 等)**:透過環境變數 `FINMIND_TOKEN` 讀取,於 MCP server 設定檔指定。
Expand Down
6 changes: 0 additions & 6 deletions src/finmind_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions src/finmind_mcp/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 18 additions & 8 deletions src/finmind_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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": {}},
),
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 8 additions & 17 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_knowledge.py
Original file line number Diff line number Diff line change
@@ -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()}
19 changes: 9 additions & 10 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading