diff --git a/apps/backend/cmd/mock-agent/mcp_client.go b/apps/backend/cmd/mock-agent/mcp_client.go index be00d69dd4..c9ed768ff6 100644 --- a/apps/backend/cmd/mock-agent/mcp_client.go +++ b/apps/backend/cmd/mock-agent/mcp_client.go @@ -51,6 +51,10 @@ func getMCPClient(serverName string) (*mcpclient.Client, error) { _ = c.Close() return nil, fmt.Errorf("initialize MCP client %s: %w", serverName, err) } + if _, err := c.ListTools(ctx, mcp.ListToolsRequest{}); err != nil { + _ = c.Close() + return nil, fmt.Errorf("list MCP tools %s: %w", serverName, err) + } mcpClients[serverName] = c return c, nil diff --git a/apps/backend/go.mod b/apps/backend/go.mod index f2b276e564..828cad22d3 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -3,11 +3,11 @@ module github.com/kandev/kandev go 1.26.0 require ( + github.com/Masterminds/semver/v3 v3.2.1 github.com/UserExistsError/conpty v0.1.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/coder/acp-go-sdk v0.13.5 github.com/creack/pty v1.1.24 - github.com/Masterminds/semver/v3 v3.2.1 github.com/gin-gonic/gin v1.9.1 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.6.0 @@ -28,6 +28,7 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b + github.com/tiktoken-go/tokenizer v0.8.1 github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 github.com/yuin/goldmark v1.7.13 go.opentelemetry.io/otel v1.43.0 @@ -58,6 +59,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2/v2 v2.5.1 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.13.0 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 17366669ef..c70d7af216 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -85,6 +85,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20= +github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -337,6 +339,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b h1:m20F4on1i8Mv3nexDOJGB6JhpOG9Q4XepN45yJvd8Lc= github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= +github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg= +github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4= github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7 h1:U7j2kc5ZS6dmwTg0H2VbRNGUwUxxEv9HtyxsyiYyS84= github.com/tuzig/vt10x v0.0.0-20231206072048-370e51642bf7/go.mod h1:kypKQ2Vd/oUOxC2dX6DFMG6FktzHsrYs+BF35RTA7p8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= diff --git a/apps/backend/internal/agent/runtime/lifecycle/mcp_attachment_snapshot_test.go b/apps/backend/internal/agent/runtime/lifecycle/mcp_attachment_snapshot_test.go index 89f96e71c9..5887e1a44c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/mcp_attachment_snapshot_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/mcp_attachment_snapshot_test.go @@ -12,7 +12,17 @@ func TestLoadMCPAttachmentHistoryDecodesTypedAndJSONMetadata(t *testing.T) { Version: streams.MCPAttachmentSchemaVersion, Current: streams.MCPAttachmentAttempt{ AttemptID: "attempt-1", - Servers: []streams.MCPServerAttachment{{Name: "kandev", Status: streams.MCPAttachmentStatusActive}}, + Servers: []streams.MCPServerAttachment{{ + Name: "kandev", + Status: streams.MCPAttachmentStatusActive, + ToolTokenEstimator: "o200k_base:mcp-tool-json-v1", + Tools: []streams.MCPToolSummary{{ + Name: "create_task_kandev", + Description: "Create a task", + InputSchema: json.RawMessage(`{"type":"object"}`), + EstimatedTokens: 23, + }}, + }}, }, } @@ -21,7 +31,14 @@ func TestLoadMCPAttachmentHistoryDecodesTypedAndJSONMetadata(t *testing.T) { if !ok { t.Fatalf("LoadMCPAttachmentHistory(%T) returned false", raw) } - if got.Current.AttemptID != want.Current.AttemptID || len(got.Current.Servers) != 1 || got.Current.Servers[0].Status != streams.MCPAttachmentStatusActive { + if len(got.Current.Servers) != 1 { + t.Fatalf("history = %+v, want one server", got) + } + server := got.Current.Servers[0] + if len(server.Tools) != 1 { + t.Fatalf("history = %+v, want one tool", got) + } + if got.Current.AttemptID != want.Current.AttemptID || server.Status != streams.MCPAttachmentStatusActive || server.Tools[0].Name != "create_task_kandev" || server.Tools[0].EstimatedTokens != 23 || string(server.Tools[0].InputSchema) != `{"type":"object"}` || server.ToolTokenEstimator != "o200k_base:mcp-tool-json-v1" { t.Fatalf("history = %+v, want %+v", got, want) } } diff --git a/apps/backend/internal/agentctl/types/streams/mcp_attachment.go b/apps/backend/internal/agentctl/types/streams/mcp_attachment.go index 8523388f38..b266d0a8e8 100644 --- a/apps/backend/internal/agentctl/types/streams/mcp_attachment.go +++ b/apps/backend/internal/agentctl/types/streams/mcp_attachment.go @@ -2,11 +2,14 @@ package streams import ( "context" + "encoding/json" "net/url" "path/filepath" "regexp" + "sort" "strings" "time" + "unicode/utf8" "github.com/kandev/kandev/internal/agent/runtime/routingerr" ) @@ -47,6 +50,18 @@ const ( // MaxMCPAttachmentErrorSummaryBytes caps persisted error summaries after // redaction so an agent error cannot inflate session metadata. MaxMCPAttachmentErrorSummaryBytes = 256 + + // MaxMCPAttachmentTools bounds the current Kandev tool catalog. + MaxMCPAttachmentTools = 128 + + // MaxMCPToolDescriptionBytes bounds one stored tool description. + MaxMCPToolDescriptionBytes = 1024 + + // MaxMCPToolInputSchemaBytes bounds one complete stored input schema. + MaxMCPToolInputSchemaBytes = 64 * 1024 + + // MaxMCPToolInputSchemasBytes bounds all schemas stored for one server. + MaxMCPToolInputSchemasBytes = 512 * 1024 ) // MCPAttachmentStatus is the strongest user-visible attachment evidence for a @@ -88,6 +103,15 @@ const ( MCPServerSourceProfile MCPServerSource = "profile" ) +// MCPToolSummary is the bounded catalog projection sent to the frontend. +type MCPToolSummary struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema,omitempty"` + InputSchemaTruncated bool `json:"input_schema_truncated,omitempty"` + EstimatedTokens int `json:"estimated_tokens,omitempty"` +} + // MCPAttachmentTestResult records a user-requested endpoint reachability test. // It intentionally remains separate from agent attachment evidence. type MCPAttachmentTestResult struct { @@ -101,40 +125,45 @@ type MCPAttachmentTestResult struct { // It deliberately has no headers, environment, command arguments, tool input, // tool result, or full endpoint URL fields. type MCPServerAttachment struct { - Name string `json:"name"` - Source MCPServerSource `json:"source,omitempty"` - Transport string `json:"transport,omitempty"` - Target string `json:"target,omitempty"` - Status MCPAttachmentStatus `json:"status"` - ReasonCode string `json:"reason_code,omitempty"` - Summary string `json:"summary,omitempty"` - ConnectionID string `json:"connection_id,omitempty"` - ToolCount int `json:"tool_count,omitempty"` - ConfiguredAt *time.Time `json:"configured_at,omitempty"` - DeliveredAt *time.Time `json:"delivered_at,omitempty"` - ConnectedAt *time.Time `json:"connected_at,omitempty"` - ToolsListedAt *time.Time `json:"tools_listed_at,omitempty"` - UsedAt *time.Time `json:"used_at,omitempty"` - FailedAt *time.Time `json:"failed_at,omitempty"` - DisconnectedAt *time.Time `json:"disconnected_at,omitempty"` - EndpointTest *MCPAttachmentTestResult `json:"endpoint_test,omitempty"` + Name string `json:"name"` + Source MCPServerSource `json:"source,omitempty"` + Transport string `json:"transport,omitempty"` + Target string `json:"target,omitempty"` + Status MCPAttachmentStatus `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` + Summary string `json:"summary,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` + ToolCount int `json:"tool_count,omitempty"` + ConfiguredAt *time.Time `json:"configured_at,omitempty"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` + ConnectedAt *time.Time `json:"connected_at,omitempty"` + ToolsListedAt *time.Time `json:"tools_listed_at,omitempty"` + UsedAt *time.Time `json:"used_at,omitempty"` + FailedAt *time.Time `json:"failed_at,omitempty"` + DisconnectedAt *time.Time `json:"disconnected_at,omitempty"` + EndpointTest *MCPAttachmentTestResult `json:"endpoint_test,omitempty"` + Tools []MCPToolSummary `json:"tools,omitempty"` + ToolCatalogTruncated bool `json:"tool_catalog_truncated,omitempty"` + ToolTokenEstimator string `json:"tool_token_estimator,omitempty"` } // MCPAttachmentEvidence is a safe normalized event. The producer may supply a // raw network/stdio target or error summary, but Apply persists only the // sanitized projection. type MCPAttachmentEvidence struct { - AttemptID string `json:"attachment_attempt_id"` - ServerName string `json:"server_name,omitempty"` - Kind MCPAttachmentEvidenceKind `json:"kind"` - OccurredAt time.Time `json:"occurred_at"` - Source MCPServerSource `json:"source,omitempty"` - Transport string `json:"transport,omitempty"` - Target string `json:"target,omitempty"` - ConnectionID string `json:"connection_id,omitempty"` - ToolCount int `json:"tool_count,omitempty"` - ReasonCode string `json:"reason_code,omitempty"` - Summary string `json:"summary,omitempty"` + AttemptID string `json:"attachment_attempt_id"` + ServerName string `json:"server_name,omitempty"` + Kind MCPAttachmentEvidenceKind `json:"kind"` + OccurredAt time.Time `json:"occurred_at"` + Source MCPServerSource `json:"source,omitempty"` + Transport string `json:"transport,omitempty"` + Target string `json:"target,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` + ToolCount int `json:"tool_count,omitempty"` + Tools []MCPToolSummary `json:"tools,omitempty"` + ToolTokenEstimator string `json:"tool_token_estimator,omitempty"` + ReasonCode string `json:"reason_code,omitempty"` + Summary string `json:"summary,omitempty"` } // MCPAttachmentAttempt is one ACP new/load/reset or passthrough process start. @@ -175,7 +204,7 @@ func (h *MCPAttachmentHistory) StartAttempt(attempt MCPAttachmentAttempt) { attempt.UpdatedAt = now h.Version = MCPAttachmentSchemaVersion if h.Current.AttemptID != "" && h.Current.AttemptID != attempt.AttemptID { - superseded := h.Current + superseded := historicalMCPAttachmentAttempt(h.Current) superseded.SupersededAt = &now superseded.UpdatedAt = now h.Previous = append([]MCPAttachmentAttempt{superseded}, h.Previous...) @@ -193,12 +222,19 @@ func (h *MCPAttachmentHistory) Apply(evidence MCPAttachmentEvidence) bool { if h.Current.AttemptID == "" || evidence.AttemptID == "" || evidence.AttemptID != h.Current.AttemptID { return false } - evidence = sanitizeMCPAttachmentEvidence(evidence) + var catalogTruncated bool + evidence, catalogTruncated = sanitizeMCPAttachmentEvidence(evidence) if evidence.OccurredAt.IsZero() { evidence.OccurredAt = time.Now().UTC() } h.Current.UpdatedAt = evidence.OccurredAt - h.Current.Evidence = appendBoundedMCPAttachmentEvidence(h.Current.Evidence, evidence) + storedEvidence := evidence + // Tool summaries belong in the current server projection. Keeping them out + // of every evidence event preserves the bounded timeline when tools/list is + // observed more than once. + storedEvidence.Tools = nil + storedEvidence.ToolTokenEstimator = "" + h.Current.Evidence = appendBoundedMCPAttachmentEvidence(h.Current.Evidence, storedEvidence) if evidence.ServerName == "" { if evidence.Kind == MCPAttachmentEvidenceExplicitError { h.Current.SessionError = evidence.Summary @@ -211,7 +247,7 @@ func (h *MCPAttachmentHistory) Apply(evidence MCPAttachmentEvidence) bool { h.Current.Servers = append(h.Current.Servers, MCPServerAttachment{Name: evidence.ServerName, Status: MCPAttachmentStatusUnknown}) index = len(h.Current.Servers) - 1 } - h.Current.Servers[index].applyEvidence(evidence) + h.Current.Servers[index].applyEvidence(evidence, catalogTruncated) return true } @@ -229,12 +265,12 @@ func (h MCPAttachmentHistory) Valid() bool { return h.Version == MCPAttachmentSchemaVersion && h.Current.AttemptID != "" } -func (s *MCPServerAttachment) applyEvidence(evidence MCPAttachmentEvidence) { - s.applyEvidenceDetails(evidence) +func (s *MCPServerAttachment) applyEvidence(evidence MCPAttachmentEvidence, catalogTruncated bool) { + s.applyEvidenceDetails(evidence, catalogTruncated) s.applyEvidenceStatus(evidence) } -func (s *MCPServerAttachment) applyEvidenceDetails(evidence MCPAttachmentEvidence) { +func (s *MCPServerAttachment) applyEvidenceDetails(evidence MCPAttachmentEvidence, catalogTruncated bool) { if evidence.Source != "" { s.Source = evidence.Source } @@ -249,6 +285,12 @@ func (s *MCPServerAttachment) applyEvidenceDetails(evidence MCPAttachmentEvidenc } if evidence.Kind == MCPAttachmentEvidenceToolsListObserved { s.ToolCount = evidence.ToolCount + // Apply sanitizes evidence before projecting it, so do not normalize the + // same catalog again here. Copy the bounded slice to keep the projection + // independent of the event value. + s.Tools = append([]MCPToolSummary(nil), evidence.Tools...) + s.ToolCatalogTruncated = catalogTruncated + s.ToolTokenEstimator = evidence.ToolTokenEstimator } if evidence.ReasonCode != "" { s.ReasonCode = evidence.ReasonCode @@ -259,6 +301,86 @@ func (s *MCPServerAttachment) applyEvidenceDetails(evidence MCPAttachmentEvidenc } +// NormalizeMCPToolCatalog removes unusable entries, bounds descriptions and +// sorts the safe summaries that Kandev publishes for the current attempt. +func NormalizeMCPToolCatalog(tools []MCPToolSummary, totalToolCount int) ([]MCPToolSummary, bool) { + normalized := make([]MCPToolSummary, 0, min(len(tools), MaxMCPAttachmentTools)) + for _, tool := range tools { + if tool.Name == "" { + continue + } + tool.Description = truncateMCPToolDescription(tool.Description) + tool.InputSchema = cloneValidMCPInputSchema(tool.InputSchema, &tool.InputSchemaTruncated) + normalized = append(normalized, tool) + } + sort.Slice(normalized, func(i, j int) bool { return normalized[i].Name < normalized[j].Name }) + truncated := totalToolCount > len(normalized) + if len(normalized) > MaxMCPAttachmentTools { + normalized = append([]MCPToolSummary(nil), normalized[:MaxMCPAttachmentTools]...) + truncated = true + } + boundCombinedMCPInputSchemas(normalized) + return normalized, truncated +} + +func cloneValidMCPInputSchema(schema json.RawMessage, truncated *bool) json.RawMessage { + if len(schema) == 0 { + return nil + } + if len(schema) > MaxMCPToolInputSchemaBytes || !json.Valid(schema) { + *truncated = true + return nil + } + return append(json.RawMessage(nil), schema...) +} + +func boundCombinedMCPInputSchemas(tools []MCPToolSummary) { + remaining := MaxMCPToolInputSchemasBytes + for index := range tools { + schemaBytes := len(tools[index].InputSchema) + if schemaBytes == 0 { + continue + } + if schemaBytes > remaining { + tools[index].InputSchema = nil + tools[index].InputSchemaTruncated = true + continue + } + remaining -= schemaBytes + } +} + +func truncateMCPToolDescription(description string) string { + description = strings.ToValidUTF8(description, "") + if len(description) <= MaxMCPToolDescriptionBytes { + return description + } + end := MaxMCPToolDescriptionBytes + for end > 0 && !utf8.ValidString(description[:end]) { + end-- + } + return description[:end] +} + +func historicalMCPAttachmentAttempt(attempt MCPAttachmentAttempt) MCPAttachmentAttempt { + if len(attempt.Servers) > 0 { + attempt.Servers = append([]MCPServerAttachment(nil), attempt.Servers...) + for index := range attempt.Servers { + attempt.Servers[index].Tools = nil + attempt.Servers[index].ToolCatalogTruncated = false + attempt.Servers[index].ToolTokenEstimator = "" + } + } + if len(attempt.Evidence) > 0 { + attempt.Evidence = append([]MCPAttachmentEvidence(nil), attempt.Evidence...) + for index := range attempt.Evidence { + attempt.Evidence[index].Tools = nil + attempt.Evidence[index].ToolTokenEstimator = "" + } + } + return attempt +} + func (s *MCPServerAttachment) applyEvidenceStatus(evidence MCPAttachmentEvidence) { when := evidence.OccurredAt switch evidence.Kind { @@ -327,14 +449,18 @@ func appendBoundedMCPAttachmentEvidence(existing []MCPAttachmentEvidence, eviden return existing } -func sanitizeMCPAttachmentEvidence(evidence MCPAttachmentEvidence) MCPAttachmentEvidence { +func sanitizeMCPAttachmentEvidence(evidence MCPAttachmentEvidence) (MCPAttachmentEvidence, bool) { + var catalogTruncated bool if evidence.Transport == "stdio" { evidence.Target = SanitizeMCPStdioTarget(evidence.Target) } else if evidence.Target != "" { evidence.Target = SanitizeMCPNetworkTarget(evidence.Target) } + if evidence.Kind == MCPAttachmentEvidenceToolsListObserved { + evidence.Tools, catalogTruncated = NormalizeMCPToolCatalog(evidence.Tools, evidence.ToolCount) + } evidence.Summary = SanitizeMCPErrorSummary(evidence.Summary) - return evidence + return evidence, catalogTruncated } var ( diff --git a/apps/backend/internal/agentctl/types/streams/mcp_attachment_catalog_test.go b/apps/backend/internal/agentctl/types/streams/mcp_attachment_catalog_test.go new file mode 100644 index 0000000000..1b5da80828 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/mcp_attachment_catalog_test.go @@ -0,0 +1,252 @@ +package streams + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" +) + +func TestMCPAttachmentCatalogNormalizesEntriesBeforeStorage(t *testing.T) { + tools := make([]map[string]string, MaxMCPAttachmentTools+2) + for i := range tools { + tools[i] = map[string]string{ + "name": string(rune('z' - i%26)), + "description": "short", + } + } + tools[0] = map[string]string{"name": "", "description": "ignored"} + tools[1] = map[string]string{"name": "alpha", "description": strings.Repeat("界", 600)} + tools[2] = map[string]string{"name": "beta", "description": "second"} + tools[len(tools)-1] = map[string]string{"name": "aardvark", "description": "late but sorted first"} + + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) + applyCatalogEvidenceJSON(t, history.Current.AttemptID, len(tools), tools, &history) + + server, ok := history.CurrentServer("kandev") + if !ok { + t.Fatal("CurrentServer() did not retain the catalog server") + } + encoded, err := json.Marshal(server) + if err != nil { + t.Fatalf("marshal server: %v", err) + } + var got map[string]any + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("unmarshal server: %v", err) + } + stored, ok := got["tools"].([]any) + if !ok { + t.Fatalf("stored tools = %#v, want catalog", got["tools"]) + } + if len(stored) != MaxMCPAttachmentTools { + t.Fatalf("stored tool count = %d, want %d", len(stored), MaxMCPAttachmentTools) + } + if got["tool_catalog_truncated"] != true { + t.Fatalf("truncated marker = %#v, want true", got["tool_catalog_truncated"]) + } + if got["tool_count"] != float64(len(tools)) { + t.Fatalf("tool count = %#v, want %d", got["tool_count"], len(tools)) + } + var alpha map[string]any + for _, value := range stored { + entry := value.(map[string]any) + if entry["name"] == "alpha" { + alpha = entry + break + } + } + if alpha == nil { + t.Fatal("stored catalog did not contain alpha") + } + var aardvark map[string]any + for _, value := range stored { + entry := value.(map[string]any) + if entry["name"] == "aardvark" { + aardvark = entry + break + } + } + if aardvark == nil { + t.Fatal("stored catalog did not retain the alphabetically first late entry") + } + description, ok := alpha["description"].(string) + if !ok || len(description) > MaxMCPToolDescriptionBytes || !utf8.ValidString(description) { + t.Fatalf("description was not safely bounded: bytes=%d valid=%v", len(description), utf8.ValidString(description)) + } + for _, value := range stored { + entry := value.(map[string]any) + if entry["name"] == "" { + t.Fatal("empty tool name was stored") + } + } + if len(history.Current.Evidence) != 1 || len(history.Current.Evidence[0].Tools) != 0 { + t.Fatalf("evidence retained tool summaries: %+v", history.Current.Evidence) + } +} + +func TestMCPAttachmentHistorySupersededAttemptDropsCatalogButKeepsCount(t *testing.T) { + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) + applyCatalogEvidenceJSON(t, history.Current.AttemptID, MaxMCPAttachmentTools+1, []map[string]string{{ + "name": "create_task_kandev", "description": "Create a task", + }}, &history) + + current, ok := history.CurrentServer("kandev") + if !ok || len(current.Tools) != 1 { + t.Fatalf("current catalog = %+v, want one tool", current) + } + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-2"}) + if len(history.Previous) != 1 { + t.Fatalf("previous attempts = %+v, want one attempt", history.Previous) + } + previousIndex := mcpAttachmentServerIndex(history.Previous[0].Servers, "kandev") + if previousIndex == -1 { + t.Fatalf("previous servers = %+v, want kandev", history.Previous[0].Servers) + } + previous := history.Previous[0].Servers[previousIndex] + if previous.ToolCount != MaxMCPAttachmentTools+1 { + t.Fatalf("previous server = %+v, want count %d", previous, MaxMCPAttachmentTools+1) + } + if len(previous.Tools) != 0 || previous.ToolCatalogTruncated { + t.Fatalf("previous catalog = %+v, want no entries and no truncation marker", previous) + } +} + +func TestMCPAttachmentCatalogRetainsBoundedSchemaAndEstimatorMetadata(t *testing.T) { + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) + raw := []byte(`{ + "attachment_attempt_id":"attempt-1", + "server_name":"kandev", + "kind":"tools_list_observed", + "tool_count":1, + "tool_token_estimator":"o200k_base:mcp-tool-json-v1", + "tools":[{ + "name":"create_task_kandev", + "input_schema":{"type":"object","properties":{"title":{"type":"string"}}}, + "estimated_tokens":42 + }] + }`) + var evidence MCPAttachmentEvidence + if err := json.Unmarshal(raw, &evidence); err != nil { + t.Fatalf("unmarshal evidence: %v", err) + } + if !history.Apply(evidence) { + t.Fatal("Apply() rejected catalog evidence") + } + + server, _ := history.CurrentServer("kandev") + encoded, err := json.Marshal(server) + if err != nil { + t.Fatalf("marshal server: %v", err) + } + for _, expected := range []string{ + `"tool_token_estimator":"o200k_base:mcp-tool-json-v1"`, + `"input_schema":{"type":"object","properties":{"title":{"type":"string"}}}`, + `"estimated_tokens":42`, + } { + if !strings.Contains(string(encoded), expected) { + t.Fatalf("server JSON = %s, want %s", encoded, expected) + } + } + + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-2"}) + previous, err := json.Marshal(history.Previous[0]) + if err != nil { + t.Fatalf("marshal previous attempt: %v", err) + } + for _, forbidden := range []string{"input_schema", "estimated_tokens", "tool_token_estimator"} { + if strings.Contains(string(previous), forbidden) { + t.Fatalf("historical attempt retained %q: %s", forbidden, previous) + } + } +} + +func TestMCPAttachmentCatalogOmitsSchemasOverPerToolAndCombinedLimits(t *testing.T) { + history := MCPAttachmentHistory{} + history.StartAttempt(MCPAttachmentAttempt{AttemptID: "attempt-1"}) + largeValue := strings.Repeat("x", 64*1024) + tools := []map[string]any{{ + "name": "oversized", + "input_schema": map[string]any{"type": "object", "description": largeValue}, + }} + for index := 0; index < 9; index++ { + tools = append(tools, map[string]any{ + "name": "combined-" + string(rune('a'+index)), + "input_schema": map[string]any{"type": "object", "description": strings.Repeat("y", 60*1024)}, + }) + } + raw, err := json.Marshal(map[string]any{ + "attachment_attempt_id": history.Current.AttemptID, + "server_name": "kandev", + "kind": MCPAttachmentEvidenceToolsListObserved, + "tool_count": len(tools), + "tools": tools, + }) + if err != nil { + t.Fatalf("marshal evidence: %v", err) + } + var evidence MCPAttachmentEvidence + if err := json.Unmarshal(raw, &evidence); err != nil { + t.Fatalf("unmarshal evidence: %v", err) + } + if !history.Apply(evidence) { + t.Fatal("Apply() rejected catalog evidence") + } + + server, _ := history.CurrentServer("kandev") + encoded, err := json.Marshal(server) + if err != nil { + t.Fatalf("marshal server: %v", err) + } + var decoded struct { + Tools []struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"input_schema"` + InputSchemaTruncated bool `json:"input_schema_truncated"` + } `json:"tools"` + } + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal server: %v", err) + } + var storedBytes int + var omitted int + for _, tool := range decoded.Tools { + storedBytes += len(tool.InputSchema) + if tool.InputSchemaTruncated { + omitted++ + if len(tool.InputSchema) != 0 { + t.Fatalf("truncated tool %q retained partial schema", tool.Name) + } + } + } + if omitted < 2 { + t.Fatalf("omitted schemas = %d, want per-tool and combined omissions", omitted) + } + if storedBytes > 512*1024 { + t.Fatalf("stored schema bytes = %d, want at most %d", storedBytes, 512*1024) + } +} + +func applyCatalogEvidenceJSON(t *testing.T, attemptID string, toolCount int, tools []map[string]string, history *MCPAttachmentHistory) { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "attachment_attempt_id": attemptID, + "server_name": "kandev", + "kind": MCPAttachmentEvidenceToolsListObserved, + "tool_count": toolCount, + "tools": tools, + }) + if err != nil { + t.Fatalf("marshal evidence: %v", err) + } + var evidence MCPAttachmentEvidence + if err := json.Unmarshal(raw, &evidence); err != nil { + t.Fatalf("unmarshal evidence: %v", err) + } + if !history.Apply(evidence) { + t.Fatal("Apply() rejected catalog evidence") + } +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index e06c3895e7..a52f98bb59 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -18,6 +18,7 @@ import ( mcpprofile "github.com/kandev/kandev/internal/mcp/profile" mcpproviders "github.com/kandev/kandev/internal/mcp/providers" "github.com/kandev/kandev/internal/mcp/toolschema" + "github.com/kandev/kandev/internal/mcp/tooltokens" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "github.com/mark3labs/mcp-go/mcp" @@ -235,7 +236,7 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log * s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceInitializeObserved, 0, "") }) hooks.AddAfterListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest, result *mcp.ListToolsResult) { - s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceToolsListObserved, len(result.Tools), "") + s.observeMCPToolsList(mcpConnectionID(ctx), result.Tools) }) hooks.AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { s.syncPluginTools(ctx) @@ -291,6 +292,52 @@ func (s *Server) SetAttachmentAttempt(attempt streams.MCPAttachmentAttempt) { } func (s *Server) observeMCPConnection(connectionID string, kind streams.MCPAttachmentEvidenceKind, toolCount int, summary string) { + s.observeMCPConnectionWithTools(connectionID, kind, toolCount, summary, nil) +} + +func (s *Server) observeMCPToolsList(connectionID string, tools []mcp.Tool) { + summaries := make([]streams.MCPToolSummary, 0, len(tools)) + for _, tool := range tools { + summaries = append(summaries, summarizeMCPTool(tool)) + } + s.observeMCPConnectionWithTools(connectionID, streams.MCPAttachmentEvidenceToolsListObserved, len(tools), "", summaries) +} + +func summarizeMCPTool(tool mcp.Tool) streams.MCPToolSummary { + summary := streams.MCPToolSummary{ + Name: tool.Name, + Description: tool.Description, + InputSchema: mcpToolInputSchema(tool), + } + definition, err := json.Marshal(tool) + if err != nil { + return summary + } + summary.EstimatedTokens, _ = tooltokens.EstimateToolJSON(definition) + return summary +} + +func mcpToolInputSchema(tool mcp.Tool) json.RawMessage { + if tool.RawInputSchema != nil { + if !json.Valid(tool.RawInputSchema) { + return nil + } + return append(json.RawMessage(nil), tool.RawInputSchema...) + } + schema, err := json.Marshal(tool.InputSchema) + if err != nil { + return nil + } + return schema +} + +func (s *Server) observeMCPConnectionWithTools( + connectionID string, + kind streams.MCPAttachmentEvidenceKind, + toolCount int, + summary string, + tools []streams.MCPToolSummary, +) { s.attachmentMu.RLock() attempt, ok := s.attachmentAttempts[connectionID] reporter := s.attachmentReporter @@ -298,7 +345,7 @@ func (s *Server) observeMCPConnection(connectionID string, kind streams.MCPAttac if !ok || reporter == nil || attempt.AttemptID == "" { return } - s.reportMCPConnection(reporter, attempt, connectionID, kind, toolCount, summary) + s.reportMCPConnectionWithTools(reporter, attempt, connectionID, kind, toolCount, summary, tools) } func (s *Server) registerMCPConnection(connectionID string) { @@ -335,18 +382,47 @@ func (s *Server) reportMCPConnection( toolCount int, summary string, ) { + s.reportMCPConnectionWithTools(reporter, attempt, connectionID, kind, toolCount, summary, nil) +} + +func (s *Server) reportMCPConnectionWithTools( + reporter func(streams.MCPAttachmentEvidence), + attempt streams.MCPAttachmentAttempt, + connectionID string, + kind streams.MCPAttachmentEvidenceKind, + toolCount int, + summary string, + tools []streams.MCPToolSummary, +) { + if kind == streams.MCPAttachmentEvidenceToolsListObserved { + // Bound summaries at the publication boundary before the callback can + // expose evidence to the agent update stream. Apply repeats the bound as + // a defense for any other evidence producer. + tools, _ = streams.NormalizeMCPToolCatalog(tools, toolCount) + } reporter(streams.MCPAttachmentEvidence{ - AttemptID: attempt.AttemptID, - ServerName: "kandev", - Kind: kind, - OccurredAt: time.Now().UTC(), - Source: streams.MCPServerSourceKandev, - ConnectionID: opaqueMCPConnectionID(connectionID), - ToolCount: toolCount, - Summary: streams.SanitizeMCPErrorSummary(summary), + AttemptID: attempt.AttemptID, + ServerName: "kandev", + Kind: kind, + OccurredAt: time.Now().UTC(), + Source: streams.MCPServerSourceKandev, + ConnectionID: opaqueMCPConnectionID(connectionID), + ToolCount: toolCount, + Tools: tools, + ToolTokenEstimator: toolTokenEstimator(tools), + Summary: streams.SanitizeMCPErrorSummary(summary), }) } +func toolTokenEstimator(tools []streams.MCPToolSummary) string { + for _, tool := range tools { + if tool.EstimatedTokens > 0 { + return tooltokens.Estimator + } + } + return "" +} + func mcpConnectionID(ctx context.Context) string { session := server.ClientSessionFromContext(ctx) if session == nil { diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 60d049077c..dfe8006a02 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/mcp/plugintools" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + "github.com/kandev/kandev/internal/mcp/tooltokens" ws "github.com/kandev/kandev/pkg/websocket" mcplib "github.com/mark3labs/mcp-go/mcp" mcpsrv "github.com/mark3labs/mcp-go/server" @@ -47,6 +48,69 @@ func TestMCPAttachmentObserverEmitsSafeConnectionEvidence(t *testing.T) { } } +func TestMCPAttachmentObserverPublishesToolSummaries(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + t.Cleanup(backend.Close) + s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask) + events := make(chan streams.MCPAttachmentEvidence, 4) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-1"}) + + s.registerMCPConnection("connection-1") + <-events + tool := mcplib.Tool{ + Name: "create_task_kandev", + Description: "Create a task", + RawInputSchema: []byte(`{"type":"object","properties":{"secret":{"type":"string"}}}`), + Meta: &mcplib.Meta{AdditionalFields: map[string]any{"private_note": "must not persist"}}, + } + s.observeMCPToolsList("connection-1", []mcplib.Tool{tool}) + + evidence := <-events + if evidence.Kind != streams.MCPAttachmentEvidenceToolsListObserved || evidence.ToolCount != 1 { + t.Fatalf("evidence = %+v, want tools-list count", evidence) + } + if len(evidence.Tools) != 1 || evidence.Tools[0].Name != "create_task_kandev" || evidence.Tools[0].Description != "Create a task" { + t.Fatalf("tool summaries = %+v, want name and description", evidence.Tools) + } + encoded, err := json.Marshal(evidence) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"input_schema":{"type":"object","properties":{"secret":{"type":"string"}}}`) + assert.Contains(t, string(encoded), `"tool_token_estimator":"o200k_base:mcp-tool-json-v1"`) + assert.NotContains(t, string(encoded), "must not persist") + definition, err := json.Marshal(tool) + require.NoError(t, err) + wantTokens, err := tooltokens.EstimateToolJSON(definition) + require.NoError(t, err) + assert.Equal(t, wantTokens, evidence.Tools[0].EstimatedTokens) +} + +func TestMCPAttachmentObserverPublishesStructuredInputSchema(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + t.Cleanup(backend.Close) + s := New(backend, "session-1", "task-1", 10005, log, "", false, ModeTask) + events := make(chan streams.MCPAttachmentEvidence, 4) + s.SetAttachmentReporter(func(evidence streams.MCPAttachmentEvidence) { events <- evidence }) + s.SetAttachmentAttempt(streams.MCPAttachmentAttempt{AttemptID: "attempt-1"}) + s.registerMCPConnection("connection-1") + <-events + + s.observeMCPToolsList("connection-1", []mcplib.Tool{{ + Name: "structured", + InputSchema: mcplib.ToolInputSchema{ + Type: "object", + Properties: map[string]any{"title": map[string]any{"type": "string"}}, + Required: []string{"title"}, + }, + }}) + evidence := <-events + require.Len(t, evidence.Tools, 1) + assert.JSONEq(t, `{"type":"object","properties":{"title":{"type":"string"}},"required":["title"]}`, string(evidence.Tools[0].InputSchema)) + assert.Positive(t, evidence.Tools[0].EstimatedTokens) +} + func TestMCPAttachmentObserverKeepsConnectionAttemptAcrossRollover(t *testing.T) { log := newTestLogger(t) backend := NewChannelBackendClient(log) diff --git a/apps/backend/internal/mcp/tooltokens/estimator.go b/apps/backend/internal/mcp/tooltokens/estimator.go new file mode 100644 index 0000000000..fecabf2a31 --- /dev/null +++ b/apps/backend/internal/mcp/tooltokens/estimator.go @@ -0,0 +1,34 @@ +// Package tooltokens estimates the token size of MCP tool definitions. +package tooltokens + +import ( + "sync" + + "github.com/tiktoken-go/tokenizer" +) + +// Estimator identifies the stable tokenizer and projection used by the MCP +// explorer contract. +const Estimator = "o200k_base:mcp-tool-json-v1" + +// EstimateToolJSON estimates one complete compact MCP tool JSON object. +func EstimateToolJSON(compactJSON []byte) (int, error) { + codec, err := o200kCodec() + if err != nil { + return 0, err + } + return codec.Count(string(compactJSON)) +} + +var ( + codecOnce sync.Once + codec tokenizer.Codec + codecErr error +) + +func o200kCodec() (tokenizer.Codec, error) { + codecOnce.Do(func() { + codec, codecErr = tokenizer.Get(tokenizer.O200kBase) + }) + return codec, codecErr +} diff --git a/apps/backend/internal/mcp/tooltokens/estimator_test.go b/apps/backend/internal/mcp/tooltokens/estimator_test.go new file mode 100644 index 0000000000..c1a541b3ed --- /dev/null +++ b/apps/backend/internal/mcp/tooltokens/estimator_test.go @@ -0,0 +1,31 @@ +package tooltokens + +import "testing" + +func TestKnownO200kVectors(t *testing.T) { + t.Parallel() + + tests := []struct { + text string + want int + }{ + {text: "", want: 0}, + {text: "hello", want: 1}, + {text: "hello world", want: 2}, + {text: "antidisestablishmentarianism", want: 6}, + {text: "お誕生日おめでとう", want: 8}, + } + for _, test := range tests { + test := test + t.Run(test.text, func(t *testing.T) { + t.Parallel() + got, err := EstimateToolJSON([]byte(test.text)) + if err != nil { + t.Fatalf("EstimateToolJSON() error = %v", err) + } + if got != test.want { + t.Fatalf("EstimateToolJSON() = %d, want %d", got, test.want) + } + }) + } +} diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx index 683c3b0831..3acddc685e 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx @@ -15,12 +15,8 @@ import { ResetContextButton } from "./reset-context-button"; import { ImplementPlanButton } from "./implement-plan-button"; import { ChatInputPluginActions } from "./chat-input-plugin-actions"; import { ContextPopover } from "./context-popover"; -import { - AttachFilesButton, - McpIndicator, - PlanToggleButton, - SubmitButton, -} from "./chat-input-toolbar-primitives"; +import { AttachFilesButton, PlanToggleButton, SubmitButton } from "./chat-input-toolbar-primitives"; +import { McpIndicator } from "./mcp-explorer/mcp-indicator"; import { type ChatInputToolbarProps } from "./chat-input-toolbar"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { SHORTCUTS } from "@/lib/keyboard/constants"; diff --git a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx index dd87c94492..fcc8cacc4e 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx @@ -11,12 +11,8 @@ import { ChatInputPluginActions } from "./chat-input-plugin-actions"; import { ContextPopover } from "./context-popover"; import { ImplementPlanButton } from "./implement-plan-button"; import { ResetContextButton } from "./reset-context-button"; -import { - AttachFilesButton, - McpIndicator, - PlanToggleButton, - SubmitButton, -} from "./chat-input-toolbar-primitives"; +import { AttachFilesButton, PlanToggleButton, SubmitButton } from "./chat-input-toolbar-primitives"; +import { McpIndicator } from "./mcp-explorer/mcp-indicator"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { SHORTCUTS } from "@/lib/keyboard/constants"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; diff --git a/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx index d59f22159e..bfcf2fccb8 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx @@ -6,22 +6,17 @@ import { IconFileTextSpark, IconPaperclip, IconPlayerPauseFilled, - IconPlugConnected, - IconPlugConnectedX, } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { Button } from "@kandev/ui/button"; -import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { formatShortcut } from "@/lib/keyboard/utils"; import { cn } from "@/lib/utils"; -import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; @@ -218,119 +213,6 @@ export function PlanToggleButton({ ); } -const mcpStatusColor: Record = { - active: "bg-emerald-500", - connected: "bg-amber-500", - delivered: "bg-amber-500", - failed: "bg-destructive", - filtered: "bg-muted-foreground/50", - unavailable: "bg-muted-foreground/50", - unknown: "bg-muted-foreground/50", -}; - -const mcpStatusLabel: Record = { - active: "Active", - connected: "Connected", - delivered: "Delivered - connection unverified", - failed: "Failed", - filtered: "Filtered", - unavailable: "Unavailable", - unknown: "Unknown", -}; - -export function McpIndicator({ - mcpServers, - attachmentHistory, -}: { - mcpServers: string[]; - attachmentHistory?: MCPAttachmentHistory; -}) { - const { t } = useTranslation(); - const usesTouchDrawer = useTouchDrawer(); - const hasMcp = mcpServers.length > 0; - const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX; - const observedServers = attachmentHistory?.current.servers; - const servers = - observedServers && observedServers.length > 0 - ? observedServers - : mcpServers.map((name) => ({ name, status: "unknown" as const, summary: undefined })); - const statusList = hasMcp ? ( -
-
{t("task:mcpServers")}
- {servers.map((server) => ( -
-
- ))} -
- ) : ( - "Agent does not support MCP" - ); - const trigger = ( - - ); - - if (usesTouchDrawer) { - return ( - - - - - - - {t("task:mcpConnectionStatus")} - -
- {statusList} -
-
-
- ); - } - - return ( - - {trigger} - {statusList} - - ); -} - export function AttachFilesButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation(); return ( diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-explorer-header.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-header.tsx new file mode 100644 index 0000000000..6fa50c0564 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { DialogDescription, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { DrawerDescription, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { useTranslation } from "react-i18next"; + +function ExplorerCloseButton({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +function HeaderCopy({ mobile }: { mobile: boolean }) { + const { t } = useTranslation(); + if (mobile) { + return ( +
+ {t("task:mcpExplorerTitle")} + + {t("task:mcpExplorerDescription")} + +
+ ); + } + return ( +
+ {t("task:mcpExplorerTitle")} + + {t("task:mcpExplorerDescription")} + +
+ ); +} + +export function McpExplorerHeader({ onClose, mobile }: { onClose: () => void; mobile: boolean }) { + const content = ( +
+ + +
+ ); + if (mobile) { + return {content}; + } + return {content}; +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.test.ts b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.test.ts new file mode 100644 index 0000000000..5849822da5 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { + MCPAttachmentHistory, + MCPAttachmentServer, +} from "@/lib/state/slices/session-runtime/types"; +import { + buildMcpExplorerServers, + getMcpCatalogState, + getMcpToolCounts, + getMcpToolSchemaState, + isKandevMcpServer, + mcpStatusLabelKey, + selectMcpServerName, + selectMcpToolName, +} from "./mcp-explorer-view-model"; + +function server(overrides: Partial): MCPAttachmentServer { + return { name: "kandev", status: "unknown", ...overrides }; +} + +function history(servers: MCPAttachmentServer[]): MCPAttachmentHistory { + return { + version: 1, + current: { attachment_attempt_id: "attempt-1", started_at: "2026-08-16T00:00:00Z", servers }, + }; +} + +describe("MCP explorer view model", () => { + it("uses observed servers and falls back to configured names", () => { + const configured = buildMcpExplorerServers(["kandev", "filesystem"]); + expect(configured.map((item) => item.name)).toEqual(["kandev", "filesystem"]); + + const observed = buildMcpExplorerServers( + ["kandev", "filesystem"], + history([server({ name: "filesystem", source: "profile", status: "delivered" })]), + ); + expect(observed.map((item) => item.name)).toEqual(["filesystem"]); + expect(observed[0].source).toBe("profile"); + }); + + it("selects kandev first and falls back when the selection disappears", () => { + const servers = [server({ name: "filesystem" }), server({ name: "kandev" })]; + expect(selectMcpServerName(servers)).toBe("kandev"); + expect(selectMcpServerName(servers, "filesystem")).toBe("filesystem"); + expect(selectMcpServerName([servers[1]], "filesystem")).toBe("kandev"); + expect(selectMcpServerName([], "kandev")).toBeNull(); + }); + + it("reports localized status keys and catalog availability", () => { + expect(mcpStatusLabelKey("active")).toBe("task:mcpStatusActive"); + expect(mcpStatusLabelKey("unknown")).toBe("task:mcpStatusUnknown"); + + const loaded = server({ + status: "active", + tools_listed_at: "2026-08-16T00:01:00Z", + tool_count: 129, + tools: [{ name: "create_task_kandev", description: "Create a task" }], + tool_catalog_truncated: true, + }); + expect(getMcpCatalogState(loaded)).toBe("loaded"); + expect(getMcpToolCounts(loaded)).toEqual({ stored: 1, total: 129, truncated: true }); + expect(getMcpCatalogState(server({ status: "unknown" }))).toBe("not_loaded"); + expect( + getMcpCatalogState(server({ name: "filesystem", source: "profile", status: "delivered" })), + ).toBe("unavailable"); + }); + + it("identifies only the backend-owned Kandev server", () => { + expect(isKandevMcpServer(server({ name: "kandev", source: "kandev" }))).toBe(true); + expect(isKandevMcpServer(server({ name: "kandev", source: "profile" }))).toBe(false); + expect(isKandevMcpServer(server({ name: "filesystem", source: "profile" }))).toBe(false); + }); + + it("selects a tool and clears a selection removed by live evidence", () => { + const selectedServer = server({ + tools: [{ name: "create_task_kandev" }, { name: "message_task_kandev" }], + }); + expect(selectMcpToolName(selectedServer, "message_task_kandev")).toBe("message_task_kandev"); + expect(selectMcpToolName(selectedServer, "deleted_tool")).toBeNull(); + expect(selectMcpToolName(null, "create_task_kandev")).toBeNull(); + }); + + it("projects common object properties as argument rows", () => { + expect( + getMcpToolSchemaState({ + name: "create_task_kandev", + input_schema: { + type: "object", + properties: { + title: { type: "string", description: "Short task title" }, + labels: { type: "array", items: { type: "string" } }, + }, + required: ["title"], + }, + }), + ).toEqual({ + kind: "schema", + arguments: [ + { name: "labels", type: "array", required: false }, + { name: "title", type: "string", required: true, description: "Short task title" }, + ], + showJSON: true, + }); + }); + + it("distinguishes no arguments from a schema removed by limits", () => { + expect(getMcpToolSchemaState({ name: "empty" })).toEqual({ kind: "none" }); + expect(getMcpToolSchemaState({ name: "large", input_schema_truncated: true })).toEqual({ + kind: "too_large", + }); + }); +}); diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.ts b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.ts new file mode 100644 index 0000000000..58c6695b52 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-explorer-view-model.ts @@ -0,0 +1,134 @@ +import type { + MCPAttachmentHistory, + MCPAttachmentServer, + MCPAttachmentStatus, + MCPToolSummary, +} from "@/lib/state/slices/session-runtime/types"; + +export type MCPToolCatalogState = "loaded" | "not_loaded" | "unavailable"; +export type MCPToolArgument = { + name: string; + type: string; + required: boolean; + description?: string; +}; + +export type MCPToolSchemaState = + | { kind: "none" } + | { kind: "too_large" } + | { kind: "schema"; arguments: MCPToolArgument[]; showJSON: boolean }; + +const STATUS_LABEL_KEYS: Record = { + active: "task:mcpStatusActive", + connected: "task:mcpStatusConnected", + delivered: "task:mcpStatusDelivered", + failed: "task:mcpStatusFailed", + filtered: "task:mcpStatusFiltered", + unavailable: "task:mcpStatusUnavailable", + unknown: "task:mcpStatusUnknown", +}; + +export function buildMcpExplorerServers( + configuredNames: string[], + attachmentHistory?: MCPAttachmentHistory, +): MCPAttachmentServer[] { + const observedServers = attachmentHistory?.current.servers; + if (observedServers && observedServers.length > 0) return observedServers; + return configuredNames.map((name) => ({ name, status: "unknown" })); +} + +export function selectMcpServerName( + servers: MCPAttachmentServer[], + selectedName?: string | null, +): string | null { + if (servers.length === 0) return null; + if (selectedName && servers.some((server) => server.name === selectedName)) { + return selectedName; + } + return servers.find((server) => server.name === "kandev")?.name ?? servers[0].name; +} + +export function mcpStatusLabelKey(status: MCPAttachmentStatus): string { + return STATUS_LABEL_KEYS[status] ?? STATUS_LABEL_KEYS.unknown; +} + +export function isKandevMcpServer(server: MCPAttachmentServer): boolean { + return server.name === "kandev" && server.source !== "profile"; +} + +export function getMcpCatalogState(server: MCPAttachmentServer): MCPToolCatalogState { + if (!isKandevMcpServer(server)) return "unavailable"; + if (server.tools_listed_at || server.tools !== undefined) return "loaded"; + if ( + server.status === "failed" || + server.status === "filtered" || + server.status === "unavailable" + ) { + return "unavailable"; + } + return "not_loaded"; +} + +export function getMcpToolCounts(server: MCPAttachmentServer) { + const stored = server.tools?.length ?? 0; + return { + stored, + total: server.tool_count ?? stored, + truncated: server.tool_catalog_truncated === true, + }; +} + +export function selectMcpToolName( + server: MCPAttachmentServer | null, + selectedName?: string | null, +): string | null { + if (!server?.tools || !selectedName) return null; + if (server.tools.some((tool) => tool.name === selectedName)) return selectedName; + return null; +} + +export function getMcpToolSchemaState(tool: MCPToolSummary): MCPToolSchemaState { + if (tool.input_schema_truncated) return { kind: "too_large" }; + if (!isRecord(tool.input_schema)) return { kind: "none" }; + const properties = isRecord(tool.input_schema.properties) ? tool.input_schema.properties : {}; + const required = new Set( + Array.isArray(tool.input_schema.required) + ? tool.input_schema.required.filter((name): name is string => typeof name === "string") + : [], + ); + const args = Object.entries(properties) + .map(([name, schema]) => mcpToolArgument(name, schema, required.has(name))) + .sort((left, right) => left.name.localeCompare(right.name)); + if (args.length === 0 && !hasComplexSchema(tool.input_schema)) return { kind: "none" }; + return { kind: "schema", arguments: args, showJSON: hasComplexSchema(tool.input_schema) }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mcpToolArgument(name: string, value: unknown, required: boolean): MCPToolArgument { + const schema = isRecord(value) ? value : {}; + const type = mcpArgumentType(schema.type); + const description = typeof schema.description === "string" ? schema.description : undefined; + return { name, type, required, ...(description ? { description } : {}) }; +} + +function mcpArgumentType(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string").join(" | "); + } + return "any"; +} + +function hasComplexSchema(schema: Record): boolean { + const complexKeys = ["$defs", "definitions", "$ref", "allOf", "anyOf", "oneOf", "not", "items"]; + if (complexKeys.some((key) => key in schema)) return true; + if (!isRecord(schema.properties)) return false; + return Object.values(schema.properties).some( + (value) => + isRecord(value) && + ("items" in value || "properties" in value || complexKeys.some((key) => key in value)), + ); +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-indicator.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-indicator.tsx new file mode 100644 index 0000000000..f28fc22505 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-indicator.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { IconPlugConnected, IconPlugConnectedX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { buildMcpExplorerServers } from "./mcp-explorer-view-model"; +import { McpServerExplorer } from "./mcp-server-explorer"; + +export function McpIndicator({ + mcpServers, + attachmentHistory, +}: { + mcpServers: string[]; + attachmentHistory?: MCPAttachmentHistory; +}) { + const { t } = useTranslation(); + const servers = buildMcpExplorerServers(mcpServers, attachmentHistory); + const hasMcp = servers.length > 0; + const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX; + const trigger = ( + + ); + + return ; +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-server-detail.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-server-detail.tsx new file mode 100644 index 0000000000..8853da961c --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-server-detail.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { MCPAttachmentServer } from "@/lib/state/slices/session-runtime/types"; +import { useTranslation } from "react-i18next"; +import { getMcpToolCounts, mcpStatusLabelKey } from "./mcp-explorer-view-model"; +import { McpStatusDot } from "./mcp-status-presentation"; + +function formatObservedAt(value?: string) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function MetadataRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function McpServerSummary({ server }: { server: MCPAttachmentServer }) { + const { t } = useTranslation(); + const counts = getMcpToolCounts(server); + const observedAt = formatObservedAt(server.tools_listed_at); + return ( +
+
+ +

{server.name}

+ + {t(mcpStatusLabelKey(server.status))} + + + {t("task:mcpToolCount", { count: counts.total })} + +
+ {server.summary &&

{server.summary}

} +
+ + {t("task:mcpConnectionDetails")} + +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.test.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.test.tsx new file mode 100644 index 0000000000..c66a023961 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.test.tsx @@ -0,0 +1,428 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MCPAttachmentHistory, + MCPAttachmentServer, +} from "@/lib/state/slices/session-runtime/types"; +import { McpIndicator } from "./mcp-indicator"; + +const testCopy = vi.hoisted(() => ({ + thirdPartyCatalogUnavailable: "Kandev does not inspect tools from this server.", +})); +const MCP_STATUS_TRIGGER_TEST_ID = "mcp-status-trigger"; +const CREATE_TASK_DESCRIPTION = "Create a task"; +const BACK_TO_TOOLS = "Back to tools"; +const TOOLS_OBSERVED_AT = "2026-08-16T00:01:00Z"; +const MCP_TOOL_DETAIL_TEST_ID = "mcp-tool-detail"; + +const responsiveMock = vi.hoisted(() => ({ + breakpoint: "desktop" as "mobile" | "tablet" | "desktop", +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveMock.breakpoint, + isMobile: responsiveMock.breakpoint === "mobile", + isTablet: responsiveMock.breakpoint === "tablet", + isDesktop: responsiveMock.breakpoint !== "mobile", + isCompactDesktop: false, + isFullDesktop: responsiveMock.breakpoint === "desktop", + isFinePointer: responsiveMock.breakpoint !== "tablet", + usesDesktopWorkbench: responsiveMock.breakpoint !== "mobile", + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + const labels: Record = { + "task:mcpExplorerTitle": "MCP servers", + "task:mcpExplorerDescription": "Review MCP connection status and available tools.", + "task:mcpServerList": "Servers", + "task:mcpStatusActive": "Active", + "task:mcpStatusDelivered": "Delivered, connection unverified", + "task:mcpToolCatalog": "Tool catalog", + "task:mcpToolCatalogUnavailable": "The Kandev tool catalog is unavailable.", + "task:mcpThirdPartyCatalogUnavailable": testCopy.thirdPartyCatalogUnavailable, + "task:mcpToolCount": `Tools: ${values?.count ?? 0}`, + "task:mcpStoredToolCount": `Showing ${values?.count ?? 0}`, + "task:mcpNoTools": "No tools were reported.", + "task:mcpBackToServers": "Back to servers", + "task:mcpBackToTools": BACK_TO_TOOLS, + "task:mcpClose": "Close", + "task:mcpConnectionDetails": "Connection details", + "task:mcpArguments": "Arguments", + "task:mcpRequired": "Required", + "task:mcpOptional": "Optional", + "task:mcpNoArguments": "No arguments", + "task:mcpSchemaTooLarge": "Schema too large to display", + "task:mcpJsonSchema": "JSON schema", + "task:mcpTokenEstimate": `~${values?.count ?? 0} tokens`, + "task:mcpTokenEstimateHelp": "Estimated with o200k_base. Actual usage varies by model.", + "task:mcpTransport": "Transport", + "task:mcpTarget": "Target", + "task:mcpConnectionId": "Connection ID", + "task:mcpStatusUnknown": "Unknown", + "task:mcpToolCatalogTruncated": "Only the first tools are shown.", + "task:mcpToolCatalogNotLoaded": "The tool catalog is not loaded yet.", + "task:mcpNoServers": "No MCP servers are configured.", + "task:showMcpConnectionStatus": "Show MCP connection status", + }; + return labels[key] ?? key; + }, + }), +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), +})); + +vi.mock("@kandev/ui/dialog", async () => { + const React = await vi.importActual("react"); + const DialogContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + return { + Dialog: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + }) => ( + {children} + ), + DialogTrigger: ({ children }: { children: React.ReactElement }) => { + const context = React.useContext(DialogContext); + return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, { + onClick: () => context?.onOpenChange(true), + }); + }, + DialogContent: ({ + children, + enterConfirms: _enterConfirms, + showCloseButton = true, + ...props + }: React.HTMLAttributes & { + enterConfirms?: boolean; + showCloseButton?: boolean; + }) => { + const context = React.useContext(DialogContext); + return context?.open ? ( +
+ {children} + {showCloseButton && ( +
+ ) : null; + }, + DialogHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + }; +}); + +vi.mock("@kandev/ui/drawer", async () => { + const React = await vi.importActual("react"); + const DrawerContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + return { + Drawer: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + }) => ( + {children} + ), + DrawerTrigger: ({ children }: { children: React.ReactElement }) => { + const context = React.useContext(DrawerContext); + return React.cloneElement(children as React.ReactElement<{ onClick?: () => void }>, { + onClick: () => context?.onOpenChange(true), + }); + }, + DrawerContent: ({ children, ...props }: React.HTMLAttributes) => { + const context = React.useContext(DrawerContext); + return context?.open ? ( +
+ {children} +
+ ) : null; + }, + DrawerHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DrawerTitle: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + DrawerDescription: ({ children, ...props }: React.HTMLAttributes) => ( +

{children}

+ ), + }; +}); + +const attachmentHistory: MCPAttachmentHistory = { + version: 1, + current: { + attachment_attempt_id: "attempt-1", + started_at: "2026-08-16T00:00:00Z", + servers: [ + { + name: "kandev", + source: "kandev", + status: "active", + tools_listed_at: TOOLS_OBSERVED_AT, + tool_token_estimator: "o200k_base:mcp-tool-json-v1", + tools: [ + { + name: "create_task_kandev", + description: CREATE_TASK_DESCRIPTION, + estimated_tokens: 42, + input_schema: { + type: "object", + properties: { + title: { type: "string", description: "Short task title" }, + }, + required: ["title"], + }, + }, + ], + tool_count: 1, + }, + { + name: "filesystem", + source: "profile", + status: "delivered", + summary: "Connection delivered", + }, + ], + }, +}; + +function historyForServers(servers: MCPAttachmentServer[]): MCPAttachmentHistory { + return { + ...attachmentHistory, + current: { ...attachmentHistory.current, servers }, + }; +} + +function renderOpenExplorer(servers: MCPAttachmentServer[]) { + render( + server.name)} + attachmentHistory={historyForServers(servers)} + />, + ); + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); +} + +afterEach(() => cleanup()); + +beforeEach(() => { + responsiveMock.breakpoint = "desktop"; +}); + +describe("MCP server explorer", () => { + it("shows the rich status tooltip and one desktop close control", () => { + render( + , + ); + + const tooltip = screen.getByTestId("mcp-status-popover"); + expect(within(tooltip).getByText("kandev")).toBeTruthy(); + expect(within(tooltip).getByText("Active")).toBeTruthy(); + expect(within(tooltip).getByText("Connection delivered")).toBeTruthy(); + + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("data-testid")).toBe("mcp-server-explorer"); + expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength(1); + }); + + it("opens a tool page and returns focus to its list row", async () => { + render( + , + ); + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + expect(screen.getByText("create_task_kandev")).toBeTruthy(); + expect(screen.getByText("~42 tokens")).toBeTruthy(); + expect(screen.queryByText(CREATE_TASK_DESCRIPTION)).toBeNull(); + + screen.getByTestId("mcp-tool-list-scroll").scrollTop = 173; + fireEvent.click(screen.getByRole("button", { name: /create_task_kandev/ })); + expect(screen.getByText(CREATE_TASK_DESCRIPTION)).toBeTruthy(); + expect(screen.getByText("Short task title")).toBeTruthy(); + expect(screen.getByText("Required")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: BACK_TO_TOOLS })); + const toolRow = screen.getByRole("button", { name: /create_task_kandev/ }); + await waitFor(() => expect(document.activeElement).toBe(toolRow)); + expect(screen.getByTestId("mcp-tool-list-scroll").scrollTop).toBe(173); + + fireEvent.click(screen.getByTestId("mcp-server-row-filesystem")); + expect(screen.getByText(testCopy.thirdPartyCatalogUnavailable)).toBeTruthy(); + expect(screen.queryByText(CREATE_TASK_DESCRIPTION)).toBeNull(); + + fireEvent.click(screen.getByTestId("mcp-explorer-close")); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("keeps the selected server while live attachment evidence updates", () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + fireEvent.click(screen.getByTestId("mcp-server-row-filesystem")); + expect(screen.getByText(testCopy.thirdPartyCatalogUnavailable)).toBeTruthy(); + + const updatedHistory: MCPAttachmentHistory = { + ...attachmentHistory, + current: { + ...attachmentHistory.current, + servers: attachmentHistory.current.servers?.map((server) => + server.name === "filesystem" ? { ...server, status: "connected" } : server, + ), + }, + }; + rerender( + , + ); + + expect(screen.getByText(testCopy.thirdPartyCatalogUnavailable)).toBeTruthy(); + }); + + it("returns to the fallback server tools when the selected server disappears", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + fireEvent.click(screen.getByTestId("mcp-server-row-filesystem")); + expect(screen.getByText(testCopy.thirdPartyCatalogUnavailable)).toBeTruthy(); + + const kandev = attachmentHistory.current.servers?.[0]; + if (!kandev) throw new Error("missing test server"); + rerender( + , + ); + expect(screen.getByTestId("mcp-tool-row-create_task_kandev")).toBeTruthy(); + expect(screen.queryByTestId(MCP_TOOL_DETAIL_TEST_ID)).toBeNull(); + }); +}); + +describe("MCP explorer catalog states and touch navigation", () => { + it("explains an unloaded Kandev catalog", () => { + renderOpenExplorer([{ name: "kandev", source: "kandev", status: "unknown" }]); + expect(screen.getByText("The tool catalog is not loaded yet.")).toBeTruthy(); + }); + + it("explains an unavailable Kandev catalog", () => { + renderOpenExplorer([{ name: "kandev", source: "kandev", status: "failed" }]); + expect(screen.getByText("The Kandev tool catalog is unavailable.")).toBeTruthy(); + }); + + it("explains an empty Kandev catalog", () => { + renderOpenExplorer([ + { + name: "kandev", + source: "kandev", + status: "active", + tools_listed_at: TOOLS_OBSERVED_AT, + tools: [], + tool_count: 0, + }, + ]); + expect(screen.getByText("No tools were reported.")).toBeTruthy(); + }); + + it("explains a truncated Kandev catalog", () => { + renderOpenExplorer([ + { + name: "kandev", + source: "kandev", + status: "active", + tools_listed_at: TOOLS_OBSERVED_AT, + tools: [{ name: "create_task_kandev", description: CREATE_TASK_DESCRIPTION }], + tool_count: 129, + tool_catalog_truncated: true, + }, + ]); + expect(screen.getByText("Showing 1")).toBeTruthy(); + expect(screen.getByText("Only the first tools are shown.")).toBeTruthy(); + }); + + it("distinguishes tools without arguments from schemas removed by limits", () => { + renderOpenExplorer([ + { + name: "kandev", + source: "kandev", + status: "active", + tools_listed_at: TOOLS_OBSERVED_AT, + tools: [{ name: "no_arguments" }, { name: "large_schema", input_schema_truncated: true }], + }, + ]); + fireEvent.click(screen.getByRole("button", { name: /no_arguments/ })); + expect(screen.getByText("No arguments")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: BACK_TO_TOOLS })); + fireEvent.click(screen.getByRole("button", { name: /large_schema/ })); + expect(screen.getByText("Schema too large to display")).toBeTruthy(); + }); + + it("uses a phone server-to-tools-to-tool flow with two Back actions", () => { + responsiveMock.breakpoint = "mobile"; + render( + , + ); + + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + expect(screen.getByTestId("mcp-server-list")).toBeTruthy(); + fireEvent.click(screen.getByTestId("mcp-server-row-kandev")); + expect(screen.getByTestId("mcp-tool-list")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Back to servers" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /create_task_kandev/ })); + expect(screen.getByTestId(MCP_TOOL_DETAIL_TEST_ID)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: BACK_TO_TOOLS })); + expect(screen.getByTestId("mcp-tool-list")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Back to servers" })); + expect(screen.getByTestId("mcp-server-list")).toBeTruthy(); + }); + + it("returns to the tools page when live evidence removes the selected tool", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId(MCP_STATUS_TRIGGER_TEST_ID)); + fireEvent.click(screen.getByRole("button", { name: /create_task_kandev/ })); + expect(screen.getByTestId(MCP_TOOL_DETAIL_TEST_ID)).toBeTruthy(); + + const kandev = attachmentHistory.current.servers?.[0]; + if (!kandev) throw new Error("missing test server"); + rerender( + , + ); + expect(screen.queryByTestId(MCP_TOOL_DETAIL_TEST_ID)).toBeNull(); + expect(screen.getByTestId("mcp-tool-list")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.tsx new file mode 100644 index 0000000000..6ea7b1503c --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-server-explorer.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { Dialog, DialogContent, DialogTrigger } from "@kandev/ui/dialog"; +import { Drawer, DrawerContent, DrawerTrigger } from "@kandev/ui/drawer"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { MCPAttachmentServer } from "@/lib/state/slices/session-runtime/types"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { McpExplorerHeader } from "./mcp-explorer-header"; +import { McpServerList } from "./mcp-server-list"; +import { McpStatusTooltip } from "./mcp-status-presentation"; +import { McpToolDetail } from "./mcp-tool-detail"; +import { McpToolList } from "./mcp-tool-list"; +import { useMcpExplorerNavigation } from "./use-mcp-explorer-navigation"; + +type Navigation = ReturnType; + +function ToolPages({ navigation, touch }: { navigation: Navigation; touch: boolean }) { + const { t } = useTranslation(); + if (!navigation.server) { + return ( +
+ {t("task:mcpNoServers")} +
+ ); + } + if (navigation.page === "tool" && navigation.tool) { + return ; + } + return ( + navigation.selectTool(name)} + onBack={touch ? navigation.backToServers : undefined} + /> + ); +} + +function ExplorerBody({ + servers, + navigation, + touch, +}: { + servers: MCPAttachmentServer[]; + navigation: Navigation; + touch: boolean; +}) { + if (touch && navigation.page === "servers") { + return ( +
+ +
+ ); + } + if (touch) return ; + return ( +
+
+ +
+
+ +
+
+ ); +} + +function DesktopExplorer({ + trigger, + servers, + open, + setOpen, + navigation, +}: { + trigger: ReactNode; + servers: MCPAttachmentServer[]; + open: boolean; + setOpen: (open: boolean) => void; + navigation: Navigation; +}) { + return ( + + + + {trigger} + + + + + + + setOpen(false)} mobile={false} /> +
+ +
+
+
+ ); +} + +export function McpServerExplorer({ + trigger, + servers, +}: { + trigger: ReactNode; + servers: MCPAttachmentServer[]; +}) { + const { isMobile, isFinePointer } = useResponsiveBreakpoint(); + const touch = isMobile || !isFinePointer; + const [open, setOpen] = useState(false); + const navigation = useMcpExplorerNavigation({ servers, open, touch }); + if (!touch) { + return ( + + ); + } + return ( + + {trigger} + + setOpen(false)} mobile /> +
+ +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-server-list.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-server-list.tsx new file mode 100644 index 0000000000..cf30e482f6 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-server-list.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { IconCircle, IconCircleCheck, IconCircleX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import type { MCPAttachmentServer } from "@/lib/state/slices/session-runtime/types"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { mcpStatusLabelKey } from "./mcp-explorer-view-model"; +import { McpStatusDot } from "./mcp-status-presentation"; + +function mcpStatusIcon(status: MCPAttachmentServer["status"]) { + switch (status) { + case "failed": + return IconCircleX; + case "active": + return IconCircleCheck; + default: + return IconCircle; + } +} + +function mcpStatusIconClass(status: MCPAttachmentServer["status"]) { + switch (status) { + case "failed": + return "text-destructive"; + case "active": + return "text-emerald-500"; + default: + return "text-muted-foreground"; + } +} + +export function McpServerList({ + servers, + selectedName, + onSelect, +}: { + servers: MCPAttachmentServer[]; + selectedName: string | null; + onSelect: (name: string) => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+ {t("task:mcpServerList")} +
+ {servers.length === 0 ? ( +

{t("task:mcpNoServers")}

+ ) : ( +
+ {servers.map((server) => { + const selected = server.name === selectedName; + const StatusIcon = mcpStatusIcon(server.status); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-status-presentation.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-status-presentation.tsx new file mode 100644 index 0000000000..1f3949192d --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-status-presentation.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { MCPAttachmentServer } from "@/lib/state/slices/session-runtime/types"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; +import { mcpStatusLabelKey } from "./mcp-explorer-view-model"; + +export const MCP_STATUS_DOT_CLASS: Record = { + active: "bg-emerald-500", + connected: "bg-amber-500", + delivered: "bg-amber-500", + failed: "bg-destructive", + filtered: "bg-muted-foreground/50", + unavailable: "bg-muted-foreground/50", + unknown: "bg-muted-foreground/50", +}; + +export function McpStatusDot({ + status, + className, +}: { + status: MCPAttachmentServer["status"]; + className?: string; +}) { + return ( +