mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
* feat(mcp): expand CRUD MCP server to near-complete CLI parity Closes the CLI-vs-MCP tool coverage gap identified by auditing every `goclaw` CLI command against the existing goclaw_* MCP tool set. Adds skill grant/revoke, agent skill pin/unpin, and full CRUD/inspection surfaces for memory, knowledge graph (including dedup/merge/prune), tenants, providers, LLM traces, channel contacts, pending messages, audit activity, system config, tenant storage (list/size/delete/move), scoped agent config export/import, secure-CLI binary registry, and a DB-backed health check. Deliberately out of scope, documented inline where relevant: - `goclaw credentials`: confirmed CLI-local (~/.goclaw/config.yaml + keychain), no server resource to wrap. goclaw_secure_cli_binaries_* covers the closest real, previously-uncovered server resource instead. - Full tar-archive agent export/import (KG + workspace files): the CLI's version streams a multi-section archive with progress events, a shape that doesn't map to a single MCP tool call. Config + context files (the portable "brain") is covered. - `kg extract` (LLM-driven text extraction): goclaw_kg_ingest accepts the same Entity/Relation shapes the extractor produces, so a caller can run extraction itself and hand off the result. Wires 8 new store dependencies (Memory, KnowledgeGraph, Tracing, Contacts, PendingMessages, Activity, SystemConfigs, SecureCLI) through gateway.Server setters -> cmd/gateway.go -> CRUDDeps, following the existing Providers/Tenants pattern. Storage and secure-CLI-binary handlers duplicate internal/http's path-escape/symlink-hiding validation logic (documented inline) since internal/http already imports internal/mcp and the reverse would cycle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(mcp): return sessionKey in goclaw_chat_send response for persistent conversations The goclaw_chat_send tool creates a new session internally when sessionKey is empty, but never returned the key to the caller. This prevented using goclaw_chat_history to fetch previous messages in the session. Add SessionKey field to ChatSendResult so callers can: 1. Start a new agent chat without providing sessionKey 2. Receive the sessionKey back in the response 3. Use that sessionKey for follow-up messages and history queries Fixes the training loop pattern: start chat → get sessionKey → call goclaw_chat_history with that key → iterate skill based on actual failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(mcp): add timing diagnostics to goclaw_chat_send for timeout troubleshooting Log request arrival, processing duration, and errors with millisecond precision. Helps identify whether timeouts occur at MCP client→goclaw, goclaw→ollama, or during agent execution. Critical for production debugging. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(gateway): add http.Server timeouts for defensive timeout handling Set explicit timeouts in http.Server: - ReadTimeout: 1h (allow large uploads, long-running agent operations) - WriteTimeout: 1h (allow streaming responses to slow clients) - IdleTimeout: 30s (close idle keep-alive connections quickly) Provides defense-in-depth when Nginx/Traefik timeouts are misconfigured. Matches Nginx timeout (3600s) to prevent race conditions. Timeout chain: traefik 3600s = nginx 3600s = goclaw 3600s Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
196 lines
7.0 KiB
Go
196 lines
7.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
|
mcpserver "github.com/mark3labs/mcp-go/server"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// registerProvidersCRUDTools registers the goclaw_providers_* MCP tools
|
|
// backed by store.ProviderStore — closes a CLI-vs-MCP coverage gap (the
|
|
// `goclaw providers create/list/models/verify-embedding` commands had no
|
|
// MCP equivalent for basic CRUD). deps.Providers was already threaded
|
|
// through CRUDDeps for heartbeat.set's provider-name resolution; this
|
|
// reuses the same store reference.
|
|
func registerProvidersCRUDTools(srv *mcpserver.MCPServer, providers store.ProviderStore) {
|
|
srv.AddTool(mcpgo.NewTool("goclaw_providers_list",
|
|
mcpgo.WithDescription("List all LLM providers (API keys masked)."),
|
|
mcpgo.WithReadOnlyHintAnnotation(true),
|
|
), handleProvidersList(providers))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_providers_get",
|
|
mcpgo.WithDescription("Get a single LLM provider by UUID or name (API key masked)."),
|
|
mcpgo.WithString("id", mcpgo.Description("Provider UUID.")),
|
|
mcpgo.WithString("name", mcpgo.Description("Provider name, used when id is not known.")),
|
|
mcpgo.WithReadOnlyHintAnnotation(true),
|
|
), handleProvidersGet(providers))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_providers_create",
|
|
mcpgo.WithDescription("Register a new LLM provider. The API key is encrypted at rest and never echoed back."),
|
|
mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Provider name (unique per tenant).")),
|
|
mcpgo.WithString("provider_type", mcpgo.Required(), mcpgo.Description("Provider type (e.g. \"anthropic\", \"openai\", \"dashscope\").")),
|
|
mcpgo.WithString("display_name", mcpgo.Description("Human-readable display name; defaults to name.")),
|
|
mcpgo.WithString("api_base", mcpgo.Description("API base URL override.")),
|
|
mcpgo.WithString("api_key", mcpgo.Description("API key; stored encrypted.")),
|
|
mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state; defaults to true.")),
|
|
), handleProvidersCreate(providers))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_providers_update",
|
|
mcpgo.WithDescription("Apply a partial update to an existing LLM provider."),
|
|
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Provider UUID.")),
|
|
mcpgo.WithString("display_name", mcpgo.Description("New display name.")),
|
|
mcpgo.WithString("api_base", mcpgo.Description("New API base URL.")),
|
|
mcpgo.WithString("api_key", mcpgo.Description("New API key; stored encrypted.")),
|
|
mcpgo.WithBoolean("enabled", mcpgo.Description("New enabled state.")),
|
|
), handleProvidersUpdate(providers))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_providers_delete",
|
|
mcpgo.WithDescription("Delete an LLM provider by UUID."),
|
|
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Provider UUID.")),
|
|
mcpgo.WithDestructiveHintAnnotation(true),
|
|
), handleProvidersDelete(providers))
|
|
}
|
|
|
|
// maskProviderAPIKey mirrors internal/http/providers.go's unexported
|
|
// maskAPIKey — duplicated because this MCP surface does not depend on
|
|
// internal/http (internal/http already imports internal/mcp, so the
|
|
// reverse import would cycle). Replaces a non-empty key with "***" so raw
|
|
// secrets never leave this process via an MCP tool response.
|
|
func maskProviderAPIKey(p *store.LLMProviderData) {
|
|
if p.APIKey != "" {
|
|
p.APIKey = "***"
|
|
}
|
|
}
|
|
|
|
func handleProvidersList(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
list, err := providers.ListProviders(ctx)
|
|
if err != nil {
|
|
return toolError("providers.list", err)
|
|
}
|
|
for i := range list {
|
|
maskProviderAPIKey(&list[i])
|
|
}
|
|
return jsonToolResult(list)
|
|
}
|
|
}
|
|
|
|
func handleProvidersGet(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
idStr := req.GetString("id", "")
|
|
name := req.GetString("name", "")
|
|
switch {
|
|
case idStr != "":
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
return toolError("providers.get", fmt.Errorf("invalid id: %w", err))
|
|
}
|
|
p, err := providers.GetProvider(ctx, id)
|
|
if err != nil {
|
|
return toolError("providers.get", err)
|
|
}
|
|
maskProviderAPIKey(p)
|
|
return jsonToolResult(p)
|
|
case name != "":
|
|
p, err := providers.GetProviderByName(ctx, name)
|
|
if err != nil {
|
|
return toolError("providers.get", err)
|
|
}
|
|
maskProviderAPIKey(p)
|
|
return jsonToolResult(p)
|
|
default:
|
|
return mcpgo.NewToolResultError("providers.get: one of id or name is required"), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleProvidersCreate(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
name, err := req.RequireString("name")
|
|
if err != nil {
|
|
return toolError("providers.create", err)
|
|
}
|
|
providerType, err := req.RequireString("provider_type")
|
|
if err != nil {
|
|
return toolError("providers.create", err)
|
|
}
|
|
p := &store.LLMProviderData{
|
|
Name: name,
|
|
DisplayName: req.GetString("display_name", name),
|
|
ProviderType: providerType,
|
|
APIBase: req.GetString("api_base", ""),
|
|
APIKey: req.GetString("api_key", ""),
|
|
Enabled: req.GetBool("enabled", true),
|
|
}
|
|
if err := providers.CreateProvider(ctx, p); err != nil {
|
|
return toolError("providers.create", err)
|
|
}
|
|
maskProviderAPIKey(p)
|
|
return jsonToolResult(p)
|
|
}
|
|
}
|
|
|
|
func handleProvidersUpdate(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
idStr, err := req.RequireString("id")
|
|
if err != nil {
|
|
return toolError("providers.update", err)
|
|
}
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
return toolError("providers.update", fmt.Errorf("invalid id: %w", err))
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
args := req.GetArguments()
|
|
if v, ok := args["display_name"]; ok {
|
|
updates["display_name"] = v
|
|
}
|
|
if v, ok := args["api_base"]; ok {
|
|
updates["api_base"] = v
|
|
}
|
|
if v, ok := args["api_key"]; ok {
|
|
updates["api_key"] = v
|
|
}
|
|
if v, ok := args["enabled"]; ok {
|
|
updates["enabled"] = v
|
|
}
|
|
if len(updates) == 0 {
|
|
return mcpgo.NewToolResultError("providers.update: no fields to update"), nil
|
|
}
|
|
|
|
if err := providers.UpdateProvider(ctx, id, updates); err != nil {
|
|
return toolError("providers.update", err)
|
|
}
|
|
p, err := providers.GetProvider(ctx, id)
|
|
if err != nil {
|
|
return toolError("providers.update", err)
|
|
}
|
|
maskProviderAPIKey(p)
|
|
return jsonToolResult(p)
|
|
}
|
|
}
|
|
|
|
func handleProvidersDelete(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
idStr, err := req.RequireString("id")
|
|
if err != nil {
|
|
return toolError("providers.delete", err)
|
|
}
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
return toolError("providers.delete", fmt.Errorf("invalid id: %w", err))
|
|
}
|
|
if err := providers.DeleteProvider(ctx, id); err != nil {
|
|
return toolError("providers.delete", err)
|
|
}
|
|
return jsonToolResult(map[string]bool{"deleted": true})
|
|
}
|
|
}
|