Files
dc605b911e feat(mcp): expand CRUD server to near-complete CLI parity (#1440)
* 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>
2026-07-16 01:56:03 +07:00

170 lines
6.7 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"
)
// registerSecureCLICRUDTools registers the goclaw_secure_cli_binaries_* MCP
// tools backed by store.SecureCLIStore — this is the closest real
// server-side resource to the CLI's local `goclaw credentials` command,
// which is otherwise out of scope for this MCP surface (it manages the
// `goclaw` CLI's own auth profile in ~/.goclaw/config.yaml + OS keychain,
// a client-local concept with no server API to wrap). SecureCLIStore
// instead manages which exec-sandboxed binaries (gh, git, etc.) are
// credential-gated and how — the actual secret values (encrypted_env) are
// never exposed here (SecureCLIBinary.EncryptedEnv/UserEnv are json:"-").
// Per-user/per-agent credential value CRUD (SetUserCredentials et al.,
// internal/http/secure_cli_user_credentials.go /
// secure_cli_agent_credentials.go) needs the same encryption handling the
// HTTP layer owns and is not exposed here.
func registerSecureCLICRUDTools(srv *mcpserver.MCPServer, secureCLI store.SecureCLIStore) {
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_list",
mcpgo.WithDescription("List registered secure-CLI binary configs (which sandboxed exec binaries are credential-gated)."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSecureCLIBinariesList(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_get",
mcpgo.WithDescription("Get a single secure-CLI binary config by UUID."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSecureCLIBinariesGet(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_create",
mcpgo.WithDescription("Register a new secure-CLI binary config."),
mcpgo.WithString("binary_name", mcpgo.Required(), mcpgo.Description("Binary name (e.g. \"gh\", \"git\").")),
mcpgo.WithString("description", mcpgo.Description("Description shown to agents.")),
mcpgo.WithBoolean("is_global", mcpgo.Description("Whether all agents can use this binary without a grant; defaults to false.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state; defaults to true.")),
mcpgo.WithNumber("timeout_seconds", mcpgo.Description("Exec timeout in seconds.")),
), handleSecureCLIBinariesCreate(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_update",
mcpgo.WithDescription("Apply a partial update to a secure-CLI binary config."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithString("description", mcpgo.Description("New description.")),
mcpgo.WithBoolean("is_global", mcpgo.Description("New is_global value.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("New enabled value.")),
mcpgo.WithNumber("timeout_seconds", mcpgo.Description("New exec timeout in seconds.")),
), handleSecureCLIBinariesUpdate(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_delete",
mcpgo.WithDescription("Delete a secure-CLI binary config."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleSecureCLIBinariesDelete(secureCLI))
}
func handleSecureCLIBinariesList(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
list, err := secureCLI.List(ctx)
if err != nil {
return toolError("secure_cli_binaries.list", err)
}
return jsonToolResult(list)
}
}
func handleSecureCLIBinariesGet(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.get", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.get", fmt.Errorf("invalid id: %w", err))
}
b, err := secureCLI.Get(ctx, id)
if err != nil {
return toolError("secure_cli_binaries.get", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesCreate(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
binaryName, err := req.RequireString("binary_name")
if err != nil {
return toolError("secure_cli_binaries.create", err)
}
b := &store.SecureCLIBinary{
BinaryName: binaryName,
Description: req.GetString("description", ""),
IsGlobal: req.GetBool("is_global", false),
Enabled: req.GetBool("enabled", true),
TimeoutSeconds: intArg(req, "timeout_seconds", 0),
CreatedBy: "mcp",
}
if err := secureCLI.Create(ctx, b); err != nil {
return toolError("secure_cli_binaries.create", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesUpdate(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.update", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.update", fmt.Errorf("invalid id: %w", err))
}
updates := map[string]any{}
args := req.GetArguments()
if v, ok := args["description"]; ok {
updates["description"] = v
}
if v, ok := args["is_global"]; ok {
updates["is_global"] = v
}
if v, ok := args["enabled"]; ok {
updates["enabled"] = v
}
if v, ok := args["timeout_seconds"]; ok {
updates["timeout_seconds"] = v
}
if len(updates) == 0 {
return mcpgo.NewToolResultError("secure_cli_binaries.update: no fields to update"), nil
}
if err := secureCLI.Update(ctx, id, updates); err != nil {
return toolError("secure_cli_binaries.update", err)
}
b, err := secureCLI.Get(ctx, id)
if err != nil {
return toolError("secure_cli_binaries.update", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesDelete(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.delete", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.delete", fmt.Errorf("invalid id: %w", err))
}
if err := secureCLI.Delete(ctx, id); err != nil {
return toolError("secure_cli_binaries.delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}