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:
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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 — 取得單一 Workflow(GUI 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;
|
||||
@@ -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_namespace(MCP 自己用)
|
||||
* - 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,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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="["item1","item2"]"></textarea>';
|
||||
}
|
||||
// object → textarea (JSON)
|
||||
if (type === 'object') {
|
||||
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="object" placeholder="{"key":"value"}"></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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -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="["item1","item2"]"></textarea>';
|
||||
}
|
||||
if (type === 'object') {
|
||||
return '<textarea id="' + id + '" data-key="' + escAttr(key) + '" data-type="object" placeholder="{"key":"value"}"></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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Introspection / debug MCP tools — LI SDD M2.2
|
||||
*
|
||||
* arcrun_validate_yaml — dry-run YAML 校驗,不部署
|
||||
* arcrun_get_execution_trace — 看 paused workflow state(task_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 觸發後若 paused,error 訊息含 '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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 search(embedding 比對)+ 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);
|
||||
}
|
||||
@@ -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_content,parse 出 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 name(yaml_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 filter(M2.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);
|
||||
}
|
||||
@@ -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 lookup(KBDB-backed)
|
||||
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
|
||||
registerAllSkillExampleTools(server, env);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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. 編譯為 .wasm,base64 編碼後提交
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user