PR #4761
Sections
Review

feat: allow session-only inherited MCP exclusions ↗

main ← feature/allow-session-only-i-2gn 83 files +? −? PR #4761 ↗

An existing task agent session can exclude an inherited catalog MCP server for that session only. The resolver subtracts excluded servers before secret resolution and delivery, and the choice is reversible.

Why this PR exists

Every session inherited all servers from the task, repositories, and profile. To remove one server for a single session, a member had to change a shared selection or disable the server workspace-wide, which affected other sessions.

Who is affected: Workspace members who run several task agent sessions from a shared MCP catalog.

Result: A member can exclude an inherited server for one session and restore it later. Sibling sessions and the owning selections stay unchanged, and Kandev does not prepare or deliver the excluded server.

What it does

Impact at a glance

User experience

Entry pointBeforeAfterSource
Task chat > MCP plug > CustomizeInherited catalog rows show their origins and any pending setup change. There is no action to remove one server from this session only.Eligible inherited rows show Exclude for this session. Excluded rows show Restore inheritance, and the editor shows a saved exclusion count and session-only scope note.apps/web/components/task/mcp-session-selector.tsx
Task chat > MCP plug > Customize on a phone or coarse pointerThe composer opens a sheet with inherited rows and no session-only removal action.The sheet shows the same Exclude and Restore actions with at least 44 by 44 pixel targets, one scroll owner, safe-area clearance, and focus return to the plug on dismiss.apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.tsx

Database changes

Migration / databaseTable / column / indexBefore / after schemaExisting data / upgrade / rollbackSource
No migration; schema initialization in apps/backend/internal/agent/settings/store/sqlite.gotask_session_mcp_exclusionsAdds a table with workspace_id, task_session_id, and mcp_server_id. The composite primary key is (workspace_id, task_session_id, mcp_server_id), mcp_server_id references mcp_server_definitions with ON DELETE CASCADE, and an index covers mcp_server_id.Existing installations create an empty table at startup. No backfill occurs and existing sessions start with no exclusions. Session deletion removes its rows, and definition deletion cascades. A rollback leaves an unused table with no effect on delivery.apps/backend/internal/agent/settings/store/sqlite.go

Feature flags and rollout

The exclusion controls and resolver behavior ship for every install. No runtime flag gates this feature. The task-session selection API gains an optional field, and older callers that omit it keep saved exclusions.

UX changes with flags off

Entry pointBeforeAfterSource
Task chat > MCP plug > CustomizeInherited rows show only pending setup changes. No session-only removal action exists.Inherited rows show Exclude for this session, and excluded rows show Restore inheritance. The action is visible without a flag.apps/web/components/task/mcp-session-selector.tsx

Architecture, end to end

The Customize dialog saves an exclusion through the existing conditional session selection write. The store records the relation, and the resolver subtracts those IDs before secret lookup and delivery. An active turn keeps its current set; the saved choice applies at idle or the next start.

flowchart TD
  UI[Customize dialog] --> API[Session selection API]
  API --> SVC[Selection service]
  SVC --> WRITE[Conditional store write]
  WRITE --> REL[(task_session_mcp_exclusions)]
  REL --> READ[Resolution inputs]
  READ --> RES[Resolver]
  RES --> DEC{Agent idle?}
  DEC -- yes --> APPLY[Resume or load complete set]
  DEC -- no --> PENDING[Saved choice, pending_idle]
  PENDING --> APPLY

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Web
API
Domain
Storage
Runtime
McpSessionExclusionAction({ excluded, definition, disabled, onChange })
Click for details →

The row action switches between exclusion and restoration, and the text states that the choice applies only to this session. A disabled definition gets a separate explanation.

Exclude or restore row action
export function McpSessionExclusionAction({
  excluded,
  definition,
  disabled,
  onChange,
}: {
  excluded: boolean;
  definition: MCPServerDefinition;
  disabled?: boolean;
  onChange: () => void;
}) {
  const { t } = useTranslation();
  return (
    <div className="mt-3 flex min-w-0 items-center justify-between gap-3 max-md:flex-col max-md:items-stretch">
      <div className="min-w-0 text-pretty text-xs text-muted-foreground">
        <p>{t(excluded ? "settings:mcpSessionExcluded" : "settings:mcpSessionInherited")}</p>
        {!definition.enabled && (
          <p className="mt-1">{t("settings:mcpRestoreDisabledDescription")}</p>
        )}
      </div>
      <Button
        type="button"
        variant="outline"
        disabled={disabled}
        className="h-auto min-h-7 shrink-0 cursor-pointer whitespace-normal py-1 max-md:min-h-11 max-md:w-full [@media(pointer:coarse)]:min-h-11"
        onClick={onChange}
      >
        {t(excluded ? "settings:mcpRestoreInheritance" : "settings:mcpExcludeForSession")}
      </Button>
    </div>
  );
}
func selectionCondition(body mcpSelectionRequest, scope mcpconfig.SelectionScope) (mcpconfig.SelectionCondition, error)
Click for details →

The handler accepts excluded IDs only with a revision condition. The session response always returns an exclusion array, including an empty array when no row exists.

Request condition
func selectionCondition(body mcpSelectionRequest, scope mcpconfig.SelectionScope) (mcpconfig.SelectionCondition, error) {
	condition := mcpconfig.SelectionCondition{}
	if body.ExpectedExcludedDefinitionIDs != nil {
		return condition, mcpconfig.ErrMCPInvalidSelection
	}
	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)
}
Response field
	if scope == mcpconfig.SelectionScopeTaskSession {
		revision := int64(0)
		if snapshot.State != nil {
			revision = snapshot.State.DesiredRevision
		}
		response.SelectionRevision = &revision
		ids := snapshot.ExcludedDefinitionIDs
		if ids == nil {
			ids = []string{}
		}
		response.ExcludedDefinitionIDs = &ids
	}
	c.JSON(http.StatusOK, response)
}
func (s *SelectionService) prepareExclusionCondition(ctx context.Context, origin SelectionOrigin, condition *SelectionCondition) error
Click for details →

The service resolves the task, profile, and repository context for the target session before any write. It rejects an exclusion condition when the context is missing or belongs to another workspace or session.

Prepare the exclusion context
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
}
func replaceMCPSessionExclusionsTx(ctx context.Context, tx *sqlx.Tx, origin mcpconfig.SelectionOrigin, previous []string, condition mcpconfig.SelectionCondition) error
Click for details →

The store replaces the session exclusion set inside the existing conditional selection transaction. It accepts a target that was already excluded or is currently inherited, and it locks definitions in the same order as catalog deletion.

Read and replace exclusions
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 (r *Resolver) Resolve(ctx context.Context, resolutionContext ResolutionContext, policy Policy) (*EffectiveMCPResolution, error)
Click for details →

For each selected definition, the resolver checks the exclusion set before it resolves a secret or prepares a server. An excluded definition produces a bounded session_excluded decision and keeps its visible origins.

Skip excluded definitions
	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
Read the changes as a list

Session exclusion control

apps/web/components/task/chat/mcp-explorer/mcp-session-exclusions.tsx

The row action switches between exclusion and restoration, and the text states that the choice applies only to this session. A disabled definition gets a separate explanation.

Exclude or restore row action
export function McpSessionExclusionAction({
  excluded,
  definition,
  disabled,
  onChange,
}: {
  excluded: boolean;
  definition: MCPServerDefinition;
  disabled?: boolean;
  onChange: () => void;
}) {
  const { t } = useTranslation();
  return (
    <div className="mt-3 flex min-w-0 items-center justify-between gap-3 max-md:flex-col max-md:items-stretch">
      <div className="min-w-0 text-pretty text-xs text-muted-foreground">
        <p>{t(excluded ? "settings:mcpSessionExcluded" : "settings:mcpSessionInherited")}</p>
        {!definition.enabled && (
          <p className="mt-1">{t("settings:mcpRestoreDisabledDescription")}</p>
        )}
      </div>
      <Button
        type="button"
        variant="outline"
        disabled={disabled}
        className="h-auto min-h-7 shrink-0 cursor-pointer whitespace-normal py-1 max-md:min-h-11 max-md:w-full [@media(pointer:coarse)]:min-h-11"
        onClick={onChange}
      >
        {t(excluded ? "settings:mcpRestoreInheritance" : "settings:mcpExcludeForSession")}
      </Button>
    </div>
  );
}

Session-only request and response fields

apps/backend/internal/agent/settings/handlers/mcp_selection_handlers.go

The handler accepts excluded IDs only with a revision condition. The session response always returns an exclusion array, including an empty array when no row exists.

Request condition
func selectionCondition(body mcpSelectionRequest, scope mcpconfig.SelectionScope) (mcpconfig.SelectionCondition, error) {
	condition := mcpconfig.SelectionCondition{}
	if body.ExpectedExcludedDefinitionIDs != nil {
		return condition, mcpconfig.ErrMCPInvalidSelection
	}
	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)
}
Response field
	if scope == mcpconfig.SelectionScopeTaskSession {
		revision := int64(0)
		if snapshot.State != nil {
			revision = snapshot.State.DesiredRevision
		}
		response.SelectionRevision = &revision
		ids := snapshot.ExcludedDefinitionIDs
		if ids == nil {
			ids = []string{}
		}
		response.ExcludedDefinitionIDs = &ids
	}
	c.JSON(http.StatusOK, response)
}

Exclusion admission and revision check

apps/backend/internal/agent/mcpconfig/selection_write.go

The service resolves the task, profile, and repository context for the target session before any write. It rejects an exclusion condition when the context is missing or belongs to another workspace or session.

Prepare the exclusion context
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
}

Atomic exclusion persistence

apps/backend/internal/agent/settings/store/mcp_session_exclusions.go

The store replaces the session exclusion set inside the existing conditional selection transaction. It accepts a target that was already excluded or is currently inherited, and it locks definitions in the same order as catalog deletion.

Read and replace exclusions
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
}

Resolver subtracts excluded IDs

apps/backend/internal/agent/mcpconfig/resolve_effective.go

For each selected definition, the resolver checks the exclusion set before it resolves a secret or prepares a server. An excluded definition produces a bounded session_excluded decision and keeps its visible origins.

Skip excluded definitions
	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

Risk

6 / 10 Medium
1 low5 medium10 high

Why this score

  • The resolver now subtracts a session-owned set before secret resolution, so a defect can change which MCP servers a session receives.
  • The write path combines selections, exclusions, work-state revision, and definition locks. A stale or partial write can separate the saved choice from the accepted setup.
  • Existing additive clients must preserve exclusions when they omit the new field, and the idle application path must not disrupt an active turn.

Trade-offs and review notes

Where to look first

  1. Check that an omitted excluded_definition_ids preserves saved exclusions for older callers.
  2. Verify the lock order between the selection owner, definition rows, and catalog deletion.
  3. Confirm an active turn keeps its current MCP set while the saved choice enters pending_idle.
  4. Check that disabled, dormant, and newly non-inherited targets cannot be excluded through a direct API write.