diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c205a..61e3ecd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,26 @@ jobs: - name: Catalog vs FinMind-Doc sync check run: python scripts/check_doc_sync.py --doc ./FinMind-Doc + + mcpb: + # Claude Desktop 擴充套件(.mcpb):用官方 CLI 驗證 + 打包,再模擬 host 解壓、 + # 以 uv 啟動並走一次 MCP 握手。--dev 讓相依指向本 PR 的原始碼,而非 PyPI 舊版。 + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # --dev 會從原始碼 build 本套件,hatch-vcs 需要 tags + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build finmind.mcpb + run: python3 scripts/build_mcpb.py --dev + + - name: Install & launch like Claude Desktop + run: python3 scripts/check_mcpb.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 84d6799..3838e42 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,6 +18,9 @@ name: Publish to PyPI # - git tag v0.1.0 # - git push origin v0.1.0 ← 推 tag 就觸發;hatch-vcs 從 tag 算出版本 0.1.0 # 自動 build → 上傳 PyPI(PyPI 版本號不可重複,所以每次用新 tag) +# - 上 PyPI 之後,mcpb job 會打包 Claude Desktop 擴充套件 finmind.mcpb(釘同版本), +# 附加到該 tag 的 GitHub Release。下載連結固定為 +# https://github.com/FinMind/FinMind-MCP/releases/latest/download/finmind.mcpb on: push: @@ -66,3 +69,41 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + mcpb: + # 必須在 PyPI 發佈之後:.mcpb 裡的 pyproject 釘 finmind-mcp==, + # 使用者安裝時由 Claude Desktop 用 uv 從 PyPI 下載。 + needs: pypi-publish + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write # 建立 / 更新 GitHub Release + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build finmind.mcpb (pinned to this tag) + run: python3 scripts/build_mcpb.py "$GITHUB_REF_NAME" + + - name: Install & launch like Claude Desktop (from PyPI) + # PyPI 剛發佈時 index 可能還沒更新,失敗就等一下重試 + run: | + for i in 1 2 3 4 5; do + python3 scripts/check_mcpb.py && exit 0 + echo "retry $i: waiting for PyPI index"; sleep 60 + done + exit 1 + + - name: Attach to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" + gh release upload "$GITHUB_REF_NAME" dist/finmind.mcpb --clobber diff --git a/README.md b/README.md index 75cad3b..acafb01 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pipx install finmind-mcp | Host | 安裝指引 | |---|---| -| Claude Desktop | [install/claude-desktop.md](install/claude-desktop.md) | +| Claude Desktop | **一鍵安裝**:下載 [finmind.mcpb](https://github.com/FinMind/FinMind-MCP/releases/latest/download/finmind.mcpb) 點兩下 → 詳見 [install/claude-desktop.md](install/claude-desktop.md) | | Claude Code | [install/claude-code.md](install/claude-code.md) | | Cursor | [install/cursor.md](install/cursor.md) | | Windsurf | [install/windsurf.md](install/windsurf.md) | @@ -52,6 +52,7 @@ FinMind-MCP/ ├── knowledge/ # SSOT markdown(dataset、行為規則、錯誤腳本、Q&A) ├── chatgpt/ # Custom GPT artifacts(OpenAPI + build script) ├── install/ # 各 host 安裝指引 +├── mcpb/ # Claude Desktop 擴充套件(.mcpb)的 manifest 與進入點 └── docs/ # 設計 spec 與實作 plan ``` @@ -79,6 +80,15 @@ git tag v0.1.0 git push origin v0.1.0 # → CI 自動 build + 發 PyPI ``` +PyPI 發佈成功後,同一個 workflow 會打包 Claude Desktop 擴充套件 `finmind.mcpb`(`finmind-mcp` 釘在同一版本)並附加到該 tag 的 GitHub Release,下載連結 `releases/latest/download/finmind.mcpb` 會自動指向最新版。本機打包測試: + +```bash +python3 scripts/build_mcpb.py --dev # 需要 Node.js;--dev 用本機原始碼 +python3 scripts/check_mcpb.py # 模擬 Claude Desktop 解壓、啟動、MCP 握手 +``` + +新增 tool 時記得同步 `mcpb/manifest.json` 的 `tools`(`tests/test_mcpb.py` 會擋)。 + `knowledge/` 由 hatch `force-include` 打包進 wheel(`finmind_mcp/_knowledge/`),server runtime 直接讀、不用 compile;發版後用戶 `uvx finmind-mcp` / `pipx install finmind-mcp` 即抓到新版。 > 首次需在 PyPI 設好 trusted publisher(project `finmind-mcp` / repo `FinMind/FinMind-MCP` / workflow `publish.yml` / env `pypi`)。 diff --git a/install/claude-desktop.md b/install/claude-desktop.md index c2641e7..6ed76c3 100644 --- a/install/claude-desktop.md +++ b/install/claude-desktop.md @@ -1,10 +1,26 @@ # Claude Desktop -在 Claude Desktop 桌面應用程式啟用 FinMind MCP server(本機 stdio 模式),讓 Claude 直接呼叫 FinMind 金融資料。 +在 Claude Desktop 桌面應用程式啟用 FinMind MCP server,讓 Claude 直接呼叫 FinMind 金融資料。 -## 安裝套件 +先依 [token 取得指引](../knowledge/token-guide.md) 取得 FinMind Token,再選擇以下任一方式安裝。 -請先安裝 `finmind-mcp`(擇一即可): +## 方式一:一鍵安裝擴充套件(推薦) + +不需要安裝 Python、不需要編輯設定檔,Windows / macOS 步驟相同: + +1. 下載 **[finmind.mcpb](https://github.com/FinMind/FinMind-MCP/releases/latest/download/finmind.mcpb)** +2. 在下載的檔案上**點兩下**,Claude Desktop 會跳出安裝視窗(若沒有反應:開啟 Claude Desktop →「設定」→「擴充功能(Extensions)」,把檔案拖進視窗) +3. 按「安裝」,在「FinMind Token」欄位貼上 token,確認啟用 + +第一次使用時 Claude Desktop 會自動下載執行環境,約需數十秒。 + +> 更新版本:重新下載上面的連結再安裝一次即可。 + +## 方式二:手動編輯設定檔 + +適合想自行管理套件版本的使用者。 + +### 安裝套件 ```bash pipx install finmind-mcp # 推薦:長駐安裝 @@ -14,7 +30,7 @@ uvx finmind-mcp --help # 用 uv 即時跑,不安裝 > **Windows 使用者**:`pipx`/`uvx` 的安裝方式、token 設定與常見 PATH 問題,請先看 [Windows 安裝指引](windows.md)。 -## 設定 +### 設定 依您的作業系統,編輯對應的設定檔: @@ -39,12 +55,16 @@ uvx finmind-mcp --help # 用 uv 即時跑,不安裝 } ``` -## 取得 Token - -請先依 [token 取得指引](../knowledge/token-guide.md) 取得 FinMind Token,並設定環境變數 `FINMIND_TOKEN`(或填入上方設定)。 - ## 驗證 -重啟 Claude Desktop,在對話框輸入「列出 FinMind 可用的 dataset」,應該看到工具圖示出現並回傳 dataset 清單。 +(方式二需先完全結束並重開 Claude Desktop)在對話框輸入「列出 FinMind 可用的 dataset」,應該看到工具圖示出現並回傳 dataset 清單。 ![](../../docs/images/install/claude-desktop.png) + +## 常見問題 + +### 擴充套件顯示無法連線 + +1. 確認 token 有貼對:「設定」→「擴充功能」→ FinMind →「設定」重新貼上。 +2. 第一次啟動需要下載執行環境,網路較慢時可能逾時;等一分鐘後關閉再開啟擴充套件。 +3. 仍失敗時,安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)(Windows:PowerShell 執行 `winget install --id astral-sh.uv -e`),然後從系統匣(工作列右下角)圖示按右鍵**結束** Claude Desktop 再重開。 diff --git a/install/windows.md b/install/windows.md index a742c38..721bd8a 100644 --- a/install/windows.md +++ b/install/windows.md @@ -2,6 +2,8 @@ 各 host 的安裝文件([Claude Desktop](claude-desktop.md)、[Cursor](cursor.md) 等)中的指令以 macOS / Linux 為例。Windows 使用者請先照本頁完成「安裝套件」與「設定 Token」,再回到對應 host 的文件貼設定;本頁最後整理了 Windows 常見問題(尤其是 host 顯示找不到 `finmind-mcp` 指令的狀況)。 +> **使用 Claude Desktop?** 直接用[一鍵安裝擴充套件](claude-desktop.md#方式一一鍵安裝擴充套件推薦):下載 `finmind.mcpb` 點兩下、貼上 token 就完成,**不需要本頁任何步驟**。本頁適用於 Cursor、Windsurf 等其他工具,或想手動設定的使用者。 + 以下指令都在 **PowerShell** 執行(開始功能表搜尋「PowerShell」開啟即可,不需系統管理員權限)。 ## 1. 安裝套件 @@ -77,7 +79,11 @@ setx FINMIND_TOKEN "your-token-here" ### host 啟動 server 失敗(找不到指令 / `ENOENT`) -最常見的 Windows 問題:`finmind-mcp` 或 `uvx` 在 PowerShell 跑得動,但 Claude Desktop 這類**圖形介面應用程式讀到的 PATH 和終端機不同**(例如安裝 uv 之前就已開著、或 PATH 是安裝程式事後才寫入的),host 啟動 MCP server 這個子行程時就找不到指令,顯示連線失敗。解法是把設定裡的 `command` 改成執行檔的**完整路徑**。 +最常見的 Windows 問題:`finmind-mcp` 或 `uvx` 在 PowerShell 跑得動,但 Claude Desktop 這類**圖形介面應用程式讀到的 PATH 和終端機不同**(例如安裝 uv 之前就已開著、或 PATH 是安裝程式事後才寫入的),host 啟動 MCP server 這個子行程時就找不到指令,顯示連線失敗。 + +**先試最簡單的**:多半只是該程式在安裝 uv 之前就開著,還沒讀到新的 PATH。把它**完全結束**(Claude Desktop 要到系統匣圖示按右鍵選結束,見下一題)再重開,通常就好了。 + +仍然不行,再把設定裡的 `command` 改成執行檔的**完整路徑**: **① 查實際位置**(在 PowerShell 執行;要打 `where.exe`,單打 `where` 在 PowerShell 是別的指令): diff --git a/mcpb/.mcpbignore b/mcpb/.mcpbignore new file mode 100644 index 0000000..f9fac8e --- /dev/null +++ b/mcpb/.mcpbignore @@ -0,0 +1,4 @@ +.venv/ +uv.lock +__pycache__/ +*.pyc diff --git a/mcpb/icon.png b/mcpb/icon.png new file mode 100644 index 0000000..76276bc Binary files /dev/null and b/mcpb/icon.png differ diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..647dba5 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,53 @@ +{ + "manifest_version": "0.4", + "name": "finmind", + "display_name": "FinMind", + "version": "0.0.0", + "description": "台股/期權/總經/全球金融資料 MCP server(FinMind)", + "long_description": "讓 Claude 直接查詢 FinMind 的台股、期貨選擇權、總體經濟與全球金融資料。安裝時輸入 FinMind Token 即可使用,不需要另外設定 Python 或編輯設定檔。", + "author": { + "name": "FinMind", + "email": "finmind.tw@gmail.com", + "url": "https://finmindtrade.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/FinMind/FinMind-MCP" + }, + "homepage": "https://github.com/FinMind/FinMind-MCP", + "documentation": "https://github.com/FinMind/FinMind-MCP/blob/master/install/claude-desktop.md", + "support": "https://github.com/FinMind/FinMind-MCP/issues", + "icon": "icon.png", + "server": { + "type": "uv", + "entry_point": "src/server.py", + "mcp_config": { + "command": "uv", + "args": ["run", "--directory", "${__dirname}", "src/server.py"], + "env": { + "FINMIND_TOKEN": "${user_config.finmind_token}" + } + } + }, + "tools": [ + { "name": "query_dataset", "description": "查詢 FinMind dataset(股價、財報、籌碼、期權、總經等)" }, + { "name": "list_datasets", "description": "列出可查詢的 dataset 與所需會員等級" }, + { "name": "get_stock_info", "description": "查詢股票代號、名稱與產業別" }, + { "name": "query_trading_daily_report", "description": "查詢個股單日分點進出" }, + { "name": "get_pricing", "description": "查詢 FinMind 會員方案與價格" } + ], + "user_config": { + "finmind_token": { + "type": "string", + "title": "FinMind Token", + "description": "到 https://finmindtrade.com/analysis/#/account/user 登入,在「API Token」分頁按「複製」後貼上。", + "required": true, + "sensitive": true + } + }, + "compatibility": { + "platforms": ["darwin", "win32", "linux"] + }, + "keywords": ["finmind", "finance", "taiwan-stock", "futures", "options"], + "license": "Apache-2.0" +} diff --git a/mcpb/pyproject.toml b/mcpb/pyproject.toml new file mode 100644 index 0000000..206b66a --- /dev/null +++ b/mcpb/pyproject.toml @@ -0,0 +1,10 @@ +# Claude Desktop 擴充套件(.mcpb)用的執行環境定義。 +# Claude Desktop 啟動時以 `uv run` 依此檔自動準備 Python 與相依套件,使用者不用自己裝。 +# finmind-mcp 的版本由 scripts/build_mcpb.py 在打包時改成與發版 tag 相同,不要手動改。 +[project] +name = "finmind-mcpb" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = [ + "finmind-mcp==0.0.0", +] diff --git a/mcpb/src/server.py b/mcpb/src/server.py new file mode 100644 index 0000000..9ac4837 --- /dev/null +++ b/mcpb/src/server.py @@ -0,0 +1,6 @@ +"""Claude Desktop 擴充套件(.mcpb)的進入點:直接啟動 PyPI 上的 finmind-mcp。""" + +from finmind_mcp.server import main + +if __name__ == "__main__": + main() diff --git a/scripts/build_mcpb.py b/scripts/build_mcpb.py new file mode 100644 index 0000000..837c628 --- /dev/null +++ b/scripts/build_mcpb.py @@ -0,0 +1,82 @@ +"""打包 Claude Desktop 擴充套件 dist/finmind.mcpb。 + +把 mcpb/ 複製到暫存目錄,將 manifest.json 與 pyproject.toml 的版本改成 VERSION, +再用官方 CLI(@anthropic-ai/mcpb)驗證並打包。需要 Node.js(npx)。 + +用法: + python scripts/build_mcpb.py 0.0.11 # 指定版本 + python scripts/build_mcpb.py # 沒給版本 → 讀環境變數 GITHUB_REF_NAME(發版 tag) + python scripts/build_mcpb.py --dev # CI 用:相依改指向本 repo 原始碼,測尚未發版的改動 + +finmind-mcp 會被釘在同一個版本,所以該版本必須已經在 PyPI 上 +(publish.yml 先上 PyPI 再打包,順序已保證)。 +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "mcpb" +OUT = ROOT / "dist" / "finmind.mcpb" +MCPB_CLI = "@anthropic-ai/mcpb@2" +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +def resolve_version(args: list[str]) -> str: + raw = args[0] if args else os.environ.get("GITHUB_REF_NAME", "") + version = raw.removeprefix("v") + if not VERSION_RE.match(version): + sys.exit(f"版本需為 X.Y.Z(可帶 v 前綴),收到:{raw!r}") + return version + + +def stamp(build_dir: Path, version: str, requirement: str) -> None: + manifest_path = build_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = version + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + pyproject_path = build_dir / "pyproject.toml" + text = pyproject_path.read_text(encoding="utf-8") + text, n_pin = re.subn(r'"finmind-mcp==[^"]*"', f'"{requirement}"', text) + text, n_ver = re.subn(r'(?m)^version = "[^"]*"$', f'version = "{version}"', text) + if n_pin != 1 or n_ver != 1: + sys.exit("mcpb/pyproject.toml 格式不符,找不到 finmind-mcp 釘版或 version 欄位") + pyproject_path.write_text(text, encoding="utf-8") + + +def main() -> None: + args = sys.argv[1:] + dev = "--dev" in args + if dev: + version, requirement = "0.0.0", f"finmind-mcp @ {ROOT.as_uri()}" + else: + version = resolve_version(args) + requirement = f"finmind-mcp=={version}" + npx = shutil.which("npx") + if npx is None: + sys.exit("找不到 npx,請先安裝 Node.js") + + with tempfile.TemporaryDirectory() as tmp: + build_dir = Path(tmp) / "finmind" + shutil.copytree(SRC, build_dir, ignore=shutil.ignore_patterns(".venv", "uv.lock", "__pycache__")) + stamp(build_dir, version, requirement) + OUT.parent.mkdir(exist_ok=True) + subprocess.run([npx, "-y", MCPB_CLI, "validate", str(build_dir)], check=True) + subprocess.run([npx, "-y", MCPB_CLI, "pack", str(build_dir), str(OUT)], check=True) + + print(f"built {OUT.relative_to(ROOT)} ({requirement})") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_mcpb.py b/scripts/check_mcpb.py new file mode 100644 index 0000000..937731d --- /dev/null +++ b/scripts/check_mcpb.py @@ -0,0 +1,100 @@ +"""模擬 Claude Desktop 安裝並啟動 dist/finmind.mcpb,確認擴充套件真的能用。 + +步驟與 host 相同:解壓縮 → 讀 manifest 的 mcp_config → 代入 ${__dirname} 與 +${user_config.*} → 啟動子行程 → 走 MCP 握手 → tools/list → 呼叫一個離線 tool。 +需要 PATH 上有 uv(Claude Desktop 啟動 uv 類型擴充套件時同樣會用 uv)。 + +用法: + python scripts/check_mcpb.py [dist/finmind.mcpb] +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_BUNDLE = ROOT / "dist" / "finmind.mcpb" +FAKE_TOKEN = "mcpb-check" + + +def substitute(value: str, dirname: Path, user_config: dict[str, str]) -> str: + value = value.replace("${__dirname}", str(dirname)) + for key, val in user_config.items(): + value = value.replace("${user_config.%s}" % key, val) + return value + + +def main() -> None: + bundle = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_BUNDLE + with tempfile.TemporaryDirectory() as tmp: + ext_dir = Path(tmp) / "finmind" + with zipfile.ZipFile(bundle) as zf: + zf.extractall(ext_dir) + + manifest = json.loads((ext_dir / "manifest.json").read_text(encoding="utf-8")) + assert manifest["server"]["type"] == "uv", manifest["server"] + user_config = {key: FAKE_TOKEN for key in manifest.get("user_config", {})} + cfg = manifest["server"]["mcp_config"] + command = shutil.which(cfg["command"]) + if command is None: + sys.exit(f"找不到 {cfg['command']},請先安裝 uv") + args = [substitute(a, ext_dir, user_config) for a in cfg.get("args", [])] + env = {k: substitute(v, ext_dir, user_config) for k, v in cfg.get("env", {}).items()} + assert env.get("FINMIND_TOKEN") == FAKE_TOKEN, "token 沒有從 user_config 傳進 env" + + proc = subprocess.Popen( + [command, *args], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", env={**os.environ, **env}, + ) + assert proc.stdin is not None and proc.stdout is not None + + def send(msg: dict) -> None: + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + + def request(msg: dict) -> dict: + # 跟真正的 host 一樣保持 stdin 開著,一問一答;略過 server 主動送的通知。 + send(msg) + while True: + line = proc.stdout.readline() + if not line: + proc.kill() + sys.exit(f"server 提早結束(等待 {msg['method']})\nstderr:\n{proc.stderr.read()}") + resp = json.loads(line) + if resp.get("id") == msg["id"]: + return resp + + try: + init = request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "check_mcpb", "version": "0"}}}) + assert "result" in init, init + send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + tools = request({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + call = request({"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "list_datasets", "arguments": {}}}) + finally: + proc.stdin.close() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + tool_names = sorted(t["name"] for t in tools["result"]["tools"]) + declared = sorted(t["name"] for t in manifest.get("tools", [])) + assert tool_names == declared, f"manifest tools {declared} != server tools {tool_names}" + result = call["result"] + assert not result.get("isError") and result["content"], call + print(f"OK finmind.mcpb {manifest['version']}: {len(tool_names)} tools {tool_names}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_mcpb.py b/tests/test_mcpb.py new file mode 100644 index 0000000..3e25ae5 --- /dev/null +++ b/tests/test_mcpb.py @@ -0,0 +1,68 @@ +"""Tests for the Claude Desktop extension bundle (mcpb/) and its build script.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +from finmind_mcp import tools + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = json.loads((ROOT / "mcpb" / "manifest.json").read_text(encoding="utf-8")) + + +def _load_build_script(): + spec = importlib.util.spec_from_file_location("build_mcpb", ROOT / "scripts" / "build_mcpb.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_manifest_tools_match_server(): + # Claude Desktop 安裝畫面顯示的是 manifest 的 tools;新增 tool 時要一起補。 + declared = sorted(t["name"] for t in MANIFEST["tools"]) + assert declared == sorted(t.name for t in tools.tool_definitions()) + + +def test_manifest_uses_uv_runtime_and_passes_token(): + server = MANIFEST["server"] + assert server["type"] == "uv" + assert (ROOT / "mcpb" / server["entry_point"]).is_file() + assert server["mcp_config"]["env"]["FINMIND_TOKEN"] == "${user_config.finmind_token}" + token = MANIFEST["user_config"]["finmind_token"] + assert token["required"] is True and token["sensitive"] is True + + +def test_manifest_does_not_require_system_python(): + # 宣告 runtimes.python 會讓 Claude Desktop 檢查系統 Python,沒裝就拒絕安裝; + # uv 類型由 host 自己準備 Python,不該宣告。 + assert "runtimes" not in MANIFEST.get("compatibility", {}) + + +def test_stamp_pins_release_version(tmp_path): + build = _load_build_script() + for name in ("manifest.json", "pyproject.toml"): + (tmp_path / name).write_text((ROOT / "mcpb" / name).read_text(encoding="utf-8"), encoding="utf-8") + + build.stamp(tmp_path, "1.2.3", "finmind-mcp==1.2.3") + + assert json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8"))["version"] == "1.2.3" + pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + assert '"finmind-mcp==1.2.3"' in pyproject + assert 'version = "1.2.3"' in pyproject + + +@pytest.mark.parametrize( + "args, env, expected", + [(["0.0.11"], "", "0.0.11"), ([], "v0.1.0", "0.1.0"), ([], "0.2.0", "0.2.0")], +) +def test_resolve_version(monkeypatch, args, env, expected): + monkeypatch.setenv("GITHUB_REF_NAME", env) + assert _load_build_script().resolve_version(args) == expected + + +def test_resolve_version_rejects_non_release(monkeypatch): + monkeypatch.setenv("GITHUB_REF_NAME", "master") + with pytest.raises(SystemExit): + _load_build_script().resolve_version([])