Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
35a87db
feat: allow session-only inherited MCP exclusions
zeval Oct 23, 2026
66514bc
test: preserve host PATH in native MCP cancellation fixture
zeval Oct 23, 2026
1f4b53c
docs: record exclusions rebase onto assignment
zeval Oct 24, 2026
7afa4cc
docs: record exclusions draft publication stack
zeval Oct 24, 2026
de9f4e1
refactor: reconcile session MCP controls with assignment handoff
zeval Oct 25, 2026
9360fe6
docs: record exclusions integration with inline profile choices
zeval Oct 25, 2026
604904a
docs: record exclusions rebase after management squash
zeval Oct 26, 2026
1e727b6
docs: record exclusions integration after assignment review
zeval Oct 27, 2026
6547962
docs: record exclusions rebase onto merged assignment
zeval Oct 28, 2026
0ee37a3
fix: preserve session MCP drafts during application retry
zeval Oct 28, 2026
95a674c
test: distinguish MCP recovery capture identities
zeval Oct 28, 2026
7accf3c
docs: record exclusions translation conflict reconciliation
zeval Oct 28, 2026
cdabe8f
test: assert truthful MCP application failure status
zeval Oct 28, 2026
694b99f
test: isolate retained Quick Chat setup failure
zeval Oct 28, 2026
71c613d
test: use default recovery control assertion timeout
zeval Oct 28, 2026
ad457b3
test: restore damaged workspace fixtures after deletion
zeval Oct 28, 2026
ceb5eb1
test: wait for rename status before selecting files
zeval Oct 28, 2026
d7b7dc4
chore: reconcile MCP catalogs with current main
zeval Oct 28, 2026
83c1ff0
fix: preserve workspace mutations during settings hydration
zeval Oct 29, 2026
8bf1051
test: await runtime update request after touch approval
zeval Oct 29, 2026
289c244
test: target the active Threads question scroll owner
zeval Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/backend/internal/agent/mcpconfig/resolve_effective.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
)
Expand Down Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions apps/backend/internal/agent/mcpconfig/selection_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
}
37 changes: 37 additions & 0 deletions apps/backend/internal/agent/mcpconfig/session_exclusions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 2 additions & 1 deletion apps/backend/internal/agent/mcpconfig/setup_comparison.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
38 changes: 26 additions & 12 deletions apps/backend/internal/agent/mcpconfig/setup_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mcpconfig
import (
"context"
"encoding/json"
"slices"
"sort"
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
Loading
Loading