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
+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)。