mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +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>
140 lines
4.2 KiB
Go
140 lines
4.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
|
|
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
|
)
|
|
|
|
// agentChatRunner implements mcpbridge.ChatRunner against the live
|
|
// *agent.Router, backing the CRUD MCP server's goclaw_chat_* tools
|
|
// (see internal/mcp/crud_chat.go). It intentionally omits the WS-only
|
|
// concerns handled by ChatMethods (internal/gateway/methods/chat.go): rate
|
|
// limiting, send debouncing, and per-WS-client session ownership checks. The
|
|
// MCP bearer token (gateway.mcp_server_token) is the sole security boundary
|
|
// for this surface, same as the rest of the CRUD MCP server.
|
|
type agentChatRunner struct {
|
|
agents *agent.Router
|
|
}
|
|
|
|
// Send runs the agent synchronously and returns the final result. Always
|
|
// non-streaming: MCP tool calls are request/response, so there is no channel
|
|
// to forward incremental run events to the caller.
|
|
func (r *agentChatRunner) Send(ctx context.Context, agentID, sessionKey, message string, mediaItems []mcpbridge.ChatMediaItem) (*mcpbridge.ChatSendResult, error) {
|
|
if agentID == "" {
|
|
if sessionKey != "" {
|
|
if parsedAgentID, _ := sessions.ParseSessionKey(sessionKey); parsedAgentID != "" {
|
|
agentID = parsedAgentID
|
|
}
|
|
}
|
|
if agentID == "" {
|
|
agentID = "default"
|
|
}
|
|
}
|
|
|
|
loop, err := r.agents.Get(ctx, agentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get agent %q: %w", agentID, err)
|
|
}
|
|
|
|
if sessionKey == "" {
|
|
sessionKey = sessions.BuildWSSessionKey(agentID, uuid.NewString())
|
|
}
|
|
|
|
var mediaFiles []bus.MediaFile
|
|
for _, item := range mediaItems {
|
|
mediaFiles = append(mediaFiles, bus.MediaFile{
|
|
Path: item.Path,
|
|
MimeType: media.DetectMIMEType(item.Path),
|
|
Filename: item.Filename,
|
|
})
|
|
}
|
|
|
|
runID := uuid.NewString()
|
|
result, err := loop.Run(ctx, agent.RunRequest{
|
|
SessionKey: sessionKey,
|
|
Message: message,
|
|
Media: mediaFiles,
|
|
Channel: "mcp",
|
|
RunID: runID,
|
|
})
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return &mcpbridge.ChatSendResult{Cancelled: true, SessionKey: sessionKey}, nil
|
|
}
|
|
return nil, fmt.Errorf("run agent %q: %w", agentID, err)
|
|
}
|
|
|
|
return &mcpbridge.ChatSendResult{
|
|
RunID: result.RunID,
|
|
SessionKey: sessionKey,
|
|
Content: result.Content,
|
|
Usage: result.Usage,
|
|
Thinking: result.Thinking,
|
|
Media: result.Media,
|
|
}, nil
|
|
}
|
|
|
|
// Abort cancels the run(s) matching runID and/or sessionKey, mirroring
|
|
// ChatMethods.handleAbort's aggregation logic (minus the caller-identity
|
|
// unauthorized-vs-notfound collapsing, which requires a WS client role).
|
|
func (r *agentChatRunner) Abort(_ context.Context, runID, sessionKey string) (*mcpbridge.ChatAbortResult, error) {
|
|
var results []agent.AbortResult
|
|
if runID != "" {
|
|
results = []agent.AbortResult{r.agents.AbortRun(runID, sessionKey)}
|
|
} else {
|
|
results = r.agents.AbortRunsForSession(sessionKey)
|
|
}
|
|
|
|
var runIDs []string
|
|
stopped, forced, alreadyAborting, notFound := 0, 0, 0, 0
|
|
for _, res := range results {
|
|
runIDs = append(runIDs, res.RunID)
|
|
switch {
|
|
case res.Stopped:
|
|
stopped++
|
|
case res.Forced:
|
|
forced++
|
|
case res.AlreadyAborting:
|
|
alreadyAborting++
|
|
case res.NotFound, res.Unauthorized:
|
|
notFound++
|
|
}
|
|
}
|
|
|
|
return &mcpbridge.ChatAbortResult{
|
|
OK: true,
|
|
Aborted: stopped+forced > 0,
|
|
Stopped: stopped > 0,
|
|
Forced: forced > 0,
|
|
AlreadyAborting: alreadyAborting > 0,
|
|
NotFound: notFound > 0 && stopped+forced+alreadyAborting == 0,
|
|
RunIDs: runIDs,
|
|
}, nil
|
|
}
|
|
|
|
// SessionStatus reports the running state and activity for a session.
|
|
func (r *agentChatRunner) SessionStatus(_ context.Context, sessionKey string) (*mcpbridge.ChatSessionStatusResult, error) {
|
|
result := &mcpbridge.ChatSessionStatusResult{
|
|
IsRunning: r.agents.IsSessionBusy(sessionKey),
|
|
}
|
|
if runID, ok := r.agents.SessionRunID(sessionKey); ok {
|
|
result.RunID = runID
|
|
}
|
|
if status := r.agents.GetActivity(sessionKey); status != nil {
|
|
result.Activity = &mcpbridge.ChatActivity{
|
|
Phase: status.Phase,
|
|
Tool: status.Tool,
|
|
Iteration: status.Iteration,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|