mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 14:13:34 +00:00
(feat) MCP Server (#1365)
* feat(hooks/observe): add structured output validation hook (ObserveHook) - hooks/types.go: Add ObserveHook interface + BuiltInHookType enum - hooks/dispatcher.go: HookDispatcher emits ObserveHook with PhaseResult payload - pipeline/observe_stage.go: ObserveStage emits hook after ObserveResult built - pipeline/substates.go: ObserveStageResult carries hook results + validation errors - hooks/config.go: BuiltInHookTypeObserve added to BuiltInHookType enum PhaseResult carries structured output, token usage, tool calls + validation errors emitted post-ObserveStage. ObserveHook implementations can validate structured output against schemas, detect tool-call loops, enforce token budgets, etc. Hook fires after ObserveStage produces ObserveResult, before results propagate to next stage. ValidationError returned by hook halts pipeline and propagates error to caller without further stage execution. Co-Authored-By: Claude <noreply@anthropic.com> * ui(hooks): add post_model_response event to web UI - Add event to Zod schema, filter dropdown, and form dialog - Implement conditional test panel UI for model response payload - Add translations (en/zh/vi) for new test panel fields - Updated beta description to reference the new event Co-Authored-By: Claude <noreply@anthropic.com> * feat(mcp): add MCP CRUD server exposing goclaw resources at /api/mcp/ with Bearer token auth and X-GoClaw-Tenant-Id header, default to master tenant * feat(mcp): add goclaw_skills_write_file tool to edit skill files on disk The CRUD MCP server's goclaw_skills_update only touched skill DB metadata, with no way to edit a skill's SKILL.md/file content on the filesystem. Extract the versioned write logic from the web UI's skill file editor (SkillsHandler.handleWriteFile) into skills.WriteVersionedFile so both surfaces share identical validation and versioning, and expose it as a new MCP tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
75 files changed
+8461
-166
No files matched your search
@@ -200,7 +200,9 @@ export function useTestHook() {
|
||||
sampleEvent,
|
||||
}: {
|
||||
config: Partial<HookConfig>;
|
||||
sampleEvent: { toolName: string; toolInput: Record<string, unknown>; rawInput?: string };
|
||||
sampleEvent:
|
||||
| { toolName: string; toolInput: Record<string, unknown>; rawInput?: string }
|
||||
| { modelResponse: Record<string, unknown>; thinking?: string; toolCalls?: Array<Record<string, unknown>>; rawInput?: string };
|
||||
}) =>
|
||||
ws.call<{ result: HookTestResult }>("hooks.test", { config, sampleEvent }),
|
||||
onError: (err) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"badge": "Beta",
|
||||
"description": "Hooks let you intercept agent lifecycle events to allow, block, or rewrite what happens. Currently in beta — expect rough edges and watch your audit log.",
|
||||
"howItWorksTitle1": "1. Pick an event",
|
||||
"howItWorksBody1": "Each hook fires on one event such as user_prompt_submit, pre_tool_use, or stop. Blocking events wait for your decision before continuing.",
|
||||
"howItWorksBody1": "Each hook fires on one event such as user_prompt_submit, pre_tool_use, post_model_response, or stop. Blocking events wait for your decision before continuing.",
|
||||
"howItWorksTitle2": "2. Choose a handler",
|
||||
"howItWorksBody2": "Script runs sandboxed JavaScript. HTTP posts to your webhook. Prompt asks an LLM. Built-in hooks (System badge) ship with GoClaw and only the enabled toggle is editable.",
|
||||
"howItWorksTitle3": "3. Test before enabling",
|
||||
@@ -104,7 +104,7 @@
|
||||
"resultHint": "What the dispatcher returned, including any input mutations from builtin hooks.",
|
||||
"emptyState": "Run the hook to see how it would respond.",
|
||||
"fire": "Run hook",
|
||||
"firing": "Running\u2026",
|
||||
"firing": "Running…",
|
||||
"decision": "Decision",
|
||||
"duration": "Duration",
|
||||
"reason": "Reason",
|
||||
@@ -113,7 +113,13 @@
|
||||
"statusCode": "Status code",
|
||||
"updatedInput": "Updated input (diff)",
|
||||
"toolNamePickerPlaceholder": "Select or type tool name...",
|
||||
"overwriteConfirm": "Current input will be replaced with template. Continue?"
|
||||
"overwriteConfirm": "Current input will be replaced with template. Continue?",
|
||||
"modelResponse": "Model Response",
|
||||
"modelResponseHint": "The model's response content (JSON). Used by post_model_response hooks.",
|
||||
"thinking": "Thinking",
|
||||
"thinkingHint": "The model's chain-of-thought reasoning (optional).",
|
||||
"toolCalls": "Tool Calls",
|
||||
"toolCallsHint": "Array of tool calls the model wants to make (optional)."
|
||||
},
|
||||
"history": {
|
||||
"title": "Execution history",
|
||||
@@ -146,4 +152,4 @@
|
||||
"error": "Error",
|
||||
"timeout": "Timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"badge": "Beta",
|
||||
"description": "Hooks cho phép bạn chen vào các sự kiện vòng đời của agent để cho phép, chặn hoặc viết lại nội dung. Đang trong giai đoạn beta — có thể còn lỗi, hãy theo dõi audit log.",
|
||||
"howItWorksTitle1": "1. Chọn sự kiện",
|
||||
"howItWorksBody1": "Mỗi hook gắn với một sự kiện như user_prompt_submit, pre_tool_use, hoặc stop. Sự kiện blocking sẽ chờ quyết định của bạn trước khi tiếp tục.",
|
||||
"howItWorksBody1": "Mỗi hook gắn với một sự kiện như user_prompt_submit, pre_tool_use, post_model_response, hoặc stop. Sự kiện blocking sẽ chờ quyết định của bạn trước khi tiếp tục.",
|
||||
"howItWorksTitle2": "2. Chọn loại xử lý",
|
||||
"howItWorksBody2": "Script chạy JavaScript trong sandbox. HTTP gọi webhook của bạn. Prompt hỏi LLM. Hook dựng sẵn (badge Hệ thống) do GoClaw cung cấp — chỉ chỉnh được toggle bật/tắt.",
|
||||
"howItWorksTitle3": "3. Test trước khi bật",
|
||||
@@ -104,7 +104,7 @@
|
||||
"resultHint": "Phản hồi từ dispatcher, bao gồm cả mutation đầu vào từ hook builtin.",
|
||||
"emptyState": "Bấm Run để xem hook phản hồi thế nào.",
|
||||
"fire": "Chạy hook",
|
||||
"firing": "Đang chạy\u2026",
|
||||
"firing": "Đang chạy…",
|
||||
"decision": "Quyết định",
|
||||
"duration": "Thời gian",
|
||||
"reason": "Lý do",
|
||||
@@ -113,7 +113,13 @@
|
||||
"statusCode": "Mã trạng thái",
|
||||
"updatedInput": "Đầu vào đã cập nhật (diff)",
|
||||
"toolNamePickerPlaceholder": "Chọn hoặc nhập tên tool...",
|
||||
"overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?"
|
||||
"overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?",
|
||||
"modelResponse": "Phản hồi Model",
|
||||
"modelResponseHint": "Nội dung phản hồi của model (JSON). Dùng cho hook post_model_response.",
|
||||
"thinking": "Quá trình suy nghĩ",
|
||||
"thinkingHint": "Chuỗi suy luận của model (tùy chọn).",
|
||||
"toolCalls": "Các cuộc gọi Tool",
|
||||
"toolCallsHint": "Mảng các cuộc gọi tool mà model muốn thực hiện (tùy chọn)."
|
||||
},
|
||||
"history": {
|
||||
"title": "Lịch sử thực thi",
|
||||
@@ -146,4 +152,4 @@
|
||||
"error": "Lỗi",
|
||||
"timeout": "Hết thời gian"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"badge": "Beta",
|
||||
"description": "钩子让你拦截 Agent 生命周期事件以放行、阻止或改写其行为。当前处于公测阶段 — 可能存在问题,请关注审计日志。",
|
||||
"howItWorksTitle1": "1. 选择事件",
|
||||
"howItWorksBody1": "每个钩子绑定一个事件,例如 user_prompt_submit、pre_tool_use 或 stop。阻塞型事件会等待你的决策后再继续。",
|
||||
"howItWorksBody1": "每个钩子绑定一个事件,例如 user_prompt_submit、pre_tool_use、post_model_response 或 stop。阻塞型事件会等待你的决策后再继续。",
|
||||
"howItWorksTitle2": "2. 选择处理器",
|
||||
"howItWorksBody2": "Script 在沙箱中运行 JavaScript。HTTP 调用你的 Webhook。Prompt 询问 LLM。内置钩子(系统徽章)由 GoClaw 提供 — 仅可切换启用状态。",
|
||||
"howItWorksTitle3": "3. 启用前先测试",
|
||||
@@ -104,7 +104,7 @@
|
||||
"resultHint": "调度器的返回结果,包括来自内置钩子的输入修改。",
|
||||
"emptyState": "运行钩子以查看响应。",
|
||||
"fire": "运行钩子",
|
||||
"firing": "运行中\u2026",
|
||||
"firing": "运行中…",
|
||||
"decision": "决策",
|
||||
"duration": "耗时",
|
||||
"reason": "原因",
|
||||
@@ -113,7 +113,13 @@
|
||||
"statusCode": "状态码",
|
||||
"updatedInput": "已更新输入(差异)",
|
||||
"toolNamePickerPlaceholder": "选择或输入工具名称...",
|
||||
"overwriteConfirm": "当前内容将被模板替换。继续?"
|
||||
"overwriteConfirm": "当前内容将被模板替换。继续?",
|
||||
"modelResponse": "模型响应",
|
||||
"modelResponseHint": "模型的响应内容(JSON)。用于 post_model_response 钩子。",
|
||||
"thinking": "思考过程",
|
||||
"thinkingHint": "模型的思维链推理(可选)。",
|
||||
"toolCalls": "工具调用",
|
||||
"toolCallsHint": "模型想要进行的工具调用数组(可选)。"
|
||||
},
|
||||
"history": {
|
||||
"title": "执行历史",
|
||||
@@ -146,4 +152,4 @@
|
||||
"error": "错误",
|
||||
"timeout": "超时"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { ScriptEditor } from "./script-editor";
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
"session_start", "user_prompt_submit", "pre_tool_use",
|
||||
"post_tool_use", "stop", "subagent_start", "subagent_stop",
|
||||
"post_tool_use", "post_model_response", "stop", "subagent_start", "subagent_stop",
|
||||
] as const;
|
||||
|
||||
interface HookFormDialogProps {
|
||||
|
||||
@@ -47,12 +47,23 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
const testMutation = useTestHook();
|
||||
|
||||
const isModelResponse = hook.event === "post_model_response";
|
||||
const saved = loadSavedSample(hook.id);
|
||||
const [toolName, setToolName] = useState<string>(saved?.toolName ?? "bash");
|
||||
const [toolInputRaw, setToolInputRaw] = useState<string>(
|
||||
saved?.toolInputRaw ?? JSON.stringify({ command: "ls -la" }, null, 2),
|
||||
);
|
||||
const [rawInput, setRawInput] = useState<string>(saved?.rawInput ?? "");
|
||||
// post_model_response specific fields
|
||||
const [modelResponse, setModelResponse] = useState<string>(
|
||||
saved?.modelResponse ?? JSON.stringify({ content: "Task completed successfully." }, null, 2),
|
||||
);
|
||||
const [thinking, setThinking] = useState<string>(
|
||||
saved?.thinking ?? "The user asked to list files. I'll use the bash tool.",
|
||||
);
|
||||
const [toolCallsRaw, setToolCallsRaw] = useState<string>(
|
||||
saved?.toolCallsRaw ?? JSON.stringify([{ name: "bash", arguments: { command: "ls -la" } }], null, 2),
|
||||
);
|
||||
const [result, setResult] = useState<HookTestResult | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
@@ -69,18 +80,33 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
|
||||
const handleFire = async () => {
|
||||
setParseError(null);
|
||||
let toolInput: Record<string, unknown>;
|
||||
let toolCalls: Array<Record<string, unknown>> | undefined;
|
||||
try {
|
||||
toolInput = JSON.parse(toolInputRaw);
|
||||
if (isModelResponse) {
|
||||
toolInput = JSON.parse(modelResponse);
|
||||
toolCalls = JSON.parse(toolCallsRaw);
|
||||
} else {
|
||||
toolInput = JSON.parse(toolInputRaw);
|
||||
}
|
||||
} catch {
|
||||
setParseError(t("test.parseError"));
|
||||
return;
|
||||
}
|
||||
|
||||
saveSample(hook.id, { toolName, toolInputRaw, rawInput });
|
||||
saveSample(hook.id, {
|
||||
toolName,
|
||||
toolInputRaw,
|
||||
rawInput,
|
||||
modelResponse,
|
||||
thinking,
|
||||
toolCallsRaw,
|
||||
});
|
||||
|
||||
const res = await testMutation.mutateAsync({
|
||||
config: hook,
|
||||
sampleEvent: { toolName, toolInput, rawInput: rawInput || undefined },
|
||||
sampleEvent: isModelResponse
|
||||
? { modelResponse: toolInput, thinking, toolCalls, rawInput: rawInput || undefined }
|
||||
: { toolName, toolInput, rawInput: rawInput || undefined },
|
||||
});
|
||||
setResult(res.result);
|
||||
};
|
||||
@@ -96,43 +122,85 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolName")}</Label>
|
||||
<ToolSingleCombobox
|
||||
value={toolName}
|
||||
onChange={setToolName}
|
||||
onToolSelect={handleToolSelect}
|
||||
placeholder={t("test.toolNamePickerPlaceholder")}
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
|
||||
</div>
|
||||
{isModelResponse ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.modelResponse")}</Label>
|
||||
<Textarea
|
||||
value={modelResponse}
|
||||
onChange={(e) => setModelResponse(e.target.value)}
|
||||
rows={6}
|
||||
placeholder='{"content": "Task completed successfully."}'
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.modelResponseHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolInput")}</Label>
|
||||
<Textarea
|
||||
value={toolInputRaw}
|
||||
onChange={(e) => setToolInputRaw(e.target.value)}
|
||||
rows={10}
|
||||
placeholder='{"command": "ls -la"}'
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
{parseError ? (
|
||||
<p className="text-xs text-destructive">{parseError}</p>
|
||||
) : (
|
||||
<p className="text-2xs text-muted-foreground">{t("test.toolInputHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.thinking")}</Label>
|
||||
<Textarea
|
||||
value={thinking}
|
||||
onChange={(e) => setThinking(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="The user asked to list files. I'll use the bash tool."
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.thinkingHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.rawInput")}</Label>
|
||||
<Input
|
||||
value={rawInput}
|
||||
onChange={(e) => setRawInput(e.target.value)}
|
||||
placeholder={t("test.rawInputPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.rawInputHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolCalls")}</Label>
|
||||
<Textarea
|
||||
value={toolCallsRaw}
|
||||
onChange={(e) => setToolCallsRaw(e.target.value)}
|
||||
rows={4}
|
||||
placeholder='[{"name": "bash", "arguments": {"command": "ls -la"}}]'
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.toolCallsHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolName")}</Label>
|
||||
<ToolSingleCombobox
|
||||
value={toolName}
|
||||
onChange={setToolName}
|
||||
onToolSelect={handleToolSelect}
|
||||
placeholder={t("test.toolNamePickerPlaceholder")}
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolInput")}</Label>
|
||||
<Textarea
|
||||
value={toolInputRaw}
|
||||
onChange={(e) => setToolInputRaw(e.target.value)}
|
||||
rows={10}
|
||||
placeholder='{"command": "ls -la"}'
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
{parseError ? (
|
||||
<p className="text-xs text-destructive">{parseError}</p>
|
||||
) : (
|
||||
<p className="text-2xs text-muted-foreground">{t("test.toolInputHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.rawInput")}</Label>
|
||||
<Input
|
||||
value={rawInput}
|
||||
onChange={(e) => setRawInput(e.target.value)}
|
||||
placeholder={t("test.rawInputPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-2xs text-muted-foreground">{t("test.rawInputHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleFire}
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { HookFormData } from "@/schemas/hooks.schema";
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
"session_start", "user_prompt_submit", "pre_tool_use",
|
||||
"post_tool_use", "stop", "subagent_start", "subagent_stop",
|
||||
"post_tool_use", "post_model_response", "stop", "subagent_start", "subagent_stop",
|
||||
] as const;
|
||||
|
||||
// parseHeaders accepts an empty string, an empty object string, or a JSON
|
||||
|
||||
@@ -5,6 +5,7 @@ export const HookEventEnum = z.enum([
|
||||
"user_prompt_submit",
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"post_model_response",
|
||||
"stop",
|
||||
"subagent_start",
|
||||
"subagent_stop",
|
||||
|
||||
Reference in new issue
Block a user