mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* 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>
150 lines
6.2 KiB
Go
150 lines
6.2 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// TestNewCRUDServer_NoDeps_RegistersOnlyQuota verifies the "degrade
|
|
// gracefully" contract documented on CRUDDeps: with a completely empty
|
|
// CRUDDeps, no store-backed tool family is registered — only the
|
|
// unconditional goclaw_quota_* family (which tolerates nil Quota/DB), so a
|
|
// deployment with nothing wired still produces a server that constructs
|
|
// without panicking and exposes no dangling/broken tools.
|
|
func TestNewCRUDServer_NoDeps_RegistersOnlyQuota(t *testing.T) {
|
|
httpServer := NewCRUDServer(CRUDDeps{}, "test")
|
|
if httpServer == nil {
|
|
t.Fatal("expected non-nil StreamableHTTPServer even with empty deps")
|
|
}
|
|
}
|
|
|
|
// TestNewCRUDServer_AgentsOnly_RegistersAgentToolsNotSessions verifies that
|
|
// each tool family is gated strictly on its own dependency being non-nil,
|
|
// independent of other families.
|
|
func TestNewCRUDServer_AgentsOnly_RegistersAgentToolsNotSessions(t *testing.T) {
|
|
agents := newFakeAgentStore()
|
|
|
|
// Build the same MCPServer construction NewCRUDServer uses internally by
|
|
// calling the registration function directly, since CRUDDeps only
|
|
// exposes the fully-assembled StreamableHTTPServer (no tool introspection
|
|
// hook) — mirror its gating logic against a bare MCPServer instead.
|
|
srv := newTestMCPServer()
|
|
registerAgentCRUDTools(srv, agents)
|
|
|
|
if tool := srv.GetTool("goclaw_agents_list"); tool == nil {
|
|
t.Error("expected goclaw_agents_list to be registered when Agents is set")
|
|
}
|
|
if tool := srv.GetTool("goclaw_sessions_list"); tool != nil {
|
|
t.Error("expected goclaw_sessions_list to NOT be registered when Sessions was never registered")
|
|
}
|
|
}
|
|
|
|
// TestNewCRUDServer_SkillManageStore_RegistersUpdateToolOnlyWhenSupported
|
|
// verifies goclaw_skills_update is only registered when the skill store also
|
|
// implements store.SkillManageStore (crud_server.go's type-assertion gate).
|
|
func TestNewCRUDServer_SkillManageStore_RegistersUpdateToolOnlyWhenSupported(t *testing.T) {
|
|
// Plain SkillStore (no manage capability): registerSkillCRUDTools alone,
|
|
// mirroring what NewCRUDServer does when the type assertion fails.
|
|
srv := newTestMCPServer()
|
|
skills := newFakeSkillStore()
|
|
registerSkillCRUDTools(srv, skills)
|
|
if tool := srv.GetTool("goclaw_skills_update"); tool != nil {
|
|
t.Error("expected goclaw_skills_update to NOT be registered for a plain SkillStore")
|
|
}
|
|
|
|
// SkillManageStore-capable store: both list/get and update should be
|
|
// registered, matching NewCRUDServer's `if manage, ok := ...; ok` branch.
|
|
srv2 := newTestMCPServer()
|
|
manage := newFakeSkillManageStore()
|
|
registerSkillCRUDTools(srv2, manage)
|
|
registerSkillUpdateCRUDTool(srv2, manage, manage)
|
|
if tool := srv2.GetTool("goclaw_skills_update"); tool == nil {
|
|
t.Error("expected goclaw_skills_update to be registered for a SkillManageStore-capable store")
|
|
}
|
|
}
|
|
|
|
// TestNewCRUDServer_ConfigOnly_Constructs is a smoke test that a single
|
|
// non-nil dependency (Config) still produces a working server without
|
|
// requiring every other field to be populated.
|
|
func TestNewCRUDServer_ConfigOnly_Constructs(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
httpServer := NewCRUDServer(CRUDDeps{Config: cfg}, "test")
|
|
if httpServer == nil {
|
|
t.Fatal("expected non-nil StreamableHTTPServer with only Config set")
|
|
}
|
|
}
|
|
|
|
// TestResolveMCPTenantID_HeaderPresentAndValid_ScopesToThatTenant verifies
|
|
// that a valid "X-GoClaw-Tenant-Id" header (UUID form) resolves to the
|
|
// matching tenant.
|
|
func TestResolveMCPTenantID_HeaderPresentAndValid_ScopesToThatTenant(t *testing.T) {
|
|
tenants := newFakeTenantStore()
|
|
want := &store.TenantData{ID: uuid.New(), Slug: "acme"}
|
|
tenants.addTenant(want)
|
|
|
|
got := resolveMCPTenantID(context.Background(), tenants, want.ID.String())
|
|
if got != want.ID {
|
|
t.Fatalf("expected tenant %s, got %s", want.ID, got)
|
|
}
|
|
}
|
|
|
|
// TestResolveMCPTenantID_HeaderPresentSlug_ScopesToThatTenant verifies slug
|
|
// lookups (non-UUID header values) also resolve correctly.
|
|
func TestResolveMCPTenantID_HeaderPresentSlug_ScopesToThatTenant(t *testing.T) {
|
|
tenants := newFakeTenantStore()
|
|
want := &store.TenantData{ID: uuid.New(), Slug: "acme"}
|
|
tenants.addTenant(want)
|
|
|
|
got := resolveMCPTenantID(context.Background(), tenants, "acme")
|
|
if got != want.ID {
|
|
t.Fatalf("expected tenant %s, got %s", want.ID, got)
|
|
}
|
|
}
|
|
|
|
// TestResolveMCPTenantID_HeaderAbsent_DefaultsToMasterTenant verifies the
|
|
// fail-safe default (master tenant, not uuid.Nil/unscoped) when the caller
|
|
// supplies no tenant header at all.
|
|
func TestResolveMCPTenantID_HeaderAbsent_DefaultsToMasterTenant(t *testing.T) {
|
|
tenants := newFakeTenantStore()
|
|
|
|
got := resolveMCPTenantID(context.Background(), tenants, "")
|
|
if got != store.MasterTenantID {
|
|
t.Fatalf("expected MasterTenantID, got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestResolveMCPTenantID_HeaderUnresolvable_DefaultsToMasterTenant verifies
|
|
// that an invalid/unknown tenant header fails safe to the master tenant
|
|
// rather than silently falling through to an unscoped (uuid.Nil) context —
|
|
// unscoped context previously allowed writes to leak into the wrong tenant
|
|
// (or master) depending on downstream store fallback behavior.
|
|
func TestResolveMCPTenantID_HeaderUnresolvable_DefaultsToMasterTenant(t *testing.T) {
|
|
tenants := newFakeTenantStore()
|
|
|
|
got := resolveMCPTenantID(context.Background(), tenants, uuid.New().String())
|
|
if got != store.MasterTenantID {
|
|
t.Fatalf("expected MasterTenantID fallback for unknown tenant id, got %s", got)
|
|
}
|
|
|
|
got = resolveMCPTenantID(context.Background(), tenants, "not-a-real-slug")
|
|
if got != store.MasterTenantID {
|
|
t.Fatalf("expected MasterTenantID fallback for unknown tenant slug, got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestResolveMCPTenantID_NilTenantStore_DefaultsToMasterTenant verifies the
|
|
// server still degrades gracefully (falls back to master tenant rather than
|
|
// panicking or leaving tenant scope unset) when CRUDDeps.Tenants is nil —
|
|
// e.g. an edition/build that never wired a tenant store.
|
|
func TestResolveMCPTenantID_NilTenantStore_DefaultsToMasterTenant(t *testing.T) {
|
|
got := resolveMCPTenantID(context.Background(), nil, uuid.New().String())
|
|
if got != store.MasterTenantID {
|
|
t.Fatalf("expected MasterTenantID with nil tenant store, got %s", got)
|
|
}
|
|
}
|