Files
293aa2a5e1 (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>
2026-07-15 09:56:43 +07:00

126 lines
3.8 KiB
Go

package skills
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Errors returned by WriteVersionedFile, mapped by callers (HTTP handler,
// MCP tool) to their own status codes.
var (
ErrSkillFileNotFound = errors.New("skill file not found")
ErrSkillIsSystem = errors.New("cannot edit a system skill")
ErrSkillInvalidPath = errors.New("invalid file path")
)
// WriteVersionedFile writes relPath's content into a new immutable version of
// a managed (non-system) skill: it copies the current version directory,
// writes the file into the copy, atomically renames it into place, and
// repoints the skill's DB row at the new version. Historical versions remain
// immutable. Shared by internal/http's skill-editor endpoint
// (SkillsHandler.handleWriteFile) and the goclaw_skills_write_file MCP tool
// so both surfaces apply identical validation and versioning.
func WriteVersionedFile(ctx context.Context, manage store.SkillManageStore, tenantSkillsDir string, id uuid.UUID, relPath, content string) (path string, version int, err error) {
if strings.Contains(relPath, "..") {
return "", 0, ErrSkillInvalidPath
}
filePath, slug, currentVersion, isSystem, ok := manage.GetSkillFilePath(ctx, id)
if !ok {
return "", 0, ErrSkillFileNotFound
}
if isSystem {
return "", 0, ErrSkillIsSystem
}
slugDir := store.SkillSlugDir(filePath)
if slugDir == "" {
return "", 0, ErrSkillFileNotFound
}
currentDir := filepath.Join(slugDir, strconv.Itoa(currentVersion))
if info, statErr := os.Stat(currentDir); statErr != nil || !info.IsDir() {
return "", 0, ErrSkillFileNotFound
}
cleanRelPath := filepath.Clean(relPath)
// Validate the path against the CURRENT version directory before staging
// a copy — cheaper failure path and keeps the escape/symlink checks close
// to the original request path.
checkPath := filepath.Join(currentDir, cleanRelPath)
if !strings.HasPrefix(checkPath, currentDir+string(filepath.Separator)) {
return "", 0, ErrSkillInvalidPath
}
if fi, lstatErr := os.Lstat(checkPath); lstatErr == nil {
if fi.Mode()&os.ModeSymlink != 0 || fi.IsDir() {
return "", 0, ErrSkillInvalidPath
}
}
if IsSystemArtifact(filepath.Base(cleanRelPath)) {
return "", 0, ErrSkillInvalidPath
}
// Create a new immutable version: lock the next version number, stage a
// copy of the current version directory, write the edited file into the
// staged copy, then atomically rename it into place and repoint the
// skill's DB row.
newVersion, commitLock, err := manage.GetNextVersionLocked(ctx, slug)
if err != nil {
return "", 0, err
}
defer commitLock() //nolint:errcheck
destDir := filepath.Join(tenantSkillsDir, slug, strconv.Itoa(newVersion))
tmpDir := destDir + ".tmp-" + uuid.NewString()
if err := CopyDir(currentDir, tmpDir); err != nil {
return "", 0, err
}
removeDestOnError := true
defer func() {
_ = os.RemoveAll(tmpDir)
if removeDestOnError {
_ = os.RemoveAll(destDir)
}
}()
absPath := filepath.Join(tmpDir, cleanRelPath)
if !strings.HasPrefix(absPath, tmpDir+string(filepath.Separator)) {
return "", 0, ErrSkillInvalidPath
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return "", 0, err
}
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
return "", 0, err
}
if err := os.Rename(tmpDir, destDir); err != nil {
return "", 0, err
}
hash, size, err := HashDir(destDir)
if err != nil {
return "", 0, err
}
if err := manage.UpdateSkill(ctx, id, map[string]any{
"version": newVersion,
"file_path": destDir,
"file_size": size,
"file_hash": &hash,
"updated_at": time.Now(),
}); err != nil {
return "", 0, err
}
removeDestOnError = false
manage.BumpVersion()
return relPath, newVersion, nil
}