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

166 lines
6.8 KiB
Go

package mcp
import (
"context"
"encoding/json"
"github.com/google/uuid"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// jsonToolResult marshals v as an MCP text tool result. Marshal failures are
// reported as tool errors rather than propagated as transport errors, since
// they indicate a bug in the value being returned, not a request problem.
func jsonToolResult(v any) (*mcpgo.CallToolResult, error) {
data, err := json.Marshal(v)
if err != nil {
return mcpgo.NewToolResultError("marshal result: " + err.Error()), nil
}
return mcpgo.NewToolResultText(string(data)), nil
}
// toolError wraps an error as an MCP tool-level error result (not a
// transport-level error), so the calling LLM sees the failure reason in the
// tool response instead of the call silently failing.
func toolError(prefix string, err error) (*mcpgo.CallToolResult, error) {
return mcpgo.NewToolResultError(prefix + ": " + err.Error()), nil
}
// resolveAgentUUID resolves an agent identifier (either UUID or agent_key) to
// its canonical UUID via a DB lookup. Mirrors
// internal/gateway/methods/agent_links.go's unexported helper of the same
// name — duplicated rather than imported since crud_*.go is a standalone MCP
// surface that does not depend on internal/gateway/methods (which itself
// depends on internal/gateway, which imports this package — importing
// methods here would create an import cycle).
func resolveAgentUUID(ctx context.Context, agents store.AgentStore, keyOrID string) (uuid.UUID, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
ag, err := agents.GetByID(ctx, id)
if err != nil {
return uuid.Nil, err
}
return ag.ID, nil
}
ag, err := agents.GetByKey(ctx, keyOrID)
if err != nil {
return uuid.Nil, err
}
return ag.ID, nil
}
// resolveAgentInfo returns full agent data for an agent identifier (UUID or
// agent_key). See resolveAgentUUID for why this is duplicated here.
func resolveAgentInfo(ctx context.Context, agents store.AgentStore, keyOrID string) (*store.AgentData, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
return agents.GetByID(ctx, id)
}
return agents.GetByKey(ctx, keyOrID)
}
// mcpTenantHeader is the HTTP header a CRUD MCP caller may use to scope a
// request to a specific tenant, mirroring internal/http/auth.go's
// "X-GoClaw-Tenant-Id" header used by the gateway-token (owner) and
// system-level API key auth paths. The MCP bearer token
// (gateway.mcp_server_token) is a single shared secret with no per-caller
// identity, so it is treated the same way the owner branch of
// resolveAuthWithBearer treats the gateway token: any tenant may be
// requested, with no membership check, falling back to store.MasterTenantID
// when absent or unresolvable.
const mcpTenantHeader = "X-GoClaw-Tenant-Id"
// resolveMCPTenantID resolves the caller-supplied tenant header (UUID or
// slug) to a concrete tenant UUID using tenants, falling back to
// store.MasterTenantID when the header is empty, unresolvable, or tenants is
// nil. This is the sole place tenant scope is established for the CRUD MCP
// surface (see NewCRUDServer's mcpserver.WithHTTPContextFunc wiring) — every
// tool handler in this package relies on the incoming ctx already carrying a
// concrete tenant ID via store.WithTenantID.
func resolveMCPTenantID(ctx context.Context, tenants store.TenantStore, headerVal string) uuid.UUID {
if headerVal == "" || tenants == nil {
return store.MasterTenantID
}
if id, err := uuid.Parse(headerVal); err == nil {
if t, err := tenants.GetTenant(ctx, id); err == nil && t != nil {
return t.ID
}
return store.MasterTenantID
}
if t, err := tenants.GetTenantBySlug(ctx, headerVal); err == nil && t != nil {
return t.ID
}
return store.MasterTenantID
}
// ChatMediaItem represents a media file attached to a goclaw_chat_send call,
// mirroring internal/gateway/methods/chat.go's chatMediaItem.
type ChatMediaItem struct {
Path string
Filename string
}
// ChatSendResult is the outcome of a goclaw_chat_send call.
type ChatSendResult struct {
RunID string `json:"runId"`
SessionKey string `json:"sessionKey"`
Content string `json:"content"`
Usage any `json:"usage,omitempty"`
Thinking string `json:"thinking,omitempty"`
Media any `json:"media,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// ChatAbortResult is the outcome of a goclaw_chat_abort call, mirroring
// internal/gateway/methods/chat.go's handleAbort response shape.
type ChatAbortResult struct {
OK bool `json:"ok"`
Aborted bool `json:"aborted"`
Stopped bool `json:"stopped"`
Forced bool `json:"forced"`
AlreadyAborting bool `json:"alreadyAborting"`
NotFound bool `json:"notFound"`
RunIDs []string `json:"runIds"`
}
// ChatActivity describes the current in-flight agent activity for a session.
type ChatActivity struct {
Phase string `json:"phase"`
Tool string `json:"tool"`
Iteration int `json:"iteration"`
}
// ChatSessionStatusResult is the outcome of a goclaw_chat_session_status call.
type ChatSessionStatusResult struct {
IsRunning bool `json:"isRunning"`
RunID string `json:"runId"`
Activity *ChatActivity `json:"activity,omitempty"`
}
// ChatRunner executes and controls agent chat runs on behalf of the CRUD MCP
// server. It is implemented by internal/gateway.Server (which holds the live
// *agent.Router) and injected here as an interface — internal/agent already
// imports internal/mcp (loop_mcp_user.go), so importing agent.Router directly
// in this package would create an import cycle. Same workaround as
// AgentRuntimeLookup in crud_agents.go.
//
// Unlike the WS chat.send/chat.abort/chat.session.status RPC methods, calls
// through this interface have no per-WS-client concept: no rate limiting, no
// send debouncing, and no session-ownership check against a caller identity.
// The MCP bearer token (gateway.mcp_server_token) is the sole security
// boundary here, matching the rest of this CRUD MCP surface (e.g.
// goclaw_sessions_* has no ownership checks either).
type ChatRunner interface {
// Send runs the agent for sessionKey (creating a new session key when
// empty) and returns the final result. Always synchronous/non-streaming:
// MCP tool calls are request/response, so there is no channel to forward
// incremental run events to the caller.
Send(ctx context.Context, agentID, sessionKey, message string, media []ChatMediaItem) (*ChatSendResult, error)
// Abort cancels the run(s) matching runID and/or sessionKey (at least one
// must be non-empty).
Abort(ctx context.Context, runID, sessionKey string) (*ChatAbortResult, error)
// SessionStatus reports whether sessionKey currently has an in-flight run.
SessionStatus(ctx context.Context, sessionKey string) (*ChatSessionStatusResult, error)
}