(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:
authored and GitHub committed 2026-07-15 09:56:43 +07:00
1 parent 4f558e2c3b
commit 293aa2a5e1
75 files changed
+8461 -166

No files matched your search

+5 -3
View File
@@ -231,10 +231,12 @@ func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.Ag
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND tenant_id = ?"
args = append(args, tid)
if tid == uuid.Nil {
slog.Warn("agents.List: tenant context missing, returning empty (fail-closed)")
return nil, nil
}
q += " AND tenant_id = ?"
args = append(args, tid)
}
q += " ORDER BY created_at DESC"
@@ -0,0 +1,43 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"testing"
)
// TestSQLiteAgentStoreList_NilTenant_FailsClosed verifies List returns an
// empty result (not an unscoped cross-tenant leak) when the incoming context
// carries no tenant scope — matching the fail-closed contract already
// enforced by GetByID/GetByKey. Regression test for the bug where List
// silently omitted the tenant_id filter whenever the context tenant was nil.
func TestSQLiteAgentStoreList_NilTenant_FailsClosed(t *testing.T) {
db := newHookTestDB(t)
tenantID, agentID := seedHookTenantAgent(t, db)
// seedHookTenantAgent leaves display_name NULL, which scanAgentRow can't
// scan into a plain string — set it so List's underlying scan succeeds.
if _, err := db.Exec(`UPDATE agents SET display_name = 'test agent' WHERE id = ?`, agentID.String()); err != nil {
t.Fatalf("set display_name: %v", err)
}
agentStore := NewSQLiteAgentStore(db)
// Sanity check: the seeded agent IS visible when properly tenant-scoped.
scoped, err := agentStore.List(sqliteTenantCtx(tenantID), "")
if err != nil {
t.Fatalf("List (tenant-scoped): %v", err)
}
if len(scoped) == 0 {
t.Fatal("expected at least one agent when tenant-scoped, got none")
}
// No tenant in context at all (uuid.Nil, not master, not cross-tenant):
// must fail closed (empty, no error), never return unscoped rows.
unscoped, err := agentStore.List(context.Background(), "")
if err != nil {
t.Fatalf("List (no tenant context): unexpected error %v", err)
}
if len(unscoped) != 0 {
t.Fatalf("List (no tenant context) leaked %d rows across tenants, want 0 (fail-closed)", len(unscoped))
}
}