mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +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>
189 lines
8.3 KiB
Go
189 lines
8.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
|
mcpserver "github.com/mark3labs/mcp-go/server"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// registerChatCRUDTools registers the goclaw_chat_* MCP tools backed by the
|
|
// live agent runtime (via ChatRunner) and store.SessionStore.
|
|
func registerChatCRUDTools(srv *mcpserver.MCPServer, runner ChatRunner, sessions store.SessionStore) {
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_send",
|
|
mcpgo.WithDescription("Send a chat message to a goclaw agent and receive the assistant's reply. Always synchronous — the underlying run's incremental events (if any) are not forwarded, only the final result."),
|
|
mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("The user message to send.")),
|
|
mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug (defaults to \"default\", or is inferred from session_key when provided).")),
|
|
mcpgo.WithString("session_key", mcpgo.Description("Existing session key to resume; a new one is created when omitted.")),
|
|
), handleChatSend(runner))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_history",
|
|
mcpgo.WithDescription("Fetch the message history for a goclaw chat session."),
|
|
mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")),
|
|
mcpgo.WithReadOnlyHintAnnotation(true),
|
|
), handleChatHistory(sessions))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_inject",
|
|
mcpgo.WithDescription("Inject a message into a goclaw session's transcript without triggering an agent run."),
|
|
mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")),
|
|
mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("Message text to inject.")),
|
|
mcpgo.WithString("label", mcpgo.Description("Optional label prefix (e.g. \"note\"), truncated to 100 chars.")),
|
|
), handleChatInject(sessions))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_abort",
|
|
mcpgo.WithDescription("Cancel a running goclaw agent invocation for a session or a specific run ID."),
|
|
mcpgo.WithString("run_id", mcpgo.Description("Specific run ID to abort.")),
|
|
mcpgo.WithString("session_key", mcpgo.Description("Session key whose active run(s) should be aborted.")),
|
|
mcpgo.WithDestructiveHintAnnotation(true),
|
|
), handleChatAbort(runner))
|
|
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_session_status",
|
|
mcpgo.WithDescription("Return the running state and current activity (phase, tool, iteration) for a goclaw chat session."),
|
|
mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")),
|
|
mcpgo.WithReadOnlyHintAnnotation(true),
|
|
), handleChatSessionStatus(runner))
|
|
}
|
|
|
|
const maxInjectLabelLen = 100
|
|
|
|
func handleChatSend(runner ChatRunner) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
if runner == nil {
|
|
return mcpgo.NewToolResultError("chat.send: chat runtime not available"), nil
|
|
}
|
|
message, err := req.RequireString("message")
|
|
if err != nil {
|
|
return toolError("chat.send", err)
|
|
}
|
|
agentID := req.GetString("agent_id", "")
|
|
sessionKey := req.GetString("session_key", "")
|
|
|
|
startTime := time.Now()
|
|
slog.Info("mcp.chat_send.start", "agent_id", agentID, "session_key", sessionKey, "message_len", len(message))
|
|
|
|
result, err := runner.Send(ctx, agentID, sessionKey, message, nil)
|
|
|
|
duration := time.Since(startTime)
|
|
if err != nil {
|
|
slog.Warn("mcp.chat_send.error", "agent_id", agentID, "session_key", sessionKey, "duration_ms", duration.Milliseconds(), "error", err)
|
|
return toolError("chat.send", err)
|
|
}
|
|
|
|
slog.Info("mcp.chat_send.done", "agent_id", agentID, "session_key", sessionKey, "run_id", result.RunID, "duration_ms", duration.Milliseconds(), "content_len", len(result.Content))
|
|
return jsonToolResult(result)
|
|
}
|
|
}
|
|
|
|
func handleChatHistory(sessions store.SessionStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
sessionKey, err := req.RequireString("session_key")
|
|
if err != nil {
|
|
return toolError("chat.history", err)
|
|
}
|
|
history := sessions.GetHistory(ctx, sessionKey)
|
|
return jsonToolResult(map[string]any{"messages": history})
|
|
}
|
|
}
|
|
|
|
func handleChatInject(sessions store.SessionStore) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
sessionKey, err := req.RequireString("session_key")
|
|
if err != nil {
|
|
return toolError("chat.inject", err)
|
|
}
|
|
message, err := req.RequireString("message")
|
|
if err != nil {
|
|
return toolError("chat.inject", err)
|
|
}
|
|
label := req.GetString("label", "")
|
|
if len(label) > maxInjectLabelLen {
|
|
label = label[:maxInjectLabelLen]
|
|
}
|
|
text := message
|
|
if label != "" {
|
|
text = "[" + label + "]\n\n" + message
|
|
}
|
|
sessions.AddMessage(ctx, sessionKey, providers.Message{Role: "assistant", Content: text})
|
|
return jsonToolResult(map[string]any{"ok": true})
|
|
}
|
|
}
|
|
|
|
func handleChatAbort(runner ChatRunner) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
if runner == nil {
|
|
return mcpgo.NewToolResultError("chat.abort: chat runtime not available"), nil
|
|
}
|
|
runID := req.GetString("run_id", "")
|
|
sessionKey := req.GetString("session_key", "")
|
|
if runID == "" && sessionKey == "" {
|
|
return mcpgo.NewToolResultError("chat.abort: one of run_id or session_key is required"), nil
|
|
}
|
|
result, err := runner.Abort(ctx, runID, sessionKey)
|
|
if err != nil {
|
|
return toolError("chat.abort", err)
|
|
}
|
|
return jsonToolResult(result)
|
|
}
|
|
}
|
|
|
|
func handleChatSessionStatus(runner ChatRunner) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
if runner == nil {
|
|
return mcpgo.NewToolResultError("chat.session.status: chat runtime not available"), nil
|
|
}
|
|
sessionKey, err := req.RequireString("session_key")
|
|
if err != nil {
|
|
return toolError("chat.session.status", err)
|
|
}
|
|
result, err := runner.SessionStatus(ctx, sessionKey)
|
|
if err != nil {
|
|
return toolError("chat.session.status", err)
|
|
}
|
|
return jsonToolResult(result)
|
|
}
|
|
}
|
|
|
|
// registerChatBehaviorCRUDTool registers goclaw_chat_behavior_preview, backed
|
|
// by the same channels.ResolveChatBehavior/PreviewResolvedChatBehavior logic
|
|
// used by the WS chat_behavior.preview method
|
|
// (internal/gateway/methods/chat_behavior.go). The WS method additionally
|
|
// requires master scope + owner role, enforced via the WS client's resolved
|
|
// role/tenant; this MCP surface has no such per-caller identity (the bearer
|
|
// token is the sole boundary), matching the rest of this CRUD MCP server.
|
|
func registerChatBehaviorCRUDTool(srv *mcpserver.MCPServer, cfg *config.Config, channelMgr *channels.Manager) {
|
|
srv.AddTool(mcpgo.NewTool("goclaw_chat_behavior_preview",
|
|
mcpgo.WithDescription("Preview resolved channel delivery behavior (streaming/quick-ack/final-split) for a channel or an ad-hoc config."),
|
|
mcpgo.WithString("channel", mcpgo.Description("Channel instance name to resolve behavior for; empty uses the global default.")),
|
|
mcpgo.WithString("content", mcpgo.Description("Sample content to preview delivery for.")),
|
|
mcpgo.WithBoolean("is_streaming", mcpgo.Description("Whether the sample response is streaming.")),
|
|
mcpgo.WithBoolean("has_tool_calls", mcpgo.Description("Whether the sample response includes tool calls.")),
|
|
mcpgo.WithReadOnlyHintAnnotation(true),
|
|
), handleChatBehaviorPreview(cfg, channelMgr))
|
|
}
|
|
|
|
func handleChatBehaviorPreview(cfg *config.Config, channelMgr *channels.Manager) mcpserver.ToolHandlerFunc {
|
|
return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
channel := req.GetString("channel", "")
|
|
|
|
var resolved channels.ResolvedChatBehavior
|
|
if channelMgr != nil {
|
|
resolved = channelMgr.ResolveChatBehavior(channel, cfg.Gateway.ChatBehavior)
|
|
} else {
|
|
resolved = channels.ResolveChatBehavior(cfg.Gateway.ChatBehavior, nil)
|
|
}
|
|
preview := channels.PreviewResolvedChatBehavior(resolved, channels.ChatBehaviorPreviewOptions{
|
|
Content: req.GetString("content", ""),
|
|
IsStreaming: req.GetBool("is_streaming", false),
|
|
HasToolCalls: req.GetBool("has_tool_calls", false),
|
|
})
|
|
return jsonToolResult(preview)
|
|
}
|
|
}
|