diff --git a/apps/backend/internal/agent/mcpconfig/resolve_effective.go b/apps/backend/internal/agent/mcpconfig/resolve_effective.go index 6926811b59b..4f1db5d6c71 100644 --- a/apps/backend/internal/agent/mcpconfig/resolve_effective.go +++ b/apps/backend/internal/agent/mcpconfig/resolve_effective.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" ) @@ -107,9 +108,15 @@ func (r *Resolver) Resolve(ctx context.Context, resolutionContext ResolutionCont ids = append(ids, id) } sort.Strings(ids) - result := &EffectiveMCPResolution{Setup: setupFromDefinitions(inputs.Definitions), DesiredRevision: inputs.DesiredRevision} + result := &EffectiveMCPResolution{Setup: setupFromInputs(inputs), DesiredRevision: inputs.DesiredRevision} names := make(map[string]string, len(ids)) for _, id := range ids { + if slices.Contains(inputs.ExcludedDefinitionIDs, id) { + if definition := inputs.Definitions[id]; definition != nil { + result.Decisions = append(result.Decisions, definitionDecision(definition, "session_excluded", "MCP definition is excluded from this session", origins[id])) + } + continue + } resolved, decision, err := r.resolveDefinition(ctx, resolutionContext, policy, id, origins[id], legacyServers, names, inputs.Definitions[id]) if err != nil { return nil, err diff --git a/apps/backend/internal/agent/mcpconfig/selection_write.go b/apps/backend/internal/agent/mcpconfig/selection_write.go index ad02b7cb6ec..7b491f654f8 100644 --- a/apps/backend/internal/agent/mcpconfig/selection_write.go +++ b/apps/backend/internal/agent/mcpconfig/selection_write.go @@ -14,6 +14,8 @@ type SelectionCondition struct { ValidateAdditions func(context.Context) error ExpectedDefinitionIDs *[]string ExpectedRevision *int64 + ExcludedDefinitionIDs *[]string + ExclusionContext *ResolutionContext } func (c SelectionCondition) Validate(scope SelectionScope) error { @@ -24,6 +26,9 @@ func (c SelectionCondition) Validate(scope SelectionScope) error { } else if c.ExpectedRevision != nil { return ErrMCPInvalidSelection } + if c.ExcludedDefinitionIDs != nil && (scope != SelectionScopeTaskSession || c.ExpectedRevision == nil) { + return ErrMCPInvalidSelection + } if c.ExpectedRevision != nil && *c.ExpectedRevision < 0 { return ErrMCPInvalidSelection } @@ -33,8 +38,9 @@ func (c SelectionCondition) Validate(scope SelectionScope) error { // SelectionSnapshot belongs to one read or committed replacement. State never // comes from a subsequent reread of another writer's selection. type SelectionSnapshot struct { - DefinitionIDs []string - State *SessionMCPSelectionState + DefinitionIDs []string + ExcludedDefinitionIDs []string + State *SessionMCPSelectionState } type ConditionalSelectionRepository interface { @@ -89,8 +95,11 @@ func (s *SelectionService) replaceConditional(ctx context.Context, origin Select if err := s.validateContext(ctx, origin.Scope, origin.WorkspaceID, origin.OwnerID); err != nil { return SelectionSnapshot{}, err } + if err := s.prepareExclusionCondition(ctx, origin, &condition); err != nil { + return SelectionSnapshot{}, err + } if repo, ok := s.repo.(ConditionalSelectionRepository); ok { - snapshot, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, uniqueSelectionIDs(ids), condition) + snapshot, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, ids, condition) if err != nil { return snapshot, err } @@ -100,7 +109,7 @@ func (s *SelectionService) replaceConditional(ctx context.Context, origin Select return s.safeSelectionSnapshot(ctx, origin, snapshot), nil } // Adapters without transactional admission may serve legacy callers only. - if condition.ExpectedDefinitionIDs != nil || condition.ExpectedRevision != nil { + if condition.ExpectedDefinitionIDs != nil || condition.ExpectedRevision != nil || condition.ExcludedDefinitionIDs != nil { return SelectionSnapshot{}, ErrMCPInvalidSelection } if err := s.Replace(ctx, origin.Scope, origin.WorkspaceID, origin.OwnerID, ids); err != nil { @@ -121,3 +130,18 @@ func (s *SelectionService) safeSelectionSnapshot(ctx context.Context, origin Sel } return snapshot } + +func (s *SelectionService) prepareExclusionCondition(ctx context.Context, origin SelectionOrigin, condition *SelectionCondition) error { + if condition.ExcludedDefinitionIDs == nil { + return nil + } + if s.resolutionContext == nil { + return ErrMCPInvalidSelection + } + rc, err := s.resolutionContext(ctx, origin.WorkspaceID, origin.OwnerID) + if err != nil || rc.WorkspaceID != origin.WorkspaceID || rc.SessionID != origin.OwnerID { + return ErrMCPInvalidSelection + } + condition.ExclusionContext = &rc + return nil +} diff --git a/apps/backend/internal/agent/mcpconfig/session_exclusions_test.go b/apps/backend/internal/agent/mcpconfig/session_exclusions_test.go new file mode 100644 index 00000000000..80bf41ca4bc --- /dev/null +++ b/apps/backend/internal/agent/mcpconfig/session_exclusions_test.go @@ -0,0 +1,37 @@ +package mcpconfig + +import ( + "context" + "testing" +) + +type sessionExclusionInputs struct { + *selectionRepositoryFake + inputs *ResolutionInputs +} + +func (r *sessionExclusionInputs) ReadMCPResolutionInputs(context.Context, ResolutionContext) (*ResolutionInputs, error) { + return r.inputs, nil +} + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.7, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6 +func TestSessionExclusionKeepsLegacyAliasSuppressed(t *testing.T) { + ctx := context.Background() + catalogRepo := newCatalogRepositoryFake() + catalog := NewCatalogService(catalogRepo) + def, err := catalog.Create(ctx, CreateDefinitionInput{WorkspaceID: "workspace", RuntimeName: "github", DisplayName: "GitHub", ExecutionMode: ExecutionModeExistingExecutable, Transport: ServerTypeStdio, Configuration: MCPServerConfiguration{Command: "catalog-command"}}) + if err != nil { + t.Fatal(err) + } + origin := SelectionOrigin{Scope: SelectionScopeProfile, WorkspaceID: "workspace", OwnerID: "profile"} + repo := &sessionExclusionInputs{selectionRepositoryFake: newSelectionRepositoryFake(), inputs: &ResolutionInputs{Origins: map[string][]SelectionOrigin{def.ID: {origin}}, Definitions: map[string]*MCPServerDefinition{def.ID: def}, ExcludedDefinitionIDs: []string{def.ID}}} + resolver := NewResolver(catalogRepo, repo) + resolver.SetLegacyProvider(&legacyConfigReaderFake{config: &ProfileConfig{Enabled: true, Servers: map[string]ServerDef{"GitHub": {Type: ServerTypeStdio, Command: "legacy-command"}, "independent": {Type: ServerTypeStdio, Command: "independent"}}}}, nil) + result, err := resolver.Resolve(ctx, ResolutionContext{WorkspaceID: "workspace", ProfileID: "profile", SessionID: "session"}, Policy{AllowStdio: true}) + if err != nil { + t.Fatal(err) + } + if len(result.Servers) != 1 || result.Servers[0].Name != "independent" || len(result.Decisions) != 1 || result.Decisions[0].ReasonCode != "session_excluded" { + t.Fatalf("legacy fallback restored excluded alias or lost independent config: %#v", result) + } +} diff --git a/apps/backend/internal/agent/mcpconfig/session_setup_comparison.go b/apps/backend/internal/agent/mcpconfig/session_setup_comparison.go index c7785b2191c..6ad0a123b8c 100644 --- a/apps/backend/internal/agent/mcpconfig/session_setup_comparison.go +++ b/apps/backend/internal/agent/mcpconfig/session_setup_comparison.go @@ -32,7 +32,8 @@ func (s *SelectionService) sessionSetupComparison(ctx context.Context, workspace unavailable.Reason = "snapshot_changed" return unavailable } - comparison := CompareMCPSetups(state, setupFromDefinitions(inputs.Definitions)) + comparison := CompareMCPSetups(state, setupFromInputs(inputs)) comparison.SelectionIDs = inputs.SessionSelectionIDs + comparison.ExcludedDefinitionIDs = inputs.ExcludedDefinitionIDs return boundedMCPComparison(comparison) } diff --git a/apps/backend/internal/agent/mcpconfig/setup_comparison.go b/apps/backend/internal/agent/mcpconfig/setup_comparison.go index a6ae27c8da3..6eb7ae84e08 100644 --- a/apps/backend/internal/agent/mcpconfig/setup_comparison.go +++ b/apps/backend/internal/agent/mcpconfig/setup_comparison.go @@ -43,11 +43,12 @@ func CompareMCPSetups(state SessionMCPSelectionState, desired *MCPSetupSnapshot) func boundedMCPComparison(result *MCPSetupComparison) *MCPSetupComparison { encoded, err := json.Marshal(result) - if len(result.Changes) > maxMCPSetupServers || err != nil || len(encoded) > maxMCPSetupBytes { + if len(result.Changes) > maxMCPSetupServers || len(result.ExcludedDefinitionIDs) > maxMCPSetupServers || err != nil || len(encoded) > maxMCPSetupBytes { result.Status = comparisonStatusUnavailable result.Reason = "comparison_too_large" result.Changes = nil result.SelectionIDs = nil + result.ExcludedDefinitionIDs = nil } return result } diff --git a/apps/backend/internal/agent/mcpconfig/setup_snapshot.go b/apps/backend/internal/agent/mcpconfig/setup_snapshot.go index 6de90379cfb..e36f9c8ec19 100644 --- a/apps/backend/internal/agent/mcpconfig/setup_snapshot.go +++ b/apps/backend/internal/agent/mcpconfig/setup_snapshot.go @@ -3,6 +3,7 @@ package mcpconfig import ( "context" "encoding/json" + "slices" "sort" ) @@ -30,22 +31,24 @@ type MCPSetupChange struct { } type MCPSetupComparison struct { - Version int `json:"version"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - DesiredRevision int64 `json:"desired_revision"` - AppliedRevision int64 `json:"applied_revision"` - AttachmentAttemptID string `json:"attachment_attempt_id,omitempty"` - SelectionIDs []string `json:"selection_ids,omitempty"` - Changes []MCPSetupChange `json:"changes,omitempty"` + Version int `json:"version"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + DesiredRevision int64 `json:"desired_revision"` + AppliedRevision int64 `json:"applied_revision"` + AttachmentAttemptID string `json:"attachment_attempt_id,omitempty"` + ExcludedDefinitionIDs []string `json:"excluded_definition_ids,omitempty"` + SelectionIDs []string `json:"selection_ids,omitempty"` + Changes []MCPSetupChange `json:"changes,omitempty"` } // ResolutionInputs is an immutable catalog/selection read, never a wire payload. type ResolutionInputs struct { - Origins map[string][]SelectionOrigin - Definitions map[string]*MCPServerDefinition - DesiredRevision int64 - SessionSelectionIDs []string + Origins map[string][]SelectionOrigin + Definitions map[string]*MCPServerDefinition + DesiredRevision int64 + SessionSelectionIDs []string + ExcludedDefinitionIDs []string } type ResolutionInputsRepository interface { @@ -85,3 +88,14 @@ func setupFromDefinitions(definitions map[string]*MCPServerDefinition) *MCPSetup } return setup } + +// setupFromInputs uses the same exclusion boundary for delivery and comparison. +func setupFromInputs(inputs *ResolutionInputs) *MCPSetupSnapshot { + definitions := make(map[string]*MCPServerDefinition, len(inputs.Definitions)) + for id, definition := range inputs.Definitions { + if !slices.Contains(inputs.ExcludedDefinitionIDs, id) { + definitions[id] = definition + } + } + return setupFromDefinitions(definitions) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_reconfiguration_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_reconfiguration_test.go index 1e6f8e4c206..40436990c3e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_reconfiguration_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_mcp_reconfiguration_test.go @@ -295,7 +295,7 @@ func newSessionMCPClient(t *testing.T, failResume, supportsResume, supportsLoad return newSessionMCPClientWithHooks(t, failResume, supportsResume, supportsLoad, beforeRequest) } -func newSessionMCPClientWithHooks(t *testing.T, failResume, supportsResume, supportsLoad bool, beforeRequest func(string)) (*agentctl.Client, <-chan string, func()) { +func newSessionMCPClientWithHooks(t *testing.T, failResume, supportsResume, supportsLoad bool, beforeRequest func(string), observe ...func(ws.Message)) (*agentctl.Client, <-chan string, func()) { t.Helper() actions := make(chan string, 8) upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} @@ -315,6 +315,9 @@ func newSessionMCPClientWithHooks(t *testing.T, failResume, supportsResume, supp continue } actions <- msg.Action + for _, observer := range observe { + observer(msg) + } if beforeRequest != nil { beforeRequest(msg.Action) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/mcp_session_exclusions_test.go b/apps/backend/internal/agent/runtime/lifecycle/mcp_session_exclusions_test.go new file mode 100644 index 00000000000..dafcb31403f --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/mcp_session_exclusions_test.go @@ -0,0 +1,108 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/agentctl/types" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.1, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.3, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.4 +func TestMCPSessionExclusionIdleAcceptanceAndFailure(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "accept", true: "failure"}[fail], func(t *testing.T) { testSessionExclusionIdle(t, fail) }) + } +} + +func testSessionExclusionIdle(t *testing.T, fail bool) { + ctx := context.Background() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + db.SetMaxOpenConns(1) + repo, _, err := store.Provide(db, db, nil) + if err != nil { + t.Fatal(err) + } + def := &mcpconfig.MCPServerDefinition{ID: "docs", WorkspaceID: "workspace-1", RuntimeName: "docs", NormalizedRuntimeName: "docs", DisplayName: "Docs", Enabled: true, ExecutionMode: "remote", Transport: "http", Configuration: mcpconfig.MCPServerConfiguration{URL: "https://example.test/mcp"}, Revision: 1} + if err := repo.CreateMCPServerDefinition(ctx, def); err != nil { + t.Fatal(err) + } + if err := repo.ReplaceMCPSelections(ctx, mcpconfig.SelectionScopeTask, def.WorkspaceID, "task-1", []string{def.ID}); err != nil { + t.Fatal(err) + } + baseline := &mcpconfig.MCPSetupSnapshot{Version: 1, Servers: []mcpconfig.MCPSetupServer{{DefinitionID: def.ID, Revision: 1, Enabled: true, DisplayName: def.DisplayName, RuntimeName: def.RuntimeName}}} + if err := repo.SaveAcceptedMCPSetup(ctx, "session-1", 1, "before", baseline); err != nil { + t.Fatal(err) + } + rc := mcpconfig.ResolutionContext{WorkspaceID: def.WorkspaceID, SessionID: "session-1", TaskID: "task-1"} + ids := []string{def.ID} + revision := int64(1) + if _, err := repo.ReplaceMCPSelectionsConditional(ctx, mcpconfig.SelectionOrigin{Scope: mcpconfig.SelectionScopeTaskSession, WorkspaceID: rc.WorkspaceID, OwnerID: rc.SessionID}, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &ids, ExclusionContext: &rc}); err != nil { + t.Fatal(err) + } + captured := make(chan json.RawMessage, 2) + client, actions, cleanup := newSessionMCPClientWithHooks(t, fail, true, false, nil, func(msg ws.Message) { + if msg.Action == "agent.session.resume" { + captured <- msg.Payload + } + }) + defer cleanup() + <-actions // initialization is independent of applying a saved exclusion + mgr := newTestManager(t) + mgr.mcpResolver = mcpconfig.NewResolver(repo, repo) + mgr.SetMCPSelectionStateRepository(repo) + execution := newSessionMCPExecution(client) + execution.Status = v1.AgentStatusRunning + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatal(err) + } + if err := mgr.applyPendingSessionMCP(ctx, execution.SessionID); err != nil { + t.Fatal(err) + } + select { + case action := <-actions: + t.Fatalf("active turn reconfigured: %s", action) + default: + } + execution.Status = v1.AgentStatusReady + applyErr := mgr.applyPendingSessionMCP(ctx, execution.SessionID) + if !fail && applyErr != nil { + t.Fatal(applyErr) + } + var payload struct { + Servers []types.McpServer `json:"mcp_servers"` + } + select { + case raw := <-captured: + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + default: + t.Fatal("idle application did not reach provider") + } + for _, server := range payload.Servers { + if server.Name == "docs" { + t.Fatal("excluded server delivered") + } + } + state, err := repo.GetMCPSelectionState(ctx, execution.SessionID) + if err != nil { + t.Fatal(err) + } + if fail { + if state.ApplyState != mcpconfig.SessionMCPApplyStateFailed || state.AppliedRevision != 1 || state.AcceptedSetup.Servers[0].DefinitionID != "docs" { + t.Fatalf("failure lost baseline: %#v", state) + } + } else if state.AppliedRevision != 2 || state.AcceptedSetup == nil || len(state.AcceptedSetup.Servers) != 0 { + t.Fatalf("wrong accepted setup: %#v", state) + } +} diff --git a/apps/backend/internal/agent/settings/handlers/mcp_selection_concurrency_test.go b/apps/backend/internal/agent/settings/handlers/mcp_selection_concurrency_test.go index 9c26b3a109d..f8216e5efae 100644 --- a/apps/backend/internal/agent/settings/handlers/mcp_selection_concurrency_test.go +++ b/apps/backend/internal/agent/settings/handlers/mcp_selection_concurrency_test.go @@ -38,6 +38,9 @@ func selectionRouter(t *testing.T) (*gin.Engine, *int) { } service := mcpconfig.NewSelectionService(repo, repo) service.SetSessionMCPStateRepository(repo) + service.SetSessionResolutionContext(func(_ context.Context, workspaceID, sessionID string) (mcpconfig.ResolutionContext, error) { + return mcpconfig.ResolutionContext{WorkspaceID: workspaceID, SessionID: sessionID, RepositoryIDs: []string{"r"}}, nil + }) notifications := 0 service.SetSessionMCPChangeNotifier(func(context.Context, string) { notifications++ }) h := NewHandlers(nil, nil, log, "") diff --git a/apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go b/apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go index 798f9a1d12a..7a81c92a448 100644 --- a/apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go +++ b/apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go @@ -15,6 +15,7 @@ import ( type mcpSelectionRequest struct { WorkspaceID string `json:"workspace_id"` ExpectedDefinitionIDs json.RawMessage `json:"expected_definition_ids"` + ExcludedDefinitionIDs json.RawMessage `json:"excluded_definition_ids"` ExpectedRevision json.RawMessage `json:"expected_revision"` ExpectedExcludedDefinitionIDs json.RawMessage `json:"expected_excluded_definition_ids"` DefinitionIDs []string `json:"definition_ids"` @@ -22,13 +23,14 @@ type mcpSelectionRequest struct { } type mcpSelectionResponse struct { - LegacyImportStatus string `json:"legacy_import_status,omitempty"` - WorkspaceID string `json:"workspace_id"` - Scope mcpconfig.SelectionScope `json:"scope"` - OwnerID string `json:"owner_id"` - DefinitionIDs []string `json:"definition_ids"` - SelectionRevision *int64 `json:"selection_revision,omitempty"` - MCPState *mcpconfig.SessionMCPSelectionState `json:"mcp_state,omitempty"` + LegacyImportStatus string `json:"legacy_import_status,omitempty"` + WorkspaceID string `json:"workspace_id"` + Scope mcpconfig.SelectionScope `json:"scope"` + OwnerID string `json:"owner_id"` + DefinitionIDs []string `json:"definition_ids"` + ExcludedDefinitionIDs *[]string `json:"excluded_definition_ids,omitempty"` + SelectionRevision *int64 `json:"selection_revision,omitempty"` + MCPState *mcpconfig.SessionMCPSelectionState `json:"mcp_state,omitempty"` } func (h *Handlers) httpListProfileMCPSelections(c *gin.Context) { @@ -154,6 +156,13 @@ func selectionCondition(body mcpSelectionRequest, scope mcpconfig.SelectionScope } condition.ExpectedRevision = revision } + if body.ExcludedDefinitionIDs != nil { + var ids []string + if err := json.Unmarshal(body.ExcludedDefinitionIDs, &ids); err != nil || ids == nil { + return condition, mcpconfig.ErrMCPInvalidSelection + } + condition.ExcludedDefinitionIDs = &ids + } return condition, condition.Validate(scope) } @@ -168,6 +177,11 @@ func (h *Handlers) writeMCPSelectionResponse(c *gin.Context, workspaceID string, revision = snapshot.State.DesiredRevision } response.SelectionRevision = &revision + ids := snapshot.ExcludedDefinitionIDs + if ids == nil { + ids = []string{} + } + response.ExcludedDefinitionIDs = &ids } c.JSON(http.StatusOK, response) } diff --git a/apps/backend/internal/agent/settings/handlers/mcp_session_exclusions_test.go b/apps/backend/internal/agent/settings/handlers/mcp_session_exclusions_test.go new file mode 100644 index 00000000000..39301d0b33a --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/mcp_session_exclusions_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" +) + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.1, AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6 +func TestMCPSessionExclusionRoundTrip(t *testing.T) { + router, notifications := selectionRouter(t) + inherited := performCatalogRequest(router, http.MethodPut, "/repositories/r", `{"workspace_id":"w","definition_ids":["docs"]}`, "") + if inherited.Code != http.StatusOK { + t.Fatal(inherited.Body.String()) + } + saved := performCatalogRequest(router, http.MethodPut, "/sessions/s", `{"workspace_id":"w","definition_ids":["other"],"excluded_definition_ids":["docs"],"expected_revision":0}`, "") + if saved.Code != http.StatusOK { + t.Fatal(saved.Body.String()) + } + var body struct { + Excluded []string `json:"excluded_definition_ids"` + IDs []string `json:"definition_ids"` + Revision int64 `json:"selection_revision"` + } + if err := json.Unmarshal(saved.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Excluded) != 1 || body.Excluded[0] != "docs" { + t.Fatalf("exclusion missing from saved result: %s", saved.Body.String()) + } + legacy := performCatalogRequest(router, http.MethodPut, "/sessions/s", `{"workspace_id":"w","definition_ids":["other"]}`, "") + if legacy.Code != http.StatusOK { + t.Fatal(legacy.Body.String()) + } + read := performCatalogRequest(router, http.MethodGet, "/sessions/s?workspace_id=w", "", "") + if err := json.Unmarshal(read.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Excluded) != 1 || body.Revision != 2 { + t.Fatalf("legacy save lost exclusion: %s", read.Body.String()) + } + restored := performCatalogRequest(router, http.MethodPut, "/sessions/s", `{"workspace_id":"w","excluded_definition_ids":[],"expected_revision":2}`, "") + if restored.Code != http.StatusOK { + t.Fatal(restored.Body.String()) + } + if err := json.Unmarshal(restored.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Excluded) != 0 || len(body.IDs) != 1 || body.IDs[0] != "other" || body.Revision != 3 { + t.Fatalf("restore must preserve additions: %s", restored.Body.String()) + } + sibling := performCatalogRequest(router, http.MethodGet, "/sessions/sibling?workspace_id=w", "", "") + if err := json.Unmarshal(sibling.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Excluded) != 0 || *notifications != 3 { + t.Fatalf("session isolation/notification failed: %s count=%d", sibling.Body.String(), *notifications) + } +} + +func TestMCPSessionExclusionInvalidWrite(t *testing.T) { + for _, tc := range []struct{ path, fields string }{ + {"/sessions/s", `"excluded_definition_ids":["docs"]`}, + {"/sessions/s", `"excluded_definition_ids":null,"expected_revision":0`}, + {"/sessions/s", `"excluded_definition_ids":["other"],"expected_revision":0`}, + {"/sessions/s", `"excluded_definition_ids":["missing"],"expected_revision":0`}, + {"/repositories/r", `"excluded_definition_ids":[],"expected_definition_ids":[]`}, + } { + t.Run(tc.path+tc.fields, func(t *testing.T) { + router, notifications := selectionRouter(t) + response := performCatalogRequest(router, http.MethodPut, tc.path, `{"workspace_id":"w","definition_ids":["docs"],`+tc.fields+`}`, "") + if response.Code != http.StatusBadRequest || *notifications != 0 { + t.Fatalf("rejected exclusion mutated state: %d %s notifications=%d", response.Code, response.Body.String(), *notifications) + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/store/mcp_catalog.go b/apps/backend/internal/agent/settings/store/mcp_catalog.go index feed1dd3609..eb59ee717c8 100644 --- a/apps/backend/internal/agent/settings/store/mcp_catalog.go +++ b/apps/backend/internal/agent/settings/store/mcp_catalog.go @@ -115,18 +115,7 @@ func (r *sqliteRepository) UpdateMCPServerDefinition(ctx context.Context, defini } func (r *sqliteRepository) DeleteMCPServerDefinition(ctx context.Context, workspaceID, id string, expectedRevision int64) error { - result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM mcp_server_definitions WHERE workspace_id = ? AND id = ? AND revision = ?`), workspaceID, id, expectedRevision) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil { - return err - } - if rows > 0 { - return nil - } - return r.mcpRevisionOrNotFound(ctx, workspaceID, id) + return r.DeleteMCPServerDefinitionWithSelections(ctx, workspaceID, id, expectedRevision) } func (r *sqliteRepository) mcpRevisionOrNotFound(ctx context.Context, workspaceID, id string) error { diff --git a/apps/backend/internal/agent/settings/store/mcp_resolution_inputs.go b/apps/backend/internal/agent/settings/store/mcp_resolution_inputs.go index 369454063cc..1f2db9e75eb 100644 --- a/apps/backend/internal/agent/settings/store/mcp_resolution_inputs.go +++ b/apps/backend/internal/agent/settings/store/mcp_resolution_inputs.go @@ -51,12 +51,10 @@ func (r *sqliteRepository) ReadMCPResolutionInputs(ctx context.Context, rc mcpco } inputs.Definitions[id] = d } - if rc.SessionID != "" { - err = tx.QueryRowxContext(ctx, tx.Rebind(`SELECT desired_revision FROM mcp_task_session_apply_state WHERE task_session_id = ?`), rc.SessionID).Scan(&inputs.DesiredRevision) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } + if err := readMCPSessionResolutionInputs(ctx, tx, rc, inputs); err != nil { + return nil, err } + return inputs, tx.Commit() } @@ -84,3 +82,19 @@ func mcpResolutionReadOptions(driver string) *sql.TxOptions { } return options } + +func readMCPSessionResolutionInputs(ctx context.Context, tx *sqlx.Tx, rc mcpconfig.ResolutionContext, inputs *mcpconfig.ResolutionInputs) error { + if rc.SessionID == "" { + return nil + } + var err error + inputs.ExcludedDefinitionIDs, err = readMCPSessionExclusionsTx(ctx, tx, rc.WorkspaceID, rc.SessionID) + if err != nil { + return err + } + err = tx.QueryRowxContext(ctx, tx.Rebind(`SELECT desired_revision FROM mcp_task_session_apply_state WHERE task_session_id = ?`), rc.SessionID).Scan(&inputs.DesiredRevision) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err +} diff --git a/apps/backend/internal/agent/settings/store/mcp_selection_write.go b/apps/backend/internal/agent/settings/store/mcp_selection_write.go index 6615bf5eab4..e9a69a0ad2c 100644 --- a/apps/backend/internal/agent/settings/store/mcp_selection_write.go +++ b/apps/backend/internal/agent/settings/store/mcp_selection_write.go @@ -34,6 +34,10 @@ func readMCPSelectionSnapshotTx(ctx context.Context, tx *sqlx.Tx, origin mcpconf if err != nil || origin.Scope != mcpconfig.SelectionScopeTaskSession { return snapshot, err } + snapshot.ExcludedDefinitionIDs, err = readMCPSessionExclusionsTx(ctx, tx, origin.WorkspaceID, origin.OwnerID) + if err != nil { + return snapshot, err + } state, err := readMCPSelectionState(ctx, tx, origin.OwnerID) if errors.Is(err, mcpconfig.ErrMCPSelectionStateNotFound) { return snapshot, nil @@ -125,18 +129,10 @@ func (r *sqliteRepository) ReplaceMCPSelectionsConditional(ctx context.Context, if err := checkMCPSelectionCondition(previous, condition); err != nil { return result, err } - ids = normalizedMCPSelectionIDs(ids) - if slices.ContainsFunc(ids, func(id string) bool { return !slices.Contains(previous.DefinitionIDs, id) }) { - if err := validateMCPSelectionAdditionOwner(ctx, tx, origin, condition.ValidateAdditions); err != nil { - return result, err - } - } - if err := validateMCPSelectionDefinitions(ctx, tx, origin.WorkspaceID, ids, previous.DefinitionIDs); err != nil { - return result, err - } - if err := replaceMCPSelectionsTx(ctx, tx, origin.Scope, origin.WorkspaceID, origin.OwnerID, ids); err != nil { + if err := writeMCPSelectionSets(ctx, tx, origin, previous, ids, condition); err != nil { return result, err } + if origin.Scope == mcpconfig.SelectionScopeTaskSession { if err := advanceMCPSelectionRevision(ctx, tx, origin.OwnerID); err != nil { return result, err @@ -195,3 +191,28 @@ func lockMCPDefinition(ctx context.Context, tx *sqlx.Tx, workspaceID, id string) } return err } + +func writeMCPSelectionSets(ctx context.Context, tx *sqlx.Tx, origin mcpconfig.SelectionOrigin, previous mcpconfig.SelectionSnapshot, ids []string, condition mcpconfig.SelectionCondition) error { + if condition.ExcludedDefinitionIDs != nil && ids == nil { + ids = previous.DefinitionIDs + } + ids = normalizedMCPSelectionIDs(ids) + if err := lockMCPSelectionDefinitions(ctx, tx, origin.WorkspaceID, ids, condition.ExcludedDefinitionIDs); err != nil { + return err + } + if err := replaceMCPSessionExclusionsTx(ctx, tx, origin, previous.ExcludedDefinitionIDs, condition); err != nil { + return err + } + if slices.ContainsFunc(ids, func(id string) bool { return !slices.Contains(previous.DefinitionIDs, id) }) { + if err := validateMCPSelectionAdditionOwner(ctx, tx, origin, condition.ValidateAdditions); err != nil { + return err + } + } + if err := validateMCPSelectionDefinitions(ctx, tx, origin.WorkspaceID, ids, previous.DefinitionIDs); err != nil { + return err + } + if err := replaceMCPSelectionsTx(ctx, tx, origin.Scope, origin.WorkspaceID, origin.OwnerID, ids); err != nil { + return err + } + return nil +} diff --git a/apps/backend/internal/agent/settings/store/mcp_selections.go b/apps/backend/internal/agent/settings/store/mcp_selections.go index dee12292dbe..cceb7c90b97 100644 --- a/apps/backend/internal/agent/settings/store/mcp_selections.go +++ b/apps/backend/internal/agent/settings/store/mcp_selections.go @@ -182,6 +182,9 @@ func (r *sqliteRepository) DeleteMCPTaskData(ctx context.Context, taskID string, continue } seen[sessionID] = struct{}{} + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM task_session_mcp_exclusions WHERE task_session_id = ?`), sessionID); err != nil { + return err + } if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM task_session_mcp_selections WHERE task_session_id = ?`), sessionID); err != nil { return err } @@ -205,6 +208,8 @@ func (r *sqliteRepository) DeleteMCPWorkspaceData(ctx context.Context, workspace } defer func() { _ = tx.Rollback() }() queries := []string{ + `DELETE FROM mcp_task_session_apply_state WHERE task_session_id IN (SELECT task_session_id FROM task_session_mcp_exclusions WHERE workspace_id = ?)`, + `DELETE FROM task_session_mcp_exclusions WHERE workspace_id = ?`, `DELETE FROM workspace_agent_profile_mcp_selections WHERE workspace_id = ? OR mcp_server_id IN (SELECT id FROM mcp_server_definitions WHERE workspace_id = ?)`, `DELETE FROM repository_mcp_selections WHERE mcp_server_id IN (SELECT id FROM mcp_server_definitions WHERE workspace_id = ?)`, `DELETE FROM task_mcp_selections WHERE mcp_server_id IN (SELECT id FROM mcp_server_definitions WHERE workspace_id = ?)`, @@ -362,6 +367,7 @@ func deleteMCPSelectionRows(ctx context.Context, tx *sqlx.Tx, definitionID strin "repository_mcp_selections", "task_mcp_selections", "task_session_mcp_selections", + "task_session_mcp_exclusions", } { if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM `+table+` WHERE mcp_server_id = ?`), definitionID); err != nil { return err diff --git a/apps/backend/internal/agent/settings/store/mcp_session_exclusions.go b/apps/backend/internal/agent/settings/store/mcp_session_exclusions.go new file mode 100644 index 00000000000..1605620e6e2 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/mcp_session_exclusions.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "errors" + "slices" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/mcpconfig" +) + +func readMCPSessionExclusionsTx(ctx context.Context, tx *sqlx.Tx, workspaceID, sessionID string) ([]string, error) { + ids := make([]string, 0) + err := tx.SelectContext(ctx, &ids, tx.Rebind(`SELECT mcp_server_id FROM task_session_mcp_exclusions WHERE workspace_id = ? AND task_session_id = ? ORDER BY mcp_server_id`), workspaceID, sessionID) + return ids, err +} + +// Positive and excluded definitions share one sorted lock order with catalog deletion. +func lockMCPSelectionDefinitions(ctx context.Context, tx *sqlx.Tx, workspaceID string, ids []string, excluded *[]string) error { + all := append([]string{}, ids...) + if excluded != nil { + all = append(all, (*excluded)...) + } + for _, id := range normalizedMCPSelectionIDs(all) { + if err := lockMCPDefinition(ctx, tx, workspaceID, id); err != nil { + if errors.Is(err, mcpconfig.ErrMCPServerDefinitionNotFound) { + return mcpconfig.ErrMCPSelectionWorkspaceMismatch + } + return err + } + } + return nil +} + +func replaceMCPSessionExclusionsTx(ctx context.Context, tx *sqlx.Tx, origin mcpconfig.SelectionOrigin, previous []string, condition mcpconfig.SelectionCondition) error { + if condition.ExcludedDefinitionIDs == nil { + return nil + } + rc := condition.ExclusionContext + if rc == nil || rc.WorkspaceID != origin.WorkspaceID || rc.SessionID != origin.OwnerID { + return mcpconfig.ErrMCPInvalidSelection + } + ids := normalizedMCPSelectionIDs(*condition.ExcludedDefinitionIDs) + inherited, err := readMCPInheritedIDsTx(ctx, tx, *rc) + if err != nil { + return err + } + for _, id := range ids { + if !slices.Contains(previous, id) && !slices.Contains(inherited, id) { + return mcpconfig.ErrMCPInvalidSelection + } + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM task_session_mcp_exclusions WHERE workspace_id = ? AND task_session_id = ?`), origin.WorkspaceID, origin.OwnerID); err != nil { + return err + } + for _, id := range ids { + if _, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO task_session_mcp_exclusions (workspace_id, task_session_id, mcp_server_id) VALUES (?, ?, ?)`), origin.WorkspaceID, origin.OwnerID, id); err != nil { + return err + } + } + return nil +} + +func readMCPInheritedIDsTx(ctx context.Context, tx *sqlx.Tx, rc mcpconfig.ResolutionContext) ([]string, error) { + scopes := []mcpconfig.SelectionOrigin{ + {Scope: mcpconfig.SelectionScopeProfile, WorkspaceID: rc.WorkspaceID, OwnerID: rc.ProfileID}, + {Scope: mcpconfig.SelectionScopeTask, WorkspaceID: rc.WorkspaceID, OwnerID: rc.TaskID}, + } + for _, id := range rc.RepositoryIDs { + scopes = append(scopes, mcpconfig.SelectionOrigin{Scope: mcpconfig.SelectionScopeRepository, WorkspaceID: rc.WorkspaceID, OwnerID: id}) + } + var result []string + for _, scope := range scopes { + if scope.OwnerID == "" { + continue + } + ids, err := readMCPSelectionIDsTx(ctx, tx, scope) + if err != nil { + return nil, err + } + result = append(result, ids...) + } + return result, nil +} + +func (r *sqliteRepository) DeleteMCPSessionData(ctx context.Context, sessionID string) error { + if strings.TrimSpace(sessionID) == "" { + return mcpconfig.ErrMCPInvalidSelection + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, table := range []string{"task_session_mcp_exclusions", "task_session_mcp_selections", "mcp_task_session_apply_state"} { + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM `+table+` WHERE task_session_id = ?`), sessionID); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/apps/backend/internal/agent/settings/store/mcp_session_exclusions_reopen_test.go b/apps/backend/internal/agent/settings/store/mcp_session_exclusions_reopen_test.go new file mode 100644 index 00000000000..b609bc7d3bd --- /dev/null +++ b/apps/backend/internal/agent/settings/store/mcp_session_exclusions_reopen_test.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/mcpconfig" +) + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3 +func TestMCPSessionExclusionDiskReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.db") + open := func() (*sqlx.DB, *sqliteRepository) { + t.Helper() + db, err := sqlx.Open("sqlite3", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + repo, err := newSQLiteRepositoryWithDB(db, db, nil) + if err != nil { + t.Fatal(err) + } + return db, repo + } + db, repo := open() + origin, rc, _ := seedSessionExclusion(t, repo) + if err := db.Close(); err != nil { + t.Fatal(err) + } + _, reopened := open() + resolver := mcpconfig.NewResolver(reopened, reopened) + resolver.SetSecretResolver(func(context.Context, string, string) (string, error) { return "test-value", nil }) + result, err := resolver.Resolve(context.Background(), rc, mcpconfig.Policy{AllowHTTP: true}) + if err != nil || len(result.Servers) != 0 || result.DesiredRevision != 1 { + t.Fatalf("reopen did not use exclusion: %#v %v", result, err) + } + revision := int64(1) + empty := []string{} + if _, err := reopened.ReplaceMCPSelectionsConditional(context.Background(), origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &empty, ExclusionContext: &rc}); err != nil { + t.Fatal(err) + } + restored, err := resolver.Resolve(context.Background(), rc, mcpconfig.Policy{AllowHTTP: true}) + if err != nil || len(restored.Servers) != 1 { + t.Fatalf("restore after reopen: %#v %v", restored, err) + } +} + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6 +func TestMCPSessionExclusionRejectedCombinedWriteRollsBack(t *testing.T) { + repo := newTestRepo(t).(*sqliteRepository) + origin, rc, def := seedSessionExclusion(t, repo) + ctx := context.Background() + foreign := testMCPDefinition() + foreign.ID = "foreign" + foreign.WorkspaceID = "another-workspace" + if err := repo.CreateMCPServerDefinition(ctx, foreign); err != nil { + t.Fatal(err) + } + disabled := testMCPDefinition() + disabled.ID = "disabled" + disabled.RuntimeName = "disabled" + disabled.NormalizedRuntimeName = "disabled" + disabled.Enabled = false + if err := repo.CreateMCPServerDefinition(ctx, disabled); err != nil { + t.Fatal(err) + } + for _, id := range []string{foreign.ID, disabled.ID} { + revision := int64(1) + empty := []string{} + if _, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, []string{id}, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &empty, ExclusionContext: &rc}); err == nil { + t.Fatal("invalid addition accepted") + } + snapshot, err := repo.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil || snapshot.State.DesiredRevision != 1 || len(snapshot.DefinitionIDs) != 0 || len(snapshot.ExcludedDefinitionIDs) != 1 || snapshot.ExcludedDefinitionIDs[0] != def.ID { + t.Fatalf("partial write: %#v %v", snapshot, err) + } + } + revision := int64(1) + ids := []string{foreign.ID} + if _, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &ids, ExclusionContext: &rc}); err == nil { + t.Fatal("foreign exclusion accepted") + } +} diff --git a/apps/backend/internal/agent/settings/store/mcp_session_exclusions_test.go b/apps/backend/internal/agent/settings/store/mcp_session_exclusions_test.go new file mode 100644 index 00000000000..284bb2c5104 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/mcp_session_exclusions_test.go @@ -0,0 +1,165 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/mcpconfig" +) + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.2, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.5, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6 +func TestMCPSessionExclusionResolution(t *testing.T) { + repo := newTestRepo(t).(*sqliteRepository) + ctx := context.Background() + def := testMCPDefinition() + def.SecretBindings = []mcpconfig.MCPSecretBinding{{InputName: "Authorization", SecretID: "secret"}} + if err := repo.CreateMCPServerDefinition(ctx, def); err != nil { + t.Fatal(err) + } + for _, r := range []string{"r1", "r2"} { + if err := repo.ReplaceMCPSelections(ctx, mcpconfig.SelectionScopeRepository, def.WorkspaceID, r, []string{def.ID}); err != nil { + t.Fatal(err) + } + } + rc := mcpconfig.ResolutionContext{WorkspaceID: def.WorkspaceID, SessionID: "s", RepositoryIDs: []string{"r1", "r2"}} + origin := mcpconfig.SelectionOrigin{Scope: mcpconfig.SelectionScopeTaskSession, WorkspaceID: def.WorkspaceID, OwnerID: "s"} + resolver := mcpconfig.NewResolver(repo, repo) + secrets := 0 + resolver.SetSecretResolver(func(context.Context, string, string) (string, error) { secrets++; return "secret-value", nil }) + before, err := resolver.Resolve(ctx, rc, mcpconfig.Policy{AllowHTTP: true}) + if err != nil || len(before.Servers) != 1 || secrets != 1 { + t.Fatalf("positive control: %#v %v secrets=%d", before, err, secrets) + } + if err := repo.SaveAcceptedMCPSetup(ctx, "s", 0, "accepted", before.Setup); err != nil { + t.Fatal(err) + } + ids := []string{def.ID} + revision := int64(0) + _, err = repo.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &ids, ExclusionContext: &rc}) + if err != nil { + t.Fatal(err) + } + secrets = 0 + after, err := resolver.Resolve(ctx, rc, mcpconfig.Policy{AllowHTTP: true}) + if err != nil { + t.Fatal(err) + } + if len(after.Servers) != 0 || secrets != 0 { + t.Fatalf("excluded definition prepared/delivered: servers=%d secrets=%d", len(after.Servers), secrets) + } + if after.Setup == nil || len(after.Setup.Servers) != 0 || len(after.Decisions) != 1 || after.Decisions[0].ReasonCode != "session_excluded" || len(after.Decisions[0].Origins) != 2 { + t.Fatalf("lost exclusion/origin evidence: %#v", after) + } + service := mcpconfig.NewSelectionService(repo, repo) + service.SetSessionMCPStateRepository(repo) + service.SetSessionResolutionContext(func(context.Context, string, string) (mcpconfig.ResolutionContext, error) { return rc, nil }) + snapshot, err := service.ReadSnapshot(ctx, origin) + if err != nil { + t.Fatal(err) + } + if c := snapshot.State.Comparison; c.Status != "known" || len(c.Changes) != 1 || c.Changes[0].Action != "remove" { + t.Fatalf("comparison did not use post-exclusion setup: %#v", c) + } + rc.SessionID = "sibling" + sibling, err := resolver.Resolve(ctx, rc, mcpconfig.Policy{AllowHTTP: true}) + if err != nil || len(sibling.Servers) != 1 { + t.Fatalf("changed sibling: %#v %v", sibling, err) + } +} + +func seedSessionExclusion(t *testing.T, repo *sqliteRepository) (mcpconfig.SelectionOrigin, mcpconfig.ResolutionContext, *mcpconfig.MCPServerDefinition) { + t.Helper() + ctx := context.Background() + def := testMCPDefinition() + if err := repo.CreateMCPServerDefinition(ctx, def); err != nil { + t.Fatal(err) + } + if err := repo.ReplaceMCPSelections(ctx, mcpconfig.SelectionScopeRepository, def.WorkspaceID, "r", []string{def.ID}); err != nil { + t.Fatal(err) + } + rc := mcpconfig.ResolutionContext{WorkspaceID: def.WorkspaceID, SessionID: "s", RepositoryIDs: []string{"r"}} + origin := mcpconfig.SelectionOrigin{Scope: mcpconfig.SelectionScopeTaskSession, WorkspaceID: def.WorkspaceID, OwnerID: "s"} + ids := []string{def.ID} + revision := int64(0) + if _, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &ids, ExclusionContext: &rc}); err != nil { + t.Fatal(err) + } + return origin, rc, def +} + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3, AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.4, AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.5 +func TestMCPSessionExclusionDormantReplay(t *testing.T) { + repo := newTestRepo(t).(*sqliteRepository) + origin, rc, def := seedSessionExclusion(t, repo) + ctx := context.Background() + if err := repo.ReplaceMCPSelections(ctx, mcpconfig.SelectionScopeRepository, def.WorkspaceID, "r", nil); err != nil { + t.Fatal(err) + } + def.Enabled = false + if err := repo.UpdateMCPServerDefinition(ctx, def, def.Revision); err != nil { + t.Fatal(err) + } + reopened, err := newSQLiteRepositoryWithDB(repo.db, repo.ro, nil) + if err != nil { + t.Fatal(err) + } + snapshot, err := reopened.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil || len(snapshot.ExcludedDefinitionIDs) != 1 { + t.Fatalf("replay lost dormant exclusion: %#v %v", snapshot, err) + } + // Retaining a disabled dormant exclusion must remain valid. + ids := []string{def.ID} + revision := snapshot.State.DesiredRevision + if _, err := reopened.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &ids, ExclusionContext: &rc}); err != nil { + t.Fatal(err) + } + revision++ + empty := []string{} + restored, err := reopened.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &empty, ExclusionContext: &rc}) + if err != nil || len(restored.DefinitionIDs) != 0 || len(restored.ExcludedDefinitionIDs) != 0 { + t.Fatalf("restore added a dormant definition: %#v %v", restored, err) + } + current, err := repo.GetMCPServerDefinition(ctx, def.WorkspaceID, def.ID) + if err != nil || current.Enabled { + t.Fatalf("restore enabled definition: %#v %v", current, err) + } +} + +func TestMCPSessionExclusionCleanup(t *testing.T) { + for _, kind := range []string{"task", "workspace", "definition", "definition_direct", "session"} { + t.Run(kind, func(t *testing.T) { + repo := newTestRepo(t).(*sqliteRepository) + origin, _, def := seedSessionExclusion(t, repo) + ctx := context.Background() + var err error + switch kind { + case "task": + err = repo.DeleteMCPTaskData(ctx, "task", []string{"s"}) + case "workspace": + err = repo.DeleteMCPWorkspaceData(ctx, def.WorkspaceID) + case "definition": + err = repo.DeleteMCPServerDefinitionWithSelections(ctx, def.WorkspaceID, def.ID, def.Revision) + case "definition_direct": + err = repo.DeleteMCPServerDefinition(ctx, def.WorkspaceID, def.ID, def.Revision) + case "session": + cleaner, ok := any(repo).(interface { + DeleteMCPSessionData(context.Context, string) error + }) + if !ok { + t.Fatal("missing individual-session cleanup") + } + err = cleaner.DeleteMCPSessionData(ctx, "s") + } + if err != nil { + t.Fatal(err) + } + snapshot, err := repo.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil || len(snapshot.ExcludedDefinitionIDs) != 0 { + t.Fatalf("orphan exclusion after %s: %#v %v", kind, snapshot, err) + } + if kind != "definition" && kind != "definition_direct" && snapshot.State != nil { + t.Fatalf("orphan apply state after %s", kind) + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/store/postgres_mcp_session_exclusions_test.go b/apps/backend/internal/agent/settings/store/postgres_mcp_session_exclusions_test.go new file mode 100644 index 00000000000..e0e99c44de3 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/postgres_mcp_session_exclusions_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/testutil" +) + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6 +func TestPostgresMCPSessionExclusionsMixedWriters(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + db.SetMaxOpenConns(4) + repo, err := newSQLiteRepositoryWithDB(db, db, nil) + if err != nil { + t.Fatal(err) + } + origin, rc, def := seedSessionExclusion(t, repo) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + barrier, err := db.BeginTxx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = barrier.Rollback() }() + if err := lockMCPSelectionOwner(ctx, barrier, origin); err != nil { + t.Fatal(err) + } + revision := int64(1) + empty := []string{} + conditions := []mcpconfig.SelectionCondition{{ExpectedRevision: &revision}, {ExpectedRevision: &revision, ExcludedDefinitionIDs: &empty, ExclusionContext: &rc}} + results := make(chan error, 2) + start := make(chan struct{}) + var ready sync.WaitGroup + ready.Add(2) + for _, condition := range conditions { + go func() { + ready.Done() + <-start + _, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, []string{def.ID}, condition) + results <- err + }() + } + ready.Wait() + close(start) + for { + var waiting int + if err := db.GetContext(ctx, &waiting, `SELECT COUNT(DISTINCT pid) FROM pg_locks WHERE NOT granted AND pid <> pg_backend_pid()`); err != nil { + t.Fatal(err) + } + if waiting >= 2 { + break + } + if err := ctx.Err(); err != nil { + t.Fatal(err) + } + } + if err := barrier.Commit(); err != nil { + t.Fatal(err) + } + a, b := <-results, <-results + valid := (a == nil && errors.Is(b, mcpconfig.ErrMCPSelectionConflict)) || (b == nil && errors.Is(a, mcpconfig.ErrMCPSelectionConflict)) + if !valid { + t.Fatalf("writers: %v / %v", a, b) + } + before, err := repo.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil { + t.Fatal(err) + } + legacy, err := repo.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{}) + if err != nil { + t.Fatal(err) + } + if len(legacy.ExcludedDefinitionIDs) != len(before.ExcludedDefinitionIDs) || legacy.State.DesiredRevision != 3 { + t.Fatalf("legacy save lost exclusions or revision: %#v", legacy) + } + reopened, err := newSQLiteRepositoryWithDB(db, db, nil) + if err != nil { + t.Fatal(err) + } + after, err := reopened.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil || after.State.DesiredRevision != 3 { + t.Fatalf("replay failed: %#v %v", after, err) + } + // Keep only an exclusion so workspace cleanup must discover its session. + revision = 3 + excluded := []string{def.ID} + if _, err := reopened.ReplaceMCPSelectionsConditional(ctx, origin, nil, mcpconfig.SelectionCondition{ExpectedRevision: &revision, ExcludedDefinitionIDs: &excluded, ExclusionContext: &rc}); err != nil { + t.Fatal(err) + } + if err := reopened.DeleteMCPWorkspaceData(ctx, def.WorkspaceID); err != nil { + t.Fatal(err) + } + after, err = reopened.ReadMCPSelectionSnapshot(ctx, origin) + if err != nil || len(after.ExcludedDefinitionIDs) != 0 || after.State != nil { + t.Fatalf("cleanup failed: %#v %v", after, err) + } +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 93d77e04711..d3c96e670ad 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -209,6 +209,14 @@ func (r *sqliteRepository) initSchema() error { FOREIGN KEY (mcp_server_id) REFERENCES mcp_server_definitions(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS task_session_mcp_exclusions ( + workspace_id TEXT NOT NULL, + task_session_id TEXT NOT NULL, + mcp_server_id TEXT NOT NULL REFERENCES mcp_server_definitions(id) ON DELETE CASCADE, + PRIMARY KEY (workspace_id, task_session_id, mcp_server_id) + ); + CREATE INDEX IF NOT EXISTS idx_task_session_mcp_exclusions_definition + ON task_session_mcp_exclusions(mcp_server_id); CREATE TABLE IF NOT EXISTS mcp_task_session_apply_state ( task_session_id TEXT PRIMARY KEY, desired_revision INTEGER NOT NULL DEFAULT 0, diff --git a/apps/backend/internal/agentctl/types/streams/mcp_resolution.go b/apps/backend/internal/agentctl/types/streams/mcp_resolution.go index 6216f627dbc..e8d62f71da9 100644 --- a/apps/backend/internal/agentctl/types/streams/mcp_resolution.go +++ b/apps/backend/internal/agentctl/types/streams/mcp_resolution.go @@ -51,7 +51,7 @@ func NormalizeMCPResolution(entries []MCPResolutionEntry) []MCPResolutionEntry { continue } switch entry.ReasonCode { - case "", "definition_missing", "definition_disabled", "secret_resolution_failed", "transport_policy", "server_policy", "invalid_transport", "filtered": + case "", "definition_missing", "definition_disabled", "session_excluded", "secret_resolution_failed", "transport_policy", "server_policy", "invalid_transport", "filtered": default: continue } diff --git a/apps/backend/internal/agentctl/types/streams/mcp_resolution_test.go b/apps/backend/internal/agentctl/types/streams/mcp_resolution_test.go index 9b109cdf6bd..d10dc21ca52 100644 --- a/apps/backend/internal/agentctl/types/streams/mcp_resolution_test.go +++ b/apps/backend/internal/agentctl/types/streams/mcp_resolution_test.go @@ -25,3 +25,13 @@ func TestMCPResolutionProjectionIsBoundedAndFrozen(t *testing.T) { t.Fatal("unbounded identity or unknown reason was accepted") } } + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6 +func TestMCPResolutionSessionExclusionEvidence(t *testing.T) { + entries := []MCPResolutionEntry{{RuntimeName: "docs", DefinitionID: "definition", ReasonCode: "session_excluded", Origins: []MCPSelectionOrigin{{Scope: "repository", WorkspaceID: "workspace", OwnerID: "repo"}}}} + frozen := MCPResolutionFromContext(WithMCPResolution(context.Background(), entries)) + evidence := MCPResolutionEvidence("attempt", frozen, time.Now()) + if len(evidence) != 1 || evidence[0].ReasonCode != "session_excluded" || evidence[0].Kind != MCPAttachmentEvidenceFiltered || len(evidence[0].Origins) != 1 { + t.Fatalf("session exclusion evidence was lost: %#v", evidence) + } +} diff --git a/apps/backend/internal/backendapp/agents.go b/apps/backend/internal/backendapp/agents.go index bb031a8683a..3cc8162b630 100644 --- a/apps/backend/internal/backendapp/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -294,6 +294,7 @@ func registerMCPDeletionCleanup(eventBus bus.EventBus, repo interface{}, log *lo if eventBus == nil || repo == nil { return } + registerMCPSessionDeletionCleanup(eventBus, repo, log) if cleaner, ok := repo.(mcpTaskDataCleaner); ok { if _, err := eventBus.Subscribe(events.TaskDeleted, func(ctx context.Context, event *bus.Event) error { taskID, sessionIDs := mcpTaskDeletionData(event) diff --git a/apps/backend/internal/backendapp/mcp_deletion_cleanup_test.go b/apps/backend/internal/backendapp/mcp_deletion_cleanup_test.go index 9c3642123a2..9a0c0ffa728 100644 --- a/apps/backend/internal/backendapp/mcp_deletion_cleanup_test.go +++ b/apps/backend/internal/backendapp/mcp_deletion_cleanup_test.go @@ -54,3 +54,20 @@ func TestRegisterMCPDeletionCleanupReadsDeletionEventScopes(t *testing.T) { t.Fatalf("workspace cleanup IDs = %#v", cleaner.workspaces) } } + +func (s *mcpDeletionCleanerStub) DeleteMCPSessionData(_ context.Context, sessionID string) error { + s.sessions = append(s.sessions, []string{sessionID}) + return nil +} + +func TestMCPSessionDeletionCleanup(t *testing.T) { + eventBus := bus.NewMemoryEventBus(logger.Default()) + cleaner := &mcpDeletionCleanerStub{} + registerMCPDeletionCleanup(eventBus, cleaner, logger.Default()) + if err := eventBus.Publish(context.Background(), events.SessionRemoved, bus.NewEvent(events.SessionRemoved, "task-service", map[string]interface{}{"session_id": "session", "task_id": "task"})); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cleaner.sessions, [][]string{{"session"}}) { + t.Fatalf("individual deletion not wired: %#v", cleaner.sessions) + } +} diff --git a/apps/backend/internal/backendapp/mcp_session_cleanup.go b/apps/backend/internal/backendapp/mcp_session_cleanup.go new file mode 100644 index 00000000000..5f1887b004b --- /dev/null +++ b/apps/backend/internal/backendapp/mcp_session_cleanup.go @@ -0,0 +1,34 @@ +package backendapp + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "go.uber.org/zap" +) + +type mcpSessionDataCleaner interface { + DeleteMCPSessionData(context.Context, string) error +} + +func registerMCPSessionDeletionCleanup(eventBus bus.EventBus, repo interface{}, log *logger.Logger) { + cleaner, ok := repo.(mcpSessionDataCleaner) + if !ok { + return + } + if _, err := eventBus.Subscribe(events.SessionRemoved, func(ctx context.Context, event *bus.Event) error { + sessionID := mcpEventString(event, "session_id") + if sessionID == "" { + return nil + } + if err := cleaner.DeleteMCPSessionData(ctx, sessionID); err != nil { + return fmt.Errorf("delete MCP session data: %w", err) + } + return nil + }); err != nil { + log.Error("failed to subscribe to MCP session deletion cleanup", zap.Error(err)) + } +} diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go index 11830cf4906..3d5cf0cfbe3 100644 --- a/apps/backend/internal/persistence/requiredstores/catalog.go +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -66,7 +66,7 @@ var catalog = []Descriptor{ {ID: storeIDTask, OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks", "task_creation_authorizations", "task_workflow_session_bindings", "task_environment_kubernetes", "harness_session_generations", "session_restore_attempts", "session_continuation_snapshots", "session_recovery_blocks", "agent_delivery_submissions", "agent_delivery_inbox", "agent_delivery_cursors", "agent_delivery_effects", "agent_delivery_cleanup_jobs", "agent_delivery_retention_state", "task_environment_checkout_adoptions", "task_message_shell_output", "task_message_shell_output_chunks", "turn_change_sets", "turn_repository_changes", "turn_file_changes", "turn_change_contents", "turn_change_content_links", "turn_change_content_leases"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, {ID: storeIDWorkflow, OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, {ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, DependsOn: []string{storeIDTask, storeIDWorkflow}, Capabilities: []Capability{CapabilityTimestamp}, Sweep: startup.StepStoresRepositories}, - {ID: storeIDAgentSettings, OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles", "agent_profile_orders"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: storeIDAgentSettings, OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles", "agent_profile_orders", "task_session_mcp_exclusions"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, {ID: storeIDUser, OwnerPackage: "internal/user/store", RequiredTables: []string{"users"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, {ID: "notification", OwnerPackage: "internal/notifications/store", RequiredTables: []string{"notification_providers"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, {ID: "editor", OwnerPackage: "internal/editors/store", RequiredTables: []string{"editors"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, 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 index 9cdb4e7d4d1..8f3545833d4 100644 --- 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 @@ -134,6 +134,7 @@ it.each([ ["definition_disabled", "DefinitionDisabled"], ["definition_missing", "DefinitionMissing"], ["secret_resolution_failed", "SecretResolution"], + ["session_excluded", "SessionExcluded"], ])("explains the recorded %s exclusion safely", (reason_code, suffix) => { expect(mcpStatusPresentation(server({ status: "filtered", reason_code })).explanationKey).toBe( `task:mcpReason${suffix}`, 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 index 96d2fa96eed..5d4633cc6d6 100644 --- 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 @@ -67,6 +67,7 @@ export function mcpStatusLabelKey(status: MCPAttachmentStatus): string { } const FILTER_REASON_KEYS: Record = { + session_excluded: "task:mcpReasonSessionExcluded", http_unsupported: "task:mcpReasonHttpUnsupported", sse_unsupported: "task:mcpReasonSseUnsupported", duplicate_name: "task:mcpReasonDuplicateName", diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.test.ts b/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.test.ts index 381c85ada67..81184d0a4a3 100644 --- a/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.test.ts +++ b/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.test.ts @@ -91,3 +91,28 @@ describe("saved MCP setup changes", () => { expect(mcpSelectionChanges(state, []).status).toBe("known"); }); }); + +it("rejects comparison from a different exclusion set", () => { + const state = { + desired_revision: 2, + applied_revision: 1, + apply_state: "pending_idle" as const, + comparison: { + version: 1 as const, + status: "known" as const, + desired_revision: 2, + applied_revision: 1, + selection_ids: [], + excluded_definition_ids: [], + changes: [], + }, + }; + expect(mcpSelectionChanges(state, [], ["docs"])).toEqual({ status: "unavailable", changes: [] }); + expect( + mcpSelectionChanges( + { ...state, comparison: { ...state.comparison, excluded_definition_ids: ["docs"] } }, + [], + ["docs"], + ), + ).toEqual({ status: "known", changes: [] }); +}); diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.ts b/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.ts index 391249467c2..9623d2ea828 100644 --- a/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.ts +++ b/apps/web/components/task/chat/mcp-explorer/mcp-selection-changes.ts @@ -50,16 +50,21 @@ function sameSelections(comparisonIds: string[], selectedIds: string[]): boolean export function mcpSelectionChanges( state: MCPSessionSelectionState | null | undefined, selectedIds: string[], + excludedIds: string[] = [], ): MCPSelectionChanges { if (!state || state.apply_state === "applied") return { status: "hidden", changes: [] }; const comparison = state.comparison; const changes = comparison?.changes ?? []; - if (!matchingComparison(state, selectedIds) || !validChanges(changes)) + if (!matchingComparison(state, selectedIds, excludedIds) || !validChanges(changes)) return { status: "unavailable", changes: [] }; return { status: "known", changes }; } -function matchingComparison(state: MCPSessionSelectionState, selectedIds: string[]): boolean { +function matchingComparison( + state: MCPSessionSelectionState, + selectedIds: string[], + excludedIds: string[], +): boolean { const comparison = state.comparison; return Boolean( comparison?.version === 1 && @@ -67,7 +72,8 @@ function matchingComparison(state: MCPSessionSelectionState, selectedIds: string comparison.desired_revision === state.desired_revision && comparison.applied_revision === state.applied_revision && (comparison.attachment_attempt_id ?? "") === (state.attachment_attempt_id ?? "") && - sameSelections(comparison.selection_ids ?? [], selectedIds), + sameSelections(comparison.selection_ids ?? [], selectedIds) && + sameSelections(comparison.excluded_definition_ids ?? [], excludedIds), ); } diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.test.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.test.tsx new file mode 100644 index 00000000000..828d0b69785 --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.test.tsx @@ -0,0 +1,127 @@ +import { render, screen, fireEvent, within, cleanup } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { MCPSessionSelectionBody } from "../../mcp-session-selector"; +import type { MCPServerDefinition } from "@/lib/types/http-mcp"; +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + workspaces: { activeId: "ws" }, + agentProfiles: { items: [] }, + repositories: { itemsByWorkspaceId: {} }, + kanban: { tasks: [] }, + workflows: { items: [] }, + taskSessions: { items: {} }, + }), +})); +afterEach(cleanup); +const definition: MCPServerDefinition = { + id: "docs", + workspace_id: "ws", + runtime_name: "docs", + normalized_runtime_name: "docs", + display_name: "Docs", + enabled: true, + execution_mode: "remote", + transport: "sse", + configuration: { url: "https://example.test" }, + source: "custom", + revision: 1, + created_at: "now", + updated_at: "now", +}; +const props = { + definitions: [definition], + selectedIds: ["docs"], + onSelectedIdsChange: vi.fn(), + query: "", + onQueryChange: vi.fn(), + inherited: [ + { + definition, + origins: [ + { scope: "repository" as const, workspace_id: "ws", owner_id: "repo" }, + { scope: "profile" as const, workspace_id: "ws", owner_id: "profile" }, + ], + }, + ], +}; +it("excludes by definition once, retains origins and requires a committed result to show saved", () => { + const change = vi.fn(); + const { rerender } = render( + , + ); + const row = screen.getByTestId("mcp-inherited-server-docs"); + expect(within(row).getByText(/Profile/)).toBeTruthy(); + fireEvent.click(within(row).getByRole("button", { name: "Exclude for this session" })); + expect(change).toHaveBeenCalledExactlyOnceWith(["docs"]); + expect(within(row).queryByRole("button", { name: "Restore inheritance" })).toBeNull(); + rerender( + , + ); + fireEvent.click(within(row).getByRole("button", { name: "Restore inheritance" })); + expect(change).toHaveBeenLastCalledWith([]); + expect(props.onSelectedIdsChange).not.toHaveBeenCalled(); +}); +it("restores a dormant disabled exclusion without adding or enabling its definition", () => { + const change = vi.fn(); + render( + , + ); + expect(screen.getByText("No longer inherited")).toBeTruthy(); + expect(screen.queryByRole("checkbox", { name: "Docs" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Restore inheritance" })); + expect(change).toHaveBeenCalledWith([]); + expect(props.onSelectedIdsChange).not.toHaveBeenCalled(); +}); +it("keeps creation additive and disables session actions while saving", () => { + const { rerender } = render(); + expect(screen.queryByRole("button", { name: "Exclude for this session" })).toBeNull(); + rerender( + , + ); + expect( + screen.getByRole("button", { name: "Exclude for this session" }).hasAttribute("disabled"), + ).toBe(true); +}); + +it("keeps the committed comparison visible when an addition draft was rejected", () => { + render( + , + ); + expect(screen.queryByTestId("mcp-pending-change-docs")).not.toBeNull(); +}); diff --git a/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.tsx b/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.tsx new file mode 100644 index 00000000000..80b241f8c9c --- /dev/null +++ b/apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { McpSectionHeading } from "@/components/mcp/mcp-origin-sections"; +import { McpDefinitionDetails } from "@/components/mcp/mcp-definition-details"; +import { McpPendingRowAnnotation } from "./mcp-selection-changes-view"; +import type { MCPSetupChange, MCPServerDefinition } from "@/lib/types/http-mcp"; + +export function McpSessionExclusionAction({ + excluded, + definition, + disabled, + onChange, +}: { + excluded: boolean; + definition: MCPServerDefinition; + disabled?: boolean; + onChange: () => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t(excluded ? "settings:mcpSessionExcluded" : "settings:mcpSessionInherited")}

+ {!definition.enabled && ( +

{t("settings:mcpRestoreDisabledDescription")}

+ )} +
+ +
+ ); +} + +export function McpDormantExclusions({ + definitions, + excludedIds, + onChange, + disabled, +}: { + definitions: MCPServerDefinition[]; + excludedIds: string[]; + onChange: (ids: string[]) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + if (definitions.length === 0) return null; + return ( +
+ +
+ {definitions.map((definition) => ( +
+

{definition.display_name}

+ +

+ {t("settings:mcpNoLongerInherited")} +

+ onChange(excludedIds.filter((id) => id !== definition.id))} + /> +
+ ))} +
+
+ ); +} + +export function McpSessionRowAnnotation({ + change, + definition, + excludedIds, + onChange, + disabled, +}: { + change?: MCPSetupChange; + definition?: MCPServerDefinition; + excludedIds: string[]; + onChange?: (ids: string[]) => void; + disabled?: boolean; +}) { + const excluded = Boolean(definition && excludedIds.includes(definition.id)); + return ( + <> + {change && } + {onChange && definition && ( + + onChange( + excluded + ? excludedIds.filter((value) => value !== definition.id) + : [...excludedIds, definition.id], + ) + } + /> + )} + + ); +} diff --git a/apps/web/components/task/mcp-session-selector.tsx b/apps/web/components/task/mcp-session-selector.tsx index e4e221517f7..5d813f03895 100644 --- a/apps/web/components/task/mcp-session-selector.tsx +++ b/apps/web/components/task/mcp-session-selector.tsx @@ -13,10 +13,11 @@ import { MCPSelectionContent } from "@/components/mcp/mcp-selection-picker"; import { McpSelectionNotice } from "./chat/mcp-explorer/mcp-selection-notice"; import { mcpSelectionStateLabelKey } from "./chat/mcp-explorer/mcp-selection-presentation"; import { mcpSelectionChanges } from "./chat/mcp-explorer/mcp-selection-changes"; +import { McpPendingChanges } from "./chat/mcp-explorer/mcp-selection-changes-view"; import { - McpPendingChanges, - McpPendingRowAnnotation, -} from "./chat/mcp-explorer/mcp-selection-changes-view"; + McpSessionRowAnnotation, + McpDormantExclusions, +} from "./chat/mcp-explorer/mcp-session-exclusions"; import { MobilePickerSheet } from "./mobile/mobile-picker-sheet"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { isHandledApiError } from "@/lib/api/client"; @@ -34,6 +35,8 @@ export type MCPSessionSelectorProps = { definitionsLoading?: boolean; selectedIds: string[]; savedSelectedIds?: string[]; + excludedIds?: string[]; + onExcludedIdsChange?: (ids: string[]) => void; onSelectedIdsChange: (ids: string[]) => void; inherited?: MCPInheritedSelection[]; state?: MCPSessionSelectionState | null; @@ -51,12 +54,25 @@ export function MCPSessionSelectionBody({ retrying, definitionsLoading, reload, + excludedIds = [], + onExcludedIdsChange, ...props }: MCPSessionSelectorProps & { query: string; onQueryChange: (query: string) => void }) { const { t } = useTranslation(); const params = useSearchParams(); const routeSession = readMCPContextHint(params, "sessionId"); - const changes = mcpSelectionChanges(state, props.savedSelectedIds ?? props.selectedIds); + const changes = mcpSelectionChanges( + state, + props.savedSelectedIds ?? props.selectedIds, + excludedIds, + ); + const inheritedIds = new Set(props.inherited?.map((item) => item.definition.id)); + const dormant = onExcludedIdsChange + ? props.definitions.filter( + (item) => excludedIds.includes(item.id) && !inheritedIds.has(item.id), + ) + : []; + const dormantIds = new Set(dormant.map((item) => item.id)); return (
{state && ( @@ -75,6 +91,21 @@ export function MCPSessionSelectionBody({
)} {reload && } + {onExcludedIdsChange && ( + <> +
+

{t("settings:mcpSessionExclusionsDescription")}

+

{t("settings:mcpRequiredToolsSeparate")}

+

{t("settings:mcpExclusionCount", { total: excludedIds.length })}

+
+ + + )} {definitionsLoading ? (

{t("settings:mcpLoading")} @@ -93,12 +124,18 @@ export function MCPSessionSelectionBody({ } : undefined } + definitions={props.definitions.filter((item) => !dormantIds.has(item.id))} error={reload ? undefined : props.error} sessionPresentation - renderServerAnnotation={(id) => { - const change = changes.changes.find((item) => item.definition_id === id); - return change ? : null; - }} + renderServerAnnotation={(id) => ( + item.definition_id === id)} + definition={props.inherited?.find((item) => item.definition.id === id)?.definition} + excludedIds={excludedIds} + onChange={onExcludedIdsChange} + disabled={props.disabled} + /> + )} sectionLabel={t("settings:mcpSessionAdditions")} testId={`${props.testId}-picker`} /> diff --git a/apps/web/e2e/helpers/mcp-pending-changes.ts b/apps/web/e2e/helpers/mcp-pending-changes.ts index 2122679f8e3..ade197e236c 100644 --- a/apps/web/e2e/helpers/mcp-pending-changes.ts +++ b/apps/web/e2e/helpers/mcp-pending-changes.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import type { ApiClient } from "./api-client"; import type { SeedData } from "../fixtures/test-base"; import type { @@ -9,6 +9,22 @@ import type { import { SessionPage } from "../pages/session-page"; import { openMCPComposerCustomization } from "./mcp-composer"; import { expectMCPCustomizationScrolling } from "./mcp-customization-scrolling"; +import { PrAssetCapture } from "./pr-asset-capture"; + +async function captureRetryRecovery(page: Page, surface: Locator, mobile: boolean) { + const capture = new PrAssetCapture(page, "mcp-retry-recovery.spec.ts", { + captureKey: mobile ? "phone" : "desktop", + }); + if (capture.capturing) { + const reloadChoices = surface.getByRole("button", { name: "Reload choices", exact: true }); + await reloadChoices.scrollIntoViewIfNeeded(); + await expect(reloadChoices).toBeInViewport(); + } + await capture.screenshot(mobile ? "phone" : "desktop", { + caption: "Reload saved MCP choices before retrying application", + }); + capture.flush(); +} export async function checkMCPPendingChanges( page: Page, @@ -167,9 +183,24 @@ export async function checkMCPPendingChanges( "Changes could not be applied", ); await expect(pending.getByRole("listitem")).toHaveCount(3); + await search.fill("Pending added"); + rejectSave = true; + await surface.getByRole("checkbox").click(); + await expect(surface.getByRole("alert")).toBeVisible(); + await expect(body.getByRole("button", { name: "Retry MCP application" })).toHaveCount(0); + await captureRetryRecovery(page, surface, mobile); + rejectSave = false; + await surface.getByRole("button", { name: "Reload choices", exact: true }).click(); + await expect(surface.getByRole("checkbox")).toBeEnabled(); + await expect(surface.getByRole("checkbox")).not.toBeChecked(); const previousWrites = writes; await body.getByRole("button", { name: "Retry MCP application" }).click(); await expect.poll(() => writes).toBe(previousWrites + 1); + await expect(surface.getByRole("checkbox")).toBeEnabled(); + await expect(surface.getByRole("checkbox")).not.toBeChecked(); + expect( + (await api.getMCPSelections("task_session", sessionId, seed.workspaceId)).definition_ids, + ).toEqual([added.id]); await expect(pending.getByRole("listitem")).toHaveCount(3); timing = "pending_idle"; diff --git a/apps/web/e2e/helpers/mcp-session-exclusions.ts b/apps/web/e2e/helpers/mcp-session-exclusions.ts new file mode 100644 index 00000000000..fc2679a96a4 --- /dev/null +++ b/apps/web/e2e/helpers/mcp-session-exclusions.ts @@ -0,0 +1,178 @@ +import { randomUUID } from "node:crypto"; +import { expect, type Page, type Locator } from "@playwright/test"; +import type { ApiClient } from "./api-client"; +import type { SeedData } from "../fixtures/test-base"; +import { SessionPage } from "../pages/session-page"; +import { openMCPComposerCustomization } from "./mcp-composer"; +import { expectMCPCustomizationScrolling } from "./mcp-customization-scrolling"; + +async function activate(button: Locator, touch: boolean) { + await button.scrollIntoViewIfNeeded(); + if (touch) await button.tap(); + else await button.press("Enter"); +} + +async function rejectThenReload( + page: Page, + surface: Locator, + sessionId: string, + button: Locator, + touch: boolean, +) { + const route = `**/api/v1/task-sessions/${sessionId}/mcp-selections`; + await page.route(route, async (request) => { + if (request.request().method() !== "PUT") return request.continue(); + await request.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ code: "mcp_selection_conflict", error: "Changed" }), + }); + }); + try { + await activate(button, touch); + await expect(surface.getByRole("alert")).toBeVisible(); + await expect(button).toBeDisabled(); + } finally { + await page.unroute(route); + } + await surface.getByRole("button", { name: "Reload choices", exact: true }).click(); + await expect(button).toBeEnabled(); + await expect( + surface.getByRole("button", { name: "Restore inheritance", exact: true }), + ).toHaveCount(0); +} + +async function seedDefinitions(api: ApiClient, workspaceId: string) { + const suffix = randomUUID().slice(0, 8); + const definition = await api.createMCPServer(workspaceId, { + runtime_name: `exclude-docs-${suffix}`, + display_name: "Inherited exclusion docs", + execution_mode: "remote", + transport: "sse", + configuration: { url: "https://mcp.example.test/docs" }, + source: "custom", + }); + for (let i = 0; i < 8; i++) + await api.createMCPServer(workspaceId, { + runtime_name: `additional-${suffix}-${i}`, + display_name: `Additional server ${i}`, + execution_mode: "remote", + transport: "sse", + configuration: { url: "https://mcp.example.test/addition" }, + source: "custom", + }); + return definition; +} + +export async function checkMCPSessionExclusions( + page: Page, + api: ApiClient, + seed: SeedData, + touch: boolean, +) { + const definition = await seedDefinitions(api, seed.workspaceId); + await api.replaceMCPSelections("repository", seed.repositoryId, seed.workspaceId, [ + definition.id, + ]); + const task = await api.createTaskWithAgent( + seed.workspaceId, + "Session exclusions", + seed.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seed.workflowId, + workflow_step_id: seed.startStepId, + repository_ids: [seed.repositoryId], + }, + ); + if (!task.session_id) throw new Error("Missing session"); + const sessionId = task.session_id; + await page.goto(`/t/${task.id}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.sendMessageViaButton("/e2e:cancel-hold"); + await expect + .poll( + async () => + (await api.listTaskSessions(task.id)).sessions.find((s) => s.id === sessionId)?.state, + ) + .toBe("RUNNING"); + let surface = await openMCPComposerCustomization(page); + const row = () => surface.getByTestId(`mcp-inherited-server-${definition.id}`); + const exclude = row().getByRole("button", { name: "Exclude for this session", exact: true }); + try { + await expect(exclude).toBeVisible(); + const bounds = await exclude.boundingBox(); + if (touch || page.viewportSize()!.width < 768) { + expect(bounds!.height).toBeGreaterThanOrEqual(44); + expect(bounds!.width).toBeGreaterThanOrEqual(44); + } + await rejectThenReload(page, surface, sessionId, exclude, touch); + await activate(exclude, touch); + await expect( + row().getByRole("button", { name: "Restore inheritance", exact: true }), + ).toBeVisible(); + await expect(row()).toContainText("Saved: excluded from this session"); + const saved = await api.getMCPSelections("task_session", sessionId, seed.workspaceId); + expect(saved.excluded_definition_ids).toEqual([definition.id]); + expect(saved.mcp_state?.apply_state).toBe("pending_idle"); + expect(saved.mcp_state?.comparison?.changes?.map((c) => c.action)).toContain("remove"); + expect( + (await api.getMCPSelections("repository", seed.repositoryId, seed.workspaceId)) + .definition_ids, + ).toEqual([definition.id]); + await expectMCPCustomizationScrolling(page, surface, touch); + await page.reload(); + await session.waitForLoad(); + surface = await openMCPComposerCustomization(page); + const restore = row().getByRole("button", { name: "Restore inheritance", exact: true }); + await expect(restore).toBeVisible(); + await activate(restore, touch); + await expect( + row().getByRole("button", { name: "Exclude for this session", exact: true }), + ).toBeVisible(); + expect( + (await api.getMCPSelections("task_session", sessionId, seed.workspaceId)) + .excluded_definition_ids, + ).toEqual([]); + await activate( + row().getByRole("button", { name: "Exclude for this session", exact: true }), + touch, + ); + await expect( + row().getByRole("button", { name: "Restore inheritance", exact: true }), + ).toBeVisible(); + await api.updateMCPServer(seed.workspaceId, definition.id, { + enabled: false, + expected_revision: definition.revision, + }); + await api.replaceMCPSelections("repository", seed.repositoryId, seed.workspaceId, []); + await page.reload(); + await session.waitForLoad(); + surface = await openMCPComposerCustomization(page); + const dormant = surface.getByTestId(`mcp-dormant-exclusion-${definition.id}`); + await expect(dormant).toContainText("No longer inherited"); + await expect(dormant).toContainText("Restoring inheritance does not enable a disabled server."); + await activate( + dormant.getByRole("button", { name: "Restore inheritance", exact: true }), + touch, + ); + await expect(dormant).toHaveCount(0); + const restored = await api.getMCPSelections("task_session", sessionId, seed.workspaceId); + expect(restored.excluded_definition_ids).toEqual([]); + expect(restored.definition_ids).toEqual([]); + expect( + (await api.listMCPServers(seed.workspaceId)).find((item) => item.id === definition.id) + ?.enabled, + ).toBe(false); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)) + .toBe(true); + await surface.getByTestId("mcp-explorer-close").click(); + await expect(page.getByTestId("mcp-status-trigger")).toBeFocused(); + } finally { + if (await surface.isVisible()) await surface.getByTestId("mcp-explorer-close").click(); + if (await session.cancelAgentButton().isVisible()) await session.cancelAgentButton().click(); + } +} diff --git a/apps/web/e2e/helpers/mcp-status-clarity.ts b/apps/web/e2e/helpers/mcp-status-clarity.ts index a38f446fcce..4e268232a9b 100644 --- a/apps/web/e2e/helpers/mcp-status-clarity.ts +++ b/apps/web/e2e/helpers/mcp-status-clarity.ts @@ -273,7 +273,12 @@ async function checkSavedReports( apply_state === "failed" ? 2 : 1, ); await expect(explorer).not.toContainText("Desired revision"); - if (apply_state !== "applied") await expect(summary).toContainText("stays in use"); + if (apply_state === "failed") { + await expect(summary).toContainText("The last accepted setup is kept for comparison."); + await expect(summary).not.toContainText("stays in use"); + } else if (apply_state !== "applied") { + await expect(summary).toContainText("stays in use"); + } if (mobile) { await expect(page.locator('[data-slot="drawer-content"][data-state="open"]')).toHaveCount( 1, diff --git a/apps/web/e2e/helpers/task-delete-recovery.ts b/apps/web/e2e/helpers/task-delete-recovery.ts index 99c9212c4cf..945f8e53f87 100644 --- a/apps/web/e2e/helpers/task-delete-recovery.ts +++ b/apps/web/e2e/helpers/task-delete-recovery.ts @@ -36,9 +36,18 @@ export async function createDamagedDeleteTask(api: ApiClient, seed: SeedData, ti const head = git("rev-parse", branch).trim(); const marker = path.join(path.dirname(checkout), ".kandev-workspace.json"); expect(fs.existsSync(marker)).toBe(true); + const markerContents = fs.readFileSync(marker); fs.writeFileSync(marker, ""); return { task, + restore: () => { + fs.rmSync(file, { force: true }); + try { + fs.writeFileSync(marker, markerContents, { flag: "r+" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + }, assertRetained: () => { expect(digest()).toBe(before); expect(fs.readFileSync(marker, "utf8")).toBe(""); @@ -142,31 +151,35 @@ export async function assertTasksPagePendingCleanup( capture: PrAssetCapture, ) { const title = "Tasks page damaged workspace"; - const { task, assertRetained } = await createDamagedDeleteTask(api, seed, title); - await page.goto("/tasks?group=none"); - const row = page.getByTestId("tasks-list-row").filter({ hasText: title }); - const action = row.getByRole("button", { name: "Delete task", exact: true }); - if ((page.viewportSize()?.width ?? 1024) < 768) { - await action.tap(); - } else { - await action.click(); + const { task, assertRetained, restore } = await createDamagedDeleteTask(api, seed, title); + try { + await page.goto("/tasks?group=none"); + const row = page.getByTestId("tasks-list-row").filter({ hasText: title }); + const action = row.getByRole("button", { name: "Delete task", exact: true }); + if ((page.viewportSize()?.width ?? 1024) < 768) { + await action.tap(); + } else { + await action.click(); + } + await deleteDamagedTaskWithPendingCleanup(page, page.getByRole("alertdialog"), task.id); + await expect(page.getByText("Task deleted. Cleanup pending.", { exact: true })).toBeVisible(); + await expect( + page.getByText( + "Files that cannot be safely removed remain protected. Kandev will retry cleanup.", + { exact: true }, + ), + ).toBeVisible(); + await expect( + page.getByTestId("toast-message").filter({ hasText: "Task deleted. Cleanup pending." }), + ).toBeInViewport({ ratio: 1 }); + await capture.screenshot("tasks-page-cleanup-pending", { + caption: "Tasks page confirms deletion and reports protected files pending cleanup.", + }); + await expect(row).toHaveCount(0); + expect((await api.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + await assertNoDocumentHorizontalOverflow(page, "Tasks page cleanup notice"); + } finally { + restore(); } - await deleteDamagedTaskWithPendingCleanup(page, page.getByRole("alertdialog"), task.id); - await expect(page.getByText("Task deleted. Cleanup pending.", { exact: true })).toBeVisible(); - await expect( - page.getByText( - "Files that cannot be safely removed remain protected. Kandev will retry cleanup.", - { exact: true }, - ), - ).toBeVisible(); - await expect( - page.getByTestId("toast-message").filter({ hasText: "Task deleted. Cleanup pending." }), - ).toBeInViewport({ ratio: 1 }); - await capture.screenshot("tasks-page-cleanup-pending", { - caption: "Tasks page confirms deletion and reports protected files pending cleanup.", - }); - await expect(row).toHaveCount(0); - expect((await api.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); - await assertNoDocumentHorizontalOverflow(page, "Tasks page cleanup notice"); } diff --git a/apps/web/e2e/tests/chat/mcp-session-exclusions.spec.ts b/apps/web/e2e/tests/chat/mcp-session-exclusions.spec.ts new file mode 100644 index 00000000000..a5b4541cda9 --- /dev/null +++ b/apps/web/e2e/tests/chat/mcp-session-exclusions.spec.ts @@ -0,0 +1,34 @@ +import { test } from "../../fixtures/test-base"; +import { checkMCPSessionExclusions } from "../../helpers/mcp-session-exclusions"; +let releaseBackendEnv: (() => Promise) | undefined; +test.beforeEach(async ({ backend }) => { + releaseBackendEnv = await backend.useEnv({ KANDEV_E2E_CANCEL_HOLD_DURATION: "0s" }); +}); +test.afterEach(async () => { + await releaseBackendEnv?.(); + releaseBackendEnv = undefined; +}); +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.1, AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.1, AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.1 +test("excludes and restores inherited MCP for one session", async ({ + testPage, + apiClient, + seedData, +}) => { + test.setTimeout(120_000); + await checkMCPSessionExclusions(testPage, apiClient, seedData, false); +}); + +test.describe("narrow fine-pointer", () => { + test.use({ viewport: { width: 320, height: 740 } }); + test("keeps exclusion controls reachable", async ({ testPage, apiClient, seedData }) => { + test.setTimeout(120_000); + await checkMCPSessionExclusions(testPage, apiClient, seedData, false); + }); +}); +test.describe("coarse-pointer tablet", () => { + test.use({ viewport: { width: 820, height: 1180 }, hasTouch: true }); + test("keeps exclusion controls touch sized", async ({ testPage, apiClient, seedData }) => { + test.setTimeout(120_000); + await checkMCPSessionExclusions(testPage, apiClient, seedData, true); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-mcp-session-exclusions.spec.ts b/apps/web/e2e/tests/chat/mobile-mcp-session-exclusions.spec.ts new file mode 100644 index 00000000000..eeb2cf21a69 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-mcp-session-exclusions.spec.ts @@ -0,0 +1,19 @@ +import { test } from "../../fixtures/test-base"; +import { checkMCPSessionExclusions } from "../../helpers/mcp-session-exclusions"; +let releaseBackendEnv: (() => Promise) | undefined; +test.beforeEach(async ({ backend }) => { + releaseBackendEnv = await backend.useEnv({ KANDEV_E2E_CANCEL_HOLD_DURATION: "0s" }); +}); +test.afterEach(async () => { + await releaseBackendEnv?.(); + releaseBackendEnv = undefined; +}); +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.3, AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.4 +test("excludes and restores inherited MCP with touch controls", async ({ + testPage, + apiClient, + seedData, +}) => { + test.setTimeout(120_000); + await checkMCPSessionExclusions(testPage, apiClient, seedData, true); +}); diff --git a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts index 12044945d2d..04459844811 100644 --- a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts @@ -222,7 +222,7 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as await apiClient.clearQueue(identity); await expect(chat.getByTestId("queue-chip")).not.toBeVisible({ timeout: 15_000 }); await gate.release(); - await expect(chat.getByText("Slow response complete", { exact: false })).toBeVisible({ + await expect(chat.getByText("Slow response complete", { exact: true })).toBeVisible({ timeout: 90_000, }); await session.waitForChatIdle({ timeout: 90_000 }); diff --git a/apps/web/e2e/tests/chat/setup-recovery.spec.ts b/apps/web/e2e/tests/chat/setup-recovery.spec.ts index b67a4da383f..21a457f8eed 100644 --- a/apps/web/e2e/tests/chat/setup-recovery.spec.ts +++ b/apps/web/e2e/tests/chat/setup-recovery.spec.ts @@ -151,111 +151,136 @@ test.describe("Setup recovery UX (desktop)", () => { seedData, }) => { test.setTimeout(90_000); - const failure = "The agent could not start."; - let created!: { task_id: string; session_id: string }; - await testPage.route("**/quick-chat", async (route) => { - if (route.request().method() !== "POST") return route.continue(); - const response = await route.fetch(); - expect(response.ok()).toBe(true); - created = await response.json(); + const { settings } = await apiClient.getUserSettings(); + await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: true }); + try { + const failure = "The agent could not start."; + let created!: { task_id: string; session_id: string }; + await testPage.route("**/quick-chat", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + const response = await route.fetch(); + expect(response.ok()).toBe(true); + created = await response.json(); + await expect + .poll( + async () => + (await apiClient.listTaskSessions(created.task_id)).sessions.find( + (row) => row.id === created.session_id, + )?.state, + { timeout: 30_000 }, + ) + .toMatch(/WAITING_FOR_INPUT|COMPLETED/); + expect( + await apiClient.stopSession({ session_id: created.session_id, force: true }), + ).toEqual({ + success: true, + }); + await expect + .poll( + async () => + ( + await apiClient.wsRequest<{ is_agent_running: boolean }>("task.session.status", { + task_id: created.task_id, + session_id: created.session_id, + }) + ).is_agent_running, + { message: "a synthetic bootstrap failure must not retain a live mock runtime" }, + ) + .toBe(false); + const occurredAt = new Date().toISOString(); + await apiClient.seedTaskSession(created.task_id, { + state: "FAILED", + completedAt: occurredAt, + sessionId: created.session_id, + agentProfileId: seedData.agentProfileId, + errorMessage: failure, + metadata: { + last_agent_error: { + message: failure, + occurred_at: occurredAt, + code: "generic_launch_failure", + phase: "bootstrap", + scope: "session", + stamp: "retained-setup-e2e", + }, + }, + }); + await apiClient.seedSessionMessage(created.session_id, { + type: "status", + content: failure, + createdAt: occurredAt, + metadata: { + recovery_actions: true, + scope: "session", + error_stamp: "retained-setup-e2e", + actions: [ + { + type: "ws_request", + label: "Resume session", + icon: "refresh", + test_id: "recovery-resume-button", + params: { + method: "session.recover", + payload: { + task_id: created.task_id, + session_id: created.session_id, + action: "resume", + }, + }, + }, + ], + }, + }); + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "failed to start session", ...created }), + }); + }); + const dialog = await openQuickChatSetup(testPage); + await selectAgentIfNeeded(dialog, testPage); + await dialog.getByTestId("task-description-input").fill("Keep this request for recovery."); + const retainedResponse = testPage.waitForResponse( + (response) => + response.url().endsWith("/quick-chat") && response.request().method() === "POST", + ); + await dialog.getByTestId("quick-chat-send").click(); + expect((await retainedResponse).status()).toBe(500); + await expect( + dialog.getByTestId("session-recovery-action-message").getByText(failure, { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByTestId("session-recovery-action-message").getByTestId("recovery-resume-button"), + ).toBeVisible(); + await testPage.reload(); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await testPage.keyboard.press(`${modifier}+Shift+q`); await expect .poll( async () => (await apiClient.listTaskSessions(created.task_id)).sessions.find( (row) => row.id === created.session_id, )?.state, - { timeout: 30_000 }, - ) - .toMatch(/WAITING_FOR_INPUT|COMPLETED/); - const occurredAt = new Date().toISOString(); - await apiClient.seedTaskSession(created.task_id, { - state: "FAILED", - completedAt: occurredAt, - sessionId: created.session_id, - agentProfileId: seedData.agentProfileId, - errorMessage: failure, - metadata: { - last_agent_error: { - message: failure, - occurred_at: occurredAt, - code: "generic_launch_failure", - phase: "bootstrap", - scope: "session", - stamp: "retained-setup-e2e", + { + timeout: 30_000, + message: "the persisted setup failure should remain failed after reload", }, - }, - }); - await apiClient.seedSessionMessage(created.session_id, { - type: "status", - content: failure, - createdAt: occurredAt, - metadata: { - recovery_actions: true, - scope: "session", - error_stamp: "retained-setup-e2e", - actions: [ - { - type: "ws_request", - label: "Resume session", - icon: "refresh", - test_id: "recovery-resume-button", - params: { - method: "session.recover", - payload: { - task_id: created.task_id, - session_id: created.session_id, - action: "resume", - }, - }, - }, - ], - }, - }); - await route.fulfill({ - status: 500, - contentType: "application/json", - body: JSON.stringify({ error: "failed to start session", ...created }), + ) + .toBe("FAILED"); + await expect( + dialog.getByTestId("session-recovery-action-message").getByText(failure, { exact: true }), + ).toBeVisible(); + await expect( + dialog.getByTestId("session-recovery-action-message").getByTestId("recovery-resume-button"), + ).toBeVisible(); + const sessions = (await apiClient.listTaskSessions(created.task_id)).sessions; + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe(created.session_id); + expect(sessions[0].state).toBe("FAILED"); + } finally { + await apiClient.saveUserSettings({ + prevent_auto_start_agent_on_open: settings.prevent_auto_start_agent_on_open === true, }); - }); - const dialog = await openQuickChatSetup(testPage); - await selectAgentIfNeeded(dialog, testPage); - await dialog.getByTestId("task-description-input").fill("Keep this request for recovery."); - const retainedResponse = testPage.waitForResponse( - (response) => - response.url().endsWith("/quick-chat") && response.request().method() === "POST", - ); - await dialog.getByTestId("quick-chat-send").click(); - expect((await retainedResponse).status()).toBe(500); - await expect( - dialog.getByTestId("session-recovery-action-message").getByText(failure, { exact: true }), - ).toBeVisible(); - await expect( - dialog.getByTestId("session-recovery-action-message").getByTestId("recovery-resume-button"), - ).toBeVisible(); - await testPage.reload(); - const modifier = process.platform === "darwin" ? "Meta" : "Control"; - await testPage.keyboard.press(`${modifier}+Shift+q`); - await expect - .poll( - async () => - (await apiClient.listTaskSessions(created.task_id)).sessions.find( - (row) => row.id === created.session_id, - )?.state, - { - timeout: 30_000, - message: "the persisted setup failure should remain failed after reload", - }, - ) - .toBe("FAILED"); - await expect( - dialog.getByTestId("session-recovery-action-message").getByText(failure, { exact: true }), - ).toBeVisible(); - await expect( - dialog.getByTestId("session-recovery-action-message").getByTestId("recovery-resume-button"), - ).toBeVisible({ timeout: 30_000 }); - const sessions = (await apiClient.listTaskSessions(created.task_id)).sessions; - expect(sessions).toHaveLength(1); - expect(sessions[0].id).toBe(created.session_id); - expect(sessions[0].state).toBe("FAILED"); + } }); }); diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts index 83f75cc9846..49926da1db1 100644 --- a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -195,18 +195,22 @@ test("deletes a damaged task while keeping its workspace files", async ({ seedData, }) => { test.setTimeout(120_000); - const { task, assertRetained } = await createDamagedDeleteTask( + const { task, assertRetained, restore } = await createDamagedDeleteTask( apiClient, seedData, "Damaged task keep files", ); - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.openTaskActionsMenu(task.id); - await testPage.getByRole("menuitem", { name: "Delete", exact: true }).click(); - await deleteDamagedTaskKeepingFiles(testPage, testPage.getByRole("alertdialog"), task.id); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.openTaskActionsMenu(task.id); + await testPage.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await deleteDamagedTaskKeepingFiles(testPage, testPage.getByRole("alertdialog"), task.id); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + } finally { + restore(); + } }); test("deletes a damaged task with workspace cleanup pending", async ({ @@ -216,21 +220,25 @@ test("deletes a damaged task with workspace cleanup pending", async ({ seedData, }) => { test.setTimeout(120_000); - const { task, assertRetained } = await createDamagedDeleteTask( + const { task, assertRetained, restore } = await createDamagedDeleteTask( apiClient, seedData, "Damaged task pending cleanup", ); - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.openTaskActionsMenu(task.id); - await testPage.getByRole("menuitem", { name: "Delete", exact: true }).click(); - await deleteDamagedTaskWithPendingCleanup( - testPage, - testPage.getByRole("alertdialog"), - task.id, - prCapture, - ); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.openTaskActionsMenu(task.id); + await testPage.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await deleteDamagedTaskWithPendingCleanup( + testPage, + testPage.getByRole("alertdialog"), + task.id, + prCapture, + ); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + } finally { + restore(); + } }); diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-order.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-order.spec.ts index 205778e06eb..32c822204ff 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-order.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-order.spec.ts @@ -47,7 +47,8 @@ async function touchDragProfile( sourceRow: Locator, targetRow: Locator, ) { - await targetRow.scrollIntoViewIfNeeded(); + // Keep this adjacent-row gesture away from the edge that activates auto-scroll. + await sourceRow.evaluate((element) => element.scrollIntoView({ block: "center" })); const from = await settledBoundingBox(sourceHandle); await expect(sourceHandle).toBeInViewport(); await expect(targetRow).toBeInViewport(); diff --git a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts index ce529740f59..306ba6c161a 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts @@ -34,7 +34,7 @@ test.describe("managed agent runtime updates on mobile", () => { caption: "Mobile update preview before approval", }); await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).tap(); - expect(runtime.postCount()).toBe(1); + await expect.poll(() => runtime.postCount()).toBe(1); expect(runtime.postTargets()).toEqual(["0.63.0"]); await runtime.emitUpdate( diff --git a/apps/web/e2e/tests/task/file-tree-rename.spec.ts b/apps/web/e2e/tests/task/file-tree-rename.spec.ts index 60888896980..0c1dfcf737f 100644 --- a/apps/web/e2e/tests/task/file-tree-rename.spec.ts +++ b/apps/web/e2e/tests/task/file-tree-rename.spec.ts @@ -127,6 +127,8 @@ test.describe("File tree inline rename", () => { .toBe(true); expect(fs.existsSync(path.join(repoDir, "rename-me.ts"))).toBe(false); + // Wait for the Git update that activates Changes before selecting Files. + await expect(session.changesFileRow("renamed.ts")).toBeVisible(); // Old node disappears from the tree, new node appears. await session.clickTab("Files"); await session.fileTree.waitForFileTreeNode("renamed.ts"); @@ -219,6 +221,8 @@ test.describe("File tree inline rename", () => { .poll(() => fs.existsSync(path.join(repoDir, "blur-final.ts")), { timeout: 10_000 }) .toBe(true); expect(fs.existsSync(path.join(repoDir, "blur-original.ts"))).toBe(false); + // The disk write can precede the Git update that activates Changes. + await expect(session.changesFileRow("blur-final.ts")).toBeVisible(); await session.clickTab("Files"); await session.fileTree.waitForFileTreeNode("blur-final.ts"); await expect(session.fileTreeNode("blur-original.ts")).toHaveCount(0); diff --git a/apps/web/e2e/tests/task/mobile-delete-discard-consent.spec.ts b/apps/web/e2e/tests/task/mobile-delete-discard-consent.spec.ts index 320a5a250c4..6d4ac455863 100644 --- a/apps/web/e2e/tests/task/mobile-delete-discard-consent.spec.ts +++ b/apps/web/e2e/tests/task/mobile-delete-discard-consent.spec.ts @@ -201,17 +201,25 @@ test("deletes a damaged task and keeps files from the phone dialog", async ({ test.setTimeout(120_000); await testPage.setViewportSize({ width: 393, height: 700 }); const title = "Mobile damaged task keep files"; - const { task, assertRetained } = await createDamagedDeleteTask(apiClient, seedData, title); - await testPage.goto(`/t/${task.id}`); - await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); - const dialog = await openDeleteDialog(testPage, title); - await assertContainedTouchDialog(dialog); - await deleteDamagedTaskKeepingFiles(testPage, dialog, task.id); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); - await backend.restart(); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); + const { task, assertRetained, restore } = await createDamagedDeleteTask( + apiClient, + seedData, + title, + ); + try { + await testPage.goto(`/t/${task.id}`); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + const dialog = await openDeleteDialog(testPage, title); + await assertContainedTouchDialog(dialog); + await deleteDamagedTaskKeepingFiles(testPage, dialog, task.id); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + await backend.restart(); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + } finally { + restore(); + } }); test("deletes a damaged task with cleanup pending from the phone dialog", async ({ @@ -224,15 +232,23 @@ test("deletes a damaged task with cleanup pending from the phone dialog", async test.setTimeout(120_000); await testPage.setViewportSize({ width: 320, height: 700 }); const title = "Mobile damaged task pending cleanup"; - const { task, assertRetained } = await createDamagedDeleteTask(apiClient, seedData, title); - await testPage.goto(`/t/${task.id}`); - await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); - const dialog = await openDeleteDialog(testPage, title); - await assertContainedTouchDialog(dialog); - await deleteDamagedTaskWithPendingCleanup(testPage, dialog, task.id, prCapture); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); - await backend.restart(); - expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); - assertRetained(); + const { task, assertRetained, restore } = await createDamagedDeleteTask( + apiClient, + seedData, + title, + ); + try { + await testPage.goto(`/t/${task.id}`); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + const dialog = await openDeleteDialog(testPage, title); + await assertContainedTouchDialog(dialog); + await deleteDamagedTaskWithPendingCleanup(testPage, dialog, task.id, prCapture); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + await backend.restart(); + expect((await apiClient.rawRequest("GET", `/api/v1/tasks/${task.id}`)).status).toBe(404); + assertRetained(); + } finally { + restore(); + } }); diff --git a/apps/web/e2e/tests/task/threads-clarification-helpers.ts b/apps/web/e2e/tests/task/threads-clarification-helpers.ts index 8752096fa4c..b39e302cb81 100644 --- a/apps/web/e2e/tests/task/threads-clarification-helpers.ts +++ b/apps/web/e2e/tests/task/threads-clarification-helpers.ts @@ -183,10 +183,12 @@ type QuestionInput = "wheel" | "touch" | "keyboard"; async function scrollQuestion(page: Page, tile: Locator, direction: number, touch: boolean) { const area = await tile.getByTestId("clarification-scroll-region").evaluate((inner) => { - const box = inner.getBoundingClientRect(); - const footer = inner - .closest('[data-testid="thread-footer-allocation"]')! - .getBoundingClientRect(); + const footerElement = inner.closest('[data-testid="thread-footer-allocation"]')!; + const scrollOwner = inner.closest('[data-scroll-owner="thread-footer"]') + ? footerElement + : inner; + const box = scrollOwner.getBoundingClientRect(); + const footer = footerElement.getBoundingClientRect(); const top = Math.max(box.top, footer.top, 0); const bottom = Math.min(box.bottom, footer.bottom, innerHeight); return { x: box.x + box.width / 2, y: (top + bottom) / 2, height: bottom - top }; diff --git a/apps/web/hooks/domains/session/use-task-session-mcp-selection.test.ts b/apps/web/hooks/domains/session/use-task-session-mcp-selection.test.ts index bc2eb3e1054..514624b9752 100644 --- a/apps/web/hooks/domains/session/use-task-session-mcp-selection.test.ts +++ b/apps/web/hooks/domains/session/use-task-session-mcp-selection.test.ts @@ -230,3 +230,88 @@ it("retains a failed session draft and does not poll away its conflict", async ( expect(vi.mocked(getMCPSelections).mock.calls.length).toBe(count); expect(result.current?.error).toBe(conflict); }); + +// @covers AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.1, AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.5 +it("keeps exclusion actions committed until save succeeds and retains them after failure", async () => { + vi.mocked(getMCPSelections).mockImplementation(async (scope, owner, workspace) => ({ + ...response(scope, owner, workspace), + ...(scope === "task_session" + ? { excluded_definition_ids: [], selection_revision: 2 } + : { definition_ids: ["docs"] }), + })); + const { result } = renderHook(() => useTaskSessionMCPSelection(context)); + await act(async () => {}); + let resolve!: (value: MCPSelectionResponse) => void; + vi.mocked(replaceMCPSelections).mockReturnValue( + new Promise((yes) => { + resolve = yes; + }), + ); + act(() => result.current?.onExcludedIdsChange?.(["docs"])); + expect(result.current?.excludedIds).toEqual([]); + expect(result.current?.disabled).toBe(true); + expect(replaceMCPSelections).toHaveBeenLastCalledWith( + "task_session", + "session-a", + WORKSPACE, + [], + { expected_revision: 2, excluded_definition_ids: ["docs"] }, + ); + await act(async () => + resolve({ + ...response("task_session", "session-a", WORKSPACE), + excluded_definition_ids: ["docs"], + selection_revision: 3, + }), + ); + expect(result.current?.excludedIds).toEqual(["docs"]); + vi.mocked(replaceMCPSelections).mockRejectedValue(new Error("save failed")); + await act(async () => result.current?.onExcludedIdsChange?.([])); + expect(result.current?.excludedIds).toEqual(["docs"]); + expect(result.current?.error).toBeTruthy(); +}); + +it.each(["succeeds", "fails"])( + "retries saved application without replacing a rejected draft when retry %s", + async (outcome) => { + const saved = { + ...response("task_session", "session-a", WORKSPACE), + definition_ids: ["saved"], + excluded_definition_ids: ["inherited"], + selection_revision: 2, + mcp_state: { desired_revision: 2, applied_revision: 1, apply_state: "failed" as const }, + }; + vi.mocked(getMCPSelections).mockImplementation(async (scope, owner, workspace) => + scope === "task_session" ? saved : response(scope, owner, workspace), + ); + const { result } = renderHook(() => useTaskSessionMCPSelection(context)); + await act(async () => {}); + const staleRetry = result.current?.retry; + vi.mocked(replaceMCPSelections).mockRejectedValueOnce(new Error("save rejected")); + await act(async () => result.current?.onSelectedIdsChange(["draft"])); + expect(result.current?.selectedIds).toEqual(["draft"]); + await act(async () => staleRetry?.()); + expect(result.current?.selectedIds).toEqual(["draft"]); + expect(result.current?.retry).toBeUndefined(); + expect(replaceMCPSelections).toHaveBeenCalledTimes(1); + await act(async () => result.current?.reload()); + expect(result.current?.selectedIds).toEqual(["draft"]); + expect(result.current?.retry).toBeTypeOf("function"); + if (outcome === "succeeds") { + vi.mocked(replaceMCPSelections).mockResolvedValue({ ...saved, selection_revision: 3 }); + } else { + vi.mocked(replaceMCPSelections).mockRejectedValue(new Error("retry rejected")); + } + await act(async () => result.current?.retry?.()); + expect(replaceMCPSelections).toHaveBeenLastCalledWith( + "task_session", + "session-a", + WORKSPACE, + ["saved"], + { expected_revision: 2 }, + ); + expect(result.current?.selectedIds).toEqual(["draft"]); + expect(result.current?.savedSelectedIds).toEqual(["saved"]); + expect(result.current?.excludedIds).toEqual(["inherited"]); + }, +); diff --git a/apps/web/hooks/domains/session/use-task-session-mcp-selection.ts b/apps/web/hooks/domains/session/use-task-session-mcp-selection.ts index 171c2e18045..a2bfd082372 100644 --- a/apps/web/hooks/domains/session/use-task-session-mcp-selection.ts +++ b/apps/web/hooks/domains/session/use-task-session-mcp-selection.ts @@ -90,19 +90,36 @@ export function useTaskSessionMCPSelection(context: SessionMCPContext) { ); const draft = useMCPSelectionDraft(contextKey, editor.selection); const error = editor.saveError ?? editor.loadError ?? definitions.error; - const state = editor.selection?.mcp_state; - usePendingSelectionRefresh( - state, - editor.reload, - editor.saving || Boolean(editor.saveError) || isHandledApiError(error), - ); + const { + mcp_state: state, + definition_ids: savedSelectedIds = [], + excluded_definition_ids: excludedIds = [], + } = editor.selection ?? {}; + const blocked = editor.saving || Boolean(editor.saveError); + usePendingSelectionRefresh(state, editor.reload, blocked || isHandledApiError(error)); const inherited = useMemo( () => Object.entries(editor.inheritedOrigins).flatMap(([id, origins]) => { const definition = definitions.definitions.find((item) => item.id === id); - return definition ? [{ definition, origins }] : []; + const ownOrigin = + editor.selection?.definition_ids.includes(id) && context.sessionId && ownerWorkspace + ? [ + { + scope: "task_session" as const, + workspace_id: ownerWorkspace, + owner_id: context.sessionId, + }, + ] + : []; + return definition ? [{ definition, origins: [...origins, ...ownOrigin] }] : []; }), - [editor.inheritedOrigins, definitions.definitions], + [ + editor.inheritedOrigins, + editor.selection, + definitions.definitions, + context.sessionId, + ownerWorkspace, + ], ); const save = useCallback( (ids: string[]) => { @@ -114,7 +131,12 @@ export function useTaskSessionMCPSelection(context: SessionMCPContext) { }, [editor.save, draft.setSelectedIds, draft.acknowledge], ); - const savedSelectedIds = editor.selection?.definition_ids ?? []; + const onExcludedIdsChange = useCallback( + (ids: string[]) => { + void editor.save(editor.selection?.definition_ids ?? [], ids).catch(() => undefined); + }, + [editor.save, editor.selection], + ); if (!available) return null; return { contextKey, @@ -124,12 +146,14 @@ export function useTaskSessionMCPSelection(context: SessionMCPContext) { definitionsLoading: definitions.loading || editor.loading, selectedIds: draft.selectedIds, savedSelectedIds, + excludedIds, + onExcludedIdsChange, inherited, onSelectedIdsChange: save, state, - disabled: editor.saving || Boolean(editor.saveError), + disabled: blocked, reload: editor.reload, - retry: () => save(savedSelectedIds), + retry: blocked ? undefined : () => void editor.save(savedSelectedIds).catch(() => undefined), retrying: editor.saving, error, }; diff --git a/apps/web/hooks/domains/workspace/use-mcp-selection-editor.test.ts b/apps/web/hooks/domains/workspace/use-mcp-selection-editor.test.ts index ee3e3985cc9..a15474a7276 100644 --- a/apps/web/hooks/domains/workspace/use-mcp-selection-editor.test.ts +++ b/apps/web/hooks/domains/workspace/use-mcp-selection-editor.test.ts @@ -254,3 +254,37 @@ describe("conditional selection writes", () => { }); }); }); + +it("saves explicit session exclusions with the accepted revision and preserves omission", async () => { + const initial = { ...selection(), excluded_definition_ids: ["inherited"] }; + vi.mocked(getMCPSelections).mockResolvedValue(initial); + vi.mocked(replaceMCPSelections).mockResolvedValue({ + ...initial, + excluded_definition_ids: [], + selection_revision: 3, + }); + const { result } = renderHook(() => + useMCPSelectionEditor("task_session", "session-a", WORKSPACE_A), + ); + await waitFor(() => expect(result.current.selection).toEqual(initial)); + await act(async () => { + await result.current.save(["saved-mcp"], []); + }); + expect(replaceMCPSelections).toHaveBeenLastCalledWith( + "task_session", + "session-a", + WORKSPACE_A, + ["saved-mcp"], + { expected_revision: 2, excluded_definition_ids: [] }, + ); + await act(async () => { + await result.current.save(["saved-mcp"]); + }); + expect(replaceMCPSelections).toHaveBeenLastCalledWith( + "task_session", + "session-a", + WORKSPACE_A, + ["saved-mcp"], + { expected_revision: 3 }, + ); +}); diff --git a/apps/web/hooks/domains/workspace/use-mcp-selection-editor.ts b/apps/web/hooks/domains/workspace/use-mcp-selection-editor.ts index 0c246cd556a..c0d11622f33 100644 --- a/apps/web/hooks/domains/workspace/use-mcp-selection-editor.ts +++ b/apps/web/hooks/domains/workspace/use-mcp-selection-editor.ts @@ -48,6 +48,7 @@ function resetMCPSelectionEditor({ async function saveMCPSelection({ definitionIds, + excludedDefinitionIds, ownerId, workspaceId, scope, @@ -62,6 +63,7 @@ async function saveMCPSelection({ setSaving, }: { definitionIds: string[]; + excludedDefinitionIds?: string[]; ownerId: string; workspaceId: string; scope: MCPSelectionScope; @@ -100,6 +102,9 @@ async function saveMCPSelection({ ? { expected_revision: accepted.selection_revision ?? accepted.mcp_state?.desired_revision ?? 0, + ...(excludedDefinitionIds !== undefined + ? { excluded_definition_ids: excludedDefinitionIds } + : {}), } : { expected_definition_ids: accepted.definition_ids }; const next = await replaceMCPSelections(scope, ownerId, workspaceId, definitionIds, condition); @@ -149,10 +154,11 @@ function useMCPSelectionSave({ setSaving: (value: boolean) => void; }) { return useCallback( - (definitionIds: string[]) => { + (definitionIds: string[], excludedDefinitionIds?: string[]) => { if (!ownerId || !workspaceId) throw new Error("MCP selection context is required"); return saveMCPSelection({ definitionIds, + excludedDefinitionIds, ownerId, workspaceId, scope, diff --git a/apps/web/lib/api/domains/mcp-api.test.ts b/apps/web/lib/api/domains/mcp-api.test.ts index 36ad1345db1..9440ade9819 100644 --- a/apps/web/lib/api/domains/mcp-api.test.ts +++ b/apps/web/lib/api/domains/mcp-api.test.ts @@ -7,8 +7,9 @@ afterEach(() => vi.unstubAllGlobals()); it.each([ ["repository", { expected_definition_ids: [] }], ["task_session", { expected_revision: 0 }], + ["task_session", { expected_revision: 2, excluded_definition_ids: [] }], ] satisfies Array<["repository" | "task_session", import("./mcp-api").MCPSelectionWriteOptions]>)( - "serializes a known-empty %s precondition and omits exclusions", + "serializes a known-empty %s condition and only explicit exclusions", async (scope, condition) => { const fetcher = vi .fn() diff --git a/apps/web/lib/api/domains/mcp-api.ts b/apps/web/lib/api/domains/mcp-api.ts index e65b186e7e3..e2108e9407e 100644 --- a/apps/web/lib/api/domains/mcp-api.ts +++ b/apps/web/lib/api/domains/mcp-api.ts @@ -142,6 +142,7 @@ export async function getMCPSelections( export type MCPSelectionWriteOptions = ApiRequestOptions & { expected_definition_ids?: string[]; expected_revision?: number; + excluded_definition_ids?: string[]; }; export async function replaceMCPSelections( @@ -161,6 +162,7 @@ export async function replaceMCPSelections( definition_ids: definitionIds, expected_definition_ids: options?.expected_definition_ids, expected_revision: options?.expected_revision, + excluded_definition_ids: options?.excluded_definition_ids, }), }, }); diff --git a/apps/web/lib/types/http-mcp.ts b/apps/web/lib/types/http-mcp.ts index 68f7e58f74c..9f257bfd15f 100644 --- a/apps/web/lib/types/http-mcp.ts +++ b/apps/web/lib/types/http-mcp.ts @@ -100,6 +100,7 @@ export type MCPSetupComparison = { applied_revision: number; attachment_attempt_id?: string; selection_ids?: string[]; + excluded_definition_ids?: string[]; changes?: MCPSetupChange[]; }; @@ -120,6 +121,7 @@ export type MCPSelectionResponse = { owner_id: string; definition_ids: string[]; selection_revision?: number; + excluded_definition_ids?: string[]; mcp_state?: MCPSessionSelectionState; }; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 97839e97e51..2a63d89e816 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "Your saved setup has been applied to this agent. Connection status is shown in Connections.", "mcpSessionStatePendingDescription": "Your current setup stays in use. Saved changes apply when the agent is idle.", "mcpSessionStateDeferredDescription": "Your current setup stays in use. Saved changes apply when the agent starts again.", - "mcpSessionStateFailedDescription": "Your previous setup stays in use. Retry now or apply the changes on the next agent start.", + "mcpSessionStateFailedDescription": "The last accepted setup is kept for comparison. Retry now or apply the changes on the next agent start.", "mcpSessionRetry": "Retry MCP application", "deleting": "Deleting...", "mcpSessionAdditions": "Add to this session", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "Choose an existing task", "mcpAssignmentSearch": "Search owners", "mcpAssignmentDisabled": "Enable this server before choosing where to use it.", - "mcpAssignmentPrevious": "Previous" + "mcpAssignmentPrevious": "Previous", + "mcpExcludeForSession": "Exclude for this session", + "mcpRestoreInheritance": "Restore inheritance", + "mcpSessionExcluded": "Saved: excluded from this session", + "mcpSessionInherited": "Saved: included by inheritance", + "mcpSessionExclusionsDescription": "Exclusions apply only to this session and remain until restored. Disabling a server in workspace settings affects other sessions.", + "mcpRequiredToolsSeparate": "Required Kandev tools are managed separately. Independently configured provider servers are not controlled here.", + "mcpSavedExclusions": "Saved exclusions", + "mcpDormantExclusionsDescription": "These servers no longer contribute to this session. Restoring inheritance does not add them.", + "mcpNoLongerInherited": "No longer inherited", + "mcpRestoreDisabledDescription": "Restoring inheritance does not enable a disabled server.", + "mcpExclusionCount": "Saved session exclusions: {{total}}" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 8ea1c5d63c4..6bd5bfd3acf 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "This server's transport configuration could not be resolved.", "mcpReasonDefinitionDisabled": "This MCP server is disabled in the workspace.", "mcpReasonDefinitionMissing": "This MCP server definition is no longer available.", + "mcpReasonSessionExcluded": "Excluded from this session", "mcpReasonSecretResolution": "Required credentials could not be resolved.", "mcpTaskSelectionInfo": "Choose extra MCP servers for this task. Servers selected in its agent profile or repositories are included automatically.", "deleteKeepFilesOption": "Keep workspace files", diff --git a/apps/web/src/locales/ja/settings.json b/apps/web/src/locales/ja/settings.json index fa4b5127d62..08c81e73899 100644 --- a/apps/web/src/locales/ja/settings.json +++ b/apps/web/src/locales/ja/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "保存した設定をこのエージェントに適用しました。接続状態は「接続」で確認できます。", "mcpSessionStatePendingDescription": "現在の設定を引き続き使用します。保存した変更はエージェントがアイドル状態になったときに適用します。", "mcpSessionStateDeferredDescription": "現在の設定を引き続き使用します。保存した変更はエージェントの次回起動時に適用します。", - "mcpSessionStateFailedDescription": "以前の設定を引き続き使用します。今すぐ再試行するか、エージェントの次回起動時に変更を適用してください。", + "mcpSessionStateFailedDescription": "最後に受け入れられた設定を比較用に保持します。今すぐ再試行するか、エージェントの次回起動時に変更を適用してください。", "mcpSessionRetry": "MCP の適用を再試行", "deleting": "削除中...", "mcpSessionAdditions": "このセッションに追加", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "既存のタスクを選択", "mcpAssignmentSearch": "対象を検索", "mcpAssignmentDisabled": "使用先を選択する前にこのサーバーを有効にしてください。", - "mcpAssignmentPrevious": "前へ" + "mcpAssignmentPrevious": "前へ", + "mcpExcludeForSession": "このセッションから除外", + "mcpRestoreInheritance": "継承を復元", + "mcpSessionExcluded": "保存済み:このセッションから除外", + "mcpSessionInherited": "保存済み:継承により含まれています", + "mcpSessionExclusionsDescription": "除外はこのセッションにのみ適用され、復元するまで保持されます。ワークスペース設定でサーバーを無効にすると、他のセッションにも影響します。", + "mcpRequiredToolsSeparate": "必須の Kandev ツールは別途管理されます。プロバイダーで個別に設定されたサーバーはここでは制御できません。", + "mcpSavedExclusions": "保存された除外", + "mcpDormantExclusionsDescription": "これらのサーバーはこのセッションに継承されなくなりました。継承を復元しても追加されません。", + "mcpNoLongerInherited": "継承されていません", + "mcpRestoreDisabledDescription": "継承を復元しても、無効なサーバーは有効になりません。", + "mcpExclusionCount": "保存されたセッションの除外:{{total}}" } diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json index 80e3b5e5b64..e3292df84b0 100644 --- a/apps/web/src/locales/ja/task.json +++ b/apps/web/src/locales/ja/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "このサーバーの転送設定を解決できませんでした。", "mcpReasonDefinitionDisabled": "この MCP サーバーはワークスペースで無効になっています。", "mcpReasonDefinitionMissing": "この MCP サーバーの定義は利用できなくなりました。", + "mcpReasonSessionExcluded": "このセッションから除外されています", "mcpReasonSecretResolution": "必要な認証情報を解決できませんでした。", "mcpTaskSelectionInfo": "このタスクに追加する MCP サーバーを選択します。エージェントプロファイルやリポジトリで選択済みのサーバーは自動的に含まれます。", "deleteKeepFilesOption": "ワークスペースのファイルを保持", diff --git a/apps/web/src/locales/ko/settings.json b/apps/web/src/locales/ko/settings.json index 860b0ffcce7..941ca377db2 100644 --- a/apps/web/src/locales/ko/settings.json +++ b/apps/web/src/locales/ko/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "저장된 설정이 이 에이전트에 적용되었습니다. 연결 상태는 연결 탭에서 확인하세요.", "mcpSessionStatePendingDescription": "현재 설정을 계속 사용합니다. 에이전트가 유휴 상태가 되면 저장된 변경 사항이 적용됩니다.", "mcpSessionStateDeferredDescription": "현재 설정을 계속 사용합니다. 에이전트가 다시 시작되면 저장된 변경 사항이 적용됩니다.", - "mcpSessionStateFailedDescription": "이전 설정을 계속 사용합니다. 지금 다시 시도하거나 다음 에이전트 시작 시 변경 사항을 적용하세요.", + "mcpSessionStateFailedDescription": "마지막으로 수락된 설정은 비교를 위해 유지됩니다. 지금 다시 시도하거나 다음 에이전트 시작 시 변경 사항을 적용하세요.", "mcpSessionRetry": "MCP 적용 다시 시도", "deleting": "삭제 중...", "mcpSessionAdditions": "이 세션에 추가", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "기존 작업 선택", "mcpAssignmentSearch": "대상 검색", "mcpAssignmentDisabled": "사용 위치를 선택하기 전에 이 서버를 활성화하세요.", - "mcpAssignmentPrevious": "이전" + "mcpAssignmentPrevious": "이전", + "mcpExcludeForSession": "이 세션에서 제외", + "mcpRestoreInheritance": "상속 복원", + "mcpSessionExcluded": "저장됨: 이 세션에서 제외됨", + "mcpSessionInherited": "저장됨: 상속으로 포함됨", + "mcpSessionExclusionsDescription": "제외는 이 세션에만 적용되며 복원할 때까지 유지됩니다. 작업 공간 설정에서 서버를 비활성화하면 다른 세션에도 영향을 줍니다.", + "mcpRequiredToolsSeparate": "필수 Kandev 도구는 별도로 관리됩니다. 공급자에서 독립적으로 구성한 서버는 여기서 제어할 수 없습니다.", + "mcpSavedExclusions": "저장된 제외 항목", + "mcpDormantExclusionsDescription": "이 서버들은 더 이상 이 세션에 상속되지 않습니다. 상속을 복원해도 추가되지 않습니다.", + "mcpNoLongerInherited": "더 이상 상속되지 않음", + "mcpRestoreDisabledDescription": "상속을 복원해도 비활성화된 서버가 활성화되지는 않습니다.", + "mcpExclusionCount": "저장된 세션 제외 항목: {{total}}" } diff --git a/apps/web/src/locales/ko/task.json b/apps/web/src/locales/ko/task.json index c7f6f5060f2..245f2404172 100644 --- a/apps/web/src/locales/ko/task.json +++ b/apps/web/src/locales/ko/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "이 서버의 전송 설정을 확인할 수 없습니다.", "mcpReasonDefinitionDisabled": "이 MCP 서버는 작업 공간에서 비활성화되었습니다.", "mcpReasonDefinitionMissing": "이 MCP 서버 정의를 더 이상 사용할 수 없습니다.", + "mcpReasonSessionExcluded": "이 세션에서 제외됨", "mcpReasonSecretResolution": "필요한 자격 증명을 확인할 수 없습니다.", "mcpTaskSelectionInfo": "이 작업에 추가할 MCP 서버를 선택하세요. 에이전트 프로필이나 저장소에서 선택한 서버는 자동으로 포함됩니다.", "deleteKeepFilesOption": "작업 공간 파일 유지", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 94e3f3ce4b0..b2652c472fc 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "Ŷōũŕ śàvēď śēţũƥ ĥàś ƀēēń àƥƥĺĩēď ţō ţĥĩś àĝēńţ. Ćōńńēćţĩōń śţàţũś ĩś śĥōŵń ĩń Ćōńńēćţĩōńś.", "mcpSessionStatePendingDescription": "Ŷōũŕ ćũŕŕēńţ śēţũƥ śţàŷś ĩń ũśē. Śàvēď ćĥàńĝēś àƥƥĺŷ ŵĥēń ţĥē àĝēńţ ĩś ĩďĺē.", "mcpSessionStateDeferredDescription": "Ŷōũŕ ćũŕŕēńţ śēţũƥ śţàŷś ĩń ũśē. Śàvēď ćĥàńĝēś àƥƥĺŷ ŵĥēń ţĥē àĝēńţ śţàŕţś àĝàĩń.", - "mcpSessionStateFailedDescription": "Ŷōũŕ ƥŕēvĩōũś śēţũƥ śţàŷś ĩń ũśē. Ŕēţŕŷ ńōŵ ōŕ àƥƥĺŷ ţĥē ćĥàńĝēś ōń ţĥē ńēxţ àĝēńţ śţàŕţ.", + "mcpSessionStateFailedDescription": "Ţĥē ĺàśţ àććēƥţēď śēţũƥ ĩś ķēƥţ ƒōŕ ćōḿƥàŕĩśōń. Ŕēţŕŷ ńōŵ ōŕ àƥƥĺŷ ţĥē ćĥàńĝēś ōń ţĥē ńēxţ àĝēńţ śţàŕţ.", "mcpSessionRetry": "Ŕēţŕŷ ḾĆƤ àƥƥĺĩćàţĩōń", "deleting": "Ďēĺēţĩńĝ...", "mcpSessionAdditions": "Àďď ţō ţĥĩś śēśśĩōń", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "Ćĥōōśē àń ēxĩśţĩńĝ ţàśķ", "mcpAssignmentSearch": "Śēàŕćĥ ōŵńēŕś", "mcpAssignmentDisabled": "Ēńàƀĺē ţĥĩś śēŕvēŕ ƀēƒōŕē ćĥōōśĩńĝ ŵĥēŕē ţō ũśē ĩţ.", - "mcpAssignmentPrevious": "Ƥŕēvĩōũś" + "mcpAssignmentPrevious": "Ƥŕēvĩōũś", + "mcpExcludeForSession": "Ēxćĺũďē ƒōŕ ţĥĩś śēśśĩōń", + "mcpRestoreInheritance": "Ŕēśţōŕē ĩńĥēŕĩţàńćē", + "mcpSessionExcluded": "Śàvēď: ēxćĺũďēď ƒŕōḿ ţĥĩś śēśśĩōń", + "mcpSessionInherited": "Śàvēď: ĩńćĺũďēď ƀŷ ĩńĥēŕĩţàńćē", + "mcpSessionExclusionsDescription": "Ēxćĺũśĩōńś àƥƥĺŷ ōńĺŷ ţō ţĥĩś śēśśĩōń àńď ŕēḿàĩń ũńţĩĺ ŕēśţōŕēď. Ďĩśàƀĺĩńĝ à śēŕvēŕ ĩń ŵōŕķśƥàćē śēţţĩńĝś àƒƒēćţś ōţĥēŕ śēśśĩōńś.", + "mcpRequiredToolsSeparate": "Ŕēqũĩŕēď Ķàńďēv ţōōĺś àŕē ḿàńàĝēď śēƥàŕàţēĺŷ. Ĩńďēƥēńďēńţĺŷ ćōńƒĩĝũŕēď ƥŕōvĩďēŕ śēŕvēŕś àŕē ńōţ ćōńţŕōĺĺēď ĥēŕē.", + "mcpSavedExclusions": "Śàvēď ēxćĺũśĩōńś", + "mcpDormantExclusionsDescription": "Ţĥēśē śēŕvēŕś ńō ĺōńĝēŕ ćōńţŕĩƀũţē ţō ţĥĩś śēśśĩōń. Ŕēśţōŕĩńĝ ĩńĥēŕĩţàńćē ďōēś ńōţ àďď ţĥēḿ.", + "mcpNoLongerInherited": "Ńō ĺōńĝēŕ ĩńĥēŕĩţēď", + "mcpRestoreDisabledDescription": "Ŕēśţōŕĩńĝ ĩńĥēŕĩţàńćē ďōēś ńōţ ēńàƀĺē à ďĩśàƀĺēď śēŕvēŕ.", + "mcpExclusionCount": "Śàvēď śēśśĩōń ēxćĺũśĩōńś: {{total}}" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 2e7ea4f9bad..30deff997f3 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "Ţĥĩś śēŕvēŕ'ś ţŕàńśƥōŕţ ćōńƒĩĝũŕàţĩōń ćōũĺď ńōţ ƀē ŕēśōĺvēď.", "mcpReasonDefinitionDisabled": "Ţĥĩś ḾĆƤ śēŕvēŕ ĩś ďĩśàƀĺēď ĩń ţĥē ŵōŕķśƥàćē.", "mcpReasonDefinitionMissing": "Ţĥĩś ḾĆƤ śēŕvēŕ ďēƒĩńĩţĩōń ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē.", + "mcpReasonSessionExcluded": "Ēxćĺũďēď ƒŕōḿ ţĥĩś śēśśĩōń", "mcpReasonSecretResolution": "Ŕēqũĩŕēď ćŕēďēńţĩàĺś ćōũĺď ńōţ ƀē ŕēśōĺvēď.", "mcpTaskSelectionInfo": "Ćĥōōśē ēxţŕà ḾĆƤ śēŕvēŕś ƒōŕ ţĥĩś ţàśķ. Śēŕvēŕś śēĺēćţēď ĩń ĩţś àĝēńţ ƥŕōƒĩĺē ōŕ ŕēƥōśĩţōŕĩēś àŕē ĩńćĺũďēď àũţōḿàţĩćàĺĺŷ.", "deleteKeepFilesOption": "Ķēēƥ ŵōŕķśƥàćē ƒĩĺēś", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index f01daf9afc6..6d0a7ff0aaf 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "A configuração guardada foi aplicada a este agente. Consulte o estado das ligações em Ligações.", "mcpSessionStatePendingDescription": "A configuração atual continua em utilização. As alterações guardadas são aplicadas quando o agente estiver inativo.", "mcpSessionStateDeferredDescription": "A configuração atual continua em utilização. As alterações guardadas são aplicadas quando o agente voltar a arrancar.", - "mcpSessionStateFailedDescription": "A configuração anterior continua em utilização. Tente novamente ou aplique as alterações no próximo arranque do agente.", + "mcpSessionStateFailedDescription": "A última configuração aceite é mantida para comparação. Tente novamente ou aplique as alterações no próximo arranque do agente.", "mcpSessionRetry": "Tentar aplicação MCP novamente", "deleting": "A eliminar...", "mcpSessionAdditions": "Adicionar a esta sessão", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "Escolher uma tarefa existente", "mcpAssignmentSearch": "Pesquisar destinos", "mcpAssignmentDisabled": "Ative este servidor antes de escolher onde o usar.", - "mcpAssignmentPrevious": "Anterior" + "mcpAssignmentPrevious": "Anterior", + "mcpExcludeForSession": "Excluir desta sessão", + "mcpRestoreInheritance": "Restaurar herança", + "mcpSessionExcluded": "Guardado: excluído desta sessão", + "mcpSessionInherited": "Guardado: incluído por herança", + "mcpSessionExclusionsDescription": "As exclusões aplicam-se apenas a esta sessão e mantêm-se até serem restauradas. Desativar um servidor nas definições do espaço de trabalho afeta outras sessões.", + "mcpRequiredToolsSeparate": "As ferramentas obrigatórias do Kandev são geridas separadamente. Os servidores configurados diretamente no fornecedor não são controlados aqui.", + "mcpSavedExclusions": "Exclusões guardadas", + "mcpDormantExclusionsDescription": "Estes servidores já não contribuem para esta sessão. Restaurar a herança não os adiciona.", + "mcpNoLongerInherited": "Já não é herdado", + "mcpRestoreDisabledDescription": "Restaurar a herança não ativa um servidor desativado.", + "mcpExclusionCount": "Exclusões guardadas nesta sessão: {{total}}" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index e2c3cc9dea7..adceb3a28b9 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "Não foi possível resolver a configuração de transporte deste servidor.", "mcpReasonDefinitionDisabled": "Este servidor MCP está desativado no espaço de trabalho.", "mcpReasonDefinitionMissing": "Esta definição de servidor MCP já não está disponível.", + "mcpReasonSessionExcluded": "Excluído desta sessão", "mcpReasonSecretResolution": "Não foi possível resolver as credenciais necessárias.", "mcpTaskSelectionInfo": "Escolha servidores MCP adicionais para esta tarefa. Os servidores selecionados no perfil do agente ou nos repositórios são incluídos automaticamente.", "deleteKeepFilesOption": "Manter os ficheiros do espaço de trabalho", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 55ca3426bf7..43e080fed0c 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "已将保存的配置应用到此代理。连接状态显示在“连接”中。", "mcpSessionStatePendingDescription": "当前配置仍在使用。代理空闲时将应用已保存的更改。", "mcpSessionStateDeferredDescription": "当前配置仍在使用。代理再次启动时将应用已保存的更改。", - "mcpSessionStateFailedDescription": "之前的配置仍在使用。请立即重试,或在代理下次启动时应用更改。", + "mcpSessionStateFailedDescription": "保留上次接受的配置以供比较。请立即重试,或在代理下次启动时应用更改。", "mcpSessionRetry": "重试应用 MCP", "deleting": "正在删除...", "mcpSessionAdditions": "添加到此会话", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "选择现有任务", "mcpAssignmentSearch": "搜索目标", "mcpAssignmentDisabled": "请先启用此服务器,再选择使用位置。", - "mcpAssignmentPrevious": "上一页" + "mcpAssignmentPrevious": "上一页", + "mcpExcludeForSession": "仅对此会话排除", + "mcpRestoreInheritance": "恢复继承", + "mcpSessionExcluded": "已保存:已从此会话排除", + "mcpSessionInherited": "已保存:通过继承包含", + "mcpSessionExclusionsDescription": "排除仅适用于此会话,并持续到恢复为止。在工作区设置中禁用服务器会影响其他会话。", + "mcpRequiredToolsSeparate": "必需的 Kandev 工具单独管理。此处无法控制在提供商中独立配置的服务器。", + "mcpSavedExclusions": "已保存的排除项", + "mcpDormantExclusionsDescription": "这些服务器已不再由此会话继承。恢复继承不会添加它们。", + "mcpNoLongerInherited": "不再继承", + "mcpRestoreDisabledDescription": "恢复继承不会启用已禁用的服务器。", + "mcpExclusionCount": "已保存的会话排除项:{{total}}" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index f1e6c3050e5..21859aa3c57 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "无法解析此服务器的传输配置。", "mcpReasonDefinitionDisabled": "此 MCP 服务器已在工作区中禁用。", "mcpReasonDefinitionMissing": "此 MCP 服务器定义已不可用。", + "mcpReasonSessionExcluded": "已从此会话排除", "mcpReasonSecretResolution": "无法解析所需的凭据。", "mcpTaskSelectionInfo": "为此任务选择额外的 MCP 服务器。代理配置或仓库中已选择的服务器会自动包含在内。", "deleteKeepFilesOption": "保留工作区文件", diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 5a94d291313..1f9903497e9 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "已將儲存的設定應用到此代理。連接狀態顯示在「連接」中。", "mcpSessionStatePendingDescription": "目前設定仍在使用。代理空閒時將應用已儲存的更改。", "mcpSessionStateDeferredDescription": "目前設定仍在使用。代理再次啓動時將應用已儲存的更改。", - "mcpSessionStateFailedDescription": "之前的設定仍在使用。請立即重試,或在代理下次啓動時應用更改。", + "mcpSessionStateFailedDescription": "保留上次接受的設定以供比較。請立即重試,或在代理下次啓動時應用更改。", "mcpSessionRetry": "重試應用 MCP", "deleting": "正在刪除...", "mcpSessionAdditions": "新增到此工作階段", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "選擇現有任務", "mcpAssignmentSearch": "搜尋目標", "mcpAssignmentDisabled": "請先啓用此伺服器,再選擇使用位置。", - "mcpAssignmentPrevious": "上一頁" + "mcpAssignmentPrevious": "上一頁", + "mcpExcludeForSession": "僅對此工作階段排除", + "mcpRestoreInheritance": "恢復繼承", + "mcpSessionExcluded": "已儲存:已從此工作階段排除", + "mcpSessionInherited": "已儲存:通過繼承包含", + "mcpSessionExclusionsDescription": "排除僅適用於此工作階段,並持續到恢復為止。在工作區設定中停用伺服器會影響其他工作階段。", + "mcpRequiredToolsSeparate": "必需的 Kandev 工具單獨管理。此處無法控制在提供者中獨立設定的伺服器。", + "mcpSavedExclusions": "已儲存的排除項", + "mcpDormantExclusionsDescription": "這些伺服器已不再由此工作階段繼承。恢復繼承不會新增它們。", + "mcpNoLongerInherited": "不再繼承", + "mcpRestoreDisabledDescription": "恢復繼承不會啓用已停用的伺服器。", + "mcpExclusionCount": "已儲存的工作階段排除項:{{total}}" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 0c238662f15..fb4e7c051fb 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "無法解析此伺服器的傳輸設定。", "mcpReasonDefinitionDisabled": "此 MCP 伺服器已在工作區中停用。", "mcpReasonDefinitionMissing": "此 MCP 伺服器定義已不可用。", + "mcpReasonSessionExcluded": "已從此工作階段排除", "mcpReasonSecretResolution": "無法解析所需的認證資料。", "mcpTaskSelectionInfo": "為此任務選擇額外的 MCP 伺服器。代理設定或儲存庫中已選擇的伺服器會自動包含在內。", "deleteKeepFilesOption": "保留工作區檔案", diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 0f7b38499e6..21484a5bf35 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -1013,7 +1013,7 @@ "mcpSessionStateAppliedDescription": "已將儲存的設定應用到此代理。連線狀態顯示在「連線」中。", "mcpSessionStatePendingDescription": "目前設定仍在使用。代理空閒時將應用已儲存的更改。", "mcpSessionStateDeferredDescription": "目前設定仍在使用。代理再次啟動時將應用已儲存的更改。", - "mcpSessionStateFailedDescription": "之前的設定仍在使用。請立即重試,或在代理下次啟動時應用更改。", + "mcpSessionStateFailedDescription": "保留上次接受的設定以供比較。請立即重試,或在代理下次啟動時應用更改。", "mcpSessionRetry": "重試應用 MCP", "deleting": "正在刪除...", "mcpSessionAdditions": "新增到此工作階段", @@ -1105,5 +1105,16 @@ "mcpAssignmentChooseTask": "選擇現有任務", "mcpAssignmentSearch": "搜尋目標", "mcpAssignmentDisabled": "請先啟用此伺服器,再選擇使用位置。", - "mcpAssignmentPrevious": "上一頁" + "mcpAssignmentPrevious": "上一頁", + "mcpExcludeForSession": "僅對此工作階段排除", + "mcpRestoreInheritance": "恢復繼承", + "mcpSessionExcluded": "已儲存:已從此工作階段排除", + "mcpSessionInherited": "已儲存:通過繼承包含", + "mcpSessionExclusionsDescription": "排除僅適用於此工作階段,並持續到恢復為止。在工作區設定中停用伺服器會影響其他工作階段。", + "mcpRequiredToolsSeparate": "必需的 Kandev 工具單獨管理。此處無法控制在提供者中獨立設定的伺服器。", + "mcpSavedExclusions": "已儲存的排除項", + "mcpDormantExclusionsDescription": "這些伺服器已不再由此工作階段繼承。恢復繼承不會新增它們。", + "mcpNoLongerInherited": "不再繼承", + "mcpRestoreDisabledDescription": "恢復繼承不會啟用已停用的伺服器。", + "mcpExclusionCount": "已儲存的工作階段排除項:{{total}}" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 514be243cc9..2ee99f1365a 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2902,6 +2902,7 @@ "mcpReasonInvalidTransport": "無法解析此伺服器的傳輸設定。", "mcpReasonDefinitionDisabled": "此 MCP 伺服器已在工作區中停用。", "mcpReasonDefinitionMissing": "此 MCP 伺服器定義已不可用。", + "mcpReasonSessionExcluded": "已從此工作階段排除", "mcpReasonSecretResolution": "無法解析所需的認證資訊。", "mcpTaskSelectionInfo": "為此任務選擇額外的 MCP 伺服器。代理設定或儲存庫中已選擇的伺服器會自動包含在內。", "deleteKeepFilesOption": "保留工作區檔案", diff --git a/apps/web/src/settings-routes.bootstrap.tsx b/apps/web/src/settings-routes.bootstrap.tsx index 9662e31aacf..369abeb057e 100644 --- a/apps/web/src/settings-routes.bootstrap.tsx +++ b/apps/web/src/settings-routes.bootstrap.tsx @@ -12,10 +12,16 @@ export function SettingsRouteBootstrap({ pathname }: { pathname: string }) { let cancelled = false; async function bootstrap() { - const initialState = await loadSettingsInitialState( - () => store.getState().agentProfiles.version, - () => store.getState().workspaces.activeId, - ); + let initialState; + let workspaceItems; + do { + workspaceItems = store.getState().workspaces.items; + initialState = await loadSettingsInitialState( + () => store.getState().agentProfiles.version, + () => store.getState().workspaces.activeId, + ); + // A workspace mutation supersedes the in-flight list snapshot. + } while (!cancelled && workspaceItems !== store.getState().workspaces.items); if (cancelled || Object.keys(initialState).length === 0) return; const desiredWorkspaceId = initialState.workspaces?.activeId ?? null; const workspaceBeforeHydration = store.getState().workspaces.activeId; diff --git a/apps/web/src/settings-routes.workspace-revision.test.tsx b/apps/web/src/settings-routes.workspace-revision.test.tsx index 2d490facbea..191fe79c51e 100644 --- a/apps/web/src/settings-routes.workspace-revision.test.tsx +++ b/apps/web/src/settings-routes.workspace-revision.test.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { StoreApi } from "zustand"; import { StateProvider, useAppStoreApi } from "@/components/state-provider"; @@ -81,6 +81,35 @@ function stubApis() { // Consumers keyed on that revision (the Failed-inbox cache) could then render // a stale, pre-switch snapshot after navigating through Settings. describe("SettingsRouteBootstrap", () => { + it("does not overwrite workspace changes made while settings are loading", async () => { + stubApis(); + const existing = SETTINGS_WORKSPACES_RESPONSE.workspaces[0]; + const created = { ...existing, id: "ws-created", name: "Created during load" }; + const renamed = { ...existing, name: "Renamed during load" }; + let releaseExecutors!: (value: { executors: [] }) => void; + listExecutorsMock.mockReturnValueOnce( + new Promise<{ executors: [] }>((resolve) => { + releaseExecutors = resolve; + }), + ); + listWorkspacesMock + .mockResolvedValueOnce(SETTINGS_WORKSPACES_RESPONSE) + .mockResolvedValue({ workspaces: [created, renamed], total: 2 }); + const { Wrapper, getStore } = makeWrapper(SETTINGS_WORKSPACE_ID); + render(, { wrapper: Wrapper }); + await act(async () => { + getStore()!.getState().setWorkspaces([created, renamed]); + releaseExecutors({ executors: [] }); + }); + await waitFor(() => expect(getStore()!.getState().settingsData.executorsLoaded).toBe(true)); + expect( + getStore()! + .getState() + .workspaces.items.map(({ id, name }) => ({ id, name })), + ).toEqual([created, renamed].map(({ id, name }) => ({ id, name }))); + expect(getStore()!.getState().workspaces.activeId).toBe(SETTINGS_WORKSPACE_ID); + }); + it("routes the resolved active workspace through setActiveWorkspace, bumping activeIdRevision", async () => { stubApis(); listWorkspacesMock.mockResolvedValue(SETTINGS_WORKSPACES_RESPONSE); diff --git a/docs/decisions/2026-09-01-workspace-mcp-configuration.md b/docs/decisions/2026-09-01-workspace-mcp-configuration.md index ab029991c3f..5c5e9d9fa3a 100644 --- a/docs/decisions/2026-09-01-workspace-mcp-configuration.md +++ b/docs/decisions/2026-09-01-workspace-mcp-configuration.md @@ -35,7 +35,7 @@ install-wide with that profile. MCP definitions are workspace-owned reusable resources. Repository, profile, task, and task-session selections reference stable definition identifiers. -The runtime effective set is the additive union of: +The positive selection set is the additive union of: 1. selections from every repository attached to the task. 2. the effective agent profile's selection in the task workspace. @@ -44,7 +44,12 @@ The runtime effective set is the additive union of: The resolver collapses duplicate definition identifiers and retains every origin for diagnostics. Different definitions cannot share one runtime name in -the same workspace. Provider and executor transport policy runs after union. +the same workspace. The 2026-10-23 [session exclusion extension](../specs/agents/system-design/session-mcp-exclusions.md) +subtracts saved definition IDs for one existing session after union and before +secrets or preparation. It leaves selecting owners unchanged. Exclusions persist +until restored or deleted with the session/definition; this reversible scope +exception does not introduce precedence among selecting owners. Provider and +executor transport policy runs after that composition. Kandev's internal MCP endpoint remains separately injected under a reserved name. diff --git a/docs/plans/session-mcp-exclusions/plan.md b/docs/plans/session-mcp-exclusions/plan.md new file mode 100644 index 00000000000..59e5164c0df --- /dev/null +++ b/docs/plans/session-mcp-exclusions/plan.md @@ -0,0 +1,1121 @@ +--- +created: 2026-10-22 +status: completed +requirements: + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-003 +system_design: + - ../../specs/agents/system-design/session-mcp-exclusions.md +legacy_specs: [] +--- + +# Implementation Plan: Session MCP Exclusions + +## Overview + +Allow an existing task agent session to exclude inherited catalog definitions +and restore inheritance. Implement the saved contract and resolver first, +prove lifecycle acceptance second, then expose the desktop and phone controls. +Implementation was authorized by the user on 2026-10-23. + +The [requirements](../../specs/agents/requirements/session-mcp-exclusions.md) +and [design](../../specs/agents/system-design/session-mcp-exclusions.md) define +the implementation contract. + +## Baseline and coordination + +- Task: `15641d1a-d7ba-4216-aefd-5a4c36a88d73`. +- Workspace: `/home/zeval/.kandev/tasks/allow-session-only-i_ae4fhzed/pr3293-followup-source-20261022`. +- Feature branch: `feature/allow-session-only-i-2gn`. +- Fetched parent and initial clean HEAD: `193de56ba2597b335a97b1e5ab01d09340f22647`. +- Integrated assignment WO01: `f9137074309bebe99e2c7576baefe936bc20358f`. +- User agreed to stack on assignment. Planned PR target: + `feature/clarify-mcp-assignme-mhh`. The original parent branch stays unchanged. + +The parent merged as `0b81ae48d4e2c358f80ac489e39388dfd8d7e6ab`. The local +stack now includes that squash through the rebased assignment branch. Keep the +PR target on assignment while it is unmerged; after assignment merges, reconcile +its actual merge and retarget to main without replaying dependency commits. No merge is authorized. Preserve other edits and +commits. Do not change the parent branch or sibling workspaces. No extra agents, +tasks, sessions, or autopilot are authorized. + +Read sibling saved plans for interface alignment: management clarity +`000d477e-f792-4c9d-8503-50ed73bb9646`, registration assignment +`5a8cb5cd-22ee-42e5-bd6d-e2179e899c8c`, and built-in management +`6d1b4323-e821-4599-acea-07273a60dd78`. Registration assignment work order 01, +`docs/plans/mcp-assignment-after-registration/task-01-guard-selection-replacement.md`, +is the committed implementation prerequisite for local work order 01. It owns +conditional replacement, session revision admission, exact committed responses, +consistent GET snapshots, locks, and conflict/retry semantics. This package +extends that primitive for exclusions. Assignment work orders 02/03 and the +other siblings are not prerequisites. Never copy sibling uncommitted code. +Omitted exclusion fields preserve saved exclusions in additive writes. + +## Scope + +### In scope + +- Session-owned persistence, atomic writes, validation, and reversible suppression. +- Origins from all repositories/profile/task/session, disabled and dormant cases. +- Desired/accepted comparison and existing safe idle/next-start application. +- Existing-session Customize actions, phone parity, localization, and focused tests. +- User documentation and activation of the narrow parent-contract exception. + +### Out of scope + +- Upstream exclusions, creation-time exclusions, and per-definition overrides. +- Built-in/tool management, native provider config editing, or forced revocation. +- Catalog usage navigation, profile management, and registration assignment. +- New reconnect protocols, automatic restarts, probes, and broad verification suites. + +## Assumption check + +Confirmed: only one session changes, origins stay visible, reversal is required, +active turns remain immutable, and built-in management belongs to a sibling. + +Verified: the parent resolver unions positive IDs. Accepted snapshots and +provider-aware application already exist. The original session handler lacked client CAS; assignment WO01 supplies it. +The session body is reused during creation and needs explicit exclusion authority. + +Design choices: exclusions last for the session identity until restored. Dormant +exclusions persist. Required Kandev capabilities remain outside the control. +These choices were in the completed package before the later implementation +request. No material design decision remains unresolved. + +## Technical approach + +### Saved state and effective composition + +Extend `internal/agent/mcpconfig` and `internal/agent/settings/store` with the +session exclusion relation. Read and write it atomically with desired revision +and positive session IDs. Reuse the assignment primitive's session +`selection_revision` / `expected_revision`, mapped to existing desired revision, +for exclusion-aware writes. Do not introduce a second counter or session +membership precondition. Non-session membership CAS remains assignment-owned. +This explicitly supersedes the crossed `expected_excluded_definition_ids` +proposal and whichever-lands-first ordering. Session ID-set preconditions are +rejected; assignment work order 01 remains the single primitive owner. +Old additions-only writes preserve E under the same store lock. + +Extend `mcp_selection_handlers.go` and the session response. Keep other scopes +additive. Extend the consistent `ReadMCPResolutionInputs` snapshot, subtract by +ID before secret resolution, and generate `session_excluded` decisions without +removing origins. Share the post-exclusion setup builder with comparison. + +Session cleanup needs a narrow subscription to the existing `events.SessionRemoved` +in `registerMCPDeletionCleanup` and a settings-store session cleanup operation. +Task/workspace/definition cleanup must also handle exclusion-only sessions. + +### Delivery acceptance + +Retain the prepared-operation boundary and existing accepted-setup persistence. +Exercise startup, resume/load, reset, actual restart, rebind, and live adoption +with excluded definitions. Keep snapshot version 1 and extend comparison's +saved-identity guard with exclusion IDs. A failed operation retains the accepted +baseline without claiming runtime rollback. + +| Delivery path | Exclusion behavior | Focused evidence | Fallback | +| --- | --- | --- | --- | +| ACP with eligible resume/load | Complete post-exclusion set at idle | Lifecycle fake client captures exact RPC and revision | Failed state retains baseline and retry | +| ACP without advertised support | Save E, no reconnect | Unsupported reconnect test | Next actual start | +| ACP with project MCP strategy | No same-process file-reload claim | Project reconnect test | Next actual start | +| Local/SSH passthrough | Prepare post-exclusion set on actual start | Existing passthrough operation tests | Save deferred state while running | +| Retained process adoption | Preserve current delivered setup | Acceptance/adoption guard regression | Pending choice remains until eligible application | +| Native provider or synthetic legacy configuration | No new catalog removal guarantee | Legacy alias and eligibility regression | Explain control's catalog-only scope | + +Provider names do not establish reconnect support. Mock transport evidence does +not certify a live provider connection. No container suite is required for the +unchanged executor contract. A changed executor boundary needs a revised work order. + +### Presentation + +Extend the shared session controller, typed API, and origin rows. Exclusion +authority is an explicit existing-session capability, not `sessionPresentation`. +Keep connections, pending status, and configuration eligibility separate. +Use existing dialog/drawer primitives and shared Save ownership elsewhere. + +## ASCII UI preview + +UI-01: Desktop. Entry: composer plug > Customize. State: saved exclusion while busy. + +```text ++--------------------------------------------------------------------+ +| MCP servers [Close] | +| [Connections] [Customize] | ++--------------------------------------------------------------------+ +| Changes waiting to apply | +| Remove Atlas Docs from setup. Applies after this turn. | +| | +| This session only. Exclusions remain until you restore them. | +| Selected for this task | +| Atlas Docs [Restore inheritance] | +| atlas-docs Remote endpoint SSE | +| Selected by: Task Alpha | +| Also selected by: Repository app; Repository api; Profile Review | +| Saved: excluded from this session | +| Last accepted setup still includes this definition. | +| | +| From agent profile | +| Review Search [Exclude for this session] | +| Saved: follows inheritance | +| | +| Additional servers | +| [Search additional servers ] | +| [ ] Other server | ++--------------------------------------------------------------------+ +``` + +UI-02: Phone/coarse pointer. Same entry and state. The full-height phone drawer +and 80dvh tablet drawer use one column, with actions below each row's details. + +```text ++----------------------------------+ +| MCP servers [Close] | +| [Connections] [Customize] | ++----------------------------------+ +| Changes waiting to apply | +| Remove Atlas Docs from setup | +| Applies after this turn | +| | +| This session only | +| Until you restore inheritance | +| Selected for this task | +| Atlas Docs | +| Task Alpha | +| Also: app, api, Review profile | +| Saved: excluded | +| Still in last accepted setup | +| [Restore inheritance ] | +| | +| Additional servers | +| [Search ] | +| [ ] Other server | +| safe-area clearance | ++----------------------------------+ +``` + +Required structure: fixed header/view controls, one body scroller, unchanged row +order, all origins, explicit action labels, and pending actions before choices. +Phone actions measure at least 44 by 44px. Desktop ordinary actions measure +28px high. The current-setup line requires a matching known comparison. Text, +spacing, example names, and exact decoration are illustrative and localized. + +UI-03: Material row and application states (inside the same body). + +```text +Excluded + workspace disabled: + Saved: excluded. Definition disabled for this workspace. + [Restore inheritance] Restoring will not enable the definition. +Dormant: + Saved exclusions / Atlas Docs / No longer inherited + [Restore inheritance] Does not add a server. +Failed application: + Saved change could not be confirmed. Last accepted setup retained. + [Retry] Or apply on the next agent start. +Conflict/rejection: + Saved choices changed elsewhere / Could not save. + Authoritative choices remain visible. Repeat the edit after refresh. +Unknown comparison: + Saved: excluded. Exact changes cannot be compared yet. +Empty inheritance: + No inherited servers. Additional servers remain available. +Initial load / refresh: + Loading on first read only. Refresh keeps existing rows and focus. +``` + +UI-01/02/03 cover AC-003.1 through .5 and AC-002.4/.5. The required built-in +explanation is passive text, not a new built-in management row. + +## Tests + +All new test names below are planned tests, not existing coverage. Use TDD after +the implementation request. Existing neighboring tests are regression inputs. + +| Acceptance criteria | Planned test and evidence | +| --- | --- | +| AC-001.1/.2 | `session_exclusions_test.go`: `TestSessionMCPExclusionsUnion` covers duplicate contributors, all repositories, session overlap, and sibling isolation | +| AC-001.3/.4/.5 | Store `mcp_session_exclusions_test.go`: `TestSessionMCPExclusionsPersistence` covers replay, reopen, dormant/disabled state, delete/recreate, session/workspace cleanup | +| AC-001.6 | Store and handler `TestSessionMCPExclusionsConflict`/`Authorization` cover stale revision, lost response recovery, mixed-client writers, exclusion-only revision advance, same-set Retry, rejected payload, and omitted-field compatibility | +| AC-001.7, AC-002.6 | Resolver `TestSessionMCPExclusionsDelivery` covers secret lookup suppression, no executable entry, reserved/synthetic targets, legacy alias guard, native-config boundary | +| AC-002.1/.2/.3/.4 | Lifecycle `mcp_session_exclusions_test.go`: `TestSessionMCPExclusionsLifecycle` covers active turn, idle RPC, restart, failure, overlapping saves, stale generation, and deferred paths | +| AC-002.5 | `setup_comparison_test.go` and `mcp-selection-changes.test.ts`: exclusion/restoration, known empty, disabled intent, unknown baseline, bounds, stale exclusion IDs | +| AC-003.1/.2/.5 | `mcp-session-exclusions.test.tsx`, editor/context tests: explicit action, retained origins, dormant/disabled rows, failed save, conflict reload, session switch, stable refresh | +| AC-003.3/.4 | Rendered desktop/phone tests: drawer/dialog geometry, keyboard/touch outcome, focus return, measured hit areas, long translations | + +IDs in the table use the `AC-AGENTS-SESSION-MCP-EXCLUSIONS-` prefix. +Real PostgreSQL tests require a disposable `KANDEV_TEST_POSTGRES_DSN`; an unset +variable produces a skip, not PostgreSQL evidence. + +## E2E tests + +Add `tests/chat/mcp-session-exclusions.spec.ts` for Chromium and +`tests/chat/mobile-mcp-session-exclusions.spec.ts` for mobile-chrome. Reuse +`fixtures/test-base`, `helpers/mcp-pending-changes.ts`, and the real saved-selection +API. Seed separate disposable definitions/profiles, two repositories, and sibling +sessions. Restore any modified shared fixture state. + +1. Exclude a multiply inherited definition during a busy mock turn. Keep origins + visible, inspect pending removal, reload, and verify the sibling is unchanged. + After idle, verify accepted setup and restoration. Covers AC-001.1/.2/.3 and + AC-002.1/.2/.3/.5, AC-003.1/.2. +2. Exclude a disabled definition, re-enable upstream, and verify exclusion persists. + Remove its contributors, restore the dormant exception, and verify no new + positive selection. Covers AC-001.4/.5. +3. Exercise local save rejection, 409 conflict, failed application/retry, next-start + deferral, and unknown comparison. Presentation injection is allowed for failure + labels, but lifecycle tests must prove provider semantics. Covers AC-001.6, + AC-002.4/.5 and AC-003.5. +4. Repeat primary exclusion/restoration by touch and keyboard. Check 320px, + canonical phone, 767px fine pointer, 768px boundary, and 900px coarse pointer. + Inspect one scroller, final-row reachability, focus return, and light/dark/pseudo + wrapping. Covers AC-003.3/.4/.5. + +Run changed desktop and mobile specs with existing MCP selection and pending-change +neighbors. Do not overlap E2E runs. Managed runners own isolated runtime ports. +Never use main :9998 or historical :48593/:48595 fixtures. + +## Work orders + +- [x] [Task 01: Persist and resolve session exclusions](task-01-persist-and-resolve.md) +- [x] [Task 02: Preserve application and recovery truth](task-02-application-recovery.md) +- [x] [Task 03: Expose responsive exclusion controls](task-03-responsive-controls.md) + +Dependency order: committed assignment work order 01 -> local 01 -> 02 -> 03. +Reverify the integrated primitive and parent tip before implementation. This is +a source dependency, not permission to implement or to merge a sibling PR. +All three work orders passed their task-defined focused checks after integration +of assignment commit `f9137074309bebe99e2c7576baefe936bc20358f`. + +## Documentation impact + +Task 03 updates the MCP selection +how-to section in `docs/public/agents-and-profiles.md` with scope, persistence, +restore, disabled definitions, timing, failure, and native/built-in limits. +Inspect `sessions-and-review.md`, `feature-status.md`, root README, and screenshots +for contradictory claims. Do not fabricate new screenshots. + +At implementation completion, reconcile the parent requirement clauses listed +in the new requirement, parent selection/pending designs, and the original ADR. +Keep additive ownership and historical parent plan/test receipts intact. Promote +the new requirement/design only when all three work orders pass. + +## Verification results + +Design validation on 2026-10-22: + +- `python3 scripts/list-docs.py validate`: passed, including both new specifications. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests. +- `validateCoverage` from `.github/scripts/pr-docs.cjs`: actual docs-only changes + are exempt. A preflight using the six documents and an anticipated + `mcpconfig/selections.go` change returned covered for all three work orders, + with no reference errors. This is a package-contract check, not a code diff. +- `git diff --check -- docs/specs docs/plans/session-mcp-exclusions`: passed. + Each untracked file also passed `git diff --no-index --check /dev/null `. +- Local Markdown target checks passed for all six new files. + +Coordination revision on 2026-10-22: assignment work order 01 owns the shared +conditional-write primitive. This package consumes its committed result. The +session revision remains existing desired revision; Retry retains same-set +application semantics. Catalog validation and specification lint passed again +after this reconciliation. All six files passed local-link and whitespace checks; +the documentation-coverage preflight remains covered for all three work orders. + +At that design checkpoint, product implementation and runtime tests had not run. +The records below supersede that historical status. + +## Risks + +- A shared handler can accidentally clear E when an older client saves additions. +- Removing origins too early can let legacy fallback reintroduce an excluded server. +- Partial provider acceptance can make a rollback claim false despite retained baseline. +- Exclusion-only sessions can escape cleanup that searches positive associations. +- Sibling edits can overlap shared selection types, copy, and parent documents. + + +## Implementation verification: 2026-10-23 + +- Backend selection, setup, exclusion, cleanup, and lifecycle suites passed with race detection. +- Real PostgreSQL mixed writers admitted one revision and rejected its stale competitor; SQLite disk reopen preserved effective suppression and restored delivery eligibility. +- SQLguard and settings-store conformance passed. Changed-Go lint passed with zero issues. +- Frontend focused suites passed: 59 cases across eight files, plus the final hook rerun after a complexity-only refactor. +- Typecheck, changed-file ESLint with zero warnings, i18n completeness, and the new-copy ratchet passed. +- Managed desktop and neighbor browser suites: 13 passed. Managed phone and neighbor suites: 9 passed. Runs were sequential and used isolated fixtures. +- Public documentation validators passed (63 validator tests, 54 pages); specification catalog/lint and whitespace checks passed. + +The initial broad resolver run hit a fixture portability failure in +`TestExecNativeMCPCommandRunnerBoundsInheritedPipeAfterCancellation`: its explicit +`/usr/bin:/bin` PATH lacked `sleep` on this Nix host. A separate one-line test fix +preserves the host PATH. The exact required broad command then passed under race +for mcpconfig, settings/store, and settings/handlers. Production runner behavior +is unchanged. Mock providers do not establish live-provider health, immediate +tool revocation, or native-config removal. No merge, parent-branch change, +additional session, or worker occurred. + +Implementation refinement: exclusion actions display committed choices only and +require a repeated explicit action after Reload choices. Addition drafts retain +the prerequisite controller's behavior. Pending comparison uses committed IDs +rather than the retained draft, so rejected writes cannot hide saved changes. + +Public docs type: how-to. Updated `docs/public/agents-and-profiles.md`; root README, +feature-status/session guides, and screenshot catalog needed no changed instructions. +The parent verification ledger remains historical and untouched. + + +## Local stack rebase: 2026-10-24 + +The coordinating session requested the exclusions step after assignment completed +its authorized rebase. The worktree was clean. Before rewriting, created +`backup/session-exclusions-pre-assignment-rebase-20261024` at +`442e9f01f77bd091f9d12d8867fa027bc74cf83e`. + +Rebased only commits after OLD prerequisite +`f9137074309bebe99e2c7576baefe936bc20358f` onto verified assignment tip +`9c9abfb13b351ced2869185eb1e730f682e30637`, which contains parent squash +`0b81ae48d4e2c358f80ac489e39388dfd8d7e6ab`. The replacement prerequisite is +`23a403dc2224a668e7f35c60ff5892952608fd3b`; range-diff confirmed its patch is +identical to the old prerequisite. It was not used as the old range boundary. + +Commit mapping: + +- `8ef7c8d6980910f61803d87ab5e4d57bb376458e` -> + `3c88cd7fa833e0a94c6ba28186ce90f1a613c557` (feature). +- `442e9f01f77bd091f9d12d8867fa027bc74cf83e` -> + `b2670fffad2b8c0566d299ce41a167bd78fcaaac` (retained verification records). + +Conflicts were limited to eight settings locale files: preserved both assignment +registration keys and exclusion keys. Range-diff shows no other feature patch +change. The PATH portability fix already exists in assignment commit `c49beff60`; +Git omitted that redundant hunk and retained both exclusion verification records. +Committed-positive-ID comparison and neighboring E2E helper corrections are +unchanged. No sibling branch, remote history, or runtime was modified. + +Rebase validation: + +- Refreshed workspace dependencies with `pnpm install --frozen-lockfile`. +- Backend conditional writes, exclusions, resolver, handlers, and application + integration passed under race detection: + `go test -trimpath -race ./internal/agent/mcpconfig ./internal/agent/settings/store ./internal/agent/settings/handlers ./internal/backendapp -run 'MCP|Selection|Effective' -count=1`. +- Lifecycle acceptance/defer/retry regressions passed: + `go test -trimpath -race ./internal/agent/runtime/lifecycle -run 'MCP|Mcp' -count=1`. +- Actual disposable PostgreSQL 17.11 passed under race detection, not skipped: + `go test -trimpath -race ./internal/agent/settings/store -run 'PostgresMCPSessionExclusions|PostgresMCPSelection|PostgresMCPResolution|PostgresConfirmedMCPDelete' -count=1 -v`. + This includes concurrent positive/exclusion writers, legacy omission preserving + exclusions, reopen, cleanup, owner-scoped CAS, import, and deletion. +- Ten frontend suites passed, 68 tests: the original eight focused exclusion + suites plus registration completion and definition-form integration tests. +- `pnpm run typecheck`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed. +- Fresh managed desktop build and browser regressions passed, 13 cases: + `pnpm e2e:run --host --project chromium tests/chat/mcp-session-exclusions.spec.ts tests/chat/mcp-pending-changes.spec.ts tests/task/task-mcp-selection.spec.ts`. + Log: `/tmp/kandev-run.e2e.Lhc5dbMZ.log`. +- Phone browser regressions passed, 9 cases, sequentially against that fresh build: + `pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-mcp-session-exclusions.spec.ts tests/chat/mobile-mcp-pending-changes.spec.ts tests/task/mobile-task-mcp-selection.spec.ts`. + Log: `/tmp/kandev-run.e2e.ioZDYK52.log`. +- Specification catalog (403 decisions, 1732 specifications), full spec lint, + and whitespace checks passed. + +Existing work-order results above remain historical evidence. Rebase validation +does not establish live-provider health. Planned PR base remains +`feature/clarify-mcp-assignme-mhh`. No push or merge is authorized by this step. + + +## Draft publication stack: 2026-10-24 + +The coordinator relayed the user's updated publication request: all four MCP +follow-ups publish draft checkpoints, without CI/review waiting or handling. +The current stack is main -> `feature/improve-mcp-manageme-mpe` -> +`feature/clarify-mcp-assignme-mhh` -> `feature/allow-session-only-i-2gn`. +The exclusions PR targets assignment, not main. Management remains a design +checkpoint; assignment WO03 still awaits management navigation. This draft does +not claim those sibling work orders are complete. Exclusions WO01/02/03 are +implemented and retain their completed results. + +Verified remote assignment head: +`6faaa61b817dc9dddc60d2c12026cc1273748a13` (draft PR #4760), on management +`f7bbe6076477eebace8740a42adb206e7229e3e4` (draft PR #4759). +Backup `backup/exclusions-pre-publication-stack-20261024` preserves +`70bc5b29ee07cec8dae3db5e792f2826e864be3a`. Replayed only exclusions-owned +commits after OLD assignment cut point `9c9abfb13b351ced2869185eb1e730f682e30637` +onto the new assignment head. No conflicts occurred; range-diff shows all three +patches unchanged: + +- `3c88cd7fa833e0a94c6ba28186ce90f1a613c557` -> + `8deeb1d125fa8b5ad91be7568b1317c42162af77`. +- `b2670fffad2b8c0566d299ce41a167bd78fcaaac` -> + `8822bfaf2f0c4320ce26b4d6fddb29ef1fe81236`. +- `70bc5b29ee07cec8dae3db5e792f2826e864be3a` -> + `20b498ed916c99ecfa35af14aab82d8170ef0c4d`. + +Committed-positive-ID comparisons, exclusion-preserving conditional writes, and +neighbor E2E helper corrections are unchanged. No sibling branch was mutated. +The manual test instance at port 48671 was shut down at the user's request; +the main instance at port 9998 was untouched. + +Post-stack validation: + +- `go test -trimpath -race ./internal/agent/mcpconfig ./internal/agent/settings/store ./internal/agent/settings/handlers ./internal/backendapp -run 'MCP|Selection|Effective' -count=1`: passed. +- Actual disposable PostgreSQL 17.11 selection/exclusion concurrency, + resolution, import/deletion, and cleanup checks under race detection: passed. +- Ten focused frontend suites: 68 tests passed. Typecheck and i18n checks passed. +- Fresh managed desktop build and regressions, including desktop/phone screenshot + capture: 14 passed (`/tmp/kandev-run.e2e.0hXF3dVv.log`). Capture assets were + inspected and compressed. The temporary capture spec was removed. +- Phone regressions passed: 9 cases, sequentially against the same build + (`/tmp/kandev-run.e2e.M7qzBerK.log`). +- Documentation catalog (403 decisions, 1738 specifications), full spec lint, + and whitespace checks passed. + +Unchanged-source lifecycle, SQLguard, store-conformance, lint, and localization +ratchet receipts remain recorded in the earlier work-order results; those broad +checks were not repeated solely for this conflict-free rebase. Mock fixtures do +not establish live-provider health. The final remote SHA and draft URL are +recorded in the saved task plan and coordinator handoff after publication. +No CI watcher, reviewer request, auto-merge, or merge is part of this checkpoint. + + +## Assignment completion integration: 2026-10-25 + +The coordinator authorized routine integration after assignment work order 03. +Verified the published assignment branch at +`fe08307e9f4701715e9fc906b1d65916c18971e1`, based on management +`45e90466378f982e13cc762c52432f14ab43c32c`. Assignment reports all its work +orders complete. The earlier pending-work descriptions above are historical. +The PR remains draft #4761 targeting `feature/clarify-mcp-assignme-mhh`. + +The clean original tip `69be161548702b4be530536f925777da628302e0` is preserved +by `backup/exclusions-pre-assignment-wo03-20261025`. Replayed only the four +exclusion-owned commits after OLD assignment +`6faaa61b817dc9dddc60d2c12026cc1273748a13` onto the verified replacement: + +- `8deeb1d125fa8b5ad91be7568b1317c42162af77` -> + `ecf6743c2a664ce436fd667bbab74b2e5d33912d`. +- `8822bfaf2f0c4320ce26b4d6fddb29ef1fe81236` -> + `80f0ba5f04d08c8e8427ce3a9915f9f28a8e9bab`. +- `20b498ed916c99ecfa35af14aab82d8170ef0c4d` -> + `64411a07c0de2f19dbcda1e6c315f6be8461e1d9`. +- `69be161548702b4be530536f925777da628302e0` -> + `d7c38c3df8c1fa24917ac47d683fd04e6ea9cd18`. + +Resolved shared response, hook, selector, and locale conflicts by preserving +both contracts: profile legacy-import status, assignment route hints and explicit +Customize activation, session origins, exclusions, and saved-versus-applied +comparison. Assignment now owns the identical committed-positive-ID correction +and both neighboring E2E helper corrections; these remain present once and no +longer appear in the exclusions diff. Range-diff shows the other three replayed +commits unchanged. No parent or sibling commits were replayed. + +Combined UI code exceeded the existing function length/complexity limits. +Extracted the row annotation into the existing exclusions module, combined the +identical exclusion capability guards, and destructured the accepted selection +snapshot once. Existing behavior tests passed before and after this refactor; +no behavior or new product scope was introduced. + +Affected checks: + +- MCP/selection/effective Go suites passed under race detection in mcpconfig, + settings/store, settings/handlers, and backendapp. This includes the new + assignment eligibility contract and exclusions on the integrated source. +- Actual disposable PostgreSQL 17.11 concurrent selection/exclusion writers, + owner conditions, resolution snapshots, import/deletion, and cleanup passed + under race detection. Receipt: `/tmp/exclusions-wo03-postgres.log`. +- Eight focused frontend suites passed (66 tests); after the refactor, the + session hook, rendered exclusions, and context-switch suites passed (18 tests). +- Typecheck, i18n completeness, changed frontend lint with zero warnings, and + changed handler Go lint with zero issues passed. +- Specification catalog (403 decisions, 1738 specifications), full spec lint, + and whitespace checks passed. + +The first managed browser attempt completed the fresh build but inherited the +previous preview's Tailscale bind address. Fixture startup failed before browser +assertions. The owned process group was stopped; the wrapper's zero exit without +a summary is not a passing test receipt. Reused that fresh build with the existing +isolated browser library path and explicit loopback bind. Initial log: +`/tmp/kandev-run.e2e.kYD7CBJ1.log`. Main port 9998 was not changed. + +Browser integration passed on the fresh build, sequentially, with one worker +and retries disabled: + +- Desktop: 22 passed (`/tmp/kandev-run.e2e.QCr4CCVz.log`), covering exclusions, + pending changes, task selection, assignment handoff, and the capture scenario. +- Phone: 17 passed (`/tmp/kandev-run.e2e.XhxN3AA8.log`), covering exclusions, + pending changes, task selection, and assignment handoff. + +Commands used the managed host runner with `--no-build` only after the completed +fresh build above. The final source was unchanged between those runs. Existing +lifecycle and SQLguard/store-conformance receipts remain applicable to unchanged +source; no full suite, CI waiter, or review handling was added. + +Fresh desktop and phone screenshot assets were then captured with +`CAPTURE_PR_ASSETS=1` in a dedicated one-case managed run, passed without retries +(`/tmp/kandev-run.e2e.rlWdhyZC.log`). Both manifest entries were checked against +this run, compressed, and visually inspected. The disposable capture spec was +removed. The images show the final integrated source; later changes are this +verification record only. The full hook receipt and final remote SHA are recorded +in the saved task plan and coordinator handoff after publication. + + +## Inline profile dependency integration: 2026-10-25 + +Verified assignment remote `f3be6a5186f43dcce4487371c7a493be6c9ec280`, containing +management `7aca966a6ec262eb1ce839fa883f66e23441d477`. This dependency update +makes profile choices inline and adapts the assignment focus hint. The session +still requires an explicit Customize action. No new exclusion scope was added. + +Preserved the clean published exclusions tip +`9d5a5250c9b8a0eca92842f61fce0a32071f0124` as +`backup/exclusions-pre-inline-profile-20261025`. Replayed only own commits after +OLD assignment `fe08307e9f4701715e9fc906b1d65916c18971e1` onto the new tip. +No conflicts occurred; range-diff marks all five patches identical: + +- `ecf6743c2a664ce436fd667bbab74b2e5d33912d` -> + `8f67dd5a3f833fef7c6e3c78bac22d0226a62f6e`. +- `80f0ba5f04d08c8e8427ce3a9915f9f28a8e9bab` -> + `f463e7ae99f7c995d0d80a51ab0a07c8c3cf16dc`. +- `64411a07c0de2f19dbcda1e6c315f6be8461e1d9` -> + `8a107656e3e1e01534b1b51467075aae32850e83`. +- `d7c38c3df8c1fa24917ac47d683fd04e6ea9cd18` -> + `2db5889451b5b5afd781b531277ba77d8f30138e`. +- `9d5a5250c9b8a0eca92842f61fce0a32071f0124` -> + `2925ed62fd7e7eabd68d0bebe986cde1ac34cdfe`. + +The backend, session hook/selector, and explorer source are byte-identical to +the previous verified publication. Existing race/PostgreSQL/lifecycle receipts +remain applicable; they were not rerun for this frontend-only dependency update. +The shared picker change removes an obsolete reveal effect from its wrapper; +the session uses the unchanged content component. Current exclusions screenshots +still show the unchanged surface. Profile UI evidence belongs to the prerequisite +PRs; no unrelated profile screenshot replaces the exclusions evidence. + +Focused integration validation: + +- Six frontend suites passed, 29 tests: shared picker, exclusion component, + session hook, assignment focus, settings draft, and profile configuration. +- Full web typecheck passed. Specification catalog and full spec lint passed. +- Fresh managed desktop build and four relevant browser journeys passed with + one worker and retries disabled (`/tmp/kandev-run.e2e.Md4DCfB7.log`): exclusion + restore, global profile assignment, workspace-owned profile assignment, and + explicit session Customize handoff. + +- The same four journeys passed on phone, sequentially against that fresh build, + with one worker and retries disabled (`/tmp/kandev-run.e2e.F0XVo080.log`). + +Only this integration record was added after checks. Normal hook receipt and +final local/remote/PR SHA are recorded in the saved task plan and coordinator +handoff. PR #4761 remains draft on assignment. No CI/review handling, merge, +sibling edits, new agents, or runtime changes are part of this update. + +## Management squash integration: 2026-10-26 + +Verified assignment remote `7604e81b0446062a5600f07857062380d0a1c587` contains +actual management squash `e5c38184cf9c191075dc88c1ffad28c636858bf5` on main. +Management PR #4759 is merged; assignment PR #4760 now targets main. Exclusions +PR #4761 continues to target the assignment branch and remains draft. + +Preserved the clean published exclusions tip +`77a3e83a6445c8a53066fdf8a7c3d0caac4ccbee` as +`backup/exclusions-pre-management-squash-20261026`. Replayed only own six commits +after OLD assignment `f3be6a5186f43dcce4487371c7a493be6c9ec280` onto the new +assignment head. The resulting range contains only these exclusion-owned commits; +no management or assignment commit was replayed: + +- `8f67dd5a3f833fef7c6e3c78bac22d0226a62f6e` -> + `8b537c513b2480269b61d3ea8edeb0ca96ce5fe6`. +- `f463e7ae99f7c995d0d80a51ab0a07c8c3cf16dc` -> + `586a1a145f99581970881c66487e453ea604fa40`. +- `8a107656e3e1e01534b1b51467075aae32850e83` -> + `a3e92b0de43fdb86a3c446246a9cc7b808a7df14`. +- `2db5889451b5b5afd781b531277ba77d8f30138e` -> + `4e212bcc3e11e4ebabe0fe01e42c38641a947d10`. +- `2925ed62fd7e7eabd68d0bebe986cde1ac34cdfe` -> + `b54f3d59cac47b2917e4f599e48305f93d0ba6d5`. +- `77a3e83a6445c8a53066fdf8a7c3d0caac4ccbee` -> + `4903e6115223b64ffad5b87c82e93d7a0a19a051`. + +Conflicts were limited to appended task-locale keys in eight catalogs. Preserved +both the new main question-image keys and the session-exclusion reason. Range-diff +shows only the changed neighboring locale context in the feature patch; the other +five replayed patches are identical. The shared settings draft hook and its +suspended-render test are byte-identical to assignment, including the layout-effect +ref publication and expected_definition_ids assertion. Inline profile hint readiness +and explicit session Customize behavior remain intact. + +The MCP resolver/store/handler code and session exclusion UI are byte-identical +to the prior publication. Existing PostgreSQL concurrency, lifecycle, SQLguard, +store-conformance and session screenshots remain applicable; the main/base update +does not change the screenshot's user-visible exclusion surface. No new product +implementation was needed. + +Affected checks completed on the integrated source: + +- `go test -trimpath -race ./internal/agent/mcpconfig ./internal/agent/settings/store ./internal/agent/settings/handlers ./internal/backendapp -run 'MCP|Selection|Effective' -count=1`: passed, including application wiring on the merged main baseline. +- Eleven focused frontend suites passed, 91 tests. This includes selection + editor/context, settings draft, assignment focus, session exclusion rendering, + pending comparison, API, and task-route admission tests. +- Full web typecheck and i18n completeness passed. All eight merged task catalogs + retained the upstream question-image keys and exclusion reason. +- Catalog validation (403 decisions, 1744 specifications), full spec lint, and + whitespace checks passed. +- Fresh managed desktop build and 21 focused Chromium E2E cases passed with + zero retries; log `/tmp/kandev-run.e2e.92U49Np8.log`. +- The same build passed 17 mobile Chrome E2E cases with zero retries; log + `/tmp/kandev-run.e2e.FEoGoO2J.log`. Both runs covered exclusion/restore, + committed-versus-pending changes, task origins, assignment handoff, and error + recovery. Desktop additionally covered narrow fine-pointer and coarse-pointer + tablet paths. Runs were sequential, with one worker each. + +Only this integration record changed after validation. Normal commit-hook receipt +and the final local/remote/PR SHA are recorded in the saved task plan and coordinator +handoff. Publish with the captured exact exclusions remote-OID lease. PR #4761 +remains draft on assignment; the next replay boundary is +`7604e81b0446062a5600f07857062380d0a1c587`. No CI/review handling, merge, sibling +edits, new agents, or main runtime changes are part of this update. + + +## Completed assignment review integration: 2026-10-27 + +Verified assignment remote `74069f8b52326eb09180846b6fd3f42503017413` and +PR #4760 OPEN/READY on main. It is not merged. Exclusions PR #4761 remains +DRAFT on `feature/clarify-mcp-assignme-mhh`. + +Preserved clean own tip `fd8bb59aaa4589fc99926b66367fa48ef3b4464a` as +`backup/exclusions-before-assignment-review-20261027`. Replayed only seven own +commits after actual integrated assignment +`7604e81b0446062a5600f07857062380d0a1c587` onto verified assignment `74069f8b`. +Intervening assignment tips were never used as the old boundary. + +- `8b537c513b2480269b61d3ea8edeb0ca96ce5fe6` -> `ef1366051864cea0454920f9884a6e3efbc22f82`. +- `586a1a145f99581970881c66487e453ea604fa40` -> `d923141e9422c0d3387d609ab2f0d98f680a0532`. +- `a3e92b0de43fdb86a3c446246a9cc7b808a7df14` -> `279e2adfef0fc61fcef03b3fdbda30013aaa2392`. +- `4e212bcc3e11e4ebabe0fe01e42c38641a947d10` -> `7eb92799d319439770e5782cf6859edba9fda4f0`. +- `b54f3d59cac47b2917e4f599e48305f93d0ba6d5` -> `b03277b8f2d48088e6ac72be0cb70454ed8b9e0a`. +- `4903e6115223b64ffad5b87c82e93d7a0a19a051` -> `74efb956583f9a6e84310d12854796269959015e`. +- `fd8bb59aaa4589fc99926b66367fa48ef3b4464a` -> `609d1e8a879aa6c7efb16990b9b3ba0279aaabc5`. + +Conflicts were limited to the shared selection service and public recovery prose. +Kept assignment's public `ReplaceConditional` addition-eligibility wrapper and +internal task-session `Replace` path through private `replaceConditional`. +Moved exclusion-context preparation into that shared private operation after +condition/owner validation and before the atomic repository write. Internal launch +writes still preserve omitted exclusions without acquiring HTTP runtime eligibility. +Public writes retain eligibility and exclusion validation. Kept nil positive-ID +presence semantics, exact committed snapshots, and postcommit notification. + +Inherited `a3a9f8dd8fff675dc18466a982b823c2ba16ac20`: membership comparison sorts a +clone of saved IDs, preserving the snapshot's database order. Shared editor +layout-effect ref publication also remains intact. Session exclusion controls and +session hook are byte-identical to the previous publication. Existing session +screenshots remain applicable. Public how-to recovery prose preserves upstream +paragraph structure and explains committed-only exclusion actions and explicit +retry after reload. Six non-feature replayed patches are identical. + +Validation on the integrated source: + +- Eleven focused frontend suites: 92 tests passed, including shared suspended-render + context/draft guards, session exclusion rendering, pending comparison, and APIs. +- Disposable PostgreSQL 17.11 UTF-8 cluster under race: numeric ICU membership, + mixed assignment/exclusion writers, conditional repository/session writers, + import/deletion, resolution snapshot, and confirmed deletion all passed. Log: + `/tmp/exclusions-assignment-review-postgres-utf8.log`. + The initial disposable cluster used SQL_ASCII and rejected ICU collation creation; + reran with explicit UTF-8, without source changes or test skips. +- Specification/catalog checks passed (409 decisions, 1769 specifications). + Public-doc validation passed for 54 pages and all 63 validator tests. +- Changed Go lint passed with zero issues. +- `go test -trimpath -race ./internal/agent/mcpconfig ./internal/agent/settings/store ./internal/agent/settings/handlers ./internal/backendapp -run 'MCP|Selection|Effective' -count=1`: passed, including internal launch eligibility, public assignment admission, exclusions round-trip and application wiring. +- Full web typecheck and i18n passed: all six real locales complete, pseudo in + sync, and 4170 guarded source files checked. +- Fresh managed build: 21 Chromium cases passed without retries; log + `/tmp/kandev-run.e2e.Fhw8i2Ih.log`. Same build: 17 mobile Chrome cases passed + without retries; log `/tmp/kandev-run.e2e.WAM1Et7M.log`. Runs were sequential, + one worker each. Coverage includes exclusions/restore, committed pending state, + assignment navigation, ownership/recovery, origins and coarse-pointer controls. + +Only this verification record changed after validation. Normal commit-hook receipt +and final local/remote/PR SHA are recorded in the saved task plan and coordinator +handoff. Publish using the captured remote-OID lease +`fd8bb59aaa4589fc99926b66367fa48ef3b4464a`. The next replay boundary is +`74069f8b52326eb09180846b6fd3f42503017413`. No CI monitoring, review handling, +merge, sibling edits, new agents, or main-runtime changes were performed. + + +## Assignment descendant merge integration: 2026-10-28 + +Independently verified assignment remote +`6f15a8dca3ffc4d512dcd75fd04545ae2134b25c`, PR #4760 OPEN/READY on main, +not merged. Exclusions began clean at +`359158d447267dc640975899881625a5530d878f`. The actual shared ancestor remains +`74069f8b52326eb09180846b6fd3f42503017413`. + +Saved `backup/exclusions-before-assignment-merge-20261028` at the previous +exclusions head. Assignment advanced by a normal merge, so integrated the verified +head with a merge and retained every existing exclusions commit ID. No upstream +commits were replayed and no published history was rewritten. The next integrated +assignment boundary is `6f15a8dca3ffc4d512dcd75fd04545ae2134b25c`. + +Automatic integration had no conflicts. Overlap was limited to the settings schema, +eight task-locale catalogs, and public agent documentation. The settings schema +retains the exclusion table/index alongside upstream Copilot profile persistence. +Inherited CAS sort-clone comparison, internal/public replacement distinction, +exclusion omission/revision semantics, and shared editor guards are unchanged. +Session exclusion controls are unchanged, so existing screenshots remain applicable. +New upstream public sections coexist with the exclusion recovery instructions. + +Affected verification on the integrated tree: + +- Eleven focused frontend suites passed, 87 tests. Upstream changed task-route + tests; the same selected suite paths were run. Exclusion/shared-editor coverage + remains present. +- Real PostgreSQL 17.11 UTF-8 tests passed under race: numeric ICU membership, + concurrent assignment/exclusion writers, conditional repository/session writes, + resolution snapshots, import/deletion and confirmed deletion. Log: + `/tmp/exclusions-20261028-postgres.log`. +- Documentation catalog (413 decisions, 1791 specifications), full spec lint, + 54 published pages and all 63 public-doc validator tests passed. +- Go race checks passed in mcpconfig, settings store, settings handlers, + backendapp and lifecycle with `-run 'MCP|Selection|Effective' -count=1`. + This includes exclusion idle acceptance/failure and internal/public selection + eligibility. Log: `/tmp/exclusions-20261028-go.log`. +- Full web typecheck and translation validation passed; pseudo stays synchronized + and all six real locales are complete (4187 guarded source files checked). +- Fresh managed build: 21 desktop cases passed without retries, log + `/tmp/kandev-run.e2e.lGkewyxG.log`. Same build: 17 phone cases passed without + retries, log `/tmp/kandev-run.e2e.VBvilgDm.log`. Runs were sequential with one + worker; included exclusions/restore, pending truth, assignment navigation, + origins, narrow fine-pointer and coarse-pointer controls. + +Only this own verification record was added to the automatic merge tree. Final +merge SHA/parents, normal hook receipt and remote/PR identity are recorded in the +saved task plan and coordinator handoff. PR #4761 remains draft on assignment. +No CI monitoring, review handling, PR merge, sibling edits, new agents, or main +runtime changes were performed. + + +## Ready review and main rebase: 2026-10-28 + +The user explicitly requested ready status, CI/comment remediation and conflict +resolution by rebase. This supersedes the earlier draft/no-review checkpoint. +PR #4761 was marked ready. Assignment #4760 merged as +`4222c3654f9bb1efd1a7d27b5151c4b4c4f1ffd2`; GitHub retargeted this PR to main. +Current main at rebase was `d17d84a8084b15ddf1df10ff465a51d27c780e64`. + +Backup `backup/exclusions-before-main-fixup-20261028` preserves +`8a71af4c2bbc169a3b326e5d484e93fec7e754d5`. Replayed only eight own non-merge +commits after the integrated assignment boundary +`6f15a8dca3ffc4d512dcd75fd04545ae2134b25c` onto main. The previous merge's +verified remerge delta contained only this plan's 52-line verification record; +restored that record byte-for-byte after rebase. No upstream assignment history +was replayed. Rebase completed without file conflicts. MCP writer/resolver and +session exclusion source remain byte-identical to the previous head. + +Initial exact-head snapshot had no actionable review comments or failed checks; +required checks were missing while the PR conflicted. Fresh CI and review +verification is pending the rebased publication. No PR merge is authorized. + + +Local verification for the ready-review rebase: + +- All five affected Go race packages passed: MCP resolution, settings store, + settings handlers, backend application wiring and runtime lifecycle. +- All 64 focused frontend cases passed; full typecheck and translation checks + passed (4198 guarded files). Catalog/spec and public-doc validation passed. +- Fresh managed build: 21 desktop cases passed without retries, recorded in + `/tmp/kandev-run.e2e.lyCI56oY.log`. +- The phone run passed 16 cases and failed the pending-state case with a missing + `mcp_state` in the intercepted response and shutdown HTTP 503 evidence. The + initial failure is retained in `/tmp/kandev-run.e2e.hvuUoHcn.log`. No product + cause was established. That single case then passed in a fresh traced run + (`/tmp/kandev-run.e2e.h38yuPfm.log`) and two further fresh-worker repetitions + (`/tmp/kandev-run.e2e.vTDln1M9.log`), all with retries disabled. No source or + timeout change was made to obtain those results. +- Main subsequently advanced to `aa229b60dd9281043f7638767777222f5723f710`. + Its only added change is database-maintenance JSON output (#4903), with no + overlap in MCP source, browser source, fixtures or dependencies. Backed up + the first rebased tip at `backup/exclusions-before-main-aa229-20261028`, + preserved this uncommitted record as a patch, and replayed the same eight + owned commits without conflicts. The final tree differs from the tested tree + only in those upstream maintenance files. Focused backend/frontend checks + passed again on the final tree (three Go race packages and all 64 frontend + cases); GitHub CI and reviewer findings remain pending. + +### Ready-review application retry correction + +Greptile thread `PRRT_kwDOQ2-eWs6xLXDu` identified a valid draft-preservation bug. +Application Retry used the addition-edit callback, replacing a retained rejected +draft with saved IDs even when the accepted revision was invalid. Two regression +cases failed before the fix and passed after it. Retry now writes saved IDs +without editing the draft and is unavailable until Reload choices succeeds after +a rejected save. The shared editor rejects stale captured callbacks without a +write; exclusion omission continues to preserve committed exclusions. Desktop +and phone pending-state journeys now cover this combined recovery path. Final +validation passed: 66 frontend cases, typecheck, changed-file lint/format, +catalog/spec checks and both desktop/phone recovery journeys with retries +disabled. Browser logs: `/tmp/kandev-run.e2e.N7s3C453.log` and +`/tmp/kandev-run.e2e.G4SYbNo5.log`. GitHub thread disposition remains pending +publication of the fix. + +CodeRabbit's grouped review comment in review `5685434777` identified that both +viewport captures shared a manifest identity. The capture helper now supplies +separate desktop/phone keys and frames the actual Reload choices action before +capture. Its existing seven manifest/capture tests pass, including preservation +of separate keys from one spec. This correction changes test artifacts only; +production retry behavior and its existing verification remain unchanged. + +Both desktop and phone capture/recovery journeys passed with retries disabled +after the identity correction; their manifest test identities are distinct. +Logs: `/tmp/kandev-run.e2e.EGVVcE5L.log`, `/tmp/kandev-run.e2e.jxqzP7M2.log`. + +### Ready-review rebase after task-creation recovery merged + +Main advanced from `aa229b60dd9281043f7638767777222f5723f710` to +`91a20b1373c567ad7b138d6d509fc18d80d3a3a6` during CI. Backed up the +published head `698ce064d727906f1f39339b94abbe96dc7bf8c7` at +`backup/exclusions-before-main-91a-20261028` and replayed the eleven owned +commits. The only conflicts were the eight task translation catalogs: retained +all upstream task-creation recovery copy and the unchanged session-exclusion +key. Duplicate-key validation and exact parsed comparisons confirmed that each +catalog equals upstream plus that key. Range-diff shows only the changed insertion +context; the other ten patches are identical. MCP source and review fixes remain +byte-identical to the previous published head. + +Validation on the reconciled tree passed: 73 focused frontend/capture tests, +three MCP Go race packages, full frontend typecheck, translation checks (4208 +guarded files), changed-file ESLint, catalog formatting, documentation catalog +and full specification lint. A fresh managed build passed four desktop cases +(pending/retry, normal exclusions, narrow fine-pointer and coarse-pointer tablet) +and two phone cases, all with retries disabled. Logs: +`/tmp/kandev-run.e2e.V0FZANvJ.log` and `/tmp/kandev-run.e2e.s1u9suhI.log`. + +The prior head's Greptile thread was replied to and resolved; its CodeRabbit +review was clear after the capture-identity correction. Claude's partial backend +review had no blockers; its session revision question was answered. The prior +Cloudflare docs-preview failure was provider error 10500; attempt 2 passed +without source changes. These are historical review/CI receipts, not validation +of the rebased head. New-head CI and automated review remain pending publication. + +### CI assertion correction for failed MCP application + +E2E shard 9 reported the same stale assertion on desktop and tablet: it required +"stays in use" for failed application. The exclusion design deliberately keeps +only the last accepted setup for comparison after failure; it cannot claim that +setup remains active. All eight CI error contexts showed the correct rendered +message. The exact desktop case reproduced locally with retries disabled +(`/tmp/kandev-run.e2e.kdA0Fx11.log`). + +The shared status helper now checks the comparison-only wording and rejects +"stays in use" for failed application, while preserving the active-setup checks +for pending-idle and deferred-restart states. No production behavior or public +documentation changed. Both desktop/tablet cases passed +(`/tmp/kandev-run.e2e.hrUojzhD.log`), and all three phone widths passed +(`/tmp/kandev-run.e2e.bUbQwn2u.log`), with retries disabled. Changed-file ESLint +and Prettier passed. New-head CI remains pending publication. + +### CI retry audit and rebase onto the plugin SDK merge + +Backed up local `bc562858dbd6cd19fc39506903cdce9cc146587a` at +`backup/exclusions-before-main-3a740-20261028`, then replayed the thirteen +owned commits after `91a20b1373c567ad7b138d6d509fc18d80d3a3a6` onto +main `3a740c6911a340e73ade7edad6f4b234035f2fc9`. The rebase was clean; +all thirteen patches are identical by range-diff. This consumes main's +scroll-settlement correction for the mobile Threads fixture and preserves its +causal MCP response/reload helper. Dependency installation completed normally. + +The complete available browser blobs for published `c92eae774` contained five +failed MCP cases (desktop, tablet and three phone widths), all from the stale +failed-application assertion corrected above. All twelve phone error contexts +matched the same cause. Three other cases passed only after retry: mobile +Threads (fixed upstream), Quick Chat recovery, and LSP hover. + +Quick Chat's failure artifact showed that the synthetic failed session resumed +and became resolved. A local fixture invariant reproduced the defect: the +supposed bootstrap failure still had a live mock runtime (expected false, +received true; `/tmp/kandev-run.e2e.45XDN34U.log`). The fixture now stops that +runtime and confirms it stopped before seeding the failure. It disables +open-time automatic restart for this explicit-recovery case and restores the +original setting in `finally`. All four setup-recovery cases pass without +retries (`/tmp/kandev-run.e2e.gaYqMudA.log`). No product recovery behavior changed. + +The LSP hover failure did not reproduce in isolation or after its five preceding +cases on two CPU cores: six cases passed without retries +(`/tmp/kandev-run.e2e.4NmXFtXq.log`). Its screenshot and protocol assertion were +inspected; no speculative LSP change was made. Fresh CI remains required. +The PostgreSQL protected-sweep failure also did not reproduce in five race runs +each on PostgreSQL 16 and 17; one failed-job rerun is in progress. Neither local +passes nor pending reruns are treated as a clean CI result. + +The rebased production tree is byte-identical to validated synthetic merge +`57465eaf3384c81a25457c9b96ea97f8aa580b6b`: only test/documentation corrections +differ. That tree passed five affected backend race packages and desktop/phone +MCP recovery. On the actual rebased branch all 73 focused frontend/capture cases +pass again. Public docs and screenshots remain accurate because these changes +only correct fixtures and consume upstream work. + +Full frontend typecheck, changed fixture ESLint/Prettier, documentation catalog +validation and specification lint also passed on the rebased branch. The existing +public feature/recovery documentation and screenshots need no change for this +fixture-only correction. + +CodeRabbit review `5688243458` requested removal of the retained-recovery +button's legacy 30-second visibility timeout. The assertion now uses the default +timeout after the existing persisted FAILED-state poll and visible error +assertion. The stopped-runtime and temporary auto-start fixture guards remain. +The exact recovery case passed with retries disabled and the default timeout +(`/tmp/kandev-run.e2e.1cBGvllL.log`); changed-file ESLint passed. + + +### Damaged-workspace fixture isolation (2026-10-28) + +The prior published head completed CI with 89 passed and 11 skipped checks. +The complete 118-report browser audit still found one retry: storage discovery +ran after deletion fixtures that left an empty workspace marker. The storage +page correctly reported unavailable data. The three-case local sequence +reproduced the same undefined-total failure with retries disabled +(`/tmp/kandev-run.e2e.lEjGjLo1.log`). + +The damaged-task helper now retains the original marker and exposes cleanup. +All callers restore it and remove the fixture-owned file in `finally`, after +the existing retained-file, branch, deletion and restart assertions. Cleanup +does not recreate a marker already removed by normal cleanup. No storage or +deletion product behavior, assertion timeout, or retry policy changed. + +The same three-case sequence passes (`/tmp/kandev-run.e2e.iMnuHdJ2.log`). +All three phone callers pass (`/tmp/kandev-run.e2e.4xvd13h8.log`), as does the +desktop Tasks page caller (`/tmp/kandev-run.e2e.PzxRu3Gg.log`), with retries +disabled. Full typecheck and changed-file ESLint/Prettier pass. Public docs and +UI captures remain accurate. Fresh CI and automated review are required after +publication; the preceding green checks do not waive the observed retry. + +Main advanced without overlapping PR paths. The exact synthetic merge of the +preceding production tree with main `f9bfdcd8907d57a067bce3484d77dd75a1cc32b7` +passed 79 frontend cases, full typecheck, five MCP backend race packages, the +streams race package, and desktop/phone MCP pending-retry journeys. Synthetic +commit: `ac14dfab98ebbb626bfd3fb943b5cf37043bcefd`. The current correction changes +only test cleanup and this record; final base/head evidence remains external. + + +### Rename fixture Git-status ordering (2026-10-28) + +The next published head completed 90 passing and 11 skipped checks. All 118 +browser reports were audited: the storage-discovery failure was gone, but the +blur-rename case passed only after retry. Its disk assertions passed while the +screenshot showed Changes active instead of Files. Three ordinary isolated runs +passed (`/tmp/kandev-run.e2e.zpr0WjpK.log`), so a temporary WebSocket probe held +only the real renamed-file status notification. Releasing it after the existing +Files selection reproduced the exact missing-tree failure +(`/tmp/kandev-run.e2e.6eaabzJA.log`). + +Waiting for the renamed file in Changes before selecting Files passed the same +controlled ordering (`/tmp/kandev-run.e2e.rt8ZhLMz.log`). The temporary probe was +removed. Both committing rename cases now wait for that visible Git update; +all disk/tree assertions and existing timeouts remain. All four rename cases +pass without retries (`/tmp/kandev-run.e2e.P9Nb7jrA.log`), as do changed-file +ESLint and Prettier. An initial local reproduction could not launch full +Chromium because host libraries were absent; a process-local library path +resolved that environment issue before the recorded test runs. + +No product behavior or public documentation changed. Current-main integration +and the next exact-head CI/review cycle remain required before delivery. + + +### Translation conflicts after main advanced (2026-10-28) + +Main advanced again while CI ran. Rebased the 17 owned commits after +`2863069ed7251f5da73ad6be1411be6a79d2914c` onto +`a33ff4139ada169271a8177fa181c45b0c0176e7`. Backup +`backup/exclusions-before-main-a33ff-20261028` preserves the published +`fe1e2b0ecc9ae496434adf064eab70ab0b6b4d3f`. Only the eight task catalogs +conflicted. Duplicate-key-aware three-way comparison found no conflicting +values; each resolved catalog is the exact semantic union of both inputs. +The exclusion reason is now beside the existing MCP reason keys, avoiding +repeated end-of-file conflicts with unrelated catalog additions. Values are +unchanged. No source or test logic changed in this integration. + +Validation passed: 73 focused frontend cases; full typecheck and i18n checks; +five affected backend race packages covering MCP, selection, effective state, +runtime storage and prompt suggestions; two desktop and two phone MCP journeys +with retries disabled (`/tmp/kandev-run.e2e.5HHH6XQU.log` and +`/tmp/kandev-run.e2e.csEcdP5m.log`); harness/spec tests and lint; conflict-file +normal hooks and Prettier. Public feature docs and screenshots remain accurate. +Fresh published-head CI/review remains required; prior-head progress is +historical and is tracked externally. + + +### Current-head browser audit and Settings recovery, 2026-10-29 + +The full 118-report browser audit for `d7b7dc4ab` found four retry signals: +workspace creation lost its new card, a phone profile drag overshot its target, +Send Now did not render its selected message in time, and the phone oversized +message response selector matched both the prompt and response. No assertion +or timeout was relaxed. + +A deferred Settings bootstrap regression reproduced the workspace loss and +rename rollback before the fix (`/tmp/exclusions-workspace-red.log`). Bootstrap +now refreshes its snapshot if the workspace list changed while loading, then +applies the result only while mounted. This preserves the existing active- +workspace revision behavior. Ten focused bootstrap/state tests and typecheck +passed. This restores existing workspace behavior; public instructions, MCP +contracts and screenshots do not change. + +The profile trace showed an edge-adjacent drag auto-scrolling its target away. +Three isolated runs did not reproduce it. A temporary 30-animation-frame +finger hold reproduced the same overshoot without retries +(`/tmp/kandev-run.e2e.xQtG1AV8.log`); centering the source before sampling drag +coordinates passed that controlled case (`/tmp/kandev-run.e2e.V3b6Mtap.log`). +The probe was removed. The permanent fixture still verifies real touch input, +44px handles, saved/reopened order and scrolling outside the handle. The +oversized-message assertion now matches the exact assistant response instead +of also matching the scripted prompt. No product drag behavior changed. + +Send Now passed three fresh isolated runs with retries disabled +(`/tmp/kandev-run.e2e.TYeysNBR.log`). Its cause is not established. The exact five-test predecessor sequence also +passed (`/tmp/kandev-run.e2e.1CeQ4bCi.log`). With affinity restricted to four +CPUs, that sequence and Settings workspace creation passed without retries +(`/tmp/kandev-run.e2e.6WaKHfPI.log`, six cases). No speculative queue change +was made. + +On a fresh managed build, both changed phone cases passed without retries +(`/tmp/kandev-run.e2e.pMSp2t3M.log`). Focused unit tests, full typecheck, i18n, +changed-file ESLint/Prettier and docs/spec validation passed. The preceding +CI run completed successfully, but its full artifact audit and retry summary +retain the four historical retry signals; fresh-head CI remains required. + + +### Runtime-update request observation, 2026-10-29 + +The `83c1ff0b3` browser artifact audit exposed a mobile runtime-update fixture +asserting its POST count synchronously after a tap. A temporary 250ms request- +admission delay reproduced the exact expected-one/actual-zero failure with +retries disabled (`/tmp/kandev-run.e2e.OxXsifV8.log`). Waiting for the existing +fixture count passed that same probe (`/tmp/kandev-run.e2e.Ja3ceBNl.log`). +The probe was removed; the permanent change is one `expect.poll` assertion. +The count remains exactly one and the target-version assertion is unchanged. +All six cases in the phone runtime-update spec then passed without retries +(`/tmp/kandev-run.e2e.mKYftEsW.log`), as did changed-file ESLint and Prettier. +No production, timeout, public documentation or screenshot change is needed. + +The same audit contained a folder-picker-open timeout in the Local scratch +source flow. Three fresh isolated runs passed (`/tmp/kandev-run.e2e.VBMRWuQ4.log`), +then all four source-flow cases passed in predecessor order with four-CPU +affinity (`/tmp/kandev-run.e2e.OVYtLlFh.log`), all with retries disabled. No +root cause was established and no speculative picker change was made. + + +### Threads question scroll-owner fixture, 2026-10-29 + +The complete `83c1ff0b3` CI run passed 90 checks with 11 skips, but its 118 +browser reports and retry summary retained three first-attempt failures. The +runtime-update observation and unreproduced folder-picker case are recorded +above. The third failure tried to swipe an offscreen inner question after its +footer had become the declared scroll owner. A temporary footer-at-bottom +probe reproduced the same negative hit-area height without retries +(`/tmp/kandev-run.e2e.uHd3aLh1.log`). Targeting the declared footer owner passed +the identical probe (`/tmp/kandev-run.e2e.sDK56DA6.log`). The probe was removed. + +The helper now derives gesture coordinates from the active scroll owner. It +retains native wheel/touch input, settling, clipping and target-hit assertions. +Four normal phone/coarse-pointer cases passed without retries +(`/tmp/kandev-run.e2e.3S6WCAi7.log`); the desktop Columns inner-owner case also +passed (`/tmp/kandev-run.e2e.0yFT9cvu.log`). The desktop Grid footer wheel +case passed (`/tmp/kandev-run.e2e.QoSuw02p.log`). Changed-file ESLint and Prettier +passed. This is a test helper correction; production behavior, public docs +and screenshots are unchanged. Fresh published-head CI is required. diff --git a/docs/plans/session-mcp-exclusions/task-01-persist-and-resolve.md b/docs/plans/session-mcp-exclusions/task-01-persist-and-resolve.md new file mode 100644 index 00000000000..325dc7126f9 --- /dev/null +++ b/docs/plans/session-mcp-exclusions/task-01-persist-and-resolve.md @@ -0,0 +1,134 @@ +--- +id: "01-persist-and-resolve" +title: "Persist and resolve session exclusions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 +acceptance_criteria: + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.1 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.2 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.4 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.5 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.7 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.5 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6 +system_design: + - ../../specs/agents/system-design/session-mcp-exclusions.md +--- + +# Task 01: Persist and resolve session exclusions + +## Summary + +Deliver the authenticated API-to-resolver path with atomic session state. +Every runtime consumer receives a post-exclusion setup through the existing resolver. +This is a backend verification boundary before controls become available. + +## In scope + +- Relation, migration replay, SQLite/PostgreSQL consistency, cleanup, and reopen tests. +- Extend assignment-owned session GET/PUT CAS with presence-aware exclusions and + compatible old writes. Reuse its lock, response, precondition, and retry rules. +- Same-workspace and inherited-target validation, disabled retention, dormant state. +- Consistent resolver input, pre-secret suppression, safe decision, and comparison identity. + +## Out of scope + +Rendered controls, built-in policy, assignment, usage navigation, and new provider behavior. + +## Acceptance + +1. One accepted write changes only the target session. Stale/invalid writes change + neither set, and omission cannot clear exclusions. +2. Resolution subtracts by ID without losing origins or triggering secret/package + preparation. Legacy fallback cannot restore the same selected catalog alias. +3. Replay/reopen, deletion, two-store PostgreSQL writers, and exact setup comparison + pass their tests with retained accepted state. +4. Exclusion-only edits advance desired revision; same-set Retry remains effective. + Stale revision rejects even matching sets. Addition-only callers preserve E, + explicit empty E clears it, null rejects, and rejected mixed writes change neither set. + +## Verification + +From repository root, after red/green tests: + +```bash +(cd apps/backend && go test -trimpath -race ./internal/agent/mcpconfig ./internal/agent/settings/store ./internal/agent/settings/handlers -run 'MCP|Selection|Effective' -count=1) +(cd apps/backend && go test -trimpath -race ./internal/backendapp -run 'MCPDeletion|MCPSession' -count=1) +(cd apps/backend && go run ./cmd/sqlguard ./internal) +(cd apps/backend && go test -trimpath -race ./internal/persistence/storeconformance -count=1) +git diff --check +``` + +Repeat the focused store command against a disposable database with +`KANDEV_TEST_POSTGRES_DSN` set. Record the exact command and whether PostgreSQL +actually ran. Do not mark it passed when skipped. Run `bash scripts/lint-go-changed` +with the actual changed Go paths after implementation. No full-backend lint. + +## Files likely touched + +- `apps/backend/internal/agent/mcpconfig/{selections.go,resolution_inputs.go,resolve_effective.go,setup_snapshot.go,setup_comparison.go,session_setup_comparison.go}` +- `apps/backend/internal/agent/mcpconfig/session_exclusions_test.go` (new) and neighboring selection/resolver/comparison tests. +- `apps/backend/internal/agent/settings/store/{sqlite.go,mcp_selections.go,mcp_resolution_inputs.go,mcp_session_state.go}` and new `mcp_session_exclusions.go`/tests. +- `apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go` and new exclusion handler tests. +- `apps/backend/internal/backendapp/agents.go`, `mcp_deletion_cleanup_test.go`, and persistence conformance fixtures/manifest if required. + +## Dependencies + +Registration assignment task `5a8cb5cd-22ee-42e5-bd6d-e2179e899c8c`, work order +`docs/plans/mcp-assignment-after-registration/task-01-guard-selection-replacement.md`. +Integrate its committed conditional-selection primitive before this work order. +Its owner delivers generic locks, session revision admission, consistent GET and +exact committed responses, and retry/conflict semantics. Extend that operation +for E; do not reproduce it. Assignment work orders 02/03 are not prerequisites. +Use `expected_revision` against `selection_revision` (existing desired revision). +The crossed session `expected_definition_ids` / `expected_excluded_definition_ids` +alternative and whichever-lands-first ordering are superseded. Reuse assignment's +rejection of invalid revisions and wrong-scope preconditions. +The empty frontmatter dependency list means no local work-order predecessor; +this external prerequisite is still required. Reverify parent tip and guidance. + +## Risks + +Database locks must precede read-modify-write. Never hold a transaction across +provider I/O. Task sessions live outside the settings database. Use the existing +`events.SessionRemoved` for individual cleanup and test the registered subscriber. + +## Parallelism + +`sequential` + +## Inputs + +- [Design](../../specs/agents/system-design/session-mcp-exclusions.md): composition, persistence, API, resolver, snapshot. +- Existing `postgres_mcp_consistency_test.go`, `mcp_setup_projection_test.go`, and `mcp_setup_comparison_test.go` patterns. +- `/tdd` and its backend test reference at implementation time. + +## Results + +Implementation authorized 2026-10-23. Integrated assignment work order 01 at +`f9137074309bebe99e2c7576baefe936bc20358f`. Reuse `SelectionCondition`, +`SelectionSnapshot`, and `ReplaceMCPSelectionsConditional`; no separate writer. +Implemented the exclusion relation, atomic conditional writes, trusted inherited +validation, resolver subtraction before secrets, comparison identity, and cleanup. + +RED evidence: round-trip omitted E, invalid exclusion writes returned 200, +resolver still delivered the excluded server, and deletion left orphan exclusions. +GREEN evidence: focused selection/setup/exclusion Go suites passed under race; +actual PostgreSQL mixed writers admitted one revision and rejected the stale +writer. SQLguard and settings-store conformance passed. Changed-Go lint passed. +Disk reopen and rollback checks are recorded with final verification in the plan. + +The broad `MCP|Selection|Effective` command initially failed in +`TestExecNativeMCPCommandRunnerBoundsInheritedPipeAfterCancellation`. Its fixture +hardcoded `/usr/bin:/bin`, which lacks `sleep` on this Nix host, so the shell +exited before cancellation. Preserving the host PATH in that test's explicit +environment fixed the fixture. The exact broad command then passed under race +for all three packages. No production command-runner behavior changed. +No live-provider connection or native-file revocation claim follows from these checks. diff --git a/docs/plans/session-mcp-exclusions/task-02-application-recovery.md b/docs/plans/session-mcp-exclusions/task-02-application-recovery.md new file mode 100644 index 00000000000..40d6a71b1f3 --- /dev/null +++ b/docs/plans/session-mcp-exclusions/task-02-application-recovery.md @@ -0,0 +1,104 @@ +--- +id: "02-application-recovery" +title: "Preserve application and recovery truth" +status: done +wave: 2 +depends_on: + - "01-persist-and-resolve" +plan: "plan.md" +requirements: + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 +acceptance_criteria: + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.7 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.1 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.2 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.3 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.4 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6 +system_design: + - ../../specs/agents/system-design/session-mcp-exclusions.md +--- + +# Task 02: Preserve application and recovery truth + +## Summary + +Prove that exclusions use the existing lifecycle boundaries and exact acceptance +snapshot. Add only missing wiring discovered by focused tests. + +## In scope + +- Busy-turn immutability, idle resume/load, next-start paths, reset/rebind, and adoption. +- Failure/retry, overlapping revisions, replacement execution, and retained baseline. +- Existing safe evidence transport for `session_excluded`, including reason sanitizers. +- Built-in injection preservation and no preparation for suppressed definitions. + +## Out of scope + +New reconnect capabilities, new workers, endpoint probes, tool pruning, and health redesign. + +## Acceptance + +1. The captured outbound set and accepted snapshot match for the same operation, + and active turns never receive an exclusion or restoration mutation. +2. Unsupported paths defer. Failure preserves historical acceptance without + claiming rollback, and an older result cannot clear newer saved state. +3. Actual restart uses saved exclusions. Live adoption preserves its attachment. + Safe evidence names session exclusion and built-in injection still occurs. + +## Verification + +```bash +(cd apps/backend && go test -trimpath -race ./internal/agent/runtime/lifecycle -run 'MCP|Mcp' -count=1) +(cd apps/backend && go test -trimpath -race ./internal/agentctl/types/... ./internal/agentctl/server/instance ./internal/agentctl/server/api -run 'MCP|Mcp' -count=1) +git diff --check +``` + +Use new `mcp_session_exclusions_test.go` for the lifecycle cases. Reuse +`mcp_accepted_setup_test.go`, `mcp_project_reconnect_test.go`, +`mcp_unsupported_reconnect_test.go`, `mcp_reconfiguration_lock_order_test.go`, +and `mcp_passthrough_operation_test.go`. Verify that the requested test names +were discovered. If no evidence consumer changes, its existing tests still +prove the new reason survives the transport. Run changed-Go lint if code changes. + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/mcp_session_exclusions_test.go` (new). +- `apps/backend/internal/agent/runtime/lifecycle/{manager_mcp_reconfiguration.go,mcp_accepted_setup.go,mcp_passthrough_operation.go,manager_profile.go}` only where a test exposes missing wiring. +- Existing lifecycle MCP tests named above and relevant `internal/agentctl/types`, `server/instance`, or `server/api` evidence tests. + +## Dependencies + +Task 01. Read scoped agentctl guidance before changing its files. + +## Risks + +Resume success followed by runtime-setting restoration failure is uncertain +application, not proven rollback. Preserve the current lock order and bounded +timeouts. Do not alter provider admission to make a test pass. + +## Parallelism + +`sequential` + +## Inputs + +- [Design](../../specs/agents/system-design/session-mcp-exclusions.md): lifecycle, recovery, evidence, built-in authority. +- Parent [resolution delivery design](../../specs/agents/system-design/workspace-mcp-resolution-delivery.md). +- `/tdd` and backend test guidance at implementation time. + +## Results + +The existing lifecycle resolver paths consume exclusions without a new worker. +A real settings-store fixture proves busy application sends no provider operation; +idle resume omits the excluded server and accepts the exact empty catalog setup. +Failed resume preserves the prior accepted setup and revision. The safe evidence +reason required a sanitizer extension, proved RED before the new reason was allowed. + +`go test -trimpath -race ./internal/agent/runtime/lifecycle -run 'MCP|Mcp' -count=1` +and focused agentctl types/instance/API suites passed. Existing acceptance, +project restart, unsupported reconnect, lock-order, passthrough, and adoption +regressions passed in that run. Tests use mock provider acceptance; no real-provider +connectivity or revocation claim is made. diff --git a/docs/plans/session-mcp-exclusions/task-03-responsive-controls.md b/docs/plans/session-mcp-exclusions/task-03-responsive-controls.md new file mode 100644 index 00000000000..bdd7184020c --- /dev/null +++ b/docs/plans/session-mcp-exclusions/task-03-responsive-controls.md @@ -0,0 +1,228 @@ +--- +id: "03-responsive-controls" +title: "Expose responsive exclusion controls" +status: done +wave: 3 +depends_on: + - "02-application-recovery" +plan: "plan.md" +requirements: + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-003 +acceptance_criteria: + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.1 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.2 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.4 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.5 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.7 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.4 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.5 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.1 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.2 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.3 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.4 + - AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.5 +system_design: + - ../../specs/agents/system-design/session-mcp-exclusions.md +--- + +# Task 03: Expose responsive exclusion controls + +## Summary + +Expose session-only exclusion and restoration through existing Customize. +Ship desktop/phone evidence and update the public selection instructions. + +## In scope + +- Typed session payloads, committed-state controller, revision conflicts, stale comparison guards. +- Origin-row actions, dormant/disabled explanations, pending comparison, and built-in limit copy. +- Desktop dialog, phone/tablet drawer, keyboard/touch parity, and seven-locale copy. +- Focused E2E journeys, public how-to update, and parent contract reconciliation. + +## Out of scope + +Registration assignment, usage navigation, global-profile editor, built-in tool +management, and exclusion controls in task/session creation or upstream editors. + +## Acceptance + +1. Exclude/restore changes only the existing session and retains every origin. + Failed or stale writes never appear saved. Pending state never claims health. +2. Desktop, phone, narrow fine-pointer, and coarse-pointer tablet paths preserve + one controller and one scroller with measured targets and reachable final rows. +3. Focused unit/E2E/i18n checks pass, public instructions match behavior, and + requirement/design activation accurately reflects completed work. + +## ASCII UI preview + +UI-01/02 excerpts. Full structure and material states: [plan preview](plan.md#ascii-ui-preview). +Applies to AC-003.1 through .5 and AC-002.4/.5. + +```text +UI-01 Desktop dialog: + MCP servers [Close] / [Connections] [Customize] + Pending: Remove Atlas Docs after this turn + Selected for this task + Atlas Docs [Restore inheritance] + Also selected by: app, api, Review profile + Saved: excluded. Still in last accepted setup. + Additional servers / [Search] / choices + +UI-02 Phone drawer: + MCP servers [Close] + [Connections] [Customize] <- fixed + Pending removal after this turn <- one scroller + Atlas Docs / all contributor names + Saved: excluded + Still in last accepted setup + [Restore inheritance ] <- 44px target + Additional servers / search / choices + Safe-area clearance + +UI-03 Variants: + Disabled: Restore does not enable this definition. + Dormant: No longer inherited. [Restore inheritance] + Failed: Last accepted setup retained. [Retry] + Unknown: Exact changes cannot be compared yet. + Rejected: Could not save. Authoritative choices remain. +``` + +Preserve shared tokens and compact desktop density. Status changes require +static text. No new animation or confirmation overlay is needed. + +## Verification + +Run from repository root. Install dependencies once before the first pnpm command. +Use current managed builds, then reuse them for the following phone run only +when no code changed. The E2E commands must run sequentially. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/domains/workspace/use-mcp-selection-editor.test.ts hooks/domains/workspace/use-mcp-selection-editor-context.test.ts hooks/domains/session/use-task-session-mcp-selection.test.ts components/mcp/mcp-selection-picker.test.tsx components/task/chat/mcp-explorer/mcp-selection-changes.test.ts components/task/chat/mcp-explorer/mcp-session-exclusions.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/chat/mcp-session-exclusions.spec.ts tests/chat/mcp-pending-changes.spec.ts tests/task/task-mcp-selection.spec.ts) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-mcp-session-exclusions.spec.ts tests/chat/mobile-mcp-pending-changes.spec.ts tests/task/mobile-task-mcp-selection.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Also run changed-file ESLint with actual edited TS/TSX paths. Generate Traditional +Chinese with `pnpm run i18n:zh-hant` and pseudo output with the repository generator +before the i18n gates. Record browser geometry and focus results against UI-01/02/03. +Run all modified test suites if implementation requires additional adjacent tests. + +## Files likely touched + +- `apps/web/lib/types/http-mcp.ts`, `lib/api/domains/mcp-api.ts`, and a focused API test if payload logic changes. +- `apps/web/hooks/domains/workspace/use-mcp-selection-editor.ts` and `hooks/domains/session/use-task-session-mcp-selection.ts`, with their tests. +- `apps/web/components/task/mcp-session-selector.tsx`, `components/mcp/mcp-origin-sections.tsx`, and `components/mcp/mcp-selection-picker.tsx`. +- `apps/web/components/task/chat/mcp-explorer/` comparison, notice, views, and new `mcp-session-exclusions.test.tsx`. +- `apps/web/src/locales/*/{settings,task}.json`, new exclusion E2E specs/shared helper, and affected MCP neighbor tests. +- `docs/public/agents-and-profiles.md`, affected parent requirement/design/ADR text, and this package's statuses/results. + +## Dependencies + +Task 02, which includes the committed assignment work order 01 prerequisite. +Reuse that package's session revision types and Reload choices conflict handling; +extend it with E without adding a competing controller. Reconcile copy by intent. + +## Risks + +`MCPSessionSelectionBody` also serves creation. Require existing-session capability +before exposing exclusions. Ordinary 403 and revision 409 are local save errors, +not shared restart recovery. A narrow viewport with a fine pointer still needs +phone geometry. Do not count status fixtures as runtime proof. + +## Parallelism + +`sequential` + +## Inputs + +- [Design](../../specs/agents/system-design/session-mcp-exclusions.md): API, editor, responsive contract. +- [Parent testing guide](../workspace-mcp-configuration/testing-guide.md), especially sections 6 through 8, as scenario context only. +- `/tdd`, `/e2e`, `/mobile-parity`, `/better-ui`, and `/docs-maintainer` during implementation. + +## Results + +Completed on 2026-10-23. Existing-session rows expose committed exclude/restore +state, preserve contributors, and keep disabled/dormant exclusions reversible. +Creation and upstream editors remain additive. The shared revision controller +handles conflicts and lost-response reloads without automatic replay. + +RED evidence: action/restore interaction tests failed before controls existed; +the reason-code test rejected the missing localized explanation. A separate +regression showed that a rejected addition draft hid the saved pending comparison. +The fix passes committed positive IDs to comparison while retaining the draft. +Exclusion actions deliberately keep no unsaved checkbox state: after Reload +choices, the user repeats the explicit action. The design records this refinement. + +Frontend focused tests passed (59 cases across eight suites, including the new +comparison regression); changed-file lint passed with zero warnings, typecheck +passed, and all locale checks/ratchet passed. Traditional Chinese and pseudo +keys were generated; unrelated generated changes were discarded. + +Managed browser results: 13 desktop/neighbor tests and 9 phone/neighbor tests +passed, sequentially. The exclusion journeys cover running-turn pending state, +409/reload, reopen, inherited restore, disabled dormant restore, keyboard/touch, +scrolling to final choices, fixed tabs, focus return, and no horizontal overflow. +320px fine-pointer and 820px coarse-pointer cases passed; touch targets met 44px. +Initial fixture-name reuse was corrected. Two neighboring helper expectations +were reconciled with assignment WO01's retained addition drafts and Reload choices. + +Public how-to updated: `docs/public/agents-and-profiles.md`. Parent requirements, +configuration/pending designs, and the composition ADR record the session-only +exception. Public docs validators, catalog/spec lint, and whitespace checks pass. +These tests prove Kandev's mock-provider and UI contracts, not live-provider +connectivity or revocation of independently configured tools. + +### Ready-review recovery correction (2026-10-28) + +Greptile identified that application Retry passed saved IDs through the addition +change callback. That replaced a rejected draft, including before Reload choices +restored the accepted revision. Two hook regressions reproduced the loss for +successful and failed retry outcomes. Retry now writes saved IDs directly and +is not offered while saving or awaiting reload after a rejected save. The shared +editor still guards a previously captured callback. Exclusions are omitted from +the retry request and therefore preserved. Desktop/phone pending-state coverage +now exercises rejected edits, reload, retry and the retained draft together. +Requirements, design and public recovery instructions describe this behavior. + +Post-fix validation: 66 frontend cases, full typecheck, changed-file lint and +format, catalog/spec validation, and desktop/phone recovery journeys passed +with retries disabled. Existing pending, saved-ID, draft and exclusion +assertions remain intact. + +CodeRabbit's grouped review also found a shared desktop/phone capture identity. +Distinct surface keys now preserve both manifest entries. The capture frames +Reload choices explicitly; the existing seven capture-helper cases pass. + +Both desktop and phone capture/recovery journeys passed with retries disabled +after the identity correction; their manifest test identities are distinct. +Logs: `/tmp/kandev-run.e2e.EGVVcE5L.log`, `/tmp/kandev-run.e2e.jxqzP7M2.log`. + +### CI assertion correction for failed MCP application + +E2E shard 9 reported the same stale assertion on desktop and tablet: it required +"stays in use" for failed application. The exclusion design deliberately keeps +only the last accepted setup for comparison after failure; it cannot claim that +setup remains active. All eight CI error contexts showed the correct rendered +message. The exact desktop case reproduced locally with retries disabled +(`/tmp/kandev-run.e2e.kdA0Fx11.log`). + +The shared status helper now checks the comparison-only wording and rejects +"stays in use" for failed application, while preserving the active-setup checks +for pending-idle and deferred-restart states. No production behavior or public +documentation changed. Both desktop/tablet cases passed +(`/tmp/kandev-run.e2e.hrUojzhD.log`), and all three phone widths passed +(`/tmp/kandev-run.e2e.bUbQwn2u.log`), with retries disabled. Changed-file ESLint +and Prettier passed. New-head CI remains pending publication. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index f7ad434bb53..a705161da33 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -1053,14 +1053,24 @@ After a definition is configured, select it where it should apply: **Customize**. Desktop opens a dialog; phones open one sheet. **Connections** keeps server status and tool inspection available. Inherited entries show their profile, repository, task, or session name when available. Choose - additions under **Additional servers**. The **From task setup** group shows - inherited entries, which are read-only. Each entry separates its description - and setup/transport badges from a **Where it comes from** section with named - origin badges. A server selected by - several scopes shows each contributing source. These describe configuration; - check **Connections** for evidence of delivery or loaded tools. - -Selections are additive. The effective set combines every selected repository, profile, task, and session definition, includes every repository attached to the task, and reports duplicate origins once. A lower scope cannot remove an inherited server. Changing a definition or selection does not alter an active turn. + additions under **Additional servers**. Inherited entries retain all contributing + origins. Choose **Exclude for this session** to leave out one inherited server, + or **Restore inheritance** to use its current inherited selections again. + Check **Connections** for evidence of delivery or loaded tools. + +Selections combine every repository attached to the task, its profile, task, +and session additions. An existing session can exclude a definition from this +combined set without changing repository, profile, task, or sibling-session +choices. Changing a definition or selection does not alter an active turn. + +Exclusions survive closing the editor, reload, and restart of the same session. +They last until restored or the session or definition is deleted. A new session +starts without exclusions. **Saved exclusions** keeps a restore action when a +server is no longer inherited. Restore does not add that server or enable a +workspace-disabled definition. Disabling a server in workspace settings affects +other sessions' future setups; session exclusion affects only this session. +Required Kandev tools remain managed separately. Independently configured +provider servers are outside this catalog control. The plug shows a clock while saved changes wait for idle, a next-start marker when reconnection is unsupported, and a failure marker when application fails. @@ -1087,17 +1097,20 @@ For an existing task session, MCP changes save immediately. A compatible ACP provider applies them by reconnecting the same session after the current turn is idle. Providers without safe reconnection apply saved changes when the agent starts again. The state notice tells you which timing applies. If reconnect -fails, the previous applied selection remains active and the UI offers retry. +fails, the last accepted setup remains available for comparison and the UI +offers retry. A failed operation can leave provider state uncertain. If another editor changes the saved choices, Kandev rejects your stale save. -Choose **Reload choices**, review your retained draft against the latest saved -choices, then save again. A save whose response is lost also requires a reload -before another attempt. +Choose **Reload choices**, review your retained addition draft against the latest +saved choices, then repeat the intended action. Exclude and restore show saved +state only after a successful response. A save whose response is lost also +requires a reload before another attempt. Repository and profile editors use **Save changes**. Session Customize saves after an explicit selection change. -Application retry remains available for a saved session setup that failed to apply. +Application retry uses the saved session setup and keeps your checkbox draft. +After a rejected save, choose **Reload choices** before retrying application. The pending-change list continues to describe the saved choices while a rejected checkbox draft remains visible. diff --git a/docs/specs/agents/requirements/session-mcp-exclusions.md b/docs/specs/agents/requirements/session-mcp-exclusions.md new file mode 100644 index 00000000000..a610ee59bae --- /dev/null +++ b/docs/specs/agents/requirements/session-mcp-exclusions.md @@ -0,0 +1,143 @@ +--- +status: active +system: agents +created: 2026-10-22 +owners: + - kandev +--- + +# Session MCP Exclusions Requirements + +## Overview + +A workspace member can exclude an inherited catalog server from one existing +task agent session. Restoring inheritance reverses that choice. Agents owns +the saved selection, composition, and application contract, including its UI. + +This extends [workspace MCP configuration](workspace-mcp-configuration.md). +It updates the session-only restrictions in AC-003.3/.7 and +AC-006.13/.16/.20/.21 of that document. Other scopes retain additive selection. +The original delivery ledger remains historical evidence. + +## Terminology + +- **Session exclusion:** A saved exception for one catalog definition and one + Kandev task agent session. It lasts until restored or the session is deleted. +- **Inherited server:** A definition contributed by a task, any attached + repository, or the effective profile in the task workspace. +- **Saved setup:** Current catalog choices after session exclusions. Definition + enablement and provider policy still govern delivery. +- **Last accepted setup:** The setup accepted at a successful attachment. + This is not proof of connection health or current tool availability. + +## Requirements + +### REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001: Reversible session scope + +**Intent:** Temporarily reduce one session's inherited catalog setup without +changing the owners that supplied it. + +#### Acceptance criteria + +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.1:** An authorized member shall be able + to exclude an inherited definition from one existing session. Repository, + profile, task, and sibling-session selections shall remain unchanged. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.2:** One exclusion shall cover every + contribution of that definition, including multiple repositories and a saved + session addition. All contributors shall remain visible. Restoring shall + remove only the exclusion and use the then-current selections. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.3:** Saved exclusions shall survive + closing the editor, page reload, backend restart, normal agent restart, and + reopening the same session. A new or duplicated session shall start without + these exclusions. Session deletion shall remove them. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.4:** If all contributors disappear, + the exclusion shall remain visible and reversible as an inactive exception. + If the same definition contributes again, the exclusion shall apply again. + Deleting the definition shall remove its exclusion. Recreating a server with + the same name shall not transfer the deleted definition's exception. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.5:** Excluding or restoring a disabled + inherited definition shall not enable it workspace-wide. Re-enabling its + definition shall preserve the session exclusion. The UI shall distinguish + workspace disablement from session exclusion. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.6:** The system shall reject unauthorized, + foreign-workspace, missing, or newly non-inherited exclusion targets without + changing saved state. Concurrent exclusion edits shall not silently overwrite + a newer saved choice. Existing additive clients shall preserve exclusions. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-001.7:** Required Kandev built-in capabilities + shall remain outside this exclusion control. Unmigrated legacy and independently + configured provider servers shall not acquire catalog exclusion controls or + a false claim of removal from the provider. + +### REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002: Safe application and recovery + +**Intent:** Separate a saved session preference from the setup used by an agent. + +#### Acceptance criteria + +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.1:** Excluding or restoring during an + active turn shall save a pending choice without changing that turn's delivered + MCP set. Saving shall not interrupt or start an agent. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.2:** At an eligible idle boundary, the + system shall use the existing safe reconnect path and complete desired set. + Unsupported or project-file-based paths shall retain the choice for the next + actual start. The session identity, transcript, profile, and runtime settings + shall retain their existing continuity guarantees. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.3:** Successful application shall accept + the exact setup sent by that operation. An older success shall not clear a + newer exclusion or restoration. Reopening shall recover the accepted baseline. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.4:** Failed application shall preserve + the last accepted baseline and saved choice. The UI shall offer retry and + explain next-start application. Retry shall retain an unsaved addition draft + and remain unavailable after a rejected save until choices reload successfully. + It shall not claim rollback or verified absence + when a provider partially accepted a failed operation. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.5:** Pending comparison shall identify + exclusion as removal from the saved setup and restoration as addition when + applicable. Search shall not hide pending actions. Unknown comparison shall + remain unknown. Reversing a choice before application shall not invent a + removal or addition when the saved and accepted setups match. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-002.6:** Excluded definitions shall not be + prepared, have secrets resolved, or be delivered by Kandev for the new setup. + Safe attachment evidence shall identify session exclusion separately from + disabled definitions, transport filtering, and connection failure. + +### REQ-AGENTS-SESSION-MCP-EXCLUSIONS-003: Clear desktop and touch controls + +**Intent:** Make the scope, timing, and reversal of exclusions understandable. + +#### Acceptance criteria + +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.1:** MCP plug > Customize shall show + an explicit Exclude for this session action on eligible inherited rows and + Restore inheritance on excluded rows. The editor shall state that exclusions + last for this session until restored. It shall explain that workspace-wide + disabling affects other sessions' future setups. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.2:** Each row shall retain its name, + metadata, all authorized origin names, saved inclusion or exclusion, and + relevant pending action. Current attachment evidence shall remain in Connections. + Saved state shall never imply connected, disconnected, or unavailable tools. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.3:** Desktop shall retain the composer + dialog. Phone and coarse-pointer users shall receive one sheet with fixed + heading and view controls, one scroll owner, and safe-area clearance. Actions + shall be reachable at 320px width without horizontal document overflow. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.4:** Exclude, restore, retry, and dismiss + shall work with keyboard and touch. Phone and coarse-pointer action hit areas + shall measure at least 44 by 44 CSS pixels. Status and error text shall not + depend on hover, motion, or color alone. Focus shall return to the plug on dismiss. +- **AC-AGENTS-SESSION-MCP-EXCLUSIONS-003.5:** Initial load, refresh, save, conflict, + rejection, pending, deferred, and failed states shall remain distinct. Background + updates shall preserve search, focus, scroll, and row order. Context changes + shall hide the previous session's state. All new copy shall use shipped locales. + +## Out of scope + +- Exclusions in repository, profile, task, or new-session creation editors. +- Registration assignment, catalog usage navigation, and global-profile UI. +- Built-in catalog management, tool pruning, or new mandatory-tool policy. +- Native provider configuration editing, legacy migration redesign, or a security + revocation guarantee for already delivered tools or independent configuration. +- New provider reconnection capabilities, automatic restarts, or endpoint probes. + +## Implementation plan + +- [Session exclusions package](../../../plans/session-mcp-exclusions/plan.md) diff --git a/docs/specs/agents/requirements/workspace-mcp-configuration.md b/docs/specs/agents/requirements/workspace-mcp-configuration.md index 983cf811468..87b8033b687 100644 --- a/docs/specs/agents/requirements/workspace-mcp-configuration.md +++ b/docs/specs/agents/requirements/workspace-mcp-configuration.md @@ -2,7 +2,7 @@ status: active system: agents created: 2026-09-01 -updated: 2026-10-19 +updated: 2026-10-23 owners: - kandev --- @@ -27,8 +27,8 @@ repositories, tasks and sessions. Agents owns composition and runtime delivery. task executor when an effective MCP set first uses that package. - **Scope selection:** MCP definition identifiers for a repository, a profile in one workspace, a task, or one task agent session. -- **Effective MCP set:** The additive union of applicable scope selections for - one attachment attempt, after duplicate definitions are collapsed. +- **Effective MCP set:** The union of applicable scope selections for one + attachment, deduplicated and reduced by saved session exclusions. - **Desired revision:** The latest saved selection state for a task agent session. - **Applied revision:** The selection state used by the current successful MCP @@ -115,7 +115,8 @@ silently removing capabilities selected by another context. not affect the same profile in another workspace. - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.3:** The effective MCP set is the union of repository, workspace-profile, task, and task-session selections. It - includes every repository in the task. + includes every repository in the task, minus saved session-only exclusions + defined by [session MCP exclusions](session-mcp-exclusions.md). - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.4:** Selecting the same MCP definition in multiple scopes creates one effective server. The effective result reports every contributing scope. @@ -123,9 +124,10 @@ silently removing capabilities selected by another context. reference a definition from another workspace or a disabled definition. - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.6:** A task keeps its saved task selection when agents are added, profiles change, or the task is resumed. -- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.7:** The selection UI distinguishes - inherited MCP servers from additions at the current scope without providing - subtraction or per-scope overrides. +- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.7:** The UI separates inherited + servers from additions. Only existing sessions offer exclude/restore; upstream + and creation editors remain additive. + - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-003.8:** Inherited-origin summaries show the profile, repository, task, or session name when available. Missing names use a localized scope label without exposing an internal identifier. @@ -256,13 +258,11 @@ and mobile. ordinary errors stay within customization and shared reload recovery remains visible outside the closed overlay. -- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.13:** Session customization shall - identify additions as belonging to this session and distinguish inherited - choices without implying they can be removed here. Application timing shall - agree with the saved provider state: no unconditional idle promise when changes - require the next agent start. Primary customization shall not expose internal - revision counters; a forbidden save shall explain that this connection cannot - save and that previous choices remain unchanged, without a false reload. +- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.13:** Customize identifies session + additions and inherited exclude/restore actions. Timing follows provider state, + including next-start requirements. Hide internal revisions. A forbidden save + explains that this connection cannot save and previous choices are unchanged; + do not falsely request reload. - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.14:** Customize shall separate application status, inherited selections and session choices with semantic @@ -279,10 +279,10 @@ and mobile. Changes waiting to apply, with idle, next-start or failed timing distinct from connection health. Unchecked/deleted servers and search filtering shall not hide pending actions. -- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.16:** Affected rows shall identify - their pending action. Unchecking shall not imply immediate removal from the - agent. Servers retained by another scope shall not show removal; inherited - entries remain read-only. +- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.16:** Rows identify pending actions + without implying immediate removal. Another scope can retain an unchecked + positive selection; a session exclusion subtracts every contribution. + - **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.17:** Reloading/reopening shall restore session-specific comparison of latest saved choices and last accepted setup. Rejected saves/failed applications shall preserve that baseline; older @@ -299,19 +299,18 @@ and mobile. #### Origin sections -- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.20:** Customize shall show only - populated task, repository and profile sections. Each inherited definition - appears once, grouped by task first, repository second, profile third without - runtime precedence. All contributors and available names remain explicit, - including multiple repositories. Unknown names use scope labels; unattributed - inherited selections remain visible without an invented source. -- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.21:** All selection lists, - including repository settings and task creation, shall show each choice's - name, runtime alias, nonblank description, mode, transport and known catalog - source. Metadata stays readable and passive, separate from origins, disabled - state and pending actions. Details wrap; the final choice is reachable on - phones. Inherited rows stay read-only and outside choices; disabled entries - cannot be newly selected. +- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.20:** Customize groups each inherited + definition once: task, repository, then profile, without runtime precedence. + Omit empty groups. Show all contributors, including multiple repositories; + missing names use scope labels, and unattributed entries stay visible. + Saved exclusions without contributors have a separate restore section. + +- **AC-AGENTS-WORKSPACE-MCP-CONFIGURATION-006.21:** All pickers show name, + alias, nonblank description, mode, transport and known source. Passive metadata + stays separate from origins, disabled state and pending actions; details wrap + and final choices remain reachable on phones. Inherited rows stay outside + positive choices. Only existing sessions expose exclude/restore. Disabled + definitions cannot be newly selected, and restore does not enable them. ### REQ-AGENTS-WORKSPACE-MCP-CONFIGURATION-007: Legacy profile compatibility diff --git a/docs/specs/agents/system-design/session-mcp-exclusions.md b/docs/specs/agents/system-design/session-mcp-exclusions.md new file mode 100644 index 00000000000..e6e36e8bb32 --- /dev/null +++ b/docs/specs/agents/system-design/session-mcp-exclusions.md @@ -0,0 +1,300 @@ +--- +status: current +system: agents +requirements: + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 + - REQ-AGENTS-SESSION-MCP-EXCLUSIONS-003 +created: 2026-10-22 +owners: + - kandev +--- + +# Session MCP Exclusions System Design + +## Boundary and grounding + +Agents owns catalog composition and desired/applied state. Tasks supplies +authorized workspace, repository, profile, and session identities. Platform +continues to own session attachment evidence. This design extends +[configuration](workspace-mcp-configuration.md), +[pending comparison](workspace-mcp-pending-changes.md), and +[operation-owned delivery](workspace-mcp-resolution-delivery.md). + +Grounding is PR #3293 at `193de56ba2597b335a97b1e5ab01d09340f22647`. +Its source has positive selections only. `SelectionService.Replace` does not +accept a client revision, and `ResolutionInputs` does not contain exclusions. +`MCPSetupSnapshot` version 1 stores selected descriptors, including disabled +definitions. It does not prove delivery or connectivity. + +This extension introduces a session exception, not precedence among selecting +owners. The [composition ADR](../../../decisions/2026-09-01-workspace-mcp-configuration.md) +records the narrow exception. +The feature-local alternatives and lifetime decision below preserve sufficient +rationale without a second independent ADR. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| REQ-AGENTS-SESSION-MCP-EXCLUSIONS-001 | Composition, persistence, API, authority | +| REQ-AGENTS-SESSION-MCP-EXCLUSIONS-002 | Snapshot, lifecycle, recovery, evidence | +| REQ-AGENTS-SESSION-MCP-EXCLUSIONS-003 | Editor and responsive contract | + +## Composition and lifetime + +Let U be the existing positive union by definition ID. Let E be the exclusions +saved for this workspace and session. The desired catalog membership is U minus E. +Enabled definitions then pass through existing secret, executor, and provider +policy. An exclusion covers every origin, including a positive session selection. +Neither the exclusion nor restoration rewrites those positive selections. + +Store exclusions by definition ID, never runtime name. They survive same-session +restart, reset, profile changes, repository changes, and archive/reopen. A +profile change may make an exclusion dormant. A new session identity starts +with no exclusions, even when created by duplication. Cross-workspace resolution +must not reuse a prior workspace's exclusions. Existing ownership checks reject +foreign definitions. Do not broaden workspace-transfer behavior in this feature. + +An exclusion remains until explicit restoration, definition deletion, or session +deletion. If U no longer contains its definition, show it under Saved exclusions +with No longer inherited. Restoring a dormant exclusion removes only E and does +not add a positive selection. If U later contains that same ID, E suppresses it. +Renaming retains the ID. Delete/recreate does not inherit suppression. + +Disabled inherited definitions can be excluded or restored. Restore means +follow inheritance, not enable the definition. An excluded-and-disabled row +shows both facts. Session-only additions keep their existing removal control. +The exclusion action is limited to inherited rows and previously saved exclusions. + +## Persistence and atomic writes + +Add `task_session_mcp_exclusions` in the existing agent settings store, with +`workspace_id`, `task_session_id`, and `mcp_server_id`. Use a unique key over +all three and a definition foreign key with deletion cleanup. Task/session rows +live in a different store, so use the established owner validator and lifecycle +cleanup callbacks rather than inventing a cross-database foreign key. + +Fresh creation and migration replay leave E empty. Extend settings-store schema +initialization and conformance fixtures for SQLite and PostgreSQL. No accepted +snapshot backfill or automatic revision increment is needed for existing rows. + +Extend the conditional session repository operation owned by registration +assignment work order 01. Atomically replace positive session selections when +supplied, replace exclusions when supplied, and advance the desired revision. +Preserve accepted setup, applied revision, and attempt ID. Reuse that operation's +workspace/scope/owner lock identity, deterministic catalog lock order, and exact +committed response. PostgreSQL acquires its advisory transaction lock before +reading; SQLite uses immediate writer admission. Lifecycle CAS and this writer +must not overwrite each other's baseline. Do not add a competing operation or +rely on `SelectionService.stateMu` across instances. + +Validate additions and exclusions under the write transaction against the +authorized workspace. New exclusions require a current inherited contribution. +Retained exclusions may be dormant. Enabled-state validation remains required +for newly added positive selections. Retaining or removing a previously selected +disabled definition must not block an unrelated exclusion edit. + +Extend `DeleteMCPTaskData`, `DeleteMCPWorkspaceData`, definition deletion, and +individual-session deletion wiring to remove exclusions. The workspace cleanup +must handle sessions with only exclusions and no positive session selection. +Exclusions do not count as positive Selected/Used in associations. Definition +deletion clears them without changing that sibling-owned usage contract. + +## API and compatibility + +Extend only the existing task-session `GET/PUT /api/v1/task-sessions/:id/mcp-selections`. +The response adds `excluded_definition_ids` alongside the assignment package's +`selection_revision`. That value is the existing session `desired_revision`, +with zero before the first write, not an independent counter. Read both saved +sets and that revision in one consistent snapshot. Existing desired-state +changes can invalidate the precondition even if positive membership is unchanged; +applied-status changes alone do not. Runtime acceptance still uses its own CAS. + +The PUT adds optional `excluded_definition_ids` and reuses `expected_revision`. +Presence of the exclusion field requires the revision. `[]` explicitly restores +all exclusions. Omission preserves E in the same transaction, including requests +from the registration-assignment sibling. `null` is invalid, not a clearing value. +The existing positive `definition_ids` and `mcp_server_ids` alias remain compatible. +An exclusion-only request can omit both positive fields and preserve additions. +An old request without an exclusion field retains its positive replacement behavior. + +The updated session editor supplies the latest revision for every write. +Exclusion actions send both sets; addition-only saves and Retry omit exclusions +to preserve them in the shared transaction. A stale expected revision returns 409 with a stable +`mcp_selection_conflict` error code, including when submitted sets match current +sets. It performs no mutation or provider notification. The shared controller retains addition drafts separately from committed state +and offers Reload choices. Exclusion actions show only committed state and must +be repeated explicitly after reload. No failed action is replayed automatically. +Old addition-only clients remain supported but must acquire the same transaction +lock, preserve E, and increment the latest desired revision. + +Reject exclusion fields on repository/profile/task routes. Authorize workspace +and session using stored ownership before reading definitions. Unknown and foreign +IDs fail without disclosing foreign metadata. Reserved built-in names and synthetic +legacy IDs are not valid definition IDs. A rejected combined write changes neither +set. Return the exact committed sets and revision, not a later writer's state. +Only a successful commit schedules the existing apply notifier. + +If a response is lost after commit, reload before retrying. Do not assume failure +means rollback. Same-set writes retain the existing retry behavior: a new desired +revision may request application, but cannot erase an accepted snapshot. Changing +only E also advances desired revision. The assignment package owns this shared +session write/retry rule; this package adds mixed addition/exclusion race tests. +Non-session `expected_definition_ids` membership preconditions are owned by the +assignment package and are not duplicated on the session route. + +This contract supersedes the crossed proposal to compare session positive +and excluded ID sets. Reject session `expected_definition_ids` and +`expected_excluded_definition_ids` instead of ignoring them. A supplied null, +negative, fractional, or malformed `expected_revision` is invalid. Non-session +routes reject `expected_revision`. Assignment owns these shared validation rules. +The assignment-first dependency below supersedes whichever-lands-first ordering. + +## Resolver and snapshot + +Extend `ReadMCPResolutionInputs` and `ResolutionInputs` with exclusion IDs read +inside the same catalog/selection snapshot. PostgreSQL keeps REPEATABLE READ. +Maintain the unfiltered origins map for presentation and legacy-name suppression. +Subtract E before `deliveryServer`, secret lookup, managed package preparation, +and provider filtering. Emit safe `MCPResolutionDecision` reason +`session_excluded` with definition identity, revision, and every origin. + +Do not remove excluded IDs from `addLegacyFallback`'s selected-name guard. +Otherwise the legacy fallback could reinject the same server by name. Unmigrated +synthetic legacy identities remain outside E. Provider-owned native configuration +can still supply a similarly named server. No native file or independent tool +permission changes follow from a catalog exclusion. + +Build desired `MCPSetupSnapshot` from definitions in U minus E using a shared +pure helper. Use that helper for both runtime preparation and comparison. +The snapshot stays version 1: omitted IDs mean absent selection intent, while +disabled descriptors retain `enabled=false`. Existing snapshots already have +those semantics and require no conversion. + +Extend `MCPSetupComparison` with the exclusion IDs corresponding to its saved +revision. The frontend verifies positive IDs, exclusion IDs, and revision before +using a comparison. Missing exclusion identity is valid only for an old response +with an empty E. Preserve known-empty versus unavailable, descriptor bounds +(1,024 entries and 256 KiB), and sanitization. Include the added IDs in the +existing bound check. Oversize explanation becomes unavailable, not truncated +exact changes. Exclusion persistence does not inherit explanation limits. + +Exclusion from an accepted set yields remove. Restoration yields add if U still +contains the definition. Reverting before acceptance can yield no changes while +application acknowledgment remains pending. Disabled descriptors compare as +setup intent, so a removal must never be described as a disconnected server. + +## Lifecycle, recovery, and evidence + +Reuse `RequestSessionMCPReconfiguration`, `applyPendingSessionMCP`, +`prepareExecutionMCP`, and `SaveAcceptedMCPSetup`. Do not add a reconnect worker. +The prepared operation freezes E's result with its exact setup and desired +revision. All actual start/reset/rebind paths use that same resolver. + +Retain startup lease then prompt-lock order, client ownership, operation deadline, +and accepted-attempt fencing. A busy turn saves pending state only. Resume/load +eligibility and strict runtime-setting restoration remain as shipped. Project +MCP strategies, passthrough, absent execution, and unsupported ACP defer to an +actual process start. Live-process adoption is not a start or acceptance. + +Provider failure, timeout, or post-RPC restoration failure leaves the last +accepted snapshot authoritative as a historical baseline. Do not claim that +the runtime rolled back: a provider may have partly accepted the operation. +Keep the failed choice and retry. Newer desired revisions survive older success +or failure, and stale results cannot affect a replacement execution. + +On backend restart, restored saved state and accepted evidence remain separate. +A retained active execution keeps its delivered set. The existing next eligible +idle/start path applies the saved exclusion. Reopening the editor never starts +an agent. Connection health continues to come from the attachment evidence path. + +Pass `session_excluded` through the existing safe resolution metadata pipeline, +including bounded reason-code consumers and localization. Never send an executable +placeholder for an excluded server. Do not synthesize disconnection or tool-count +events. Exclusion decisions remain distinct from `definition_disabled` and +provider transport filtering. No new metrics or endpoint probes are required. + +## Built-in and sibling authority + +Kandev's reserved endpoint remains separately injected. This control cannot remove +its lifecycle, question, completion, or other built-in capabilities. Do not +introduce a built-in catalog record, optional-group policy, tool pruning, or a +mandatory-tool list. The built-in-management sibling owns those decisions. +Customize includes a short explanation that required Kandev tools are managed +separately. Connections retains actual built-in evidence without a new toggle. + +The management-clarity sibling owns Used in navigation and neutral connection +presentation. Registration assignment task +`5a8cb5cd-22ee-42e5-bd6d-e2179e899c8c` owns optional assignment after creation, +non-session membership preconditions, the session revision precondition, shared +transaction serialization, consistent reads/exact committed responses, and +conflict/retry semantics. This package owns E persistence, presence-aware +combined writes, inherited-target validation, composition, and exclusion UI. + +Before local work order 01, integrate the assignment package's committed +`docs/plans/mcp-assignment-after-registration/task-01-guard-selection-replacement.md` +implementation. Reuse its primitive and reconcile conflicts by intent. Do not +copy sibling uncommitted code or implement a second CAS API. Assignment work +orders 02/03, management, and built-in management are not prerequisites. The prerequisite was integrated at `f9137074309bebe99e2c7576baefe936bc20358f` +after direct user implementation authorization on 2026-10-23. The exclusions +branch stacks on `feature/clarify-mcp-assignme-mhh`. + +## Editor and responsive contract + +Keep one `useTaskSessionMCPSelection` controller above responsive composition. +Extend `useMCPSelectionEditor` and typed `mcp-api.ts` requests with the session +options. Preserve committed state, context generation, save/read guards, and +the existing application poller. Do not construct an independent exclusions poller. + +Extend `MCPSessionSelectionBody` and `McpOriginSections` through an explicit +existing-session exclusions capability. `sessionPresentation` alone is not +authority: the same body appears in new-session creation. Shared repository, +profile, task, and creation pickers remain additive and read-only for inheritance. + +Inherited rows retain task/repository/profile grouping and all origin names. +Add a labelled Exclude for this session button, replaced by Restore inheritance +after an accepted save. Keep the row in place. Show Saved: excluded from this +session and its pending comparison annotation. Include dormant rows in Saved +exclusions with their restore action. Search continues to filter additions only. +The pending section stays before origins and remains independent of search. + +On rejection, retain committed choices and a local error. On conflict, retain +addition drafts separately, keep exclusion rows at their committed state, +announce that saved choices changed, and offer Reload choices followed by +explicit review and a repeated exclusion action. Reuse the assignment controller's +conflict handling. Application Retry writes the committed positive IDs directly, +without setting or acknowledging the addition draft. Do not offer Retry while a +save is running or a rejected save awaits Reload choices. Keep shared stale-page recovery ownership. Pending/failed/deferred timing stays in `McpSelectionNotice`, not +duplicated as connection badges. Use a translated live status region. Do not +display internal revisions in primary copy. + +Desktop retains the existing `McpServerExplorer` dialog. Phone uses its full-height +drawer, and coarse-pointer tablets retain its 80dvh drawer. The nearest shipped +exemplars are that explorer and `MobilePickerSheet`/`mobile-menu-sheet.tsx` for +fixed headings and safe-area scrolling. This occasional multi-server edit needs +one focused sheet, not a nested confirmation or a new page. + +The header and Connections/Customize selector stay fixed. One body scroller owns +status, origins, exclusions, and additions. Use `min-h-0`, dynamic viewport bounds, +safe-area padding, wrapping metadata, and contained overscroll. On phone, the +action follows the row's descriptive text at full available width. Desktop can +align the action beside the name. Ordinary desktop buttons remain 28px. Phone +and coarse-pointer hit areas are at least 44px in both dimensions. + +Keep existing neutral surfaces, border/radius tokens, Tabler icons, and visible +focus. No new animation is needed for frequent exclude/restore actions. Existing +motion respects reduced-motion settings. All copy uses i18next in English, +pt-pt, zh-cn, zh-hk, zh-tw, ja, and ko, with generated Traditional Chinese and +pseudo output. No health colors or hover-only explanation convey saved state. + +## Alternatives and verification + +Editing upstream selections would affect sibling sessions. Browser-local state +would lose exclusions on restart. A one-turn expiry would unexpectedly restore +capabilities. Name-based suppression could capture a different definition. +The selected design preserves stable ownership and makes reversal explicit. + +The [plan](../../../plans/session-mcp-exclusions/plan.md) maps acceptance to +transaction, resolver, lifecycle, hook, and rendered tests. Mock acceptance proves +Kandev's delivery contract only, not real-provider connection or tool revocation. diff --git a/docs/specs/agents/system-design/workspace-mcp-configuration.md b/docs/specs/agents/system-design/workspace-mcp-configuration.md index b18d393a0df..6881ee703ce 100644 --- a/docs/specs/agents/system-design/workspace-mcp-configuration.md +++ b/docs/specs/agents/system-design/workspace-mcp-configuration.md @@ -164,9 +164,8 @@ Advanced settings contains a compact MCP label/count/help row matching priority. Opening it shows inherited origins and task additions in a bounded desktop popover or touch drawer. Keep the main form free of persistent helper text. -The picker separates inherited rows from additions at the current scope. Each -inherited row lists its origins. It does not show a misleading checkbox that -implies subtraction. +Pickers separate inherited origins from positive additions. Only existing sessions +expose explicit [exclude/restore actions](session-mcp-exclusions.md). Origin identities remain stable IDs in selection and attachment contracts. Presentation resolves names from existing workspace-contextual profile options, @@ -247,11 +246,10 @@ nonblank descriptions and passive metadata from `mcpDefinitionBadgeKeys` on inherited rows and every shared picker choice, including repository/task forms. Legacy import is not Custom. Retain aliases, disabled state, pending annotations, checkbox accessibility and existing search. -No transport configuration is exposed. Inherited rows remain read-only. - -Keep dialog/drawer ownership, wrapped details, one scroller and touch targets. -Status and pending changes precede groups, independent of search. No API, -storage, polling or save change. Origin grouping remains session-specific. +Transport configuration stays hidden. Existing-session exclusion actions extend +read-only inheritance in other pickers. Preserve wrapped details, one scroller, +touch targets, and status/pending groups independent of search. Origin grouping +adds no storage or polling; exclusions extend the shared write transaction. Original phone composition: [origin sections plan](../../../plans/mcp-customize-origins/plan.md). Only the raw JSON editor is retired. The profile transport policy card remains, @@ -327,7 +325,7 @@ Resolution follows this order: 1. Load repository selections for every task repository. 2. Load the profile selection for the execution workspace. 3. Load task and task-session selections. -4. Union rows by stable definition ID and accumulate origin descriptors. +4. Union rows by definition ID, retain origins, then subtract session exclusions. 5. Load enabled definitions and sort them by normalized runtime name. 6. Resolve secret references on the server at the last responsible moment. 7. Apply executor and provider transport policy. diff --git a/docs/specs/agents/system-design/workspace-mcp-pending-changes.md b/docs/specs/agents/system-design/workspace-mcp-pending-changes.md index 296eb60d43b..a03fa043676 100644 --- a/docs/specs/agents/system-design/workspace-mcp-pending-changes.md +++ b/docs/specs/agents/system-design/workspace-mcp-pending-changes.md @@ -80,10 +80,10 @@ after descriptors as needed. An unavailable comparison contains a stable reason such as missing baseline or inconsistent snapshot, with no definitive actions. Build desired descriptors from the complete saved profile/repository/task/ -session union without revealing secrets, materializing packages, or probing -endpoints. Return a comparison only when its selection revision and descriptors -were read consistently. An obsolete comparison cannot decorate newer checkbox -state. Failed projection reads do not fail an otherwise successful saved write; +session union, minus [saved session exclusions](session-mcp-exclusions.md), without +revealing secrets, materializing packages, or probing endpoints. Return a comparison only when its selection revision and descriptors +were read consistently. Compare against committed positive and excluded IDs, not an unsaved addition +draft. An obsolete comparison cannot decorate newer saved state. Failed projection reads do not fail an otherwise successful saved write; they yield unavailable comparison and retain existing application authority. Diff by definition ID: desired-only is add; accepted-only is remove; same ID