Files
goclaw/cmd/gateway_http_wiring.go
T
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

404 lines
16 KiB
Go

package cmd
import (
"context"
"log/slog"
"os"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/webhooks"
)
// httpHandlers bundles the results of wireHTTP() for passing to wireHTTPHandlersOnServer.
type httpHandlers struct {
agents *httpapi.AgentsHandler
skills *httpapi.SkillsHandler
traces *httpapi.TracesHandler
mcp *httpapi.MCPHandler
channelInstances *httpapi.ChannelInstancesHandler
providers *httpapi.ProvidersHandler
builtinTools *httpapi.BuiltinToolsHandler
pendingMessages *httpapi.PendingMessagesHandler
teamEvents *httpapi.TeamEventsHandler
secureCLI *httpapi.SecureCLIHandler
secureCLIGrant *httpapi.SecureCLIGrantHandler
mcpUserCreds *httpapi.MCPUserCredentialsHandler
mcpOAuth *httpapi.MCPOAuthHandler
}
// wireHTTPHandlersOnServer registers all HTTP handler objects onto the gateway server.
// Called after wireHTTP() and wireExtras() have returned their results.
func (d *gatewayDeps) wireHTTPHandlersOnServer(
h httpHandlers,
wakeH *httpapi.WakeHandler,
mcpPool *mcpbridge.Pool,
postTurn tools.PostTurnProcessor,
mediaStore *media.Store,
) {
if h.providers != nil {
h.providers.SetAPIBaseFallback(d.cfg.Providers.APIBaseForType)
}
if h.agents != nil {
d.server.SetAgentsHandler(h.agents)
}
if h.skills != nil {
d.server.SetSkillsHandler(h.skills)
}
if h.traces != nil {
d.server.SetTracesHandler(h.traces)
}
// External wake/trigger API — wakeH was created by caller before invoking this method.
d.server.SetWakeHandler(wakeH)
if h.mcp != nil {
if mcpPool != nil {
h.mcp.SetPoolEvictor(mcpPool)
}
d.server.SetMCPHandler(h.mcp)
}
if h.mcpUserCreds != nil {
d.server.SetMCPUserCredentialsHandler(h.mcpUserCreds)
}
if h.mcpOAuth != nil {
if mcpPool != nil {
h.mcpOAuth.SetEvictor(mcpPool)
}
d.server.SetMCPOAuthHandler(h.mcpOAuth)
}
if h.channelInstances != nil {
d.server.SetChannelInstancesHandler(h.channelInstances)
}
if h.providers != nil {
d.server.SetProvidersHandler(h.providers)
}
if h.teamEvents != nil {
d.server.SetTeamEventsHandler(h.teamEvents)
}
if d.pgStores != nil && d.pgStores.Teams != nil {
d.server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(d.pgStores.Teams, d.workspace))
d.server.SetWorkspaceUploadHandler(httpapi.NewWorkspaceUploadHandler(d.pgStores.Teams, d.workspace, d.msgBus))
}
if h.builtinTools != nil {
d.server.SetBuiltinToolsHandler(h.builtinTools)
}
if h.pendingMessages != nil {
if pc := d.cfg.Channels.PendingCompaction; pc != nil {
h.pendingMessages.SetKeepRecent(pc.KeepRecent)
h.pendingMessages.SetMaxTokens(pc.MaxTokens)
h.pendingMessages.SetProviderModel(pc.Provider, pc.Model)
}
d.server.SetPendingMessagesHandler(h.pendingMessages)
}
if h.secureCLI != nil {
d.server.SetSecureCLIHandler(h.secureCLI)
}
if h.secureCLIGrant != nil {
d.server.SetSecureCLIGrantHandler(h.secureCLIGrant)
}
if d.pgStores != nil && d.pgStores.BrowserCookies != nil {
d.server.SetBrowserCookiesHandler(httpapi.NewBrowserCookiesHandler(d.pgStores.BrowserCookies))
}
// Activity audit log API
if d.pgStores.Activity != nil {
d.server.SetActivityHandler(httpapi.NewActivityHandler(d.pgStores.Activity))
}
// System configs API
if d.pgStores.SystemConfigs != nil {
d.server.SetSystemConfigsHandler(httpapi.NewSystemConfigsHandler(d.pgStores.SystemConfigs, d.msgBus))
// Refresh in-memory config when system_configs change via HTTP API
d.msgBus.Subscribe(bus.TopicSystemConfigChanged, func(evt bus.Event) {
// Use tenant context from the request that triggered the change
ctx := context.Background()
if reqCtx, ok := evt.Payload.(context.Context); ok {
ctx = reqCtx
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
if sysConfigs, err := d.pgStores.SystemConfigs.List(ctx); err == nil && len(sysConfigs) > 0 {
d.cfg.ApplySystemConfigs(sysConfigs)
// Update PGMemoryStore chunk config so new documents use updated settings
if mem := d.cfg.Agents.Defaults.Memory; mem != nil {
if pgMem, ok := d.pgStores.Memory.(*pg.PGMemoryStore); ok {
pgMem.UpdateChunkConfig(mem.MaxChunkLen, mem.ChunkOverlap)
}
}
// Note: vault enrichment provider is resolved per-tenant at runtime,
// no hot-reload needed here
slog.Debug("system_configs refreshed to in-memory config", "keys", len(sysConfigs))
}
})
}
d.server.SetBrandingAssetsHandler(httpapi.NewBrandingAssetsHandler(d.dataDir))
// Usage analytics API
if d.pgStores.Snapshots != nil {
d.server.SetUsageHandler(httpapi.NewUsageHandler(d.pgStores.Snapshots, d.pgStores.UsageEvents, d.pgStores.DB))
}
if d.pgStores.UsageCaps != nil {
d.server.SetUsageCapsHandler(httpapi.NewUsageCapsHandler(d.pgStores.UsageCaps, d.pgStores.Tenants))
}
// Runtime package management (install/uninstall system/pip/npm/github packages)
// Wire the update registry AFTER initGitHubInstaller so DefaultGitHubInstaller() is set.
initGitHubInstaller()
pkgHandler := wirePackagesHandler(d)
d.server.SetPackagesHandler(pkgHandler)
d.server.SetGatewayUpgradeHandler(httpapi.NewGatewayUpgradeHandlerFromEnv())
// API documentation (OpenAPI spec + Swagger UI at /docs)
d.server.SetDocsHandler(httpapi.NewDocsHandler())
// Edition info (public, no auth — used by desktop UI comparison modal)
d.server.SetEditionHandler(httpapi.NewEditionHandler())
if d.pgStores != nil && d.pgStores.APIKeys != nil {
d.server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(d.pgStores.APIKeys, d.msgBus))
d.server.SetAPIKeyStore(d.pgStores.APIKeys)
httpapi.InitAPIKeyCache(d.pgStores.APIKeys, d.msgBus)
}
// K10: single shared webhookLimiter — one per process enforces per-tenant RPM cap across
// both LLM and message endpoints. Two separate instances would double the effective cap.
webhookEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
// K6: refuse to mount any webhook handler when GOCLAW_ENCRYPTION_KEY is unset.
// crypto.Encrypt("", "") returns plaintext unchanged, so an empty key would silently
// persist raw secrets to the database — defeating the stated DB-leak protection.
// Skip-mount approach: process still starts (all other subsystems work), but
// /v1/webhooks/* returns 404. Set GOCLAW_ENCRYPTION_KEY to re-enable webhooks.
if webhookEncKey == "" {
slog.Error("webhook subsystem disabled: GOCLAW_ENCRYPTION_KEY not set. Set the env var to enable /v1/webhooks/* endpoints.")
} else {
sharedWebhookLimiter := httpapi.NewWebhookLimiter()
// Webhook admin CRUD — available in all editions (Standard + Lite).
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06.
// adminH is captured so the test endpoint (POST /v1/webhooks/{id}/test) can be wired
// with the runtime invokers (llm/message handlers) once they are constructed below.
var adminH *httpapi.WebhooksAdminHandler
if d.pgStores != nil && d.pgStores.Webhooks != nil {
adminH = httpapi.NewWebhooksAdminHandler(
d.pgStores.Webhooks,
d.pgStores.WebhookCalls,
d.pgStores.Tenants,
d.msgBus,
)
adminH.SetEncKey(webhookEncKey)
d.server.SetWebhooksAdminHandler(adminH)
}
// Webhook message endpoint — Standard edition only (channels required).
// Phase 05b: POST /v1/webhooks/message → sync channel send (text + optional media).
var msgH *httpapi.WebhookMessageHandler
if edition.Current().AllowsChannels() &&
d.pgStores != nil &&
d.pgStores.Webhooks != nil &&
d.pgStores.WebhookCalls != nil &&
d.pgStores.ChannelInstances != nil &&
d.channelMgr != nil {
msgH = httpapi.NewWebhookMessageHandler(
d.channelMgr,
d.pgStores.ChannelInstances,
d.pgStores.WebhookCalls,
d.pgStores.Webhooks,
sharedWebhookLimiter, // K10: shared limiter
)
msgH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time
d.server.SetWebhookMessageHandler(msgH)
}
// Webhook LLM endpoint — all editions (Standard + Lite).
// Phase 06: POST /v1/webhooks/llm → sync agent run (≤30s) or async enqueue.
// LocalhostOnly enforcement is handled by WebhookAuthMiddleware at request time.
// lane=nil → handler self-creates internal default lane (4-slot).
var llmH *httpapi.WebhookLLMHandler
if d.pgStores != nil &&
d.pgStores.Webhooks != nil &&
d.pgStores.WebhookCalls != nil &&
d.agentRouter != nil {
llmH = httpapi.NewWebhookLLMHandler(
d.agentRouter,
d.pgStores.WebhookCalls,
d.pgStores.Webhooks,
sharedWebhookLimiter, // K10: shared limiter
nil, // lane: nil → internal default (4-slot); configurable in future via cfg
webhooks.ResolveTimeoutSec(d.cfg.Gateway.WebhookSyncTimeoutSec),
webhooks.ResolveStream(d.cfg.Gateway.WebhookStream),
)
llmH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time
d.server.SetWebhookLLMHandler(llmH)
}
// Wire the admin test endpoint with runtime invokers. msgH is nil on Lite — the
// admin handler guards on nil and rejects message tests there.
if adminH != nil {
adminH.SetTesters(llmH, msgH)
}
}
// Allow browser-paired users to access HTTP APIs
if d.pgStores.Pairing != nil {
httpapi.InitPairingAuth(d.pgStores.Pairing)
}
// Memory management API
if d.pgStores != nil && d.pgStores.Memory != nil {
d.server.SetMemoryHandler(httpapi.NewMemoryHandler(d.pgStores.Memory, d.pgStores.Agents))
}
// Knowledge graph API
if d.pgStores != nil && d.pgStores.KnowledgeGraph != nil {
kgHandler := httpapi.NewKnowledgeGraphHandler(d.pgStores.KnowledgeGraph, d.providerRegistry)
kgHandler.SetUsageCapService(d.usageCapSvc)
d.server.SetKnowledgeGraphHandler(kgHandler)
}
// V3: Evolution metrics + suggestions API
if d.pgStores != nil && d.pgStores.EvolutionMetrics != nil && d.pgStores.EvolutionSuggestions != nil {
var evoOpts []httpapi.EvolutionHandlerOpt
if manageStore, ok := d.pgStores.Skills.(store.SkillManageStore); ok && d.skillsLoader != nil {
evoOpts = append(evoOpts, httpapi.WithSkillCreation(manageStore, d.skillsLoader, d.dataDir))
}
if d.pgStores.Agents != nil {
evoOpts = append(evoOpts, httpapi.WithAgentStore(d.pgStores.Agents))
}
if d.pgStores.BuiltinToolTenantCfgs != nil {
evoOpts = append(evoOpts, httpapi.WithToolTenantCfgs(d.pgStores.BuiltinToolTenantCfgs))
}
d.server.SetEvolutionHandler(httpapi.NewEvolutionHandler(d.pgStores.EvolutionMetrics, d.pgStores.EvolutionSuggestions, evoOpts...))
}
// V3: Knowledge Vault document API
if d.pgStores != nil && d.pgStores.Vault != nil {
vh := httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams)
vh.SetEnrichProgress(d.enrichProgress)
vh.SetEnrichWorker(d.enrichWorker)
d.server.SetVaultHandler(vh)
// Lightweight graph visualization endpoints (vault + KG).
var kgGraph store.KGGraphStore
if d.pgStores.KnowledgeGraph != nil {
kgGraph = newKGGraphStore(d.pgStores.DB)
}
vgHandler := httpapi.NewVaultGraphHandler(
newVaultGraphStore(d.pgStores.DB), kgGraph, d.pgStores.Teams,
)
d.server.SetVaultGraphHandler(vgHandler)
}
// V3: Episodic memory summaries API
if d.pgStores != nil && d.pgStores.Episodic != nil {
d.server.SetEpisodicHandler(httpapi.NewEpisodicHandler(d.pgStores.Episodic))
}
// V3: Orchestration mode API (read-only)
if d.pgStores != nil && d.pgStores.Agents != nil {
d.server.SetOrchestrationHandler(httpapi.NewOrchestrationHandler(d.pgStores.Agents, d.pgStores.Teams, d.pgStores.AgentLinks))
}
// V3: Per-agent v3 feature flags API
if d.pgStores != nil && d.pgStores.Agents != nil {
d.server.SetV3FlagsHandler(httpapi.NewV3FlagsHandler(d.pgStores.Agents))
}
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
d.server.SetFilesHandler(httpapi.NewFilesHandler(d.workspace, d.dataDir))
// Storage file management — browse/delete files under the resolved workspace directory.
d.server.SetStorageHandler(httpapi.NewStorageHandler(d.workspace, d.pgStores.Tenants))
// Media upload endpoint — accepts multipart file uploads, returns temp path + MIME type.
d.server.SetMediaUploadHandler(httpapi.NewMediaUploadHandler())
// Media serve endpoint — serves persisted media files by ID for WS/web clients.
if mediaStore != nil {
d.server.SetMediaServeHandler(httpapi.NewMediaServeHandler(mediaStore))
}
// ElevenLabs voice list + refresh endpoints (GET /v1/voices, POST /v1/voices/refresh).
// VoiceCache is shared between the HTTP handler and the WS voices.list method.
// TTL 1h + LRU cap 1000 tenants.
{
voiceCache := audio.NewVoiceCache(1*time.Hour, 1000)
var secretStore store.ConfigSecretsStore
if d.pgStores != nil && d.pgStores.ConfigSecrets != nil {
secretStore = d.pgStores.ConfigSecrets
}
var tenantStore store.TenantStore
if d.pgStores != nil && d.pgStores.Tenants != nil {
tenantStore = d.pgStores.Tenants
}
voicesH := httpapi.NewVoicesHandler(voiceCache, secretStore, tenantStore)
d.server.SetVoicesHandler(voicesH)
// Wire WS method — provider nil means each request resolves key via secretStore at HTTP layer.
// For WS, use same cache. Provider is resolved via secretStore at WS level in a future phase.
methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router())
// Wire the same cache + secret store into the CRUD MCP server (see
// internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()).
d.server.SetVoiceCache(voiceCache, secretStore)
}
// TTS synthesize endpoint — shares audio.Manager with setupTTS.
if d.audioMgr != nil {
ttsH := httpapi.NewTTSHandler(d.audioMgr)
// Reuse the server's rate limiter (per-IP/token; NOT per-user).
// Server.RateLimiter() is non-nil by construction (server.go:104).
if rl := d.server.RateLimiter(); rl != nil && rl.Enabled() {
ttsH.SetRateLimiter(rl.Allow)
}
// Wire stores for per-tenant TTS config lookup at synthesis time.
if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil {
ttsH.SetStores(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets)
// Wire tenant resolver for channels TTS auto-apply
d.audioMgr.SetTenantResolver(httpapi.NewTenantTTSResolver(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets))
}
d.server.SetTTSHandler(ttsH)
d.ttsHandler = ttsH // store for hot-reload
}
// Per-tenant TTS config endpoint — allows tenant admins to configure TTS.
if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil {
d.server.SetTTSConfigHandler(httpapi.NewTTSConfigHandler(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets, d.pgStores.Tenants))
}
// Workstations API — Standard edition only.
// Lite edition MUST NOT expose these routes (silent orphan data + contract violation).
if edition.Current().Name != "lite" {
if d.pgStores != nil && d.pgStores.Workstations != nil && d.pgStores.WorkstationLinks != nil {
wsH := httpapi.NewWorkstationsHandler(
d.pgStores.Workstations,
d.pgStores.WorkstationLinks,
d.pgStores.Tenants,
)
if d.pgStores.WorkstationPermissions != nil {
wsH.SetPermStore(d.pgStores.WorkstationPermissions)
}
if d.pgStores.WorkstationActivity != nil {
wsH.SetActivityStore(d.pgStores.WorkstationActivity)
}
d.server.SetWorkstationsHandler(wsH)
}
}
// Seed + apply builtin tool disables
if d.pgStores.BuiltinTools != nil {
seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools)
migrateBuiltinToolSettings(context.Background(), d.pgStores.BuiltinTools)
backfillWebFetchSettings(context.Background(), d.pgStores.BuiltinTools)
applyBuiltinToolDisables(context.Background(), d.pgStores.BuiltinTools, d.toolsReg)
}
}