feat: 薄殼原則落地 + seed 下沉 API + MCP 進主庫 + 部署一致性

壓測四橫向問題修正(docs 壓測報告):

① 薄殼原則成鐵律:能力長在 API,CLI/MCP/lib 只暴露
   - seed 下沉成 API 行為:cypher-executor POST /init/seed(一次灌 API+auth recipe),
     種子資料移到 server src/lib/api-recipe-seeds.ts,CLI 改薄殼一次呼叫
   - 解除 deployFullyOk 連坐 + init 補 seed auth recipe + update 補 seed/全 KV
   - registry SUBMISSIONS_KV 補進 REQUIRED_KV_NAMESPACES(修 20/21)

② MCP 統一帳號來源(單一 remote MCP + .env 切 MCP URL)
   - MCP 從 sibling repo 搬進 arcrun/mcp/(remote Worker,route 改 mcp.arcrun.dev)
   - config 加 mcp_url 三層解析 + getMcpUrl + DEFAULT_MCP_URL
   - 新增 acr mcp-setup:依 config 寫專案 .mcp.json(接案切資料夾自動切 MCP)
   - acr --version 改動態讀 package.json(根治漂移)

③ Deploy 一致性
   - tests/release.feature + scripts/check-release.sh
   - local-deploy.sh:CLI npm publish + auto patch bump + CHANGELOG
   - local-deploy.sh bash 3.2 相容修正(mapfile / 空陣列 set -u)
   - builtins/pnpm-lock.yaml

④ README self-hosted 同步現況(移除 R2 殘留、加 flag/env、多帳號)

CLI bump → 1.3.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-06 15:45:35 +08:00
parent 5f381a44a6
commit 3e65e22775
58 changed files with 8608 additions and 74 deletions
+27 -7
View File
@@ -95,16 +95,17 @@ arcrun 是 self-hosted 開源:你用**自己的** Cloudflare 帳號跑整套
**Account ID**:登入後在 [dash.cloudflare.com](https://dash.cloudflare.com) 隨便進一個區段,網址列會是 `dash.cloudflare.com/<這串就是 Account ID>`;或在右側欄 "Account ID" 直接複製。
**API Token**:到 [My Profile → API Tokens](https://dash.cloudflare.com/profile/api-tokens) → **Create Token****Create Custom Token**,給以下權限(CLI 用它建 KV / 部署 Worker):
**API Token**:到 [My Profile → API Tokens](https://dash.cloudflare.com/profile/api-tokens) → **Create Token****Create Custom Token**,給以下**兩個**權限(CLI 用它建 KV / 部署 Worker):
| 類型 | 項目 | 權限 |
|---|---|---|
| Account | Workers Scripts | Edit |
| Account | Workers KV Storage | Edit |
| Account | Workers R2 Storage | Edit |
建好後**複製那串 token**(只會顯示一次)。CLI 不代管你的憑證——你自己建、自己持有。
> 只需要這兩個權限。**不需要 R2、不需要綁信用卡**——arcrun self-hosted 只用 Workers + KV,兩者都在 Cloudflare 免費額度。
### 2. 安裝 Cloudflare CLIwrangler
CLI 部署 Worker 時會用到 wrangler。先裝它:
@@ -129,16 +130,27 @@ CLI 會問你 **Account ID** 和 **API Token**(步驟 1 取得的),貼上
```
→ 驗證 Cloudflare 憑證... ✓
→ KV WEBHOOKS... ✓ (建 7 個 KV namespace,已存在則重用)
→ R2 WASM_BUCKET... ✓
→ KV WEBHOOKS... ✓ (建 KV namespace,已存在則重用)
→ workers.dev subdomain: your-account
→ 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...
✓ 部署完成:19 個 Worker 全部成功
→ seed 10 個 API recipe... ✓
✓ Cloudflare 資源就緒(7 KV + R2
✓ 部署完成:所有 Worker 全部成功
→ seed recipeAPI recipe + auth recipe,由 API 灌入)... ✓
✓ Cloudflare 資源就緒(KV 全建妥,免費額度即可,無需綁卡
✓ 設定寫入 ~/.arcrun/config.yaml
```
**非互動 / 給 AI、CI 用**:不想被問答打斷,可用 flag 或環境變數一次帶入:
```bash
# flag(優先序最高)
acr init --self-hosted --account-id <你的 Account ID> --api-token <你的 API Token>
# 或環境變數(與 wrangler 慣例一致)
export CLOUDFLARE_ACCOUNT_ID=<你的 Account ID>
export CLOUDFLARE_API_TOKEN=<你的 API Token>
acr init --self-hosted
```
你不需要懂 git、不需要懂 tinygo、不需要手動建任何東西——預編譯好的零件(`.wasm`)直接從 GitHub 下載,用**你自己的** CF token 部署到**你的**帳號。
**最後一步(手動,CLI 會印提示)**:設定加密金鑰 secret。這一步刻意不自動化(密鑰不進工具流程):
@@ -155,6 +167,14 @@ wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
> 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後直接跳到下面「寫一個工作流」。
### 多帳號 / 接案(同一台電腦連不同 CF 帳號)
設定採三層,優先序 **環境變數 > 專案層 `.arcrun.yaml` > 全域 `~/.arcrun/config.yaml`**(仿 `git config`):
- **自己的專案**:什麼都不放 → 自動用全域(你自己的 CF)。
- **幫客戶接案**:在客戶資料夾放一份 `.arcrun.yaml`(含該客戶的 cypher URL / api_key)→ 只在這個資料夾樹生效,離開自動切回全域。`.arcrun.yaml` 含憑證,`init` 會自動幫你加進 `.gitignore`
- **不確定現在用哪個帳號?** `acr config --where` 會逐欄告訴你每個值來自哪一層。
---
## 裝好之後:開始用
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.23
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250219.0
version: 4.20260606.1
typescript:
specifier: ^5.7.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.98.0(@cloudflare/workers-types@4.20260606.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260603.1':
resolution: {integrity: sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260603.1':
resolution: {integrity: sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260603.1':
resolution: {integrity: sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260603.1':
resolution: {integrity: sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260603.1':
resolution: {integrity: sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260606.1':
resolution: {integrity: sha512-0FFUsixapowVJcAjRlXLb6UEZG1caUlSuUX1KHhKgWgjKxq20dY5XeCS5lKPqgc80XJ9puwffJ2H1U6Fwr5N1g==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.23:
resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260603.0:
resolution: {integrity: sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.8.2:
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260603.1:
resolution: {integrity: sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.98.0:
resolution: {integrity: sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260603.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.20.1:
resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260603.1
'@cloudflare/workerd-darwin-64@1.20260603.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260603.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260603.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260603.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260603.1':
optional: true
'@cloudflare/workers-types@4.20260606.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.23: {}
kleur@4.1.5: {}
miniflare@4.20260603.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260603.1
ws: 8.20.1
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.8.2: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260603.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260603.1
'@cloudflare/workerd-darwin-arm64': 1.20260603.1
'@cloudflare/workerd-linux-64': 1.20260603.1
'@cloudflare/workerd-linux-arm64': 1.20260603.1
'@cloudflare/workerd-windows-64': 1.20260603.1
wrangler@4.98.0(@cloudflare/workers-types@4.20260606.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260603.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260603.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260606.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.20.1: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
+22
View File
@@ -0,0 +1,22 @@
# arcrun CLI Changelog
> 由 `scripts/local-deploy.sh` 在 deploy 時自動維護(version bump + 此檔記錄)。
> 也可手動編輯補充細節。最新在最上。
## 1.3.0 — 2026-06-06
- MCP 搬進 arcrun/mcp/ + acr mcp-setup(依 config mcp_url 寫專案 .mcp.json,接案切資料夾自動切 MCP)
- config 加 mcp_url 三層解析(env ARCRUN_MCP_URL > 專案 > 全域)+ getMcpUrl()
- acr --version 改從 package.json 動態讀(不再 hardcode,避免漂移)
- seed 下沉成 API 行為(POST /init/seed);CLI init/update 改薄殼一次呼叫
- registry SUBMISSIONS_KV 補進 REQUIRED_KV_NAMESPACES(修 20/21
## 1.2.0 — 2026-06-05
- self-hosted fork 阻斷四項修正:strip arcrun.dev routes / R2 / AI binding;移除 R2(不綁卡)
- init 非互動(--account-id/--api-token + CLOUDFLARE_* env
- 多帳號/專案 scope:三層 configenv > 專案 .arcrun.yaml > 全域)+ acr config --where
## 1.1.0 — earlier
- auth recipe 系統:20 個服務預建;acr auth-recipe 指令
## 1.0.x — earlier
- 初始發布 → recipe / creds / push / parts / webhooks / config 分離等(見 arcrun.md CLI 版本表)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "arcrun",
"version": "1.0.2",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arcrun",
"version": "1.0.2",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "arcrun",
"version": "1.2.0",
"version": "1.3.0",
"description": "AI Workflow CLI for arcrun — self-host WASM-based AI workflows on your own Cloudflare",
"bin": {
"acr": "dist/index.js"
+36 -35
View File
@@ -16,8 +16,8 @@ import {
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds.js';
import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
@@ -57,6 +57,14 @@ export async function cmdInit(options: InitOptions): Promise<void> {
} catch (e) {
console.log(chalk.gray(` harness 安裝略過:${e instanceof Error ? e.message : e};可稍後跑 acr install-harness`));
}
// 順便寫專案 .mcp.json,讓 Claude Code 連對的 MCP(依 config 的 mcp_urlSDD mcp-account-source.md)。
// 失敗不影響 init(可事後 acr mcp-setup 補)。
try {
cmdMcpSetup();
} catch (e) {
console.log(chalk.gray(` .mcp.json 略過:${e instanceof Error ? e.message : e};可稍後跑 acr mcp-setup`));
}
}
async function initLocal(): Promise<void> {
@@ -129,7 +137,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
/**
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
* 驗 token → 建 7 KV(冪等)→ 查 subdomain → 下載 release 部署 Worker
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
*/
@@ -208,6 +216,8 @@ async function initSelfHosted(
const deploy = await downloadAndDeploy(deployCtx);
const cypherUrl = deploy.cypherExecutorUrl
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
// 誠實回報部署結果;但**不**用「全部成功」字串 gate 後續 seed(壓測 §4.1
// registry 一個無關 worker 失敗就連坐讓 seed 永遠被跳過)。seed 只看 cypher-executor 是否可達。
const deployFullyOk = /全部成功/.test(deploy.message);
console.log(deployFullyOk ? chalk.green(`${deploy.message}`) : chalk.yellow(`${deploy.message}`));
@@ -224,15 +234,14 @@ async function initSelfHosted(
saveConfig(config);
createCredentialsYamlIfMissing();
// 6. seed API recipe部署成功 + 有 cypher URL 才打;否則提示稍後 acr update 後再 seed
if (deployFullyOk && cypherUrl) {
await seedApiRecipes(cypherUrl);
} else if (cypherUrl) {
console.log(chalk.gray(` → recipe seed 待部署穩定後再執行(${API_RECIPE_SEEDS.length} 個;acr update 會重試)`));
// 6. seed recipe薄殼:呼叫 API 的 /init/seed 一次,由 API 灌 API recipe + auth recipe)。
// 只要 cypher-executor 可達就 seed——不被無關 worker(registry)的失敗連坐(壓測 §4.1)。
if (cypherUrl) {
await callSeedEndpoint(cypherUrl);
}
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
console.log(chalk.green('\n ✓ Cloudflare 資源就緒(7 KV,免費額度即可,無需綁卡)'));
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
@@ -245,35 +254,27 @@ async function initSelfHosted(
console.log(chalk.gray(' 生成:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"\n'));
}
/** seed API recipe 到目標 cypher-executor(部署完成後)。*/
async function seedApiRecipes(cypherUrl: string): Promise<void> {
process.stdout.write(chalk.gray(` → seed ${API_RECIPE_SEEDS.length} 個 API recipe...`));
let ok = 0;
for (const r of API_RECIPE_SEEDS) {
try {
const res = await fetch(`${cypherUrl}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
canonical_id: r.canonical_id,
display_name: r.display_name,
description: r.description,
endpoint: r.endpoint,
method: r.method,
auth_service: r.auth_service,
exposure_consent: {
confirmed_by_human: true,
understood: `platform seed recipe: ${r.canonical_id}${r.endpoint}`,
confirmed_at: new Date().toISOString(),
},
}),
});
if (res.ok) ok++;
} catch {
// 單筆失敗不中斷整個 init;最終回報數量
/**
* 薄殼:呼叫 API 的 /init/seed 一次(rule 07)。
* seed 的編排 + 種子資料全在 cypher-executorroutes/init-seed.ts),CLI 不自己迴圈 POST。
* 之前 seedApiRecipes 在 CLI 迴圈 POST + deployFullyOk gate 是壓測 §4.1 的反例,已移除。
*/
async function callSeedEndpoint(cypherUrl: string): Promise<void> {
process.stdout.write(chalk.gray(' → seed recipeAPI recipe + auth recipe,由 API 灌入)...'));
try {
const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' });
const body = await res.json().catch(() => null) as
| { success?: boolean; message?: string }
| null;
if (res.ok && body?.success) {
console.log(chalk.green(`${body.message ?? ''}`));
} else {
// 誠實:不假綠。seed 沒全成就明說,提示 acr update 可重跑(冪等)。
console.log(chalk.yellow(`${body?.message ?? `HTTP ${res.status}`}(可 acr update 重跑,seed 冪等)`));
}
} catch (e) {
console.log(chalk.yellow(` ⚠ seed 端點呼叫失敗(${e instanceof Error ? e.message : e});cypher 穩定後 acr update 重跑`));
}
console.log(ok === API_RECIPE_SEEDS.length ? chalk.green(' ✓') : chalk.yellow(` ${ok}/${API_RECIPE_SEEDS.length}`));
}
function createHelloYamlIfMissing(): void {
+57
View File
@@ -0,0 +1,57 @@
/**
* acr mcp-setup — 在目前資料夾寫 / 更新 .mcp.json,讓 Claude Code 連對的 arcrun MCP。
*
* 薄殼原則(rule 07 + SDD mcp-account-source.md):
* MCP URL 與 cypher URL 一樣,由同一份三層 config 解析(env > 專案 .arcrun.yaml > 全域)決定。
* 「切換帳號」= 在客戶資料夾跑 acr mcp-setup(讀該資料夾的 .arcrun.yaml)→ 產指向客戶 MCP 的 .mcp.json。
* 進哪個專案資料夾 → Claude Code 讀該專案 .mcp.json → 連對應 MCP(綁該帳號的 cypher)。
*
* 不重實作身份解析:呼叫 config.ts 的 getMcpUrl()(唯一解析來源)。
*/
import chalk from 'chalk';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig, getMcpUrl, DEFAULT_MCP_URL } from '../lib/config.js';
/** Claude Code .mcp.json 的 server 條目(remote HTTP MCP)。 */
interface McpServerEntry {
type: 'http';
url: string;
}
interface McpJson {
mcpServers?: Record<string, McpServerEntry | Record<string, unknown>>;
[k: string]: unknown;
}
const SERVER_KEY = 'arcrun';
export function cmdMcpSetup(): void {
const config = loadConfig();
const mcpUrl = getMcpUrl(config);
const target = join(process.cwd(), '.mcp.json');
// 讀既有 .mcp.json(保留其他 MCP server 條目,只覆寫 arcrun 這條)
let doc: McpJson = {};
if (existsSync(target)) {
try {
doc = JSON.parse(readFileSync(target, 'utf8')) as McpJson;
} catch {
console.log(chalk.yellow(` ⚠ 既有 .mcp.json 解析失敗,將以新內容覆寫整檔。`));
doc = {};
}
}
if (!doc.mcpServers || typeof doc.mcpServers !== 'object') doc.mcpServers = {};
doc.mcpServers[SERVER_KEY] = { type: 'http', url: mcpUrl };
writeFileSync(target, JSON.stringify(doc, null, 2) + '\n', 'utf8');
console.log(chalk.green(`\n ✓ 已寫入 ${target}`));
console.log(chalk.gray(` arcrun MCP → ${mcpUrl}`));
if (mcpUrl === DEFAULT_MCP_URL && (!config.mcp_url || config.mcp_url.trim() === '')) {
console.log(chalk.gray(` (用平台預設;要連自己/客戶的 MCP,在 config 設 mcp_url 或 ARCRUN_MCP_URL env,再重跑)`));
}
console.log(chalk.gray(` Claude Code 進此資料夾會自動連這台 MCP。切帳號 = 在對應資料夾重跑 acr mcp-setup。\n`));
}
+37 -5
View File
@@ -12,9 +12,11 @@
import chalk from 'chalk';
import { loadConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
wranglerAvailable,
downloadAndDeploy,
REQUIRED_KV_NAMESPACES,
type DeployContext,
} from '../lib/deploy.js';
@@ -40,20 +42,50 @@ export async function cmdUpdate(): Promise<void> {
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
const kvNamespaceIds: Record<string, string> = {};
try {
const existing = await cf.listKvNamespaces();
for (const title of REQUIRED_KV_NAMESPACES) {
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
}
} catch (e) {
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
process.exit(1);
}
const ctx: DeployContext = {
accountId: config.cloudflare_account_id,
apiToken: config.cf_api_token,
workerSubdomain: extractSubdomain(config.cypher_executor_url),
kvNamespaceIds: {
WEBHOOKS: config.webhooks_kv_namespace_id ?? '',
CREDENTIALS_KV: config.credentials_kv_namespace_id ?? '',
},
kvNamespaceIds,
};
const result = await downloadAndDeploy(ctx);
if (result.implemented) {
console.log(chalk.green('\n ✓ 更新完成\n'));
console.log(chalk.green('\n ✓ 部署完成'));
// 重跑 seed(薄殼:呼叫 API /init/seed;冪等,覆寫既有)。
// 修壓測 §4.1.3「update 不做 seed,但 init 提示說 update 會重試 seed」的矛盾。
const cypherUrl = config.cypher_executor_url
?? result.cypherExecutorUrl
?? (ctx.workerSubdomain ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` : '');
if (cypherUrl) {
process.stdout.write(chalk.gray(' → 重新 seed recipeAPI + auth,由 API 灌入)...'));
try {
const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' });
const body = await res.json().catch(() => null) as { success?: boolean; message?: string } | null;
console.log(res.ok && body?.success
? chalk.green(`${body.message ?? ''}`)
: chalk.yellow(`${body?.message ?? `HTTP ${res.status}`}`));
} catch (e) {
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e}`));
}
}
console.log('');
} else {
console.log(chalk.yellow(' ⚠ 更新尚未自動化:'));
console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n');
+22 -1
View File
@@ -7,6 +7,9 @@
* 使用:acr <指令>
*/
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { cmdInit } from './commands/init.js';
import { cmdConfig } from './commands/config.js';
import { cmdCredsPush } from './commands/creds.js';
@@ -19,14 +22,26 @@ import { cmdList } from './commands/list.js';
import { cmdLogs } from './commands/logs.js';
import { cmdUpdate } from './commands/update.js';
import { cmdInstallHarness } from './commands/install-harness.js';
import { cmdMcpSetup } from './commands/mcp-setup.js';
import { cmdAuthRecipeList, cmdAuthRecipeInfo, cmdAuthRecipeScaffold } from './commands/auth-recipe.js';
const program = new Command();
// 版本從 package.json 動態讀(dist/index.js 旁的 ../package.json),不 hardcode → 永不漂移。
// 之前 hardcode '1.1.0' 與 package.json '1.2.0' 不一致,正是「忘了改」的反例。
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
return (JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }).version ?? '0.0.0';
} catch {
return '0.0.0';
}
}
program
.name('acr')
.description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM')
.version('1.1.0');
.version(readVersion());
// acr init [--self-hosted] [--account-id <id>] [--api-token <token>]
program
@@ -146,4 +161,10 @@ program
.description('把 arcrun 的 CC harnessmindset/提醒/防做歪 hook/指令)裝進當前專案')
.action(() => cmdInstallHarness());
// acr mcp-setup(依 config 解析的 mcp_url 寫專案 .mcp.json,讓 Claude Code 連對的 MCP
program
.command('mcp-setup')
.description('在目前資料夾寫 .mcp.json 連對的 arcrun MCP(依 config 的 mcp_url;接案切資料夾自動切)')
.action(() => cmdMcpSetup());
program.parse(process.argv);
+23 -1
View File
@@ -21,6 +21,12 @@ export interface ArcrunConfig {
credentials_kv_namespace_id?: string;
webhooks_kv_namespace_id?: string;
// 共用
// MCP server URL(薄殼原則:CLI 與 MCP 同一份身份解析)。
// self-hosted / 接案:指向自己 / 客戶的 remote MCP Worker(綁該帳號的 cypher)。
// 未設 → fallback 平台預設(SaaS 用戶)。acr mcp-setup 依此寫專案 .mcp.json
// 讓「進哪個專案資料夾 → Claude Code 連那台 MCP」自動生效。
// SDD: sdk-and-website/mcp-account-source.md
mcp_url?: string;
multi_tenant?: boolean;
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
@@ -43,10 +49,17 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
ARCRUN_API_KEY: 'api_key',
ARCRUN_ENCRYPTION_KEY: 'encryption_key',
ARCRUN_CYPHER_EXECUTOR_URL: 'cypher_executor_url',
ARCRUN_MCP_URL: 'mcp_url',
CLOUDFLARE_ACCOUNT_ID: 'cloudflare_account_id',
CLOUDFLARE_API_TOKEN: 'cf_api_token',
};
/**
* 平台預設 MCP URLmcp_url 未設時的 fallbackSaaS 用戶用)。
* MCP 搬進 arcrun 主庫後改用 arcrun.dev zonemcp/wrangler.toml route = mcp.arcrun.dev)。
*/
export const DEFAULT_MCP_URL = 'https://mcp.arcrun.dev';
export function configExists(): boolean {
return existsSync(CONFIG_PATH) || findProjectConfig() !== undefined;
}
@@ -114,7 +127,7 @@ export function resolveConfigSources(): Array<{ field: keyof ArcrunConfig; value
const env = readEnvOverrides();
const fields: (keyof ArcrunConfig)[] = [
'mode', 'api_key', 'encryption_key', 'cloudflare_account_id',
'cf_api_token', 'cypher_executor_url',
'cf_api_token', 'cypher_executor_url', 'mcp_url',
];
const rows: Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> = [];
for (const f of fields) {
@@ -146,3 +159,12 @@ export function getCypherExecutorUrl(config: ArcrunConfig): string {
}
return 'https://cypher.arcrun.dev';
}
/**
* 取得 MCP server URL(薄殼原則:與 cypher_url 同一份 config 解析)。
* config 有 mcp_urlenv/專案/全域 任一層)→ 用它;否則 fallback 平台預設。
* acr mcp-setup 用此決定要寫進專案 .mcp.json 的 URL → 切資料夾自動切 MCP。
*/
export function getMcpUrl(config: ArcrunConfig): string {
return config.mcp_url && config.mcp_url.trim() !== '' ? config.mcp_url : DEFAULT_MCP_URL;
}
+8 -1
View File
@@ -17,7 +17,13 @@ import { join } from 'node:path';
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
/** init 要建立的 7 個 KV namespacetitle)。權威來源:.claude/rules/01-tech-stack.md 資料儲存表。*/
/**
* init 要建立的 KV namespacetitle)。
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
* SUBMISSIONS_KVregistry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
* 殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗)。補進來後回到 21/21。
*/
export const REQUIRED_KV_NAMESPACES = [
'WEBHOOKS',
'CREDENTIALS_KV',
@@ -26,6 +32,7 @@ export const REQUIRED_KV_NAMESPACES = [
'SESSIONS_KV',
'ANALYTICS_KV',
'EXEC_CONTEXT',
'SUBMISSIONS_KV',
] as const;
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
+6 -3
View File
@@ -2,8 +2,9 @@
* seed-api-recipes.ts
*
* 將現役 API recipe 種子上傳至目標 cypher-executorprod 或 self-host)。
* 種子資料的單一來源在 CLI 端(cli/src/lib/api-recipe-seeds.ts),此腳本 import 它
* 避免兩份種子定義漂移。
* 種子資料的單一來源在 server 端(src/lib/api-recipe-seeds.ts,薄殼原則 rule 07),此腳本 import 它
* 注意:self-host init 與 prod 補灌建議改打 POST /init/seedAPI 行為,一次灌 API+auth recipe);
* 本腳本保留作為 KV 直寫的備援路徑。
*
* 執行:
* npx tsx scripts/seed-api-recipes.ts
@@ -18,7 +19,9 @@
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
*/
import { API_RECIPE_SEEDS } from '../../cli/src/lib/api-recipe-seeds.js';
// 種子資料的單一來源已移到 server 端(src/lib/api-recipe-seeds.ts,薄殼原則 rule 07)。
// 注意:現在 prod 補灌建議直接打 POST /init/seedAPI 行為);本腳本保留作為 KV 直寫的備援。
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
const BASE_URL = process.env.ARCRUN_API_URL ?? 'https://cypher.arcrun.dev';
const API_KEY = process.env.ARCRUN_API_KEY ?? '';
+2
View File
@@ -19,6 +19,7 @@ import { webhooksNamedRouter } from './routes/webhooks-named';
import { authRouter } from './routes/auth';
import { resumeRouter } from './routes/resume';
import { executionsRouter } from './routes/executions';
import { initSeedRouter } from './routes/init-seed';
const app = new Hono<{ Bindings: Bindings }>();
@@ -46,6 +47,7 @@ app.route('/', credentialsRouter);
app.route('/', authRouter);
app.route('/', resumeRouter);
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
@@ -1,16 +1,17 @@
/**
* api-recipe-seeds.ts
* api-recipe-seeds.tsserver
*
* API recipe self-host init RECIPES KV
* cypher-executor POST /recipes CF KV REST API
* API recipe self-host POST /init/seed RECIPES KV
*
* API recipe = http_request + endpoint/method
* deploy Workercypher-executor fetch cypher-executor/src/routes/recipes.ts
* deploy Workercypher-executor fetch routes/recipes.ts
*
* CLI cypher-executor/src
* - seed installer KV CLI SDD self-hosted-init.md §4
* - rule 02 §2.2 hook cypher-executor TS hard-code API endpointseed endpoint
* CLI
* cypher-executor/src rule 07 + §5.5
* - recipe API CLI seed API
* POST /init/seedCLI/MCP server
* - endpoint sheets.googleapis.com recipe
* TS / http_requestrule 02 §2.2 hook
* richblack 2026-06-06 auth-recipe-seeds.ts
*
* 2026-06-01 prod cypher.arcrun.dev/recipes
* SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
+102
View File
@@ -0,0 +1,102 @@
/**
* /init/seed — 一鍵把平台預建的 recipe 種子灌進 RECIPES KVAPI 行為,非介面層職責)
*
* 薄殼原則(rule 07 + 壓測 §4.1/§5.5):
* 「裝好後預設有哪些 recipe」是 API 的能力。seed 由本端點完成,CLI/MCP 等薄殼只呼叫一次。
* 之前 seed 寫在 CLI init.ts(迴圈 POST + deployFullyOk gate),導致 registry 20/21 連坐 →
* seed 永遠被跳過、auth recipe 從不被 seed(壓測 §4.1)。本端點把 seed 下沉到 API,根除連坐。
*
* 行為:
* - 冪等:已存在的 recipe 直接覆寫(重跑安全)。
* - 一次灌「API recipeAPI_RECIPE_SEEDS+ auth recipeAUTH_RECIPE_SEEDS)」兩者。
* - 直接寫 KV(不走 POST /recipes 的 exposure_consent gate):種子是平台預建、非用戶互動 push,
* 帶 seed 層級的 consent 憑證(誠實標來源,軌跡可審;mindset §7:機制價值是歸責+可審非防偽)。
* - 誠實回報:逐筆 ok/fail 計數,不假綠。
*
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
import type { RecipeDefinition, AuthRecipeDefinition } from './recipes';
import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds';
import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds';
export const initSeedRouter = new Hono<{ Bindings: Bindings }>();
initSeedRouter.post('/init/seed', async (c) => {
const now = Date.now();
const seedConsent = {
confirmed_by_human: true as const,
understood: 'platform seed (init/seed): 平台預建 recipe,非用戶互動 push',
confirmed_at: new Date(now).toISOString(),
};
let apiOk = 0;
let apiFail = 0;
const apiErrors: string[] = [];
for (const seed of API_RECIPE_SEEDS) {
try {
const canonicalId = seed.canonical_id.trim().toLowerCase();
const hashId = await deriveRecipeHash(canonicalId);
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
const recipe: RecipeDefinition = {
canonical_id: canonicalId,
hash_id: hashId,
display_name: seed.display_name,
description: seed.description,
endpoint: seed.endpoint,
method: (seed.method ?? 'POST').toUpperCase(),
auth_service: seed.auth_service,
exposure_consent: existing?.exposure_consent ?? seedConsent,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
apiOk++;
} catch (e) {
apiFail++;
apiErrors.push(`${seed.canonical_id}: ${e instanceof Error ? e.message : String(e)}`);
}
}
let authOk = 0;
let authFail = 0;
const authErrors: string[] = [];
for (const seed of AUTH_RECIPE_SEEDS) {
try {
const service = seed.service.trim().toLowerCase();
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
const recipe: AuthRecipeDefinition = {
...seed,
service,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
authOk++;
} catch (e) {
authFail++;
authErrors.push(`${seed.service}: ${e instanceof Error ? e.message : String(e)}`);
}
}
const allOk = apiFail === 0 && authFail === 0;
return c.json(
{
success: allOk,
api_recipes: { seeded: apiOk, failed: apiFail, errors: apiErrors },
auth_recipes: { seeded: authOk, failed: authFail, errors: authErrors },
message: allOk
? `seed 完成:${apiOk} 個 API recipe + ${authOk} 個 auth recipe`
: `seed 部分失敗(誠實回報,未假綠):API ${apiOk}✓/${apiFail}✗,auth ${authOk}✓/${authFail}`,
},
allOk ? 200 : 207,
);
});
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
.wrangler/
*.sqlite
*.sqlite-shm
*.sqlite-wal
.env
.env.*
!.env.example
dev/
# Kiro IDE spec files (internal planning artifacts)
.kiro/
.swarm/
ruvector.db
+477
View File
@@ -0,0 +1,477 @@
# u6u 開發指南
> u6u 是 **AI 優先(AI-First** 的工作流自動化平台。
> 跟 AI 描述你的意圖,u6u 幫你把它變成可重複執行、不需要 AI 的自動化工作流。
---
## 目錄
1. [核心概念:三元組(Triplet](#核心概念三元組triplet)
2. [開發流程總覽](#開發流程總覽)
3. [建議的專案檔案結構](#建議的專案檔案結構)
4. [Workflow_Plan_YAML 格式](#workflow_plan_yaml-格式)
5. [Component_Plan_YAML 格式](#component_plan_yaml-格式)
6. [工作流(Workflow)部署流程](#工作流workflow部署流程)
7. [零件(Component)開發流程](#零件component開發流程)
8. [Tag 管理說明](#tag-管理說明)
---
## 核心概念:三元組(Triplet)
u6u 的一切都建立在「三元組」上。三元組是描述業務邏輯的最小單位,格式極度簡單,AI 不會出錯,人也能一眼看懂。
### 格式
```
subject predicate object
```
| 部分 | 說明 | 範例 |
|------|------|------|
| `subject` | 執行者(誰) | `user``system``payment-service` |
| `predicate` | 動作(做什麼) | `submits``validates``sends` |
| `object` | 對象(對什麼) | `form``input``email` |
### 命名規則
- 全部小寫,單字以連字號(`-`)連接
- predicate 用動詞原形
- 避免縮寫,語意要清楚
### 範例:匯率通知工作流
```
system fetches exchange-rate
system parses rate-data
system sends telegram-notification
```
三個 triplet,三個動作,串起來就是一個完整的工作流。每個 triplet 對應一個**零件(component)**,零件負責實作那個動作的具體邏輯。
### 為什麼這麼簡單?
因為 AI 擅長理解意圖,三元組擅長表達意圖。你跟 AI 說「去抓銀行匯率,用 Telegram 通知我」,AI 把它拆成三元組,u6u 查零件庫、組裝、執行。第一次需要 AI,之後自動跑,不再花 Token。
---
## 開發流程總覽
```
你的意圖
AI 產出 Workflow_Plan_YAML(每次規劃新專案)
u6u_search_components(查零件庫)
有缺件?→ AI 產出 Component_Plan_YAML → u6u_publish_component
u6u_execute_workflow(沙盒測試)
u6u_deploy_workflow(正式部署)
(選填)建立 tag,為工作流與零件分類
```
**重要原則:**
- Workflow_Plan_YAML 和 Component_Plan_YAML 由 AI 輸出在對話中,**你自行存入本地 repo**
- MCP Server 不儲存 YAML 全文,只記錄部署成功後的 metadata(ID、名稱、時間)
- YAML 是你的原始碼,用 Git 管理
---
## 建議的專案檔案結構
```
your-project/
├── workflows/ # 工作流 YAMLWorkflow_Plan_YAML
│ ├── exchange-rate-notify.yaml
│ └── user-checkout-flow.yaml
├── components/ # 零件 YAMLComponent_Plan_YAML,只在有缺件時才有)
│ ├── system-fetches-exchange-rate.yaml
│ └── system-sends-telegram-notification.yaml
├── .gitignore # 建議加入下方的 ignore 項目
└── README.md
```
### 建議的 `.gitignore`
```gitignore
# 環境變數(含 API Key,絕對不能 commit
.env
.env.*
!.env.example
# IDE 內部規劃檔案(非產品程式碼)
.kiro/
# 其他常見
node_modules/
dist/
.DS_Store
```
---
## Workflow_Plan_YAML 格式
**產出時機:** 每次你向 AI 描述新的業務需求,AI 就會在對話中輸出此 YAML。將它存入 `workflows/` 目錄。
### 格式說明
```yaml
workflow:
name: <工作流名稱,kebab-case>
description: <業務目的說明>
version: "1.0.0"
tags:
- <tag 名稱(選填)>
triplets:
- subject: <執行者>
predicate: <動作>
object: <對象>
description: <此步驟說明(選填)>
context_schema:
<key>: <type> # 工作流執行時需要的輸入參數
```
### 完整範例:匯率通知
```yaml
workflow:
name: exchange-rate-notify
description: 每日抓取銀行匯率,透過 Telegram 通知用戶
version: "1.0.0"
tags:
- notification
- finance
triplets:
- subject: system
predicate: fetches
object: exchange-rate
description: 從銀行 API 取得當日匯率
- subject: system
predicate: parses
object: rate-data
description: 整理匯率資料格式
- subject: system
predicate: sends
object: telegram-notification
description: 透過 Telegram Bot 發送通知
context_schema:
currency_pair: string # 例如 "USD/TWD"
chat_id: string # Telegram chat ID
```
### 完整範例:結帳流程
```yaml
workflow:
name: user-checkout-flow
description: 處理使用者結帳,從確認購物車到完成付款
version: "1.0.0"
tags:
- ecommerce
- payment
triplets:
- subject: user
predicate: confirms
object: cart-items
- subject: system
predicate: calculates
object: total-price
- subject: user
predicate: submits
object: payment-info
- subject: payment-service
predicate: processes
object: payment
- subject: system
predicate: creates
object: order-record
- subject: system
predicate: sends
object: confirmation-email
context_schema:
user_id: string
cart_id: string
currency: string
```
---
## Component_Plan_YAML 格式
**產出時機:** 只有當 `u6u_search_components` 回報有缺件時,AI 才會產出此 YAML。不是每次都需要。
### 格式說明
```yaml
components:
- component_id: <零件唯一 IDkebab-case>
name: <零件名稱>
triplet: "<subject> <predicate> <object>"
description: <零件功能說明,AI 用此進行語意匹配>
tags:
- <tag 名稱(選填)>
# 選擇其中一種定義方式:
# 方式一:API Config(直接呼叫外部 API
api_config:
method: POST
url: https://api.example.com/endpoint
headers:
Content-Type: application/json
body_template:
key: "{{context.value}}"
# 方式二:Gherkin(行為驅動開發規格,功能型零件使用)
gherkin: |
Feature: <功能名稱>
Scenario: <情境描述>
Given <前置條件>
When <觸發動作>
Then <預期結果>
```
### 完整範例
```yaml
components:
- component_id: system-fetches-exchange-rate
name: 系統抓取匯率
triplet: "system fetches exchange-rate"
description: 呼叫銀行 API 取得指定貨幣對的當日匯率
tags:
- finance
api_config:
method: GET
url: https://api.exchangerate.host/latest
headers:
Accept: application/json
body_template:
base: "{{context.currency_pair}}"
- component_id: system-sends-telegram-notification
name: 系統發送 Telegram 通知
triplet: "system sends telegram-notification"
description: 透過 Telegram Bot API 發送訊息給指定 chat
tags:
- notification
api_config:
method: POST
url: https://api.telegram.org/bot{{env.TELEGRAM_BOT_TOKEN}}/sendMessage
headers:
Content-Type: application/json
body_template:
chat_id: "{{context.chat_id}}"
text: "{{context.message}}"
- component_id: payment-service-processes-payment
name: 付款服務處理交易
triplet: "payment-service processes payment"
description: 呼叫付款閘道 API 處理信用卡交易
tags:
- payment
- ecommerce
api_config:
method: POST
url: https://api.payment-gateway.com/v1/charges
headers:
Content-Type: application/json
Authorization: "Bearer {{env.PAYMENT_API_KEY}}"
body_template:
amount: "{{context.total_price}}"
currency: "{{context.currency}}"
card_token: "{{context.payment_token}}"
- component_id: system-sends-confirmation-email
name: 系統寄送確認信
triplet: "system sends confirmation-email"
description: 透過 Email 服務寄送訂單確認信給使用者
tags:
- notification
- ecommerce
gherkin: |
Feature: 訂單確認信
Scenario: 成功寄送確認信
Given 訂單已建立,且 context 包含 user_email 與 order_id
When system sends confirmation-email
Then Email 服務收到寄信請求
And 使用者收到包含 order_id 的確認信
And 回傳 { success: true, message_id: "<id>" }
```
---
## 工作流(Workflow)部署流程
### 步驟一:取得 Workflow_Plan_YAML
向 AI 描述業務需求,AI 輸出 YAML。存入 `workflows/` 目錄。
### 步驟二:確認零件完整性
呼叫 `u6u_search_components`,傳入所有 triplet
```json
{
"triplets": [
"system fetches exchange-rate",
"system parses rate-data",
"system sends telegram-notification"
]
}
```
回應會告知哪些零件已存在、哪些缺失。若有缺件,先完成[零件開發流程](#零件component開發流程)。
### 步驟三:沙盒測試
```json
// u6u_execute_workflow
{
"triplets": [
"system fetches exchange-rate",
"system parses rate-data",
"system sends telegram-notification"
],
"context": {
"currency_pair": "USD/TWD",
"chat_id": "123456789"
}
}
```
### 步驟四:正式部署
```json
// u6u_deploy_workflow
{
"yaml_content": "workflow:\n name: exchange-rate-notify\n ..."
}
```
部署成功後,系統回傳 `workflow_id`,並自動記錄 metadata 至 KBDB。
### 步驟五:加上 Tag(選填)
```json
// u6u_tag_resource
{
"resource_type": "workflow",
"resource_id": "wf-abc123",
"tag_name": "finance"
}
```
### 查詢已部署的工作流
```
u6u_list_workflows → 列出所有工作流
u6u_list_workflows(tag=finance) → 按 tag 篩選
u6u_get_workflow(workflow_id) → 取得特定工作流 metadata
```
---
## 零件(Component)開發流程
### 步驟一:確認缺件
`u6u_search_components` 回報缺件後,AI 產出 Component_Plan_YAML。存入 `components/` 目錄。
### 步驟二:發佈零件
```json
// u6u_publish_componentAPI Config 方式)
{
"component_id": "system-fetches-exchange-rate",
"api_config": {
"method": "GET",
"url": "https://api.exchangerate.host/latest"
}
}
```
```json
// u6u_publish_componentGherkin 方式)
{
"component_id": "system-sends-confirmation-email",
"gherkin": "Feature: 訂單確認信\n Scenario: ..."
}
```
### 步驟三:加上 Tag(選填)
```json
// u6u_tag_resource
{
"resource_type": "component",
"resource_id": "system-fetches-exchange-rate",
"tag_name": "finance"
}
```
### 查詢已發佈的零件
```
u6u_list_components → 列出所有零件
u6u_list_components(tag=payment) → 按 tag 篩選
u6u_get_component(component_id) → 取得特定零件 metadata
```
---
## Tag 管理說明
Tag 是用戶自訂的標籤,可附加至工作流或零件,用於分類與篩選。
### Tag 操作
```json
// 建立 tag
// u6u_create_tag
{ "name": "finance", "description": "金融相關" }
// 列出所有 tag
// u6u_list_tags(無需參數)
{}
// 刪除 tag(不影響已打上此 tag 的資源關聯)
// u6u_delete_tag
{ "tag_name": "deprecated-tag" }
// 為資源加上 tag
// u6u_tag_resource
{
"resource_type": "workflow", // 或 "component"
"resource_id": "wf-abc123",
"tag_name": "finance"
}
// 移除資源的 tag
// u6u_untag_resource
{
"resource_type": "component",
"resource_id": "system-fetches-exchange-rate",
"tag_name": "beta"
}
```
### Tag 管理建議
- 專案開始前先規劃 tag 命名規則,保持一致性
- 用有意義的名稱(`user-auth``auth` 好)
- 同一資源可附加多個 tag,靈活組合篩選
- 定期清理不再使用的 tag
+200
View File
@@ -0,0 +1,200 @@
# Arcrun MCP Server
> Arcrun 是 **AI 優先(AI-First** 的工作流自動化平台。
> 跟 AI 描述你的意圖,Arcrun 幫你把它變成可重複執行、不需要 AI 的自動化工作流。
Arcrun 是反過來的 n8n。n8n 從手寫程式開始,Arcrun 從 AI 描述開始——你說「去抓銀行匯率,用 Telegram 通知我」,AI 把它拆成三元組,Arcrun 查零件庫、組裝、執行。第一次需要 AI,之後自動跑,不再花 Token。
本 repo 是 Arcrun 的 **MCP Server**,讓 Claude Desktop、Cursor 等 AI client 能直接呼叫 Arcrun 的工作流與零件管理功能。
---
## 快速上手
### 取得 API Key
API Key 是由邀請者提供,格式為 `pk_live_...`
### 連線設定
#### Claude Desktop
編輯 `~/Library/Application Support/Claude/claude_desktop_config.json`macOS):
```json
{
"mcpServers": {
"arcrun": {
"type": "http",
"url": "https://mcp.finally.click/mcp",
"headers": {
"Authorization": "Bearer pk_live_YOUR_API_KEY"
}
}
}
}
```
#### Cursor
在 Cursor 的 MCP 設定中新增:
```json
{
"arcrun": {
"type": "http",
"url": "https://mcp.finally.click/mcp",
"headers": {
"Authorization": "Bearer pk_live_YOUR_API_KEY"
}
}
}
```
> 使用 `type: http`Streamable HTTP transport)。舊版 SSE 格式(`type: sse`)已不支援。
---
## MCP Tools 說明
### 零件開發(WASM
零件是 Arcrun 的最小執行單元,以 TinyGo 編譯為 `.wasm`,透過 stdin/stdout JSON 通訊。
| Tool | 說明 |
|------|------|
| `u6u_get_component_guide` | **開發新零件前必須先呼叫。** 取得 TinyGo 開發指引,包含白名單 import、禁止行為、contract YAML 範例、本地測試指令。 |
| `u6u_search_components` | 用自然語言語意搜尋零件庫。例如:「查詢 Google Sheets 資料」、「發送 LINE 訊息」。回傳零件清單含 canonical_id、描述、評分。 |
| `u6u_get_component` | 取得指定零件的完整合約(input_schema、output_schema、gherkin_tests、評分統計等)。 |
| `u6u_publish_component` | 提交 TinyGo WASM 零件。需提供 `contract`(合約物件)與 `wasm_base64`(編譯後的 .wasm base64)。Registry 自動執行沙盒驗收。 |
### 工作流執行
| Tool | 說明 |
|------|------|
| `u6u_execute_workflow` | 在沙盒中執行工作流。輸入 `triplets`(三元組陣列)與 `context`,用於部署前驗證。 |
| `u6u_deploy_workflow` | 將工作流 YAML 部署至雲端引擎。輸入 `yaml_content`。 |
### 工作流管理
| Tool | 說明 |
|------|------|
| `u6u_list_workflows` | 列出已部署的工作流。可傳入選填的 `tag` 參數篩選。 |
| `u6u_get_workflow` | 取得指定工作流的 metadata。輸入 `workflow_id`。 |
### 零件管理
| Tool | 說明 |
|------|------|
| `u6u_list_components` | 列出已發佈的零件。可傳入選填的 `tag` 參數篩選。 |
### Tag 管理
| Tool | 說明 |
|------|------|
| `u6u_create_tag` | 建立新 tag。輸入 `name`(必填)與 `description`(選填)。 |
| `u6u_list_tags` | 列出當前命名空間下所有 tag。 |
| `u6u_delete_tag` | 刪除指定 tag。輸入 `tag_name`。 |
| `u6u_tag_resource` | 為工作流或零件加上 tag。輸入 `resource_type``resource_id``tag_name`。 |
| `u6u_untag_resource` | 移除工作流或零件的 tag。 |
---
## 零件開發流程(WASM
Arcrun 的零件是 TinyGo 編譯的 `.wasm`,透過 stdin/stdout JSON 通訊,可在 Cloudflare WorkersTier 1/2)和 Wazero 邊緣環境(Tier 3)執行。
### 步驟一:取得開發指引
```
u6u_get_component_guide
```
指引包含:TinyGo 白名單 import、禁止行為、`component.contract.yaml` 完整範例、本地測試指令。
### 步驟二:搜尋現有零件
```
u6u_search_components("查詢 Google Sheets 資料")
```
若已有符合的零件,直接使用,不需要重新開發。
### 步驟三:開發零件(若缺件)
依指引用 TinyGo 撰寫零件,只使用白名單 import:
```go
import (
"os"
"io"
"encoding/json"
)
```
編譯:
```bash
tinygo build -o my_component.wasm -target=wasi .
```
本地測試:
```bash
echo '{"input_field":"value"}' | wasmtime my_component.wasm
```
### 步驟四:提交零件
```
u6u_publish_component(
contract={...}, // component.contract.yaml 內容
wasm_base64="..." // base64(my_component.wasm)
)
```
Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)。
---
## 工作流開發流程
### 步驟一:搜尋零件
```
u6u_search_components("查詢匯率")
u6u_search_components("發送 Telegram 訊息")
```
### 步驟二:沙盒測試
```
u6u_execute_workflow(
triplets=["system >> 查詢匯率 >> get-exchange-rate", ...],
context={"currency_pair": "USD/TWD"}
)
```
### 步驟三:部署
```
u6u_deploy_workflow(yaml_content="...")
```
---
## Inspector 測試界面
開啟 [https://mcp.finally.click/inspector](https://mcp.finally.click/inspector) 即可在瀏覽器中互動式測試所有 MCP tools。
---
## 搭配 arcrun-gui 使用
[arcrun-gui](../arcrun-gui) 是 Arcrun 的人類操作介面,與 arcrun-mcp 共享同一個 KBDB 狀態:
- AI 透過 arcrun-mcp 操作(搜尋零件、執行 Workflow)
- 人類透過 arcrun-gui 操作(拖拉畫布、查看零件庫)
- AI 的操作結果即時反映在 arcrun-gui 的畫布上
詳細開發指南請參閱 **[GUIDE.md](./GUIDE.md)**。
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@inkstone/arcrun-mcp",
"version": "1.0.0",
"description": "u6u Remote MCP Server — 基於 Cloudflare Workers 的工作流管理工具",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:dry": "wrangler deploy --dry-run",
"test": "vitest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"hono": "^4.0.0",
"yaml": "^2.9.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0",
"fast-check": "^4.6.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0",
"wrangler": "^4.68.1"
}
}
+2523
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "u6u Component Definition",
"type": "object",
"required": ["component_id", "gherkin"],
"properties": {
"component_id": { "type": "string" },
"gherkin": { "type": "string" }
}
}
+242
View File
@@ -0,0 +1,242 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./types.js";
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
import { handleMcpRequest } from "./mcp-handler.js";
import { inspectorHtml } from "./pages/inspector.js";
import { kbdbFetch } from "./lib/kbdb-client.js";
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
const app = _app.basePath('/mcp');
app.use("*", cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Content-Type"],
maxAge: 600,
}));
app.get("/", (c) => c.text("u6u MCP Server is running."));
app.get("/inspector", (c) => {
return c.html(inspectorHtml);
});
// ── GUI 認證端點 ───────────────────────────────────────────────────────────────
// GET /auth/verify — GUI 登入驗證,重用 partnerAuthMiddleware
app.get("/auth/verify", partnerAuthMiddleware, (c) => {
const orgNamespace = c.get("org_namespace");
return c.json({ valid: true, org_namespace: orgNamespace });
});
// ── GUI REST 端點(與 MCP tools 平行) ────────────────────────────────────────
// GET /workflows — 列出 Workflow 清單(GUI 用)
app.get("/workflows", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const resp = await kbdbFetch(
c.env,
`/records/search?template=workflow_metadata&user_id=${encodeURIComponent(orgNamespace)}`
);
if (!resp.ok) return c.json({ workflows: [] });
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
const workflows = (data.records ?? []).map(r => ({
id: r.id,
name: (r.slots?.display_name as string | undefined) ?? (r.slots?.name as string | undefined) ?? r.id,
last_run: r.slots?.last_run as string | undefined,
status: r.slots?.status as string | undefined,
slots: r.slots,
}));
return c.json({ workflows });
} catch {
return c.json({ workflows: [] });
}
});
// GET /workflows/:id — 取得單一 WorkflowGUI poll 用)
app.get("/workflows/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
if (!resp.ok) return c.json({ error: "Not found" }, 404);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
name: (data.slots?.display_name as string | undefined) ?? data.id,
slots: data.slots,
});
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// POST /action-log — GUI 寫入用戶動作記錄
app.post("/action-log", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const body = await c.req.json<{
action_type: string;
payload?: Record<string, unknown>;
occurred_at?: string;
}>();
const occurred_at = body.occurred_at ?? new Date().toISOString();
await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_id: "tpl-action-log",
user_id: orgNamespace,
slots: {
org_namespace: orgNamespace,
action_type: body.action_type,
payload: JSON.stringify(body.payload ?? {}),
occurred_at,
},
}),
});
return c.json({ ok: true });
} catch {
return c.json({ ok: false }, 500);
}
});
// ── Prototype Pages REST 端點 ─────────────────────────────────────────────────
// GET /prototype-pages — 列出 Prototype Pages
app.get("/prototype-pages", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const resp = await kbdbFetch(
c.env,
`/records/search?template=tpl-page-block&user_id=${encodeURIComponent(orgNamespace)}`
);
if (!resp.ok) return c.json({ pages: [] });
const data = await resp.json<{ records: Array<{ id: string; slots?: Record<string, unknown> }> }>();
const pages = (data.records ?? []).map(r => ({
id: r.id,
page_name: (r.slots?.page_name as string | undefined) ?? 'Untitled',
components_json: (r.slots?.components_json as string | undefined) ?? '[]',
last_edited_by: (r.slots?.last_edited_by as string | undefined) ?? 'gui',
last_edited_at: (r.slots?.last_edited_at as string | undefined) ?? '',
status: (r.slots?.status as string | undefined) ?? 'draft',
}));
return c.json({ pages });
} catch {
return c.json({ pages: [] });
}
});
// POST /prototype-pages — 建立新 Prototype Page
app.post("/prototype-pages", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
try {
const body = await c.req.json<{ page_name?: string }>();
const page_name = body.page_name ?? 'Untitled';
const now = new Date().toISOString();
const resp = await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template_id: "tpl-page-block",
user_id: orgNamespace,
slots: {
page_name,
org_namespace: orgNamespace,
components_json: '[]',
last_edited_by: 'gui',
last_edited_at: now,
status: 'draft',
},
}),
});
if (!resp.ok) return c.json({ error: "Failed to create" }, 500);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
page_name,
components_json: '[]',
last_edited_by: 'gui',
last_edited_at: now,
status: 'draft',
}, 201);
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// GET /prototype-pages/:id — 取得單一 Prototype Page
app.get("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`);
if (!resp.ok) return c.json({ error: "Not found" }, 404);
const data = await resp.json<{ id: string; slots?: Record<string, unknown> }>();
return c.json({
id: data.id,
page_name: (data.slots?.page_name as string | undefined) ?? 'Untitled',
components_json: (data.slots?.components_json as string | undefined) ?? '[]',
last_edited_by: (data.slots?.last_edited_by as string | undefined) ?? 'gui',
last_edited_at: (data.slots?.last_edited_at as string | undefined) ?? '',
status: (data.slots?.status as string | undefined) ?? 'draft',
});
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// PUT /prototype-pages/:id — 儲存 Prototype Page
app.put("/prototype-pages/:id", partnerAuthMiddleware, async (c) => {
const id = c.req.param("id") ?? '';
try {
if (!id) return c.json({ error: "Missing id" }, 400);
const body = await c.req.json<{
components_json?: string;
page_name?: string;
}>();
const now = new Date().toISOString();
const slots: Record<string, unknown> = {
last_edited_by: 'gui',
last_edited_at: now,
};
if (body.components_json !== undefined) slots.components_json = body.components_json;
if (body.page_name !== undefined) slots.page_name = body.page_name;
const resp = await kbdbFetch(c.env, `/records/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slots }),
});
if (!resp.ok) return c.json({ error: "Failed to save" }, 500);
return c.json({ ok: true });
} catch {
return c.json({ error: "Internal error" }, 500);
}
});
// ── MCP 端點 ──────────────────────────────────────────────────────────────────
app.options("/mcp", (c) => {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
});
app.post("/mcp", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
});
export default app;
+101
View File
@@ -0,0 +1,101 @@
/**
* Cypher-executor service binding wrapper — LI SDD M2.2
*
* 對應 .agents/specs/llm-interface/ Milestone 2.2。
* 統一 arcrun-mcp 對 cypher-executor 的呼叫,預設 fetch 樣板 + auth header 注入。
*
* arcrun 平台「ak_」級 api_key 跟 MCP 「pk_live」級 token 是兩層 auth
* - pk_live (partner-auth middleware) → org_namespaceMCP 自己用)
* - ak_xxx (X-Arcrun-API-Key) → cypher-executor workflow 操作
*
* 此 client 統一處理 ak_xxx 注入 + error contract 化(給 AI 看的 next_actions)。
*/
import type { Env } from "../types.js";
export interface CypherCallOpts {
apiKey: string;
method?: string;
body?: unknown;
query?: Record<string, string | number>;
}
export async function cypherFetch(
env: Env,
path: string,
opts: CypherCallOpts,
): Promise<Response> {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`http://cypher-executor${path}`);
if (opts.query) {
for (const [k, v] of Object.entries(opts.query)) {
url.searchParams.set(k, String(v));
}
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
"X-Arcrun-API-Key": opts.apiKey,
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
}
/**
* 統一 error response 格式化(LI SDD §1.3
*
* 用法:
* const res = await cypherFetch(...);
* if (!res.ok) return errorResponse('not_found', `...`, [...], await res.text());
*/
export function errorResponse(
error_code: string,
human_message: string,
next_actions: string[],
detail?: string,
): {
content: { type: "text"; text: string }[];
isError: true;
} {
return {
content: [
{
type: "text",
text: JSON.stringify(
{ ok: false, error_code, human_message, next_actions, detail },
null,
2,
),
},
],
isError: true,
};
}
/**
* 成功 response 格式化
*/
export function successResponse(
data: unknown,
hints?: string[],
): {
content: { type: "text"; text: string }[];
} {
return {
content: [
{
type: "text",
text: JSON.stringify(
{ ok: true, data, ...(hints ? { hints } : {}) },
null,
2,
),
},
],
};
}
+13
View File
@@ -0,0 +1,13 @@
import { Env } from "../types.js";
/**
* Wrapper around env.KBDB.fetch that automatically injects
* the KBDB_INTERNAL_TOKEN Authorization header.
*/
export function kbdbFetch(env: Env, path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers((init?.headers as HeadersInit) || {});
if (env.KBDB_INTERNAL_TOKEN) {
headers.set("Authorization", `Bearer ${env.KBDB_INTERNAL_TOKEN}`);
}
return env.KBDB.fetch(`http://kbdb${path}`, { ...init, headers });
}
+19
View File
@@ -0,0 +1,19 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { registerAllTools } from "./tools/registry.js";
import { Env } from "./types.js";
export async function handleMcpRequest(
request: Request,
env: Env,
orgNamespace: string,
partnerToken: string,
): Promise<Response> {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = new McpServer({ name: "u6u-mcp-server", version: "1.0.0" });
registerAllTools(server, env, orgNamespace, partnerToken);
await server.connect(transport);
return transport.handleRequest(request);
}
+35
View File
@@ -0,0 +1,35 @@
import { Context, Next } from "hono";
import { Env } from "../types.js";
export async function partnerAuthMiddleware(
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
next: Next
) {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401);
}
const token = authHeader.slice(7);
const resp = await c.env.KBDB.fetch(
`http://kbdb/partners/${encodeURIComponent(token)}/info`,
{
headers: {
'Authorization': `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`
}
}
);
if (!resp.ok) {
return c.json({ error: 'Invalid or expired partner key' }, 401);
}
const info = await resp.json<{ valid: boolean; org_namespace: string }>();
if (!info.valid) {
return c.json({ error: 'Invalid or expired partner key' }, 401);
}
c.set('org_namespace', info.org_namespace);
c.set('partner_token', token); // 給下游(cypher-executor / KBDB)轉發用
await next();
}
+674
View File
@@ -0,0 +1,674 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>u6u MCP Server 測試界面</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3350;
--accent: #6c8ef5;
--accent-hover: #8aa4ff;
--text: #e2e8f0;
--text-muted: #8892a4;
--error-bg: #3b1a1a;
--error-border: #c0392b;
--error-text: #ff6b6b;
--success-bg: #1a2e1a;
--success-border: #27ae60;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.logo {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.logo span { color: var(--text-muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
.api-key-group {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
.api-key-group label {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.api-key-group input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 7px 12px;
width: 280px;
outline: none;
transition: border-color 0.15s;
}
.api-key-group input:focus { border-color: var(--accent); }
.key-status {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
transition: background 0.2s;
}
.key-status.active { background: #27ae60; }
main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
aside {
width: 260px;
min-width: 200px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.tool-count {
background: var(--surface2);
border-radius: 10px;
padding: 2px 7px;
font-size: 11px;
color: var(--text-muted);
}
.tool-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.tool-item {
padding: 9px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-muted);
transition: background 0.1s, color 0.1s;
word-break: break-all;
}
.tool-item:hover { background: var(--surface2); color: var(--text); }
.tool-item.active { background: var(--accent); color: #fff; }
.tool-list-loading {
padding: 16px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
/* Content area */
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel {
flex: 1;
display: flex;
gap: 0;
overflow: hidden;
}
/* Form panel */
.form-panel {
flex: 1;
padding: 24px;
overflow-y: auto;
border-right: 1px solid var(--border);
min-width: 0;
}
.tool-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
}
.tool-description {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 24px;
line-height: 1.5;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 8px;
}
.empty-state .icon { font-size: 40px; }
.empty-state p { font-size: 14px; }
.field-group {
margin-bottom: 18px;
}
.field-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.field-label .optional {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
}
.field-label .type-badge {
font-size: 10px;
font-family: var(--mono);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text-muted);
}
.field-desc {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
line-height: 1.4;
}
input[type="text"], textarea, select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 9px 12px;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus, textarea:focus, select:focus {
border-color: var(--accent);
}
textarea {
font-family: var(--mono);
font-size: 12px;
resize: vertical;
min-height: 80px;
}
select option { background: var(--surface2); }
.submit-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
margin-top: 8px;
}
.submit-btn:hover { background: var(--accent-hover); }
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Response panel */
.response-panel {
flex: 1;
padding: 24px;
overflow-y: auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.response-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.response-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.status-ok { background: var(--success-bg); color: #27ae60; border: 1px solid var(--success-border); }
.status-err { background: var(--error-bg); color: var(--error-text); border: 1px solid var(--error-border); }
.response-body {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
overflow-y: auto;
min-height: 200px;
}
.response-body.is-error {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.response-empty {
color: var(--text-muted);
font-style: italic;
}
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
@media (max-width: 768px) {
aside { width: 200px; }
.panel { flex-direction: column; }
.form-panel { border-right: none; border-bottom: 1px solid var(--border); }
.api-key-group input { width: 200px; }
}
</style>
</head>
<body>
<header>
<div class="logo">u6u MCP <span>Server 測試界面</span></div>
<div class="api-key-group">
<label for="apiKey">API Key</label>
<div class="key-status" id="keyStatus"></div>
<input type="text" id="apiKey" placeholder="輸入 Bearer Token..." autocomplete="off" spellcheck="false">
</div>
</header>
<main>
<aside>
<div class="sidebar-header">
Tools
<span class="tool-count" id="toolCount"></span>
</div>
<div class="tool-list" id="toolList">
<div class="tool-list-loading">載入中…</div>
</div>
</aside>
<div class="content">
<div class="panel">
<div class="form-panel" id="formPanel">
<div class="empty-state">
<div class="icon">🔧</div>
<p>從左側選擇一個工具</p>
</div>
</div>
<div class="response-panel">
<div class="response-header">
Response
<span class="response-status" id="responseStatus" style="display:none"></span>
</div>
<div class="response-body response-empty" id="responseBody">尚未發送請求</div>
</div>
</div>
</div>
</main>
<script>
const BASE_URL = '';
let tools = [];
let selectedTool = null;
let reqId = 1;
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
const keyStatus = document.getElementById('keyStatus');
const toolList = document.getElementById('toolList');
const toolCount = document.getElementById('toolCount');
const formPanel = document.getElementById('formPanel');
const responseBody = document.getElementById('responseBody');
const responseStatus = document.getElementById('responseStatus');
let loadToolsDebounce = null;
apiKeyInput.addEventListener('input', () => {
const key = apiKeyInput.value.trim();
keyStatus.classList.toggle('active', key.length > 0);
clearTimeout(loadToolsDebounce);
if (key.length > 0) {
loadToolsDebounce = setTimeout(loadTools, 400);
} else {
toolList.innerHTML = '<div class="tool-list-loading">請先輸入 API Key</div>';
toolCount.textContent = '—';
tools = [];
}
});
function getHeaders() {
const h = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
};
const key = apiKeyInput.value.trim();
if (key) h['Authorization'] = 'Bearer ' + key;
return h;
}
async function loadTools() {
try {
const res = await fetch(BASE_URL + '/mcp', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ jsonrpc: '2.0', id: reqId++, method: 'tools/list', params: {} })
});
const text = await res.text();
const data = parseMcpResponse(text);
tools = (data.result && data.result.tools) || [];
renderToolList();
} catch (e) {
toolList.innerHTML = '<div class="tool-list-loading" style="color:#ff6b6b">載入失敗:' + e.message + '</div>';
}
}
function parseMcpResponse(text) {
const dataLine = text.split('\n').find(l => l.startsWith('data: '));
if (dataLine) {
try { return JSON.parse(dataLine.slice(6)); } catch {}
}
try { return JSON.parse(text); } catch {}
return {};
}
function renderToolList() {
toolCount.textContent = tools.length;
if (!tools.length) {
toolList.innerHTML = '<div class="tool-list-loading">無可用工具</div>';
return;
}
toolList.innerHTML = tools.map((t, i) =>
'<div class="tool-item" data-index="' + i + '">' + escHtml(t.name) + '</div>'
).join('');
toolList.querySelectorAll('.tool-item').forEach(el => {
el.addEventListener('click', () => selectTool(parseInt(el.dataset.index)));
});
}
function selectTool(index) {
selectedTool = tools[index];
toolList.querySelectorAll('.tool-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
});
renderForm(selectedTool);
clearResponse();
}
function renderForm(tool) {
const schema = tool.inputSchema || {};
const props = schema.properties || {};
const required = schema.required || [];
let html = '<div class="tool-title">' + escHtml(tool.name) + '</div>';
if (tool.description) {
html += '<div class="tool-description">' + escHtml(tool.description) + '</div>';
}
const keys = Object.keys(props);
if (keys.length === 0) {
html += '<p style="color:var(--text-muted);font-size:13px;margin-bottom:16px;">此工具無需輸入參數</p>';
} else {
keys.forEach(key => {
const prop = props[key];
const isRequired = required.includes(key);
const type = prop.type || 'string';
const fieldId = 'field_' + key;
html += '<div class="field-group">';
html += '<div class="field-label">';
html += '<label for="' + fieldId + '">' + escHtml(key) + '</label>';
html += '<span class="type-badge">' + escHtml(type) + '</span>';
if (!isRequired) html += '<span class="optional">(optional)</span>';
html += '</div>';
if (prop.description) {
html += '<div class="field-desc">' + escHtml(prop.description) + '</div>';
}
html += renderField(fieldId, key, prop, type);
html += '</div>';
});
}
html += '<button class="submit-btn" id="submitBtn" onclick="submitTool()">送出請求</button>';
formPanel.innerHTML = html;
}
function renderField(id, key, prop, type) {
// string with enum → select
if (type === 'string' && prop.enum && prop.enum.length > 0) {
let opts = prop.enum.map(v =>
'<option value="' + escAttr(v) + '">' + escHtml(v) + '</option>'
).join('');
return '<select id="' + id + '" data-key="' + escAttr(key) + '" data-type="enum">' + opts + '</select>';
}
// array → textarea
if (type === 'array') {
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="array" placeholder="[&quot;item1&quot;,&quot;item2&quot;]"></textarea>';
}
// object → textarea (JSON)
if (type === 'object') {
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="object" placeholder="{&quot;key&quot;:&quot;value&quot;}"></textarea>';
}
// default: string → input text
return '<input type="text" id="' + id + '" data-key="' + escAttr(key) + '" data-type="string" placeholder="' + escAttr(prop.description || '') + '">';
}
async function submitTool() {
if (!selectedTool) return;
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> 送出中…';
const schema = selectedTool.inputSchema || {};
const props = schema.properties || {};
const required = schema.required || [];
const args = {};
let valid = true;
Object.keys(props).forEach(key => {
const el = document.getElementById('field_' + key);
if (!el) return;
const dtype = el.dataset.type;
const raw = el.value.trim();
if (!raw) {
if (required.includes(key)) {
el.style.borderColor = 'var(--error-border)';
valid = false;
}
return;
}
el.style.borderColor = '';
if (dtype === 'array' || dtype === 'object') {
try {
args[key] = JSON.parse(raw);
} catch {
el.style.borderColor = 'var(--error-border)';
valid = false;
}
} else {
args[key] = raw;
}
});
if (!valid) {
btn.disabled = false;
btn.textContent = '送出請求';
showResponse({ error: { message: '請修正標紅的欄位(必填或 JSON 格式錯誤)' } }, false);
return;
}
const payload = {
jsonrpc: '2.0',
id: reqId++,
method: 'tools/call',
params: { name: selectedTool.name, arguments: args }
};
try {
const res = await fetch(BASE_URL + '/mcp', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
const text = await res.text();
const data = parseMcpResponse(text);
const isError = (data.result && data.result.isError) || !!data.error;
showResponse(data, isError);
} catch (e) {
showResponse({ error: { message: e.message } }, true);
} finally {
btn.disabled = false;
btn.textContent = '送出請求';
}
}
function showResponse(data, isError) {
responseBody.textContent = JSON.stringify(data, null, 2);
responseBody.classList.toggle('is-error', isError);
responseBody.classList.remove('response-empty');
responseStatus.style.display = '';
if (isError) {
responseStatus.textContent = 'Error';
responseStatus.className = 'response-status status-err';
} else {
responseStatus.textContent = 'OK';
responseStatus.className = 'response-status status-ok';
}
}
function clearResponse() {
responseBody.textContent = '尚未發送請求';
responseBody.classList.remove('is-error');
responseBody.classList.add('response-empty');
responseStatus.style.display = 'none';
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escAttr(s) {
return String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Don't auto-load on page open — wait for API Key input
toolList.innerHTML = '<div class="tool-list-loading">請先輸入 API Key</div>';
window.submitTool = submitTool;
}); // end DOMContentLoaded
</script>
</body>
</html>
+661
View File
@@ -0,0 +1,661 @@
// Auto-generated: exports inspector.html content as a string for Cloudflare Workers
// Source of truth is inspector.html — keep in sync manually or via build step
export const inspectorHtml = `<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>u6u MCP Server 測試界面</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3350;
--accent: #6c8ef5;
--accent-hover: #8aa4ff;
--text: #e2e8f0;
--text-muted: #8892a4;
--error-bg: #3b1a1a;
--error-border: #c0392b;
--error-text: #ff6b6b;
--success-bg: #1a2e1a;
--success-border: #27ae60;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.logo {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.logo span { color: var(--text-muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
.api-key-group {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
.api-key-group label {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.api-key-group input {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 7px 12px;
width: 280px;
outline: none;
transition: border-color 0.15s;
}
.api-key-group input:focus { border-color: var(--accent); }
.key-status {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
transition: background 0.2s;
}
.key-status.active { background: #27ae60; }
main {
display: flex;
flex: 1;
overflow: hidden;
}
aside {
width: 260px;
min-width: 200px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.tool-count {
background: var(--surface2);
border-radius: 10px;
padding: 2px 7px;
font-size: 11px;
color: var(--text-muted);
}
.tool-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.tool-item {
padding: 9px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text-muted);
transition: background 0.1s, color 0.1s;
word-break: break-all;
}
.tool-item:hover { background: var(--surface2); color: var(--text); }
.tool-item.active { background: var(--accent); color: #fff; }
.tool-list-loading {
padding: 16px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel {
flex: 1;
display: flex;
gap: 0;
overflow: hidden;
}
.form-panel {
flex: 1;
padding: 24px;
overflow-y: auto;
border-right: 1px solid var(--border);
min-width: 0;
}
.tool-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
}
.tool-description {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 24px;
line-height: 1.5;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 8px;
}
.empty-state .icon { font-size: 40px; }
.empty-state p { font-size: 14px; }
.field-group { margin-bottom: 18px; }
.field-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.field-label .optional {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
}
.field-label .type-badge {
font-size: 10px;
font-family: var(--mono);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text-muted);
}
.field-desc {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
line-height: 1.4;
}
input[type="text"], textarea, select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 9px 12px;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus, textarea:focus, select:focus { border-color: var(--accent); }
textarea {
font-family: var(--mono);
font-size: 12px;
resize: vertical;
min-height: 80px;
}
select option { background: var(--surface2); }
.submit-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
margin-top: 8px;
}
.submit-btn:hover { background: var(--accent-hover); }
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.response-panel {
flex: 1;
padding: 24px;
overflow-y: auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.response-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.response-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.status-ok { background: var(--success-bg); color: #27ae60; border: 1px solid var(--success-border); }
.status-err { background: var(--error-bg); color: var(--error-text); border: 1px solid var(--error-border); }
.response-body {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
overflow-y: auto;
min-height: 200px;
}
.response-body.is-error {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.response-empty { color: var(--text-muted); font-style: italic; }
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
@media (max-width: 768px) {
aside { width: 200px; }
.panel { flex-direction: column; }
.form-panel { border-right: none; border-bottom: 1px solid var(--border); }
.api-key-group input { width: 200px; }
}
</style>
</head>
<body>
<header>
<div class="logo">u6u MCP <span>Server 測試界面</span></div>
<div class="api-key-group">
<label for="apiKey">API Key</label>
<div class="key-status" id="keyStatus"></div>
<input type="text" id="apiKey" placeholder="輸入 Bearer Token..." autocomplete="off" spellcheck="false">
</div>
</header>
<main>
<aside>
<div class="sidebar-header">
Tools
<span class="tool-count" id="toolCount">—</span>
</div>
<div class="tool-list" id="toolList">
<div class="tool-list-loading">載入中…</div>
</div>
</aside>
<div class="content">
<div class="panel">
<div class="form-panel" id="formPanel">
<div class="empty-state">
<div class="icon">🔧</div>
<p>從左側選擇一個工具</p>
</div>
</div>
<div class="response-panel">
<div class="response-header">
Response
<span class="response-status" id="responseStatus" style="display:none"></span>
</div>
<div class="response-body response-empty" id="responseBody">尚未發送請求</div>
</div>
</div>
</div>
</main>
<script>
const BASE_URL = '';
let tools = [];
let selectedTool = null;
let reqId = 1;
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
const keyStatus = document.getElementById('keyStatus');
const toolList = document.getElementById('toolList');
const toolCount = document.getElementById('toolCount');
const formPanel = document.getElementById('formPanel');
const responseBody = document.getElementById('responseBody');
const responseStatus = document.getElementById('responseStatus');
let loadToolsDebounce = null;
apiKeyInput.addEventListener('input', () => {
const key = apiKeyInput.value.trim();
keyStatus.classList.toggle('active', key.length > 0);
clearTimeout(loadToolsDebounce);
if (key.length > 0) {
loadToolsDebounce = setTimeout(loadTools, 400);
} else {
toolList.innerHTML = '<div class="tool-list-loading">請先輸入 API Key</div>';
toolCount.textContent = '—';
tools = [];
}
});
function getHeaders() {
const h = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
};
const key = apiKeyInput.value.trim();
if (key) h['Authorization'] = 'Bearer ' + key;
return h;
}
async function loadTools() {
try {
const res = await fetch(BASE_URL + '/mcp', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ jsonrpc: '2.0', id: reqId++, method: 'tools/list', params: {} })
});
const text = await res.text();
const data = parseMcpResponse(text);
tools = (data.result && data.result.tools) || [];
renderToolList();
} catch (e) {
toolList.innerHTML = '<div class="tool-list-loading" style="color:#ff6b6b">載入失敗:' + e.message + '</div>';
}
}
function parseMcpResponse(text) {
const dataLine = text.split('\\n').find(function(l) { return l.startsWith('data: '); });
if (dataLine) {
try { return JSON.parse(dataLine.slice(6)); } catch(e) {}
}
try { return JSON.parse(text); } catch(e) {}
return {};
}
function renderToolList() {
toolCount.textContent = tools.length;
if (!tools.length) {
toolList.innerHTML = '<div class="tool-list-loading">無可用工具</div>';
return;
}
toolList.innerHTML = tools.map((t, i) =>
'<div class="tool-item" data-index="' + i + '">' + escHtml(t.name) + '</div>'
).join('');
toolList.querySelectorAll('.tool-item').forEach(el => {
el.addEventListener('click', () => selectTool(parseInt(el.dataset.index)));
});
}
function selectTool(index) {
selectedTool = tools[index];
toolList.querySelectorAll('.tool-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
});
renderForm(selectedTool);
clearResponse();
}
function renderForm(tool) {
const schema = tool.inputSchema || {};
const props = schema.properties || {};
const required = schema.required || [];
let html = '<div class="tool-title">' + escHtml(tool.name) + '</div>';
if (tool.description) {
html += '<div class="tool-description">' + escHtml(tool.description) + '</div>';
}
const keys = Object.keys(props);
if (keys.length === 0) {
html += '<p style="color:var(--text-muted);font-size:13px;margin-bottom:16px;">此工具無需輸入參數</p>';
} else {
keys.forEach(key => {
const prop = props[key];
const isRequired = required.includes(key);
const type = prop.type || 'string';
const fieldId = 'field_' + key;
html += '<div class="field-group">';
html += '<div class="field-label">';
html += '<label for="' + fieldId + '">' + escHtml(key) + '</label>';
html += '<span class="type-badge">' + escHtml(type) + '</span>';
if (!isRequired) html += '<span class="optional">(optional)</span>';
html += '</div>';
if (prop.description) {
html += '<div class="field-desc">' + escHtml(prop.description) + '</div>';
}
html += renderField(fieldId, key, prop, type);
html += '</div>';
});
}
html += '<button class="submit-btn" id="submitBtn" onclick="submitTool()">送出請求</button>';
formPanel.innerHTML = html;
}
function renderField(id, key, prop, type) {
if (type === 'string' && prop.enum && prop.enum.length > 0) {
let opts = prop.enum.map(v =>
'<option value="' + escAttr(v) + '">' + escHtml(v) + '</option>'
).join('');
return '<select id="' + id + '" data-key="' + escAttr(key) + '" data-type="enum">' + opts + '</select>';
}
if (type === 'array') {
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="array" placeholder="[&quot;item1&quot;,&quot;item2&quot;]"></textarea>';
}
if (type === 'object') {
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="object" placeholder="{&quot;key&quot;:&quot;value&quot;}"></textarea>';
}
return '<input type="text" id="' + id + '" data-key="' + escAttr(key) + '" data-type="string" placeholder="' + escAttr(prop.description || '') + '">';
}
async function submitTool() {
if (!selectedTool) return;
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> 送出中…';
const schema = selectedTool.inputSchema || {};
const props = schema.properties || {};
const required = schema.required || [];
const args = {};
let valid = true;
Object.keys(props).forEach(key => {
const el = document.getElementById('field_' + key);
if (!el) return;
const dtype = el.dataset.type;
const raw = el.value.trim();
if (!raw) {
if (required.includes(key)) {
el.style.borderColor = 'var(--error-border)';
valid = false;
}
return;
}
el.style.borderColor = '';
if (dtype === 'array' || dtype === 'object') {
try {
args[key] = JSON.parse(raw);
} catch {
el.style.borderColor = 'var(--error-border)';
valid = false;
}
} else {
args[key] = raw;
}
});
if (!valid) {
btn.disabled = false;
btn.textContent = '送出請求';
showResponse({ error: { message: '請修正標紅的欄位(必填或 JSON 格式錯誤)' } }, false);
return;
}
const payload = {
jsonrpc: '2.0',
id: reqId++,
method: 'tools/call',
params: { name: selectedTool.name, arguments: args }
};
try {
const res = await fetch(BASE_URL + '/mcp', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
const text = await res.text();
const data = parseMcpResponse(text);
const isError = (data.result && data.result.isError) || !!data.error;
showResponse(data, isError);
} catch (e) {
showResponse({ error: { message: e.message } }, true);
} finally {
btn.disabled = false;
btn.textContent = '送出請求';
}
}
function showResponse(data, isError) {
responseBody.textContent = JSON.stringify(data, null, 2);
responseBody.classList.toggle('is-error', isError);
responseBody.classList.remove('response-empty');
responseStatus.style.display = '';
if (isError) {
responseStatus.textContent = 'Error';
responseStatus.className = 'response-status status-err';
} else {
responseStatus.textContent = 'OK';
responseStatus.className = 'response-status status-ok';
}
}
function clearResponse() {
responseBody.textContent = '尚未發送請求';
responseBody.classList.remove('is-error');
responseBody.classList.add('response-empty');
responseStatus.style.display = 'none';
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escAttr(s) {
return String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Don't auto-load on page open — wait for API Key input
toolList.innerHTML = '<div class="tool-list-loading">請先輸入 API Key</div>';
window.submitTool = submitTool;
}); // end DOMContentLoaded
<\/script>
</body>
</html>`;
+213
View File
@@ -0,0 +1,213 @@
/**
* Introspection / debug MCP tools — LI SDD M2.2
*
* arcrun_validate_yaml — dry-run YAML 校驗,不部署
* arcrun_get_execution_trace — 看 paused workflow statetask_id 細節)
* arcrun_list_paused_executions — 列當前所有等 callback 的 workflow
* arcrun_list_recent_executions — 列某 workflow 最近 N 次執行 verdict
*
* 對應 cypher-executor 新路由(commit 989fbeb+ 既有 /validate。
* 所有 tool 都需要 api_key (ak_xxx) 參數 — 跟 MCP partner-auth 的 pk_live 是兩層 auth。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
const apiKeyDesc =
"你 (用戶) 的 arcrun api_key (ak_xxx)。從 https://arcrun.dev/me 取得。注意:跟 MCP 連線用的 pk_live token 是不同層 auth — pk_live 給 MCP 用,ak_xxx 給 workflow 操作用";
export function registerValidateYaml(server: McpServer, env: Env) {
server.tool(
"arcrun_validate_yaml",
"Dry-run YAML 校驗。不部署、無 side effect。回 {valid, errors?, nodeCount, edgeCount}。**永遠先 call 此 tool 再 push_workflow**,避免反覆 deploy 失敗。",
{
api_key: z.string().describe(apiKeyDesc),
graph: z.object({
nodes: z.array(z.unknown()).describe("workflow 節點陣列"),
edges: z.array(z.unknown()).describe("workflow 邊陣列 (cypher binding 三元組)"),
}).passthrough().describe("workflow graph object(已 parse 過 YAML 的結構,非 raw YAML string"),
},
async ({ api_key, graph }) => {
try {
const res = await cypherFetch(env, "/validate", {
apiKey: api_key,
method: "POST",
body: graph,
});
const body = await res.json().catch(() => null) as {
valid?: boolean;
errors?: unknown[];
nodeCount?: number;
edgeCount?: number;
} | null;
if (!res.ok || !body?.valid) {
return errorResponse(
"validation_failed",
body?.errors ? `校驗失敗,${(body.errors as unknown[]).length} 個錯誤` : `校驗失敗 HTTP ${res.status}`,
[
"依 errors 陣列逐項修改 YAML",
"若 errors 提到 '未知關係詞',看 design.md §3 列出的合法關係詞",
"若 errors 提到 'node 不存在',檢查 edges 的 from/to 是否拼錯",
],
JSON.stringify(body?.errors ?? body),
);
}
return successResponse(body, [
`校驗通過:${body.nodeCount} 個節點 / ${body.edgeCount} 條邊`,
"可以 call arcrun_push_workflow 部署了",
]);
} catch (e) {
return errorResponse(
"internal_error",
`validate 內部錯:${e instanceof Error ? e.message : String(e)}`,
["重試一次", "若持續失敗,告訴 leo 並貼錯誤訊息"],
);
}
},
);
}
export function registerListPausedExecutions(server: McpServer, env: Env) {
server.tool(
"arcrun_list_paused_executions",
"列當前 api_key 下所有 paused workflow(等 daemon callback resume 的)。給 debug 用:claude_api 等 async 零件會把 workflow 暫停,此 tool 告訴你哪些還沒回來。",
{
api_key: z.string().describe(apiKeyDesc),
limit: z.number().int().min(1).max(100).optional().describe("最多回幾個(預設 20,最多 100)"),
},
async ({ api_key, limit }) => {
try {
const res = await cypherFetch(env, "/executions/paused", {
apiKey: api_key,
query: limit ? { limit } : undefined,
});
const body = await res.json().catch(() => null);
if (!res.ok) {
return errorResponse(
"fetch_failed",
`撈 paused 列表失敗 HTTP ${res.status}`,
["檢查 api_key 是否正確", "稍後重試"],
JSON.stringify(body),
);
}
return successResponse(body);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerGetExecutionTrace(server: McpServer, env: Env) {
server.tool(
"arcrun_get_execution_trace",
"看單一 paused workflow 的 state 細節(trace、graph、context、pending_result)。task_id 從 paused 錯誤訊息或 list_paused_executions 取得。",
{
api_key: z.string().describe(apiKeyDesc),
task_id: z.string().describe(
"Paused workflow 的 task_id。來源:workflow 觸發後若 pausederror 訊息含 'waiting for task task_XXX';或 list_paused_executions 回的 task_id 欄位",
),
},
async ({ api_key, task_id }) => {
try {
const res = await cypherFetch(env, `/executions/${encodeURIComponent(task_id)}`, {
apiKey: api_key,
});
const body = await res.json().catch(() => null);
if (res.status === 404) {
return errorResponse(
"not_found",
`task_id "${task_id}" 沒對應的 paused state`,
[
"call list_paused_executions 看當前所有 paused,確認 task_id 正確",
"若該 workflow 不是 paused 型,看 list_recent_executions 查歷史 verdict",
],
);
}
if (!res.ok) {
return errorResponse(
"fetch_failed",
`撈 execution trace 失敗 HTTP ${res.status}`,
["檢查 task_id 格式是否正確"],
JSON.stringify(body),
);
}
return successResponse(body);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerListRecentExecutions(server: McpServer, env: Env) {
server.tool(
"arcrun_list_recent_executions",
"列某 workflow 最近 N 次執行 verdict(成功 / 失敗 / duration)。資料來源是 ANALYTICS_KV 90 天保留期。",
{
api_key: z.string().describe(apiKeyDesc),
workflow_name: z.string().describe("workflow 名稱(acr push 時的 name 欄)"),
limit: z.number().int().min(1).max(100).optional().describe("最多回幾筆(預設 10,最多 100)"),
},
async ({ api_key, workflow_name, limit }) => {
try {
const res = await cypherFetch(
env,
`/workflows/${encodeURIComponent(workflow_name)}/executions`,
{
apiKey: api_key,
query: limit ? { limit } : undefined,
},
);
const body = await res.json().catch(() => null);
if (res.status === 404) {
return errorResponse(
"not_found",
`workflow "${workflow_name}" 不存在或不屬於你`,
[
"call list_workflows 看你有什麼 workflow",
"確認 workflow 名稱拼寫正確",
],
);
}
if (!res.ok) {
return errorResponse(
"fetch_failed",
`撈執行歷史失敗 HTTP ${res.status}`,
["稍後重試"],
JSON.stringify(body),
);
}
return successResponse(body);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerAllIntrospectionTools(server: McpServer, env: Env) {
registerValidateYaml(server, env);
registerListPausedExecutions(server, env);
registerGetExecutionTrace(server, env);
registerListRecentExecutions(server, env);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* arcrun_report_feedback — explicit feedback tool for AI agents
*
* 對應 SDD .agents/specs/llm-interface/ M1.3
*
* AI agent 每次完成 workflow / 卡住 / 解掉問題後 **MUST** call 此 tool。
* 結構化 issue_type enum 防自由文字難聚合。寫入 KBDB type=agent-feedback block。
*
* 後續 M4 weekly_review workflow 聚合這些 block 產出 arcrun-roadmap。
*
* 命名注意:M5 全面 rename u6u → arcrun 前,本 tool 直接用新名 arcrun_ prefix
* 立下範例。其他 u6u_* tool 等 M5 一次切。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
const ISSUE_TYPES = [
"success_story", // 順利完成,值得記錄這個 pattern
"doc_unclear", // AGENTS.md / skill / contract 講不清楚
"tool_missing", // 該有的 MCP tool 沒有
"error_unhelpful", // 錯誤訊息看不懂下一步
"unexpected_behavior", // 跟我預期的不一樣
"feature_request", // 我想要 X 功能
] as const;
export function registerReportFeedback(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"arcrun_report_feedback",
"AI agent 完成 workflow 任務 / 卡住 / 解掉問題後 **必須** call 此 tool 回報。即使順利也要 call (issue_type=success_story),那是告訴平台「這 pattern 已 work,可推廣」。回饋會寫進 KBDB type=agent-feedback,週報自動聚合產出平台改善 roadmap。",
{
issue_type: z.enum(ISSUE_TYPES).describe(
"回報類型。success_story=順利做完 / doc_unclear=文件不清楚 / tool_missing=該有的 MCP tool 缺 / error_unhelpful=錯誤訊息看不懂下一步 / unexpected_behavior=與預期不符 / feature_request=想要新功能"
),
description: z.string().min(10).describe(
"詳述:你做了什麼、發生什麼、為什麼這算 issue / story。至少 10 字。若是 success_story,描述 pattern 與適用情境"
),
workflow_name: z.string().optional().describe("相關 workflow 名稱(若有)"),
retry_count: z.number().int().min(0).optional().describe("為了搞定,你重試了幾次(含修 YAML / 改參數)"),
blocked: z.boolean().optional().describe("是否完全擋住(true = 無法繼續),預設 false"),
suggested_fix: z.string().optional().describe("你建議的修補方向(optional,但很有價值)"),
agent_user_agent: z.string().optional().describe(
"你(AI agent)的 client 識別字串。e.g. 'claude-code/1.x'、'cursor-mcp/0.4'、'mira-bot'。讓平台知道哪個 AI 客戶端踩到問題"
),
},
async ({ issue_type, description, workflow_name, retry_count, blocked, suggested_fix, agent_user_agent }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const blockBody = {
api_key: env.PLATFORM_API_KEY || undefined, // 若 platform key 在,聚集;否則用用戶 namespace
type: "agent-feedback",
source: "mcp-tool-call",
user_id: orgNamespace,
content: description,
metadata_json: JSON.stringify({
issue_type,
workflow_name,
retry_count,
blocked: blocked ?? false,
suggested_fix,
agent_user_agent,
reported_at: new Date().toISOString(),
}),
tags_json: JSON.stringify([
"agent-feedback",
`issue:${issue_type}`,
...(blocked ? ["blocked"] : []),
...(workflow_name ? [`wf:${workflow_name}`] : []),
]),
};
// 走 KBDB service binding(既有 pattern
const createResp = await kbdbFetch(env, `/blocks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(blockBody),
});
if (!createResp.ok) {
const errBody = await createResp.text();
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: false,
error_code: "kbdb_write_failed",
human_message: `回饋寫入 KBDB 失敗:HTTP ${createResp.status}`,
next_actions: [
"確認 KBDB 服務在線(試 https://kbdb-get.arcrun.dev/health",
"若持續失敗,可暫先在本地記下回饋,稍後重試",
],
detail: errBody.slice(0, 200),
}, null, 2),
},
],
isError: true,
};
}
const data = await createResp.json().catch(() => null);
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: true,
data: {
reported: true,
issue_type,
block_id: (data as { id?: string } | null)?.id,
},
hints: [
issue_type === "success_story"
? "感謝記錄成功 pattern!這會被納入週報自動推廣。"
: "感謝回報!平台週報會聚合這類問題(M4 完成後可看 arcrun-roadmap block",
"若還有相關問題(例如同 workflow 不同 issue),可繼續 call",
],
}, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: false,
error_code: "internal_error",
human_message: `report_feedback 內部錯誤:${error instanceof Error ? error.message : String(error)}`,
next_actions: ["重試一次", "若持續失敗,請告訴用戶這個 issue 並貼錯誤訊息給 leo"],
}, null, 2),
},
],
isError: true,
};
}
}
);
}
+315
View File
@@ -0,0 +1,315 @@
/**
* Skills + Examples lookup MCP tools — LI SDD M3.2
*
* 對應 .agents/specs/llm-interface/ Milestone 3.2 + 3.4。
*
* - arcrun_list_skills — 列 KBDB type=agent-skill 全部
* - arcrun_get_skill — 用 slug 拿 skill markdown 全文
* - arcrun_list_examples — 列 KBDB type=workflow-example 全部
* - arcrun_get_example — 用 slug 拿 example yaml + description + tags
* - arcrun_search_examples — 自然語言 use case → 命中相關 example
*
* Skills / examples 由 arcrun/scripts/sync-registry-to-kbdb.py 從
* arcrun/registry/{skills,examples} 同步進 KBDB。
*
* 直接走 KBDB service binding(既有 pattern),不經 cypher-executor。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
interface KbdbBlock {
id: string;
page_name?: string | null;
content?: string | null;
type?: string;
tags_json?: string;
metadata_json?: string | null;
source?: string | null;
updated_at?: number;
}
async function kbdbList(env: Env, type: string, limit = 100): Promise<KbdbBlock[]> {
const resp = await kbdbFetch(env, `/blocks?type=${type}&limit=${limit}`);
if (!resp.ok) throw new Error(`KBDB list type=${type} HTTP ${resp.status}`);
const data = await resp.json<{ blocks?: KbdbBlock[] }>();
return data.blocks ?? [];
}
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
const resp = await kbdbFetch(env, `/blocks?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) return null;
const data = await resp.json<{ blocks?: KbdbBlock[] }>();
return data.blocks?.[0] ?? null;
}
function parseTags(tagsJson?: string): string[] {
if (!tagsJson) return [];
try {
const arr = JSON.parse(tagsJson);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
export function registerListSkills(server: McpServer, env: Env) {
server.tool(
"arcrun_list_skills",
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
{
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
},
async ({ tag }) => {
try {
const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks
.map((b) => {
const tags = parseTags(b.tags_json);
let title = b.page_name?.replace(/^skill-/, "") ?? "(no title)";
try {
const meta = b.metadata_json ? JSON.parse(b.metadata_json) : null;
if (meta?.title) title = meta.title;
} catch {}
return {
slug: b.page_name?.replace(/^skill-/, "") ?? "",
page_name: b.page_name,
title,
tags,
chars: (b.content ?? "").length,
};
})
.filter((s) => !tag || s.tags.includes(`skill:${tag}`) || s.tags.includes(tag) || s.slug.includes(tag));
return successResponse(
{ count: skills.length, skills },
[
skills.length === 0
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
],
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試", "若持續失敗,告訴 leo"],
);
}
},
);
}
export function registerGetSkill(server: McpServer, env: Env) {
server.tool(
"arcrun_get_skill",
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
{
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
},
async ({ slug }) => {
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`skill "${slug}" 不存在`,
[
"call arcrun_list_skills() 看可用 slug",
"確認拼字正確(不需要 'skill-' prefix",
],
);
}
return successResponse({
slug,
page_name: block.page_name,
content: block.content,
tags: parseTags(block.tags_json),
});
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerListExamples(server: McpServer, env: Env) {
server.tool(
"arcrun_list_examples",
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
{
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
},
async ({ tag }) => {
try {
const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks
.map((b) => {
const tags = parseTags(b.tags_json);
return {
slug: b.page_name?.replace(/^example-/, "") ?? "",
page_name: b.page_name,
tags,
chars: (b.content ?? "").length,
};
})
.filter((e) => !tag || e.tags.includes(tag) || e.tags.includes(`example:${tag}`) || e.slug.includes(tag));
return successResponse(
{ count: examples.length, examples },
[
examples.length === 0
? "沒有 example 命中。試 list_examples() 不帶 tag 看全部"
: "call arcrun_get_example(slug) 拿單個 YAML + description",
],
);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerGetExample(server: McpServer, env: Env) {
server.tool(
"arcrun_get_example",
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
{
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
},
async ({ slug }) => {
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`example "${slug}" 不存在`,
[
"call arcrun_list_examples() 看可用 slug",
"或 arcrun_search_examples(use_case) 用自然語言找",
],
);
}
let description_md = "";
try {
const meta = block.metadata_json ? JSON.parse(block.metadata_json) : null;
description_md = meta?.description_md ?? "";
} catch {}
return successResponse({
slug,
page_name: block.page_name,
workflow_yaml: block.content,
description_md,
tags: parseTags(block.tags_json),
}, [
"拿 workflow_yaml 改成你自己的 → call arcrun_push_workflow",
"看 description_md 了解設計意圖 / 改造方向",
]);
} catch (e) {
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerSearchExamples(server: McpServer, env: Env) {
server.tool(
"arcrun_search_examples",
"用自然語言 use case 搜 workflow examples。回最相關 N 個。內部走 KBDB semantic searchembedding 比對)+ tag 過濾。",
{
query: z.string().min(3).describe("用 use case 描述,例如 '每天早上發 email 摘要' / 'RAG 從文件回答問題'"),
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5"),
},
async ({ query, top_k }) => {
try {
const k = top_k ?? 5;
// KBDB /search 是 unified semantic search(既有),filter type=workflow-example
const resp = await kbdbFetch(env, `/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
topK: k * 3, // overfetch 後 filter type
}),
});
if (!resp.ok) {
return errorResponse(
"fetch_failed",
`KBDB search HTTP ${resp.status}`,
["稍後重試", "改用 arcrun_list_examples(tag=...) 過濾"],
await resp.text().catch(() => ""),
);
}
const data = await resp.json<{ results?: Array<{ block?: KbdbBlock; score?: number }> }>();
const all = data.results ?? [];
const examples = all
.filter((r) => r.block?.type === "workflow-example")
.slice(0, k)
.map((r) => {
const b = r.block!;
return {
slug: b.page_name?.replace(/^example-/, "") ?? "",
page_name: b.page_name,
score: r.score,
tags: parseTags(b.tags_json),
preview: (b.content ?? "").slice(0, 200),
};
});
if (examples.length === 0) {
return successResponse(
{ count: 0, examples: [], query },
[
"沒命中。可能 KBDB /search 還在等 embedding 建好(剛 sync 完要 1-5 分鐘)",
"改用 arcrun_list_examples(tag='...') 走 tag 過濾",
"或 arcrun_list_examples() 看全部清單自己挑",
],
);
}
return successResponse(
{ count: examples.length, examples, query },
[
"call arcrun_get_example(slug) 拿完整 YAML",
"score 高 = 跟你 query 更相關",
],
);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
}
+379
View File
@@ -0,0 +1,379 @@
/**
* Workflow CRUD tools — LI SDD M2.2
*
* 對應 .agents/specs/llm-interface/ Milestone 2.2。
*
* 取代既有 u6u_deploy_workflow(呼叫 /workflows/deploy — 該 endpoint 不存在,
* 是壞掉的 tool+ u6u_list_workflows / u6u_get_workflow 透過 KBDB 撈 metadata
* 而非直接問 cypher-executor 的真實狀態。
*
* 新 tool 直打 cypher-executor /webhooks/named*
* - arcrun_push_workflow
* - arcrun_list_workflows
* - arcrun_get_workflow
* - arcrun_delete_workflow
* - arcrun_run_workflow
*
* 舊 u6u_* 待 M5 一次 rename + 退場(leo 2026-05-16 拍板)。在此之前,
* AI 看到兩套 tool — 用 arcrun_* 為主,u6u_* 有 deprecation hint。
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
import { parse as parseYaml } from "yaml";
const apiKeyDesc =
"你(用戶)的 arcrun api_key (ak_xxx)。從 https://arcrun.dev/me 取得";
/**
* arcrun_push_workflow — 部署 YAML workflow
*
* 接受 yaml_content 或 graph object 兩種輸入。yaml_content 內部 parse 成 graph。
*/
export function registerPushWorkflow(server: McpServer, env: Env) {
server.tool(
"arcrun_push_workflow",
"部署 workflow 到 arcrun(取代 u6u_deploy_workflow,後者打不存在的 endpoint)。輸入可以是 YAML 字串或 graph 物件。**建議先 call arcrun_validate_yaml 確認 schema**。",
{
api_key: z.string().describe(apiKeyDesc),
yaml_content: z.string().optional().describe(
"YAML 字串。內部會 parse 成 {name, flow, config}。優先於 graph 參數"
),
graph: z.unknown().optional().describe(
"已 parse 過的 graph 物件(含 nodes, edges)。yaml_content 沒給才用此"
),
name: z.string().optional().describe(
"workflow 名稱(只能 [a-zA-Z0-9_-])。若給 yaml_content 會從 yaml 抽 name 欄"
),
description: z.string().optional().describe("workflow 描述(選填)"),
},
async ({ api_key, yaml_content, graph, name, description }) => {
let workflowName: string | undefined = name;
let workflowGraph: unknown = graph;
let workflowConfig: unknown = undefined;
// 如果有 yaml_contentparse 出 name + graph + config
if (yaml_content) {
try {
const parsed = parseYaml(yaml_content) as {
name?: string;
description?: string;
flow?: string[];
config?: Record<string, unknown>;
nodes?: unknown[];
edges?: unknown[];
};
workflowName = workflowName ?? parsed.name;
description = description ?? parsed.description;
workflowConfig = parsed.config;
// 若 yaml 是已展開的 {nodes, edges} 格式
if (parsed.nodes && parsed.edges) {
workflowGraph = { nodes: parsed.nodes, edges: parsed.edges };
}
// 若 yaml 是 cypher binding {flow, config} 格式,傳 raw 給 cypher-executor parse
else if (parsed.flow && parsed.config) {
workflowGraph = { flow: parsed.flow, config: parsed.config };
}
} catch (e) {
return errorResponse(
"validation_failed",
`YAML parse 失敗:${e instanceof Error ? e.message : String(e)}`,
["檢查 YAML 縮排 / 引號 / 冒號", "用 yamllint 或 validator 先過一次"],
);
}
}
if (!workflowName) {
return errorResponse(
"validation_failed",
"缺少 workflow nameyaml_content 內 'name:' 欄或 name 參數)",
["yaml 加 name: my_workflow 欄", "或直接傳 name 參數"],
);
}
if (!workflowGraph) {
return errorResponse(
"validation_failed",
"缺少 graph 資料(yaml_content 內 flow+config 或 nodes+edges,或直接傳 graph 參數)",
["yaml 至少含 flow: + config: 兩欄", "或直接傳 graph 參數"],
);
}
try {
const res = await cypherFetch(env, "/webhooks/named", {
apiKey: api_key,
method: "POST",
body: {
name: workflowName,
graph: workflowGraph,
config: workflowConfig,
description,
},
});
const body = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) {
return errorResponse(
"push_failed",
`部署失敗 HTTP ${res.status}: ${(body as { error?: string }).error ?? 'unknown'}`,
[
"先 call arcrun_validate_yaml 確認 graph schema 正確",
"確認 workflow name 符合 [a-zA-Z0-9_-] 格式",
"確認 api_key 是 ak_xxx 格式且有效",
],
JSON.stringify(body),
);
}
const result = body as { name?: string; webhook_url?: string };
return successResponse(result, [
`部署成功!webhook URL: ${result.webhook_url}`,
`下一步:call arcrun_run_workflow('${result.name}', {你的 input}) 測試`,
"或對 webhook URL 直接 curl POST 觸發",
]);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerListWorkflows(server: McpServer, env: Env) {
server.tool(
"arcrun_list_workflows",
"列出你 (api_key 對應 namespace) 已部署的所有 workflow。回 [{name}]。",
{
api_key: z.string().describe(apiKeyDesc),
},
async ({ api_key }) => {
try {
const res = await cypherFetch(env, "/webhooks/named", {
apiKey: api_key,
});
const body = await res.json().catch(() => null);
if (!res.ok) {
return errorResponse(
"fetch_failed",
`撈 workflow 列表失敗 HTTP ${res.status}`,
["確認 api_key 正確", "稍後重試"],
JSON.stringify(body),
);
}
const data = body as { workflows?: Array<{ name: string; webhook_url?: string }> };
return successResponse(data, [
`${data.workflows?.length ?? 0} 個 workflow`,
"call arcrun_get_workflow(name) 看單個細節",
"call arcrun_list_recent_executions(workflow_name) 看執行歷史",
]);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerGetWorkflow(server: McpServer, env: Env) {
server.tool(
"arcrun_get_workflow",
"看單一 workflow 的完整定義(graph + config)。",
{
api_key: z.string().describe(apiKeyDesc),
name: z.string().describe("workflow 名稱"),
},
async ({ api_key, name }) => {
try {
// cypher-executor 既有 /webhooks/named GET 只回 [{name}] 不含細節,
// 要走 KV 直接讀 — 目前沒有單個 workflow GET endpoint。
// workaround:撈 list 然後 client filterM2.x 加 GET /webhooks/named/:name
const res = await cypherFetch(env, "/webhooks/named", {
apiKey: api_key,
});
const body = await res.json().catch(() => null) as {
workflows?: Array<{ name: string; webhook_url?: string }>;
} | null;
if (!res.ok || !body?.workflows) {
return errorResponse(
"fetch_failed",
`撈 workflow 列表失敗`,
["確認 api_key 正確"],
JSON.stringify(body),
);
}
const found = body.workflows.find((w) => w.name === name);
if (!found) {
return errorResponse(
"not_found",
`workflow "${name}" 不存在或不屬於你`,
[
"call arcrun_list_workflows 看你有什麼 workflow",
"確認名稱拼寫正確(注意大小寫)",
],
);
}
return successResponse(
{
name: found.name,
webhook_url: found.webhook_url,
note: "目前 list endpoint 不回完整 graph,未來會加 GET /webhooks/named/:name",
},
[
"可 call arcrun_list_recent_executions 看執行歷史",
"可 call arcrun_run_workflow 觸發測試",
],
);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerDeleteWorkflow(server: McpServer, env: Env) {
server.tool(
"arcrun_delete_workflow",
"刪除 workflow。**不可逆,確認後再做**。會清掉對應 cron index 與 webhook URL。",
{
api_key: z.string().describe(apiKeyDesc),
name: z.string().describe("要刪的 workflow 名稱"),
confirm: z.literal(true).describe("必須傳 true 確認"),
},
async ({ api_key, name, confirm: _confirm }) => {
try {
const res = await cypherFetch(env, `/webhooks/named/${encodeURIComponent(name)}`, {
apiKey: api_key,
method: "DELETE",
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
return errorResponse(
res.status === 404 ? "not_found" : "delete_failed",
res.status === 404
? `workflow "${name}" 不存在`
: `刪除失敗 HTTP ${res.status}`,
[
"call arcrun_list_workflows 確認名稱",
"若已不存在可忽略此錯誤",
],
JSON.stringify(body),
);
}
return successResponse({ deleted: name }, [
`已刪除 ${name}`,
"若該 workflow 有 cron,索引也已清",
]);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerRunWorkflow(server: McpServer, env: Env) {
server.tool(
"arcrun_run_workflow",
"觸發 workflow 執行。input 物件帶進 trigger context。回 {success, data, trace?, duration_ms}。",
{
api_key: z.string().describe(apiKeyDesc),
name: z.string().describe("workflow 名稱"),
input: z.record(z.unknown()).optional().describe(
"trigger context(會塞進 workflow 第一個節點的輸入)。記得帶 api_key 給內部需要的節點用"
),
},
async ({ api_key, name, input }) => {
try {
const triggerBody = input ?? {};
// 若 input 沒帶 api_key,自動補(內部多數零件需要)
if (!('api_key' in triggerBody)) {
(triggerBody as Record<string, unknown>).api_key = api_key;
}
const res = await cypherFetch(env, `/webhooks/named/${encodeURIComponent(name)}/trigger`, {
apiKey: api_key,
method: "POST",
body: triggerBody,
});
const body = await res.json().catch(() => null) as {
success?: boolean;
data?: unknown;
error?: string;
duration_ms?: number;
trace?: unknown;
} | null;
if (res.status === 404) {
return errorResponse(
"not_found",
`workflow "${name}" 不存在`,
["call arcrun_list_workflows 確認名稱", "或先 arcrun_push_workflow 部署"],
);
}
// workflow 自己 success/fail 不算 HTTP 錯誤
const isPaused = body?.error && /workflow paused/i.test(body.error);
if (isPaused) {
return successResponse(
{ ...body, status: "running_async" },
[
"workflow 已接受,正在背景跑(等 claude_api 等 daemon callback",
"call arcrun_list_paused_executions 看當前 running_async 的",
"正常 30-90 秒會 resume 完成(從 user 角度像同步完成)",
],
);
}
if (!body?.success) {
return errorResponse(
"execution_failed",
body?.error ?? `執行失敗 HTTP ${res.status}`,
[
"看 trace 陣列第一個 status=failed 的 node 是哪個",
"call arcrun_list_recent_executions 看歷史趨勢",
],
JSON.stringify(body),
);
}
return successResponse(body, [
`執行成功,耗時 ${body.duration_ms}ms`,
"call arcrun_list_recent_executions 看歷史 verdict",
]);
} catch (e) {
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerAllWorkflowCrudTools(server: McpServer, env: Env) {
registerPushWorkflow(server, env);
registerListWorkflows(server, env);
registerGetWorkflow(server, env);
registerDeleteWorkflow(server, env);
registerRunWorkflow(server, env);
}
+49
View File
@@ -0,0 +1,49 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Env } from "../types.js";
import { registerSearchComponents } from "./u6u_search_components.js";
import { registerExecuteWorkflow } from "./u6u_execute_workflow.js";
import { registerDeployWorkflow } from "./u6u_deploy_workflow.js";
import { registerPublishComponent } from "./u6u_publish_component.js";
import { registerListWorkflows } from "./u6u_list_workflows.js";
import { registerGetWorkflow } from "./u6u_get_workflow.js";
import { registerListComponents } from "./u6u_list_components.js";
import { registerGetComponent } from "./u6u_get_component.js";
import { registerGetComponentGuide } from "./u6u_get_component_guide.js";
import { registerCreateTag } from "./u6u_create_tag.js";
import { registerListTags } from "./u6u_list_tags.js";
import { registerDeleteTag } from "./u6u_delete_tag.js";
import { registerTagResource } from "./u6u_tag_resource.js";
import { registerUntagResource } from "./u6u_untag_resource.js";
import { registerGetGuiContext } from "./u6u_get_gui_context.js";
import { registerReportFeedback } from "./arcrun_report_feedback.js";
import { registerAllIntrospectionTools } from "./arcrun_introspection.js";
import { registerAllWorkflowCrudTools } from "./arcrun_workflow_crud.js";
import { registerAllSkillExampleTools } from "./arcrun_skills_examples.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
registerSearchComponents(server, env, orgNamespace);
registerExecuteWorkflow(server, env, orgNamespace, partnerToken);
registerDeployWorkflow(server, env, orgNamespace);
registerPublishComponent(server, env, orgNamespace);
registerListWorkflows(server, env, orgNamespace);
registerGetWorkflow(server, env, orgNamespace);
registerListComponents(server, env, orgNamespace);
registerGetComponent(server, env, orgNamespace);
registerGetComponentGuide(server, env, orgNamespace);
registerCreateTag(server, env, orgNamespace);
registerListTags(server, env, orgNamespace);
registerDeleteTag(server, env, orgNamespace);
registerTagResource(server, env, orgNamespace);
registerUntagResource(server, env, orgNamespace);
registerGetGuiContext(server, env, orgNamespace);
// LI SDD M1.3: explicit feedback tool (新命名規範 arcrun_*)
registerReportFeedback(server, env, orgNamespace);
// LI SDD M2.2: introspection tools (validate / paused / trace / recent executions)
registerAllIntrospectionTools(server, env);
// LI SDD M2.2: workflow CRUD (push / list / get / delete / run)
// 取代既有 u6u_deploy_workflow (打不存在的 /workflows/deploy endpoint)
registerAllWorkflowCrudTools(server, env);
// LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
registerAllSkillExampleTools(server, env);
}
+47
View File
@@ -0,0 +1,47 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerCreateTag(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_create_tag",
"在當前命名空間下建立新的 tag,用於分類工作流與零件。",
{
name: z.string().describe("Tag 名稱(在當前 org_namespace 下唯一)"),
description: z.string().optional().describe("Tag 描述(選填)")
},
async ({ name, description }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
// Check for duplicate
const checkResp = await kbdbFetch(env, `/records/search?template=tag&user_id=${encodeURIComponent(orgNamespace)}&name=${encodeURIComponent(name)}`);
if (checkResp.ok) {
const checkData = await checkResp.json<{ records: unknown[] }>();
if (checkData.records && checkData.records.length > 0) {
return { content: [{ type: "text", text: `Error: Tag '${name}' already exists in this namespace` }], isError: true };
}
}
const recordId = `tag-${orgNamespace}-${name}`;
const createResp = await kbdbFetch(env, `/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template: "tag",
record_id: recordId,
user_id: orgNamespace,
values: { name, description: description ?? "", org_namespace: orgNamespace, created_at: new Date().toISOString() }
})
});
if (!createResp.ok) {
return { content: [{ type: "text", text: `Error creating tag: ${await createResp.text()}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(await createResp.json(), null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+30
View File
@@ -0,0 +1,30 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerDeleteTag(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_delete_tag",
"刪除當前命名空間下的指定 tag。",
{ tag_name: z.string().describe("要刪除的 Tag 名稱") },
async ({ tag_name }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const recordId = `tag-${orgNamespace}-${tag_name}`;
const deleteResp = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: "DELETE" });
if (deleteResp.status === 404) {
return { content: [{ type: "text", text: `Error: Tag '${tag_name}' not found` }], isError: true };
}
if (!deleteResp.ok) {
return { content: [{ type: "text", text: `Error deleting tag: ${await deleteResp.text()}` }], isError: true };
}
return { content: [{ type: "text", text: `Tag '${tag_name}' deleted successfully` }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+81
View File
@@ -0,0 +1,81 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerDeployWorkflow(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_deploy_workflow",
"將工作流 YAML 配置正式部署至雲端引擎,完成註冊與排程設定。",
{
yaml_content: z.string().describe("工作流的 YAML 配置內容")
},
async ({ yaml_content }) => {
try {
if (!env.CYPHER_EXECUTOR) {
return {
content: [{ type: "text", text: "Error: CYPHER_EXECUTOR service binding is not configured." }],
isError: true
};
}
const response = await env.CYPHER_EXECUTOR.fetch("http://cypher-executor/workflows/deploy", {
method: "POST",
headers: { "Content-Type": "application/yaml" },
body: yaml_content
});
if (!response.ok) {
const errorText = await response.text();
return {
content: [{ type: "text", text: `Deployment failed: ${errorText}` }],
isError: true
};
}
const result = await response.json<{ workflow_id?: string; [key: string]: unknown }>();
const workflowId = result.workflow_id ?? crypto.randomUUID();
// Parse workflow name from YAML
const nameMatch = yaml_content.match(/^name:\s*(.+)$/m);
const workflowName = nameMatch ? nameMatch[1].trim() : workflowId;
// Store workflow metadata in KBDB
if (env.KBDB) {
const kbdbResp = await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template: "workflow_metadata",
record_id: `wf-${workflowId}`,
user_id: orgNamespace,
values: {
workflow_id: workflowId,
name: workflowName,
deployed_at: new Date().toISOString(),
org_namespace: orgNamespace
}
})
});
if (!kbdbResp.ok) {
const errText = await kbdbResp.text();
return {
content: [{ type: "text", text: `Deployment succeeded but failed to store metadata: ${errText}` }],
isError: true
};
}
}
return {
content: [{ type: "text", text: `Successfully deployed workflow: ${JSON.stringify(result, null, 2)}` }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true
};
}
}
);
}
+54
View File
@@ -0,0 +1,54 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
export function registerExecuteWorkflow(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
server.tool(
"u6u_execute_workflow",
"在沙盒環境中即時執行工作流,驗證 triplets 邏輯是否正確。每個 config 鍵對應 triplets 中的節點名,內含 component(零件 canonical_id)、recipe(prompt_recipe:xxx,選用)、與該節點的其他靜態參數。",
{
triplets: z.array(z.string()).describe("工作流三元組,例:['input >> 完成後 >> synth']"),
context: z.record(z.string(), z.any()).describe("初始變數(測試資料 / 上游節點輸出模擬)"),
config: z.record(z.string(), z.record(z.string(), z.any())).optional().describe("每節點配置:{ node_name: { component, recipe?, ...params } }")
},
async ({ triplets, context, config }) => {
try {
if (!env.CYPHER_EXECUTOR) {
return {
content: [{ type: "text", text: "Error: CYPHER_EXECUTOR service binding is not configured." }],
isError: true
};
}
// KI-12 修:改打 /cypher/execute(吃 triplets+config),原 /execute 是吃完整 graph 的舊路徑
// KI-15 修:轉發 partner token 給 cypher-executor,讓 recipe expander 能用 ak_ key 抓 KBDB
const response = await env.CYPHER_EXECUTOR.fetch("http://cypher-executor/cypher/execute", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Arcrun-API-Key": partnerToken
},
body: JSON.stringify({ triplets, context, config })
});
if (!response.ok) {
const errorText = await response.text();
return {
content: [{ type: "text", text: `Execution failed: ${errorText}` }],
isError: true
};
}
const result = await response.json();
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true
};
}
}
);
}
+59
View File
@@ -0,0 +1,59 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
/**
* u6u_get_component — 取得零件完整合約
* 呼叫 Component Registry GET /components/:id
*/
export function registerGetComponent(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_get_component",
"取得指定零件的完整合約,包含 canonical_id、display_name、category、version、stability、input_schema、output_schema、gherkin_tests、評分統計等。",
{
canonical_id: z.string().describe("零件 canonical_id(如 validate_json"),
},
async ({ canonical_id }) => {
try {
if (!env.COMPONENT_REGISTRY) {
return {
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
isError: true,
};
}
const response = await env.COMPONENT_REGISTRY.fetch(
`http://component-registry/components/${encodeURIComponent(canonical_id)}`,
{ method: "GET" },
);
if (response.status === 404) {
return {
content: [{ type: "text", text: `零件 '${canonical_id}' 不存在。可用 u6u_search_components 搜尋相似零件。` }],
isError: true,
};
}
if (!response.ok) {
return {
content: [{ type: "text", text: `Error: ${await response.text()}` }],
isError: true,
};
}
const result = await response.json() as { data?: unknown };
return {
content: [{
type: "text",
text: JSON.stringify(result.data ?? result, null, 2),
}],
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
+47
View File
@@ -0,0 +1,47 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Env } from "../types.js";
/**
* u6u_get_component_guide — 取得零件開發指引
* 呼叫 Component Registry GET /components/guide
* AI 在開發新零件前應先讀取此指引
*/
export function registerGetComponentGuide(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_get_component_guide",
"取得 u6u 零件開發指引(Markdown 格式)。包含 TinyGo 白名單 import、禁止行為、component.contract.yaml 完整範例、本地測試指令。開發新零件前必須先讀取此指引。",
{},
async () => {
try {
if (!env.COMPONENT_REGISTRY) {
return {
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
isError: true,
};
}
const response = await env.COMPONENT_REGISTRY.fetch(
"http://component-registry/components/guide",
{ method: "GET" },
);
if (!response.ok) {
return {
content: [{ type: "text", text: `Error fetching guide: ${await response.text()}` }],
isError: true,
};
}
const guide = await response.text();
return {
content: [{ type: "text", text: guide }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
+84
View File
@@ -0,0 +1,84 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
interface ActionLogSlots {
org_namespace?: string;
action_type?: string;
payload?: string;
occurred_at?: string;
}
interface ActionLogRecord {
id: string;
slots?: ActionLogSlots;
}
export function registerGetGuiContext(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_get_gui_context",
"查詢用戶在 GUI 上的最近操作記錄,了解用戶的當前意圖與操作上下文。" +
"回傳最近 N 條操作記錄(從新到舊),以及用戶當前所在頁面和正在編輯的 Workflow ID。",
{
limit: z.number().int().min(1).max(100).default(20)
.describe("要取回的最近操作數量(預設 20,最大 100)"),
},
async ({ limit }) => {
try {
if (!env.KBDB) {
return {
content: [{ type: "text", text: "Error: KBDB service binding unavailable" }],
isError: true,
};
}
const resp = await kbdbFetch(
env,
`/records/search?template_id=tpl-action-log&user_id=${encodeURIComponent(orgNamespace)}&limit=${limit ?? 20}`
);
if (!resp.ok) {
return {
content: [{ type: "text", text: `Error querying action log: ${await resp.text()}` }],
isError: true,
};
}
const data = await resp.json<{ records: ActionLogRecord[] }>();
const records = data.records ?? [];
// 按 occurred_at 降序排列(最新在前)
const sorted = records
.map(r => ({
action_type: r.slots?.action_type ?? '',
payload: (() => {
try { return JSON.parse(r.slots?.payload ?? '{}'); } catch { return {}; }
})(),
occurred_at: r.slots?.occurred_at ?? '',
}))
.sort((a, b) => b.occurred_at.localeCompare(a.occurred_at))
.slice(0, limit ?? 20);
// 提取當前頁面和正在操作的 Workflow
const lastNavigate = sorted.find(a => a.action_type === 'NAVIGATE');
const lastOpenWorkflow = sorted.find(a => a.action_type === 'OPEN_WORKFLOW');
const context = {
recent_actions: sorted,
current_page: (lastNavigate?.payload as { page?: string })?.page ?? null,
open_workflow_id: (lastOpenWorkflow?.payload as { workflow_id?: string })?.workflow_id ?? null,
};
return {
content: [{ type: "text", text: JSON.stringify(context, null, 2) }],
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
+33
View File
@@ -0,0 +1,33 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerGetWorkflow(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_get_workflow",
"取得指定工作流的 metadata,包含名稱、部署時間與 tag 列表。",
{ workflow_id: z.string().describe("工作流 ID") },
async ({ workflow_id }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const resp = await kbdbFetch(env, `/records/wf-${encodeURIComponent(workflow_id)}`);
if (resp.status === 404) {
return { content: [{ type: "text", text: `Error: Workflow '${workflow_id}' not found` }], isError: true };
}
if (!resp.ok) {
return { content: [{ type: "text", text: `Error querying workflow: ${await resp.text()}` }], isError: true };
}
const record = await resp.json<{ slots: { workflow_id: string; name: string; deployed_at: string; org_namespace: string } }>();
if (record.slots.org_namespace !== orgNamespace) {
return { content: [{ type: "text", text: `Error: Workflow '${workflow_id}' not found` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(record.slots, null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+39
View File
@@ -0,0 +1,39 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerListComponents(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_list_components",
"列出當前命名空間下所有已發佈的零件,可選擇按 tag 篩選。",
{ tag: z.string().optional().describe("按 tag 名稱篩選(選填)") },
async ({ tag }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
let componentIds: string[] | null = null;
if (tag) {
const tagResp = await kbdbFetch(env, `/records/search?template=resource_tag&user_id=${encodeURIComponent(orgNamespace)}&tag_name=${encodeURIComponent(tag)}&resource_type=component`);
if (!tagResp.ok) {
return { content: [{ type: "text", text: `Error querying tags: ${await tagResp.text()}` }], isError: true };
}
const tagData = await tagResp.json<{ records: Array<{ slots: { resource_id: string } }> }>();
componentIds = tagData.records.map(r => r.slots.resource_id);
if (componentIds.length === 0) return { content: [{ type: "text", text: JSON.stringify([], null, 2) }] };
}
const resp = await kbdbFetch(env, `/records/search?template=component_metadata&user_id=${encodeURIComponent(orgNamespace)}`);
if (!resp.ok) {
return { content: [{ type: "text", text: `Error querying components: ${await resp.text()}` }], isError: true };
}
const data = await resp.json<{ records: Array<{ slots: { component_id: string; name: string; published_at: string; org_namespace: string } }> }>();
let components = data.records.map(r => r.slots);
if (componentIds !== null) components = components.filter(c => componentIds!.includes(c.component_id));
return { content: [{ type: "text", text: JSON.stringify(components, null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+26
View File
@@ -0,0 +1,26 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerListTags(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_list_tags",
"列出當前命名空間下所有的 tag。",
{},
async () => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const resp = await kbdbFetch(env, `/records/search?template=tag&user_id=${encodeURIComponent(orgNamespace)}`);
if (!resp.ok) {
return { content: [{ type: "text", text: `Error fetching tags: ${await resp.text()}` }], isError: true };
}
const data = await resp.json<{ records: unknown[] }>();
return { content: [{ type: "text", text: JSON.stringify(data.records, null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+39
View File
@@ -0,0 +1,39 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerListWorkflows(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_list_workflows",
"列出當前命名空間下所有已部署的工作流,可選擇按 tag 篩選。",
{ tag: z.string().optional().describe("按 tag 名稱篩選(選填)") },
async ({ tag }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
let workflowIds: string[] | null = null;
if (tag) {
const tagResp = await kbdbFetch(env, `/records/search?template=resource_tag&user_id=${encodeURIComponent(orgNamespace)}&tag_name=${encodeURIComponent(tag)}&resource_type=workflow`);
if (!tagResp.ok) {
return { content: [{ type: "text", text: `Error querying tags: ${await tagResp.text()}` }], isError: true };
}
const tagData = await tagResp.json<{ records: Array<{ slots: { resource_id: string } }> }>();
workflowIds = tagData.records.map(r => r.slots.resource_id);
if (workflowIds.length === 0) return { content: [{ type: "text", text: JSON.stringify([], null, 2) }] };
}
const resp = await kbdbFetch(env, `/records/search?template=workflow_metadata&user_id=${encodeURIComponent(orgNamespace)}`);
if (!resp.ok) {
return { content: [{ type: "text", text: `Error querying workflows: ${await resp.text()}` }], isError: true };
}
const data = await resp.json<{ records: Array<{ slots: { workflow_id: string; name: string; deployed_at: string; org_namespace: string } }> }>();
let workflows = data.records.map(r => r.slots);
if (workflowIds !== null) workflows = workflows.filter(w => workflowIds!.includes(w.workflow_id));
return { content: [{ type: "text", text: JSON.stringify(workflows, null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+83
View File
@@ -0,0 +1,83 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
/**
* u6u_publish_component — 提交 TinyGo WASM 零件至 Component Registry
*
* AI 工作流:
* 1. 先呼叫 u6u_get_component_guide 取得開發指引
* 2. 依指引用 TinyGo 撰寫零件(stdin/stdout JSON I/O
* 3. 編譯為 .wasmbase64 編碼後提交
* 4. Registry 自動執行沙盒驗收(體積、syscall 掃描、Gherkin 測試)
*/
export function registerPublishComponent(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_publish_component",
"提交 TinyGo WASM 零件至 Component Registry。需提供 component.contract.yaml 內容與編譯後的 .wasm base64。提交前請先呼叫 u6u_get_component_guide 取得開發規範。",
{
contract: z.object({
canonical_id: z.string().describe("零件功能名稱(小寫底線,如 validate_json"),
display_name: z.string().describe("顯示名稱(可自由命名)"),
category: z.enum(["logic", "api", "ui", "style", "anim"]).describe("零件分類"),
version: z.string().describe("版本(格式 vN,如 v1"),
wasi_target: z.literal("preview1"),
stability: z.enum(["floating", "stable", "pinned"]).default("floating"),
runtime_compat: z.array(z.string()).describe("相容 runtime,如 [\"cf-workers\",\"wazero\"]"),
constraints: z.object({
max_size_kb: z.number().default(2048),
max_cold_start_ms: z.number().default(50),
no_network_syscall: z.boolean().default(true),
io_model: z.literal("stdin_stdout_json"),
}),
input_schema: z.record(z.unknown()).describe("JSON Schema"),
output_schema: z.record(z.unknown()).describe("JSON Schema"),
gherkin_tests: z.array(z.object({
scenario: z.string(),
given: z.string().describe("JSON 字串"),
then_contains: z.string().describe("預期輸出包含的字串"),
})).min(2).describe("至少一個 happy path 和一個 error path"),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
}).describe("component.contract.yaml 內容"),
wasm_base64: z.string().describe("編譯後的 .wasm 檔案 base64 編碼"),
},
async ({ contract, wasm_base64 }) => {
try {
if (!env.COMPONENT_REGISTRY) {
return {
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
isError: true,
};
}
const response = await env.COMPONENT_REGISTRY.fetch("http://component-registry/components", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contract, wasm_base64 }),
});
if (!response.ok) {
const errorText = await response.text();
return {
content: [{ type: "text", text: `Publish failed: ${errorText}` }],
isError: true,
};
}
const result = await response.json() as Record<string, unknown>;
return {
content: [{
type: "text",
text: `零件 ${contract.canonical_id} v${contract.version} 提交成功:\n${JSON.stringify(result, null, 2)}`,
}],
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
+65
View File
@@ -0,0 +1,65 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
/**
* u6u_search_components — 語意搜尋零件庫
* 呼叫 Component Registry GET /components/search?q=...
*/
export function registerSearchComponents(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_search_components",
"用自然語言語意搜尋零件庫,找出符合需求的零件。例如:「查詢 Google Sheets 資料」、「發送 LINE 訊息」、「驗證 JSON 格式」。回傳零件清單含 canonical_id、描述、評分。",
{
query: z.string().describe("自然語言搜尋詞,如「查詢 Google Sheets 資料」"),
},
async ({ query }) => {
try {
if (!env.COMPONENT_REGISTRY) {
return {
content: [{ type: "text", text: "Error: COMPONENT_REGISTRY service binding is not configured." }],
isError: true,
};
}
const response = await env.COMPONENT_REGISTRY.fetch(
`http://component-registry/components/search?q=${encodeURIComponent(query)}`,
{ method: "GET", headers: { "Content-Type": "application/json" } },
);
if (!response.ok) {
const errorText = await response.text();
return {
content: [{ type: "text", text: `Search failed: ${errorText}` }],
isError: true,
};
}
const result = await response.json() as { data?: { results?: unknown[]; count?: number } };
const results = result.data?.results ?? [];
const count = result.data?.count ?? 0;
if (count === 0) {
return {
content: [{
type: "text",
text: `找不到符合「${query}」的零件。可以用 u6u_publish_component 提交新零件。`,
}],
};
}
return {
content: [{
type: "text",
text: `找到 ${count} 個零件:\n${JSON.stringify(results, null, 2)}`,
}],
};
} catch (error) {
return {
content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
+57
View File
@@ -0,0 +1,57 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerTagResource(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_tag_resource",
"為當前命名空間下的工作流或零件加上 tag 標籤。",
{
resource_type: z.enum(["workflow", "component"]).describe("資源類型:workflow 或 component"),
resource_id: z.string().describe("資源 ID"),
tag_name: z.string().describe("要套用的 tag 名稱")
},
async ({ resource_type, resource_id, tag_name }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const tagResp = await kbdbFetch(env, `/records/search?template=tag&user_id=${encodeURIComponent(orgNamespace)}&name=${encodeURIComponent(tag_name)}`);
if (!tagResp.ok) {
return { content: [{ type: "text", text: "Error: KBDB service unavailable" }], isError: true };
}
const tagData = await tagResp.json<{ records: unknown[] }>();
if (!tagData.records || tagData.records.length === 0) {
return { content: [{ type: "text", text: `Error: Tag not found` }], isError: true };
}
const prefix = resource_type === "workflow" ? "wf" : "comp";
const resourceResp = await kbdbFetch(env, `/records/${prefix}-${resource_id}`);
if (!resourceResp.ok) {
return { content: [{ type: "text", text: `Error: Resource not found` }], isError: true };
}
const resourceData = await resourceResp.json<{ slots: { org_namespace: string } }>();
if (!resourceData.slots || resourceData.slots.org_namespace !== orgNamespace) {
return { content: [{ type: "text", text: `Error: Resource not found` }], isError: true };
}
const recordId = `rt-${resource_type}-${resource_id}-${tag_name}`;
const createResp = await kbdbFetch(env, `/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
template: "resource_tag",
record_id: recordId,
user_id: orgNamespace,
values: { resource_type, resource_id, tag_name, org_namespace: orgNamespace }
})
});
if (!createResp.ok) {
return { content: [{ type: "text", text: `Error creating resource_tag: ${await createResp.text()}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(await createResp.json(), null, 2) }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+42
View File
@@ -0,0 +1,42 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
export function registerUntagResource(server: McpServer, env: Env, orgNamespace: string) {
server.tool(
"u6u_untag_resource",
"移除當前命名空間下工作流或零件的 tag 標籤。",
{
resource_type: z.enum(["workflow", "component"]).describe("資源類型:workflow 或 component"),
resource_id: z.string().describe("資源 ID"),
tag_name: z.string().describe("要移除的 tag 名稱")
},
async ({ resource_type, resource_id, tag_name }) => {
try {
if (!env.KBDB) {
return { content: [{ type: "text", text: "Error: KBDB service binding unavailable" }], isError: true };
}
const recordId = `rt-${resource_type}-${resource_id}-${tag_name}`;
const getResp = await kbdbFetch(env, `/records/${recordId}`);
if (getResp.status === 404) {
return { content: [{ type: "text", text: "Error: Resource tag association not found" }], isError: true };
}
if (!getResp.ok) {
return { content: [{ type: "text", text: "Error: KBDB service unavailable" }], isError: true };
}
const record = await getResp.json<{ slots: { org_namespace: string } }>();
if (!record.slots || record.slots.org_namespace !== orgNamespace) {
return { content: [{ type: "text", text: "Error: Resource tag association not found" }], isError: true };
}
const deleteResp = await kbdbFetch(env, `/records/${recordId}`, { method: "DELETE" });
if (!deleteResp.ok) {
return { content: [{ type: "text", text: "Error: KBDB service unavailable" }], isError: true };
}
return { content: [{ type: "text", text: `Tag '${tag_name}' removed from ${resource_type} '${resource_id}' successfully` }] };
} catch (error) {
return { content: [{ type: "text", text: `Internal Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
}
+17
View File
@@ -0,0 +1,17 @@
export interface Env {
COMPONENT_REGISTRY: Fetcher;
CYPHER_EXECUTOR: Fetcher;
KBDB: Fetcher;
KBDB_INTERNAL_TOKEN: string;
API_KEY?: string;
// Platform telemetry / feedback aggregation key (optional)
// 對應 arcrun SDD .agents/specs/llm-interface/ M1.2-1.3
// 設了會把 agent-feedback / agent-telemetry block 都寫到 platform user_id 下;
// 沒設則 fallback 寫進 user 自己的 namespace
PLATFORM_API_KEY?: string;
}
export interface ToolContext {
env: Env;
orgNamespace: string;
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "u6u Workflow Configuration",
"type": "object",
"required": ["name", "description", "version", "triplets"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"version": { "type": "string" },
"trigger": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["schedule", "webhook", "event"] },
"cron": { "type": "string" }
}
},
"triplets": {
"type": "array",
"items": { "type": "string" }
},
"context": {
"type": "object",
"additionalProperties": true
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from "vitest";
// Unit tests for partner-auth middleware logic
// Tests the auth extraction and validation behaviour without a live KBDB
function extractBearerToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
return authHeader.slice(7);
}
describe("partner-auth: token extraction", () => {
it("returns null when Authorization header is missing", () => {
expect(extractBearerToken(undefined)).toBeNull();
});
it("returns null when header does not start with 'Bearer '", () => {
expect(extractBearerToken("Basic abc123")).toBeNull();
expect(extractBearerToken("bearer abc123")).toBeNull();
expect(extractBearerToken("Token abc123")).toBeNull();
});
it("extracts token from valid Bearer header", () => {
expect(extractBearerToken("Bearer my-secret-key")).toBe("my-secret-key");
});
it("handles token with special characters", () => {
expect(extractBearerToken("Bearer abc.def_ghi-123")).toBe("abc.def_ghi-123");
});
});
describe("partner-auth: KBDB response validation", () => {
it("rejects when valid is false", () => {
const info = { valid: false, org_namespace: "org-a" };
expect(info.valid).toBe(false);
});
it("accepts when valid is true and extracts org_namespace", () => {
const info = { valid: true, org_namespace: "org-a" };
expect(info.valid).toBe(true);
expect(info.org_namespace).toBe("org-a");
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
// Unit tests for workflow metadata record structure and ID conventions
describe("workflow metadata: record ID format", () => {
it("workflow record ID is prefixed with 'wf-'", () => {
const workflowId = "abc123";
const recordId = `wf-${workflowId}`;
expect(recordId).toBe("wf-abc123");
expect(recordId.startsWith("wf-")).toBe(true);
});
it("record ID is deterministic for the same workflow_id", () => {
const workflowId = "my-workflow";
expect(`wf-${workflowId}`).toBe(`wf-${workflowId}`);
});
});
describe("workflow metadata: required fields", () => {
it("workflow_metadata record contains required fields", () => {
const record = {
workflow_id: "wf-abc",
name: "My Workflow",
deployed_at: new Date().toISOString(),
org_namespace: "org-test",
};
expect(record.workflow_id).toBeTruthy();
expect(record.name).toBeTruthy();
expect(record.deployed_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(record.org_namespace).toBeTruthy();
});
it("deployed_at is ISO 8601 format", () => {
const ts = new Date().toISOString();
expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
});
describe("workflow metadata: namespace isolation", () => {
it("get workflow query URL includes org_namespace", () => {
const orgNamespace = "org-test";
const workflowId = "abc123";
const recordId = `wf-${workflowId}`;
const url = `http://kbdb/records/${encodeURIComponent(recordId)}?org_namespace=${encodeURIComponent(orgNamespace)}`;
expect(url).toContain("org-test");
expect(url).toContain("wf-abc123");
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
// Unit tests for tag management logic
// Validates tag name constraints and resource_tag record structure
describe("tag management: name validation", () => {
it("accepts non-empty tag names", () => {
const validNames = ["frontend", "v2", "my-tag", "tag_123"];
for (const name of validNames) {
expect(name.length).toBeGreaterThan(0);
}
});
it("tag record ID format is deterministic", () => {
// Tags are stored as blocks; resource_tag records link resources to tags
const orgNamespace = "org-test";
const tagName = "frontend";
const encoded = encodeURIComponent(tagName);
expect(encoded).toBe("frontend");
});
});
describe("tag management: resource_tag record structure", () => {
it("resource_tag record contains required fields", () => {
const record = {
resource_type: "workflow" as const,
resource_id: "wf-abc123",
tag_name: "frontend",
org_namespace: "org-test",
};
expect(record.resource_type).toBe("workflow");
expect(record.resource_id).toBeTruthy();
expect(record.tag_name).toBeTruthy();
expect(record.org_namespace).toBeTruthy();
});
it("resource_type is either workflow or component", () => {
const validTypes = ["workflow", "component"];
expect(validTypes).toContain("workflow");
expect(validTypes).toContain("component");
expect(validTypes).not.toContain("tag");
});
});
describe("tag management: namespace isolation", () => {
it("different org_namespaces produce different query URLs", () => {
const orgA = "org-a";
const orgB = "org-b";
const urlA = `http://kbdb/blocks?type=tag&user_id=${encodeURIComponent(orgA)}`;
const urlB = `http://kbdb/blocks?type=tag&user_id=${encodeURIComponent(orgB)}`;
expect(urlA).not.toBe(urlB);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
+26
View File
@@ -0,0 +1,26 @@
name = "arcrun-mcp" # 2026-06-06 搬進主庫 arcrun/mcp/,對齊 arcrun-* 命名(SDD mcp-account-source.md §5
main = "src/index.ts"
compatibility_date = "2024-11-27"
compatibility_flags = [ "nodejs_compat" ]
workers_dev = true # 對齊 arcrun 部署慣例(rule 05):deploy 掃描自動啟用 workers.dev URL
# Service Bindings
# 2026-05-07COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry
# 原因:舊的 inkstone-component-registry 期望不同 query 參數名,MCP search 失敗。
# 新的 arcrun-registryregistry.arcrun.dev)才是現役。
services = [
{ binding = "COMPONENT_REGISTRY", service = "arcrun-registry" },
{ binding = "CYPHER_EXECUTOR", service = "arcrun-cypher-executor" },
{ binding = "KBDB", service = "inkstone-kbdb-api" }
]
# Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。
# 舊的 studio.finally.click 是 inkstone 平台 zonearcrun 帳號沒有該 zone → 部署 route 失敗。
# 對外正式 MCP URL = mcp.arcrun.dev;對內 / fork 仍可走 workers_dev=true 的 arcrun-mcp.<sub>.workers.dev。
[[routes]]
pattern = "mcp.arcrun.dev/*"
zone_name = "arcrun.dev"
# [alias] 移除:之前是 Zod 4 的 compat shim,但 MCP SDK 1.x 用的是 Zod 3 介面,
# shim 讓 SDK 拿到 Zod 4 內部結構,introspect schema 時報 _zod undefined。
# 降到 Zod 3.x 後不需要 shimpackage.json)。
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# check-release.sh — release.feature 的可執行佐證:列出每個 publish target 的目前狀態
#
# 用途:「完成推送」後跑這個,一眼確認所有對外介面是否同步到位(壓測報告第 3 點)。
# - CF workers / landing:是否由 CI 部署(看 deploy.yml 掃描範圍)
# - npm CLI:本機 cli/package.json version vs npm 上的版本(最常漂移的一個)
# - MCP:是否有未部署變動
#
# 不做破壞性動作,只讀狀態。需要 networknpm view / curl)才能查線上版本;
# 無 network 時只比對本機可知的部分,並誠實標「無法查線上」。
#
# 對應:tests/release.feature、.claude/rules/05-deploy-convention.md
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; GRAY='\033[0;90m'; NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}${NC} $1"; }
err() { echo -e " ${RED}${NC} $1"; }
info() { echo -e " ${GRAY}·${NC} $1"; }
echo ""
echo "═══ arcrun release 狀態檢查(release.feature 佐證)═══"
echo ""
# ── 1. CF Worker 部署單位(由 scripts/local-deploy.sh 掃描部署)──────────
# 註:GH Actions 2026-05-16 停用、公開 repo 已移除 .github/,現行 deploy 走 local-deploy.sh。
echo "【CF Workersscripts/local-deploy.sh 掃 wrangler.toml 後 wrangler deploy)】"
WORKER_DIRS=$(find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.test.toml' \
| xargs -n1 dirname 2>/dev/null | sort -u)
WORKER_COUNT=$(echo "$WORKER_DIRS" | grep -c . )
info "掃到 $WORKER_COUNT 個部署單位(push 對應目錄即自動 deploy"
echo "$WORKER_DIRS" | sed 's/^/ /'
# ── 2. CLI npm(最常漂移)──────────────────────────────────────────────
echo ""
echo "【npm CLIarcrun / acr)】"
LOCAL_CLI=$(node -p "require('./cli/package.json').version" 2>/dev/null || echo "?")
if command -v npm >/dev/null 2>&1; then
REMOTE_CLI=$(npm view arcrun version 2>/dev/null || echo "無法查線上")
else
REMOTE_CLI="無 npm CLI 可查"
fi
if [ "$LOCAL_CLI" = "$REMOTE_CLI" ]; then
ok "CLI 版本同步:本機 $LOCAL_CLI = npm $REMOTE_CLI"
elif [ "$REMOTE_CLI" = "無法查線上" ] || [ "$REMOTE_CLI" = "無 npm CLI 可查" ]; then
info "本機 CLI $LOCAL_CLI$REMOTE_CLI"
else
warn "CLI 版本漂移:本機 $LOCAL_CLI ≠ npm $REMOTE_CLI"
warn "→ 跑 scripts/local-deploy.sh(含 cli/ 變動時)會 npm publish;或手動 cd cli && npm publish"
fi
# 偵測「cli 有未發佈變動但版本可能未 bump」
if git rev-parse --git-dir >/dev/null 2>&1; then
LAST_CLI_PKG_CHANGE=$(git log -1 --format=%h -- cli/package.json 2>/dev/null || echo "")
LAST_CLI_SRC_CHANGE=$(git log -1 --format=%h -- cli/src 2>/dev/null || echo "")
if [ -n "$LAST_CLI_SRC_CHANGE" ] && [ "$LAST_CLI_SRC_CHANGE" != "$LAST_CLI_PKG_CHANGE" ]; then
info "cli/src 最後變動($LAST_CLI_SRC_CHANGE)晚於 package.json$LAST_CLI_PKG_CHANGE)→ 確認是否需 bump version"
fi
fi
# ── 3. cypher-executor 線上健康 ────────────────────────────────────────
echo ""
echo "【cypher-executorAPI 本體)】"
if command -v curl >/dev/null 2>&1; then
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 8 https://cypher.arcrun.dev/health 2>/dev/null || echo "000")
if [ "$CODE" = "200" ]; then
ok "https://cypher.arcrun.dev/health → 200"
else
warn "https://cypher.arcrun.dev/health → $CODE(部署中或不可達?)"
fi
else
info "無 curl,跳過線上健康檢查"
fi
# ── 4. MCP server(已搬進主庫 arcrun/mcp/)──────────────────────────────
echo ""
echo "【MCP serverarcrun/mcp/,已進主庫)】"
if [ -d mcp ] && [ -f mcp/wrangler.toml ]; then
MCP_NAME=$(grep '^name' mcp/wrangler.toml | head -1 | sed -E 's/^name[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/')
ok "arcrun/mcp/ 存在(worker: $MCP_NAME);由 local-deploy.sh 一併 wrangler deploy"
info "薄殼一致性:MCP 工具集應對齊 cypher-executor 最新 APIrule 07"
info "用戶連哪台 MCP 由 .mcp.json 決定(acr mcp-setup 依 config mcp_url 產)"
else
warn "找不到 arcrun/mcp/(應已搬進主庫)"
fi
echo ""
echo "═══ 完成。⚠ = 需注意;· = 資訊;✗ = 失敗 ═══"
echo ""
+82 -10
View File
@@ -49,7 +49,11 @@ done
# 排除 node_modules、wrangler.test.toml、Pages 專案
echo "🔍 Scanning worker directories..."
mapfile -t ALL_DIRS < <(
# bash 3.2macOS 內建)沒有 mapfile → 用 while read 相容寫法
ALL_DIRS=()
while IFS= read -r d; do
[ -n "$d" ] && ALL_DIRS+=("$d")
done < <(
find . -type f -name 'wrangler.toml' \
-not -path '*/node_modules/*' \
-not -name 'wrangler.test.toml' \
@@ -93,19 +97,22 @@ else
exit 1
fi
mapfile -t CHANGED < <(git diff --name-only "$BASE_REF" HEAD)
CHANGED=()
while IFS= read -r f; do
[ -n "$f" ] && CHANGED+=("$f")
done < <(git diff --name-only "$BASE_REF" HEAD)
echo "Changed files (${#CHANGED[@]}):"
for f in "${CHANGED[@]}"; do echo " $f"; done
for f in ${CHANGED[@]+"${CHANGED[@]}"}; do echo " $f"; done
for d in "${ALL_DIRS[@]}"; do
hit=0
for f in "${CHANGED[@]}"; do
for f in ${CHANGED[@]+"${CHANGED[@]}"}; do
if [[ "$f" == "$d"/* ]]; then hit=1; break; fi
done
# 連動:改 registry/components/{name}/ 也要 deploy .component-builds/{name}/
if [[ $hit -eq 0 && "$d" == .component-builds/* ]]; then
name="${d#.component-builds/}"
for f in "${CHANGED[@]}"; do
for f in ${CHANGED[@]+"${CHANGED[@]}"}; do
if [[ "$f" == "registry/components/$name"/* ]]; then hit=1; break; fi
done
fi
@@ -132,9 +139,9 @@ done
echo ""
echo "📦 Deploy plan:"
echo " Tier 1 (components, ${#TIER1[@]}):"
for t in "${TIER1[@]}"; do echo " - $t"; done
for t in ${TIER1[@]+"${TIER1[@]}"}; do echo " - $t"; done
echo " Tier 2 (orchestration, ${#TIER2[@]}):"
for t in "${TIER2[@]}"; do echo " - $t"; done
for t in ${TIER2[@]+"${TIER2[@]}"}; do echo " - $t"; done
echo ""
if [[ "$DRY_RUN" == true ]]; then
@@ -198,10 +205,10 @@ deploy_one() {
}
FAILED=()
for d in "${TIER1[@]}"; do
for d in ${TIER1[@]+"${TIER1[@]}"}; do
deploy_one "$d" || FAILED+=("$d")
done
for d in "${TIER2[@]}"; do
for d in ${TIER2[@]+"${TIER2[@]}"}; do
deploy_one "$d" || FAILED+=("$d")
done
@@ -211,6 +218,71 @@ if [[ ${#FAILED[@]} -eq 0 ]]; then
echo "✅ All ${#TARGETS[@]} workers deployed successfully"
else
echo "⚠️ ${#FAILED[@]}/${#TARGETS[@]} workers failed:"
for f in "${FAILED[@]}"; do echo "$f"; done
for f in ${FAILED[@]+"${FAILED[@]}"}; do echo "$f"; done
fi
# ── 6. CLI npm publish(壓測報告第 3 點:deploy 不只推 workernpm CLI 也要同步)──
# 「推送 = 全部 publish target 到位」(tests/release.feature)。worker 走 wrangler
# CLI 走 npm。只有 cli/ 在本次 diff 內 + 版本比 npm 新時才 publish(同版跳過,不假失敗)。
# 不在 --dry-run 時跑。需 npm loginnpm whoami 確認)。
if [[ "${DRY_RUN:-false}" != "true" ]]; then
CLI_CHANGED=false
for t in "${TARGETS[@]:-}"; do [[ "$t" == "cli" || "$t" == "cli/" ]] && CLI_CHANGED=true; done
# --all 或 diff 含 cli/ 都算
if git diff --name-only "${BASE_REF}"..HEAD 2>/dev/null | grep -q '^cli/'; then CLI_CHANGED=true; fi
if [[ "${DEPLOY_ALL:-false}" == "true" ]]; then CLI_CHANGED=true; fi
if [[ "$CLI_CHANGED" == "true" ]]; then
echo ""
echo "▶ CLI npm publish ..."
LOCAL_V=$(node -p "require('./cli/package.json').version" 2>/dev/null || echo "?")
REMOTE_V=$(npm view arcrun version 2>/dev/null || echo "none")
# 自動昇版(richblackdeploy 時自動 bump,避免忘了改):
# 若本機版本 == npm 上版本(= 改了 cli 但沒 bump)→ 自動 patch +1。
# 留版本記錄:把本次 commit subject 寫進 cli/CHANGELOG.md。
if [[ "$LOCAL_V" == "$REMOTE_V" ]]; then
echo " · 版本未 bump$LOCAL_V 已在 npm),自動 patch +1 ..."
NEW_V=$(cd cli && npm version patch --no-git-tag-version 2>/dev/null | tr -d 'v')
LOCAL_V="$NEW_V"
# CHANGELOGprepend 新版本 + 本次 commit subject(無 commit 則標 manual deploy
COMMIT_SUBJ=$(git log -1 --format='%s' 2>/dev/null || echo 'manual deploy')
COMMIT_DATE=$(git log -1 --format='%ad' --date=short 2>/dev/null || echo '')
CHANGELOG="cli/CHANGELOG.md"
TMP_CL=$(mktemp)
{
echo "# arcrun CLI Changelog"
echo ""
echo "## $NEW_V$COMMIT_DATE"
echo "- $COMMIT_SUBJ"
echo ""
if [[ -f "$CHANGELOG" ]]; then tail -n +2 "$CHANGELOG"; fi
} > "$TMP_CL"
mv "$TMP_CL" "$CHANGELOG"
echo " · 已 bump → $NEW_V,並記錄進 $CHANGELOG(記得 commit 這兩個檔)"
fi
if ! npm whoami >/dev/null 2>&1; then
echo " ⚠ 未 npm loginnpm whoami 失敗),跳過 publish。手動:cd cli && npm publish"
FAILED+=("cli:npm-publish(未登入)")
else
echo " 📦 publish arcrun $REMOTE_V$LOCAL_V ..."
if (cd cli && npm run build >/dev/null 2>&1 && npm publish --access public 2>&1 | tail -3); then
echo " ✅ npm publish 完成(arcrun@$LOCAL_V"
else
echo " ❌ npm publish 失敗"
FAILED+=("cli:npm-publish")
fi
fi
fi
fi
echo ""
echo "════════════════════════════════════════════════════════════"
if [[ ${#FAILED[@]} -eq 0 ]]; then
echo "✅ 全部 publish target 到位(worker + 必要時 CLI npm"
else
echo "⚠️ ${#FAILED[@]} 項失敗(誠實回報,未假綠):"
for f in ${FAILED[@]+"${FAILED[@]}"}; do echo "$f"; done
exit 1
fi
+71
View File
@@ -0,0 +1,71 @@
# language: zh-TW
# release.feature — 「推送 = 全部 publish target 到位」的可驗證規格
#
# 來源:docs/壓測報告.md 第 3 點(開發者「只推 GitHub,沒把 wrangler / npm 都 deploy」,
# 壓測時有些可用有些不可用,浪費壓測時間)。
#
# 用途:每次「完成推送」時,必須確認下列每個 publish target 都已到位。
# 這不是單元測試,是 release 把關清單(Gherkin = 人讀得懂的契約)。
# 對應驗證腳本:scripts/check-release.sh(列出每個 target 的目前版本/部署狀態)。
#
# ⚠️ 實際部署機制(誠實,mindset §7):GH Actions 於 2026-05-16 配額爆掉後停用,
# 且公開 repo 的 .github/ 已於 037cf9b 移除(對外只留)。**現行 deploy 走本機
# `scripts/local-deploy.sh`**wrangler deploy worker + 本輪新增 CLI npm publish)。
# 本檔描述「該到位什麼」;real 動作由 local-deploy.sh 執行。
# `.github/workflows/publish-cli.yml` 是「若哪天重啟 GH Actions」的預備檔(目前 gitignored、不跑)。
功能: Release — 一次推送,所有對外 publish target 同步到位
arcrun
CF workers / landing / npm CLI / MCP
/
背景:
假設 commit
並且 wrangler loginworker + npm loginCLI
# ── 由 scripts/local-deploy.sh 執行(現行機制)─────────────────
場景: cypher-executorAPI 本體)部署到最新
cypher-executor/
那麼 local-deploy.sh wrangler deploy arcrun-cypher-executor
並且 https://cypher.arcrun.dev/health 200
場景: 所有零件 Worker 部署到最新
.component-builds/*/ registry/components/*/
那麼 local-deploy.sh rebuild WASM wrangler deploy arcrun-{name} Worker
並且 workers.dev URLarcrun-{kebab}.{subdomain}.workers.dev 200
場景: registry / builtins Worker 部署到最新
registry/ builtins/
那麼 local-deploy.sh tier2 wrangler deploy Worker
場景: landingarcrun.dev)部署到最新
landing/
那麼 next build + wrangler pages deploy arcrun-landing
場景: CLInpm 套件 arcrun)發佈到最新
cli/ cli/package.json version bump
那麼 local-deploy.sh 6 npm publish arcrun@{}
並且 npm view arcrun version cli/package.json version
# 壓測 §2.6:曾發生 GitHub main 1.2.0 但 npm 仍 1.1.0 → 此場景就是要防這個。
# 根因:舊 local-deploy.sh 只 wrangler deploy worker,從不 npm publish CLI。本輪補上。
場景: MCP server 部署到最新
arcrun-mcp/src/ arcrun-mcp repo
那麼 arcrun-mcp wrangler deploy MCP Worker
並且 MCP cypher-executor API rule 07
# ── 需人為動作(不假裝自動)────────────────────────────────────
@manual
場景: CLI 版本未 bump 時不會發版(誠實:改了 cli 但忘了 bump → npm 不會更新)
假設 cli/ cli/package.json version
那麼 local-deploy.sh publishversion npm
並且 bump version
# check-release.sh 會把「cli/src 變動晚於 package.json」標為 ⚠️ 提醒
@manual
場景: 新 publish target 加入時必須補進本清單
假設 python-sdk / js-sdk
那麼
並且 scripts/check-release.sh + scripts/local-deploy.sh /