diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go index 9d2a34161..d8bff27a0 100644 --- a/clients/go/ahp/reducers.go +++ b/clients/go/ahp/reducers.go @@ -367,18 +367,38 @@ func containerChildren(c *ahptypes.Customization) *[]ahptypes.ChildCustomization return nil } -func setContainerEnabled(c *ahptypes.Customization, enabled bool) { +func effectiveEnablement(enablement []ahptypes.CustomizationEnablement) bool { + if len(enablement) == 0 { + return true + } + switch decision := enablement[0].Value.(type) { + case *ahptypes.CustomizationEnablementGlobal: + return decision.Enabled + case *ahptypes.CustomizationEnablementWorkspace: + return decision.Enabled + case *ahptypes.CustomizationEnablementSession: + return decision.Enabled + default: + return true + } +} + +func applyContainerEnablement(c *ahptypes.Customization, enablement []ahptypes.CustomizationEnablement) { + enabled := effectiveEnablement(enablement) + provenance := append([]ahptypes.CustomizationEnablement(nil), enablement...) switch v := c.Value.(type) { case *ahptypes.PluginCustomization: - v.Enabled = enabled + v.Enablement = provenance case *ahptypes.DirectoryCustomization: v.Enabled = enabled case *ahptypes.McpServerCustomization: - v.Enabled = enabled + v.Enablement = provenance } } -func setChildEnabled(c *ahptypes.ChildCustomization, enabled bool) { +func applyChildEnablement(c *ahptypes.ChildCustomization, enablement []ahptypes.CustomizationEnablement) { + enabled := effectiveEnablement(enablement) + provenance := append([]ahptypes.CustomizationEnablement(nil), enablement...) switch v := c.Value.(type) { case *ahptypes.AgentCustomization: v.Enabled = &enabled @@ -391,15 +411,15 @@ func setChildEnabled(c *ahptypes.ChildCustomization, enabled bool) { case *ahptypes.HookCustomization: v.Enabled = &enabled case *ahptypes.McpServerCustomization: - v.Enabled = enabled + v.Enablement = provenance } } -func applyToggle(list []ahptypes.Customization, id string, enabled bool) bool { +func applyToggle(list []ahptypes.Customization, id string, enablement []ahptypes.CustomizationEnablement) bool { for i := range list { got, ok := customizationID(list[i]) if ok && got == id { - setContainerEnabled(&list[i], enabled) + applyContainerEnablement(&list[i], enablement) return true } } @@ -411,7 +431,7 @@ func applyToggle(list []ahptypes.Customization, id string, enabled bool) bool { for j := range *children { got, ok := childCustomizationID((*children)[j]) if ok && got == id { - setChildEnabled(&(*children)[j], enabled) + applyChildEnablement(&(*children)[j], enablement) return true } } @@ -938,7 +958,7 @@ func ApplyActionToSession(state *ahptypes.SessionState, action ahptypes.StateAct if state.Customizations == nil { return ReduceOutcomeNoOp } - if applyToggle(state.Customizations, a.Id, a.Enabled) { + if applyToggle(state.Customizations, a.Id, a.Enablement) { return ReduceOutcomeApplied } return ReduceOutcomeNoOp diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go index fccdb904b..8e8d296e7 100644 --- a/clients/go/ahptypes/actions.generated.go +++ b/clients/go/ahptypes/actions.generated.go @@ -982,22 +982,26 @@ type SessionCustomizationsChangedAction struct { Customizations []Customization `json:"customizations"` } -// A client toggled a customization on or off. +// A client updated a customization's enablement decisions. // // Matches `id` against every top-level customization first — a plugin or // directory container, or a bare top-level MCP server — then against the -// children inside each container (a skill, agent, or other entry), and -// sets the matched entry's `enabled` flag. Disabling a container still -// disables all of its children — the effective state of a child is -// `container.enabled && (child.enabled ?? true)` — so toggling a child -// only matters while its container is enabled. Is a no-op when no +// children inside each container (a skill, agent, or other entry). Plugins +// and MCP servers retain the matched entry's explicit decisions; other +// entries update their `enabled` flag. Disabling a plugin still disables all +// of its children — the effective state of a plugin child is the plugin's +// derived enabled value and `(child.enabled ?? true)` — so toggling a child +// only matters while its plugin is enabled. Is a no-op when no // customization has the given `id`. +// +// The `enablement` array completely replaces all explicit decisions. A caller +// changing one scope must include every decision it intends to preserve. type SessionCustomizationToggledAction struct { Type ActionType `json:"type"` - // The id of the container or child to toggle. + // The id of the container or child to update. Id string `json:"id"` - // Whether to enable or disable the targeted customization. - Enabled bool `json:"enabled"` + // Explicit enablement decisions, replacing the previous list entirely. + Enablement []CustomizationEnablement `json:"enablement"` } // Upserts a top-level customization (plugin or directory). diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go index 99693a7bb..d53d41063 100644 --- a/clients/go/ahptypes/state.generated.go +++ b/clients/go/ahptypes/state.generated.go @@ -320,6 +320,15 @@ const ( CustomizationTypeMcpServer CustomizationType = "mcpServer" ) +// Scope at which customization enablement is decided. +type CustomizationEnablementKind string + +const ( + CustomizationEnablementKindGlobal CustomizationEnablementKind = "global" + CustomizationEnablementKindWorkspace CustomizationEnablementKind = "workspace" + CustomizationEnablementKindSession CustomizationEnablementKind = "session" +) + // Discriminant values for {@link CustomizationLoadState}. type CustomizationLoadStatus string @@ -2397,8 +2406,6 @@ type PluginCustomization struct { // protocol; producers and consumers agree on its contents // out-of-band. Meta map[string]json.RawMessage `json:"_meta,omitempty"` - // Whether this container is currently enabled. - Enabled bool `json:"enabled"` // `clientId` of the client that contributed this container. Absent for // server-originated entries. ClientId *string `json:"clientId,omitempty"` @@ -2412,6 +2419,8 @@ type PluginCustomization struct { // nothing. Children []ChildCustomization `json:"children,omitempty"` Type CustomizationType `json:"type"` + // Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Version of the plugin, sourced from the // [Open Plugins](https://open-plugins.com/) manifest's optional // `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -2458,8 +2467,6 @@ type ClientPluginCustomization struct { // protocol; producers and consumers agree on its contents // out-of-band. Meta map[string]json.RawMessage `json:"_meta,omitempty"` - // Whether this container is currently enabled. - Enabled bool `json:"enabled"` // `clientId` of the client that contributed this container. Absent for // server-originated entries. ClientId *string `json:"clientId,omitempty"` @@ -2473,6 +2480,8 @@ type ClientPluginCustomization struct { // nothing. Children []ChildCustomization `json:"children,omitempty"` Type CustomizationType `json:"type"` + // Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` // Version of the plugin, sourced from the // [Open Plugins](https://open-plugins.com/) manifest's optional // `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -2482,6 +2491,15 @@ type ClientPluginCustomization struct { Version *string `json:"version,omitempty"` // Opaque version token used by the host to detect changes. Nonce *string `json:"nonce,omitempty"` + // Explicit enablement decisions for children this plugin contributes, + // keyed by child name (for MCP servers, the server name as it appears in + // the bundled `.mcp.json`). + // + // Bundled children are discovered by the host rather than published by the + // client, so the client cannot attach `enablement` to them directly. This + // carries the client's global decision for each one; the host applies it + // under the child's durable key. + ChildEnablement map[string][]CustomizationEnablement `json:"childEnablement,omitempty"` } // A directory the host watches for this session. @@ -2521,8 +2539,6 @@ type DirectoryCustomization struct { // protocol; producers and consumers agree on its contents // out-of-band. Meta map[string]json.RawMessage `json:"_meta,omitempty"` - // Whether this container is currently enabled. - Enabled bool `json:"enabled"` // `clientId` of the client that contributed this container. Absent for // server-originated entries. ClientId *string `json:"clientId,omitempty"` @@ -2536,6 +2552,8 @@ type DirectoryCustomization struct { // nothing. Children []ChildCustomization `json:"children,omitempty"` Type CustomizationType `json:"type"` + // Whether this container is currently enabled. + Enabled bool `json:"enabled"` // Which child customization type this directory holds. Contents CustomizationType `json:"contents"` // Whether clients may write into this directory. @@ -2580,9 +2598,10 @@ type AgentCustomization struct { // turned off on its own. // // This flag is independent of the parent container's: the **effective** - // enabled state of a child is - // `container.enabled && (child.enabled ?? true)`, so a disabled container - // disables every child regardless of each child's own flag. + // enabled state of a plugin child is the plugin's derived enabled value and + // `(child.enabled ?? true)`, so a disabled plugin disables every child + // regardless of each child's own flag. A directory child instead uses the + // directory's `enabled` value and its own flag. // // A child is turned on or off by id with // {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -2652,9 +2671,10 @@ type SkillCustomization struct { // turned off on its own. // // This flag is independent of the parent container's: the **effective** - // enabled state of a child is - // `container.enabled && (child.enabled ?? true)`, so a disabled container - // disables every child regardless of each child's own flag. + // enabled state of a plugin child is the plugin's derived enabled value and + // `(child.enabled ?? true)`, so a disabled plugin disables every child + // regardless of each child's own flag. A directory child instead uses the + // directory's `enabled` value and its own flag. // // A child is turned on or off by id with // {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -2707,9 +2727,10 @@ type PromptCustomization struct { // turned off on its own. // // This flag is independent of the parent container's: the **effective** - // enabled state of a child is - // `container.enabled && (child.enabled ?? true)`, so a disabled container - // disables every child regardless of each child's own flag. + // enabled state of a plugin child is the plugin's derived enabled value and + // `(child.enabled ?? true)`, so a disabled plugin disables every child + // regardless of each child's own flag. A directory child instead uses the + // directory's `enabled` value and its own flag. // // A child is turned on or off by id with // {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -2761,9 +2782,10 @@ type RuleCustomization struct { // turned off on its own. // // This flag is independent of the parent container's: the **effective** - // enabled state of a child is - // `container.enabled && (child.enabled ?? true)`, so a disabled container - // disables every child regardless of each child's own flag. + // enabled state of a plugin child is the plugin's derived enabled value and + // `(child.enabled ?? true)`, so a disabled plugin disables every child + // regardless of each child's own flag. A directory child instead uses the + // directory's `enabled` value and its own flag. // // A child is turned on or off by id with // {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -2814,9 +2836,10 @@ type HookCustomization struct { // turned off on its own. // // This flag is independent of the parent container's: the **effective** - // enabled state of a child is - // `container.enabled && (child.enabled ?? true)`, so a disabled container - // disables every child regardless of each child's own flag. + // enabled state of a plugin child is the plugin's derived enabled value and + // `(child.enabled ?? true)`, so a disabled plugin disables every child + // regardless of each child's own flag. A directory child instead uses the + // directory's `enabled` value and its own flag. // // A child is turned on or off by id with // {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -2861,8 +2884,25 @@ type McpServerCustomization struct { // out-of-band. Meta map[string]json.RawMessage `json:"_meta,omitempty"` Type CustomizationType `json:"type"` - // Whether this MCP server is currently enabled. - Enabled bool `json:"enabled"` + // Explicit enablement decisions for this customization, one entry per scope + // that has one. This is a wire contract: producers MUST publish entries + // sorted by descending specificity (Session, Workspace, then Global). + // The agent host emits at most one Workspace entry, for the session's primary + // working directory. Consumers MAY treat + // `enablement[0]` as the decisive decision and + // `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + // absent or empty array means no explicit decision exists, so the + // customization is enabled by default. + // + // Flows in both directions. A client publishes this alongside a customization + // to assert its global decision, which is authoritative for the Global scope; + // a client always includes its global entry, even when enabled. The host + // publishes the fully resolved set across all scopes, and consumers derive + // the effective enabled value from that set. + Enablement []CustomizationEnablement `json:"enablement,omitempty"` + // Whether the client explicitly bundled this server and owns its Global + // enablement decision. + IsClientBundled *bool `json:"isClientBundled,omitempty"` // Current lifecycle state of the MCP server. State McpServerState `json:"state"` // An `mcp://`-protocol channel the client uses to side-channel traffic @@ -3557,6 +3597,74 @@ type ResourceChange struct { Type ResourceChangeType `json:"type"` } +// ─── Customization Enablement Union ─────────────────────────────────────── + +// CustomizationEnablement is a single explicit customization enablement decision. +type CustomizationEnablement struct { + Value isCustomizationEnablement +} + +type isCustomizationEnablement interface{ isCustomizationEnablement() } + +type CustomizationEnablementGlobal struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementGlobal) isCustomizationEnablement() {} + +type CustomizationEnablementWorkspace struct { + Kind string `json:"kind"` + URI URI `json:"uri"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementWorkspace) isCustomizationEnablement() {} + +type CustomizationEnablementSession struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` +} + +func (*CustomizationEnablementSession) isCustomizationEnablement() {} + +func (e *CustomizationEnablement) UnmarshalJSON(data []byte) error { + disc, _, err := readDiscriminator(data, "kind") + if err != nil { + return err + } + switch disc { + case "global": + var value CustomizationEnablementGlobal + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + case "workspace": + var value CustomizationEnablementWorkspace + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + case "session": + var value CustomizationEnablementSession + if err := json.Unmarshal(data, &value); err != nil { + return err + } + e.Value = &value + default: + return &json.UnmarshalTypeError{Value: "CustomizationEnablement"} + } + return nil +} + +func (e CustomizationEnablement) MarshalJSON() ([]byte, error) { + if e.Value == nil { + return []byte("null"), nil + } + return json.Marshal(e.Value) +} + // ToolInput is raw tool input represented inline or by content reference. type ToolInput struct { Inline *string diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt index 6222ed855..b23d47d41 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt @@ -271,21 +271,36 @@ private fun withCustomizationChildren(c: Customization, children: List c } -private fun withCustomizationEnabled(c: Customization, enabled: Boolean): Customization = when (c) { - is CustomizationPlugin -> CustomizationPlugin(c.value.copy(enabled = enabled)) +private fun effectiveEnablement(enablement: List): Boolean = when (val decision = enablement.firstOrNull()) { + is CustomizationEnablement.Global -> decision.value.enabled + is CustomizationEnablement.Workspace -> decision.value.enabled + is CustomizationEnablement.Session -> decision.value.enabled + null -> true +} + +private fun withCustomizationEnablement(c: Customization, enablement: List): Customization { + val enabled = effectiveEnablement(enablement) + val provenance = enablement.takeIf { it.isNotEmpty() }?.toList() + return when (c) { + is CustomizationPlugin -> CustomizationPlugin(c.value.copy(enablement = provenance)) is CustomizationDirectory -> CustomizationDirectory(c.value.copy(enabled = enabled)) - is CustomizationMcpServer -> CustomizationMcpServer(c.value.copy(enabled = enabled)) + is CustomizationMcpServer -> CustomizationMcpServer(c.value.copy(enablement = provenance)) is CustomizationUnknown -> c + } } -private fun withChildCustomizationEnabled(c: ChildCustomization, enabled: Boolean): ChildCustomization = when (c) { +private fun withChildCustomizationEnablement(c: ChildCustomization, enablement: List): ChildCustomization { + val enabled = effectiveEnablement(enablement) + val provenance = enablement.takeIf { it.isNotEmpty() }?.toList() + return when (c) { is ChildCustomizationAgent -> ChildCustomizationAgent(c.value.copy(enabled = enabled)) is ChildCustomizationSkill -> ChildCustomizationSkill(c.value.copy(enabled = enabled)) is ChildCustomizationPrompt -> ChildCustomizationPrompt(c.value.copy(enabled = enabled)) is ChildCustomizationRule -> ChildCustomizationRule(c.value.copy(enabled = enabled)) is ChildCustomizationHook -> ChildCustomizationHook(c.value.copy(enabled = enabled)) - is ChildCustomizationMcpServer -> ChildCustomizationMcpServer(c.value.copy(enabled = enabled)) + is ChildCustomizationMcpServer -> ChildCustomizationMcpServer(c.value.copy(enablement = provenance)) is ChildCustomizationUnknown -> c + } } private fun childCustomizationId(c: ChildCustomization): String? = when (c) { @@ -685,7 +700,7 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat val idx = list.indexOfFirst { customizationId(it) == a.id } if (idx >= 0) { val updated = list.toMutableList() - updated[idx] = withCustomizationEnabled(updated[idx], a.enabled) + updated[idx] = withCustomizationEnablement(updated[idx], a.enablement) state.copy(customizations = updated) } else run { for (i in list.indices) { @@ -693,7 +708,7 @@ public fun sessionReducer(state: SessionState, action: StateAction): SessionStat val childIdx = children.indexOfFirst { childCustomizationId(it) == a.id } if (childIdx < 0) continue val newChildren = children.toMutableList() - newChildren[childIdx] = withChildCustomizationEnabled(newChildren[childIdx], a.enabled) + newChildren[childIdx] = withChildCustomizationEnablement(newChildren[childIdx], a.enablement) val updated = list.toMutableList() updated[i] = withCustomizationChildren(list[i], newChildren) return@run state.copy(customizations = updated) diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt index cde9f9ede..b28cc47d1 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt @@ -1058,13 +1058,13 @@ data class SessionCustomizationsChangedAction( data class SessionCustomizationToggledAction( val type: ActionType, /** - * The id of the container or child to toggle. + * The id of the container or child to update. */ val id: String, /** - * Whether to enable or disable the targeted customization. + * Explicit enablement decisions, replacing the previous list entirely. */ - val enabled: Boolean + val enablement: List ) @Serializable diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt index d6fe2b4e6..81c458561 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt @@ -557,6 +557,19 @@ enum class CustomizationType { MCP_SERVER } +/** + * Scope at which customization enablement is decided. + */ +@Serializable +enum class CustomizationEnablementKind { + @SerialName("global") + GLOBAL, + @SerialName("workspace") + WORKSPACE, + @SerialName("session") + SESSION +} + /** * Discriminant values for {@link CustomizationLoadState}. */ @@ -3276,10 +3289,6 @@ data class PluginCustomization( */ @SerialName("_meta") val meta: Map? = null, - /** - * Whether this container is currently enabled. - */ - val enabled: Boolean, /** * `clientId` of the client that contributed this container. Absent for * server-originated entries. @@ -3299,6 +3308,10 @@ data class PluginCustomization( */ val children: List? = null, val type: CustomizationType, + /** + * Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + */ + val enablement: List? = null, /** * Version of the plugin, sourced from the * [Open Plugins](https://open-plugins.com/) manifest's optional @@ -3352,10 +3365,6 @@ data class ClientPluginCustomization( */ @SerialName("_meta") val meta: Map? = null, - /** - * Whether this container is currently enabled. - */ - val enabled: Boolean, /** * `clientId` of the client that contributed this container. Absent for * server-originated entries. @@ -3375,6 +3384,10 @@ data class ClientPluginCustomization( */ val children: List? = null, val type: CustomizationType, + /** + * Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + */ + val enablement: List? = null, /** * Version of the plugin, sourced from the * [Open Plugins](https://open-plugins.com/) manifest's optional @@ -3387,7 +3400,18 @@ data class ClientPluginCustomization( /** * Opaque version token used by the host to detect changes. */ - val nonce: String? = null + val nonce: String? = null, + /** + * Explicit enablement decisions for children this plugin contributes, + * keyed by child name (for MCP servers, the server name as it appears in + * the bundled `.mcp.json`). + * + * Bundled children are discovered by the host rather than published by the + * client, so the client cannot attach `enablement` to them directly. This + * carries the client's global decision for each one; the host applies it + * under the child's durable key. + */ + val childEnablement: Map>? = null ) @Serializable @@ -3432,10 +3456,6 @@ data class DirectoryCustomization( */ @SerialName("_meta") val meta: Map? = null, - /** - * Whether this container is currently enabled. - */ - val enabled: Boolean, /** * `clientId` of the client that contributed this container. Absent for * server-originated entries. @@ -3455,6 +3475,10 @@ data class DirectoryCustomization( */ val children: List? = null, val type: CustomizationType, + /** + * Whether this container is currently enabled. + */ + val enabled: Boolean, /** * Which child customization type this directory holds. */ @@ -3513,9 +3537,10 @@ data class AgentCustomization( * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3605,9 +3630,10 @@ data class SkillCustomization( * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3681,9 +3707,10 @@ data class PromptCustomization( * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3744,9 +3771,10 @@ data class RuleCustomization( * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3818,9 +3846,10 @@ data class HookCustomization( * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3873,9 +3902,28 @@ data class McpServerCustomization( val meta: Map? = null, val type: CustomizationType, /** - * Whether this MCP server is currently enabled. + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Flows in both directions. A client publishes this alongside a customization + * to assert its global decision, which is authoritative for the Global scope; + * a client always includes its global entry, even when enabled. The host + * publishes the fully resolved set across all scopes, and consumers derive + * the effective enabled value from that set. */ - val enabled: Boolean, + val enablement: List? = null, + /** + * Whether the client explicitly bundled this server and owns its Global + * enablement decision. + */ + val isClientBundled: Boolean? = null, /** * Current lifecycle state of the MCP server. */ @@ -4662,6 +4710,76 @@ data class ResourceChange( val type: ResourceChangeType ) +// ─── Customization Enablement Union ───────────────────────────────────── + +/** + * A single explicit customization enablement decision. + */ +@Serializable(with = CustomizationEnablementSerializer::class) +sealed interface CustomizationEnablement { + @JvmInline value class Global(val value: CustomizationEnablementGlobal) : CustomizationEnablement + @JvmInline value class Workspace(val value: CustomizationEnablementWorkspace) : CustomizationEnablement + @JvmInline value class Session(val value: CustomizationEnablementSession) : CustomizationEnablement +} + +@Serializable +data class CustomizationEnablementGlobal( + val enabled: Boolean, + val kind: String = "global", +) + +@Serializable +data class CustomizationEnablementWorkspace( + val uri: URI, + val enabled: Boolean, + val kind: String = "workspace", +) + +@Serializable +data class CustomizationEnablementSession( + val enabled: Boolean, + val kind: String = "session", +) + +internal object CustomizationEnablementSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CustomizationEnablement") + + override fun deserialize(decoder: Decoder): CustomizationEnablement { + val input = decoder as? JsonDecoder + ?: error("CustomizationEnablement can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CustomizationEnablement") + return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { + "global" -> CustomizationEnablement.Global( + input.json.decodeFromJsonElement(CustomizationEnablementGlobal.serializer(), element), + ) + "workspace" -> CustomizationEnablement.Workspace( + input.json.decodeFromJsonElement(CustomizationEnablementWorkspace.serializer(), element), + ) + "session" -> CustomizationEnablement.Session( + input.json.decodeFromJsonElement(CustomizationEnablementSession.serializer(), element), + ) + else -> error("Unknown CustomizationEnablement kind") + } + } + + override fun serialize(encoder: Encoder, value: CustomizationEnablement) { + val output = encoder as? JsonEncoder + ?: error("CustomizationEnablement can only be serialized to JSON") + val element: JsonElement = when (value) { + is CustomizationEnablement.Global -> + output.json.encodeToJsonElement(CustomizationEnablementGlobal.serializer(), value.value) + is CustomizationEnablement.Workspace -> + output.json.encodeToJsonElement(CustomizationEnablementWorkspace.serializer(), value.value) + is CustomizationEnablement.Session -> + output.json.encodeToJsonElement(CustomizationEnablementSession.serializer(), value.value) + } + output.encodeJsonElement(element) + } +} + // ─── Tool Input ────────────────────────────────────────────────────────────── /** diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs index 849a339c5..e4369dda1 100644 --- a/clients/rust/crates/ahp-types/src/actions.rs +++ b/clients/rust/crates/ahp-types/src/actions.rs @@ -16,11 +16,12 @@ use crate::state::{ AgentInfo, AgentSelection, Annotation, AnnotationEntry, Changeset, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, ChangesetStatus, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ChatSummary, - ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, - Message, ModelSelection, PendingMessageKind, ResponsePart, SessionActiveClient, - SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, - ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, ToolCallResult, - ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, UsageInfo, + ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, + McpAuthRequirement, McpServerState, Message, ModelSelection, PendingMessageKind, ResponsePart, + SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, + TextRange, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributor, + ToolCallResult, ToolCallRiskAssessment, ToolDefinition, ToolInput, ToolResultContent, Turn, + UsageInfo, }; // ─── ActionType ────────────────────────────────────────────────────── @@ -1158,23 +1159,27 @@ pub struct SessionCustomizationsChangedAction { pub customizations: Vec, } -/// A client toggled a customization on or off. +/// A client updated a customization's enablement decisions. /// /// Matches `id` against every top-level customization first — a plugin or /// directory container, or a bare top-level MCP server — then against the -/// children inside each container (a skill, agent, or other entry), and -/// sets the matched entry's `enabled` flag. Disabling a container still -/// disables all of its children — the effective state of a child is -/// `container.enabled && (child.enabled ?? true)` — so toggling a child -/// only matters while its container is enabled. Is a no-op when no +/// children inside each container (a skill, agent, or other entry). Plugins +/// and MCP servers retain the matched entry's explicit decisions; other +/// entries update their `enabled` flag. Disabling a plugin still disables all +/// of its children — the effective state of a plugin child is the plugin's +/// derived enabled value and `(child.enabled ?? true)` — so toggling a child +/// only matters while its plugin is enabled. Is a no-op when no /// customization has the given `id`. +/// +/// The `enablement` array completely replaces all explicit decisions. A caller +/// changing one scope must include every decision it intends to preserve. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionCustomizationToggledAction { - /// The id of the container or child to toggle. + /// The id of the container or child to update. pub id: String, - /// Whether to enable or disable the targeted customization. - pub enabled: bool, + /// Explicit enablement decisions, replacing the previous list entirely. + pub enablement: Vec, } /// Upserts a top-level customization (plugin or directory). diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs index 0e9f5cba7..44f0ea48c 100644 --- a/clients/rust/crates/ahp-types/src/state.rs +++ b/clients/rust/crates/ahp-types/src/state.rs @@ -446,6 +446,17 @@ pub enum CustomizationType { McpServer, } +/// Scope at which customization enablement is decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CustomizationEnablementKind { + #[serde(rename = "global")] + Global, + #[serde(rename = "workspace")] + Workspace, + #[serde(rename = "session")] + Session, +} + /// Discriminant values for {@link CustomizationLoadState}. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CustomizationLoadStatus { @@ -2929,8 +2940,6 @@ pub struct PluginCustomization { /// out-of-band. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Whether this container is currently enabled. - pub enabled: bool, /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2946,6 +2955,9 @@ pub struct PluginCustomization { /// nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub children: Option>, + /// Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Version of the plugin, sourced from the /// [Open Plugins](https://open-plugins.com/) manifest's optional /// `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -2998,8 +3010,6 @@ pub struct ClientPluginCustomization { /// out-of-band. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Whether this container is currently enabled. - pub enabled: bool, /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3015,6 +3025,9 @@ pub struct ClientPluginCustomization { /// nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub children: Option>, + /// Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, /// Version of the plugin, sourced from the /// [Open Plugins](https://open-plugins.com/) manifest's optional /// `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -3026,6 +3039,16 @@ pub struct ClientPluginCustomization { /// Opaque version token used by the host to detect changes. #[serde(default, skip_serializing_if = "Option::is_none")] pub nonce: Option, + /// Explicit enablement decisions for children this plugin contributes, + /// keyed by child name (for MCP servers, the server name as it appears in + /// the bundled `.mcp.json`). + /// + /// Bundled children are discovered by the host rather than published by the + /// client, so the client cannot attach `enablement` to them directly. This + /// carries the client's global decision for each one; the host applies it + /// under the child's durable key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_enablement: Option>>, } /// A directory the host watches for this session. @@ -3070,8 +3093,6 @@ pub struct DirectoryCustomization { /// out-of-band. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Whether this container is currently enabled. - pub enabled: bool, /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3087,6 +3108,8 @@ pub struct DirectoryCustomization { /// nothing. #[serde(default, skip_serializing_if = "Option::is_none")] pub children: Option>, + /// Whether this container is currently enabled. + pub enabled: bool, /// Which child customization type this directory holds. pub contents: CustomizationType, /// Whether clients may write into this directory. @@ -3136,9 +3159,10 @@ pub struct AgentCustomization { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3218,9 +3242,10 @@ pub struct SkillCustomization { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3281,9 +3306,10 @@ pub struct PromptCustomization { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3341,9 +3367,10 @@ pub struct RuleCustomization { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3402,9 +3429,10 @@ pub struct HookCustomization { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3453,8 +3481,27 @@ pub struct McpServerCustomization { /// out-of-band. #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - /// Whether this MCP server is currently enabled. - pub enabled: bool, + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Flows in both directions. A client publishes this alongside a customization + /// to assert its global decision, which is authoritative for the Global scope; + /// a client always includes its global entry, even when enabled. The host + /// publishes the fully resolved set across all scopes, and consumers derive + /// the effective enabled value from that set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enablement: Option>, + /// Whether the client explicitly bundled this server and owns its Global + /// enablement decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_client_bundled: Option, /// Current lifecycle state of the MCP server. pub state: McpServerState, /// An `mcp://`-protocol channel the client uses to side-channel traffic @@ -4258,6 +4305,20 @@ pub struct ResourceChange { pub r#type: ResourceChangeType, } +// ─── Customization Enablement Union ─────────────────────────────────────── + +/// A single explicit customization enablement decision. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CustomizationEnablement { + #[serde(rename = "global")] + Global { enabled: bool }, + #[serde(rename = "workspace")] + Workspace { uri: Uri, enabled: bool }, + #[serde(rename = "session")] + Session { enabled: bool }, +} + /// Raw tool input represented inline or by content reference. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs index 027f48e06..05f2d96cd 100644 --- a/clients/rust/crates/ahp/src/reducers.rs +++ b/clients/rust/crates/ahp/src/reducers.rs @@ -59,15 +59,16 @@ use ahp_types::actions::{ }; use ahp_types::state::{ ActiveTurn, AnnotationsState, ChangesetOperationStatus, ChangesetState, ChangesetStatus, - ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, ErrorInfo, - InputRequestResponsePart, McpServerStartingState, McpServerState, McpServerStoppedState, - PendingMessage, PendingMessageKind, ResourceWatchState, ResponsePart, RootState, - SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, TerminalCommandPart, - TerminalContentPart, TerminalState, TerminalUnclassifiedPart, ToolCallAuthRequiredState, - ToolCallCancellationReason, ToolCallCancelledState, ToolCallCompletedState, - ToolCallConfirmationReason, ToolCallContributor, ToolCallPendingConfirmationState, - ToolCallPendingResultConfirmationState, ToolCallResponsePart, ToolCallRunningState, - ToolCallState, ToolCallStatus, ToolCallStreamingState, ToolInput, Turn, TurnState, + ChatInputRequest, ChatState, ChildCustomization, ConfirmationOption, Customization, + CustomizationEnablement, ErrorInfo, InputRequestResponsePart, McpServerStartingState, + McpServerState, McpServerStoppedState, PendingMessage, PendingMessageKind, ResourceWatchState, + ResponsePart, RootState, SessionInputRequest, SessionLifecycle, SessionState, SessionStatus, + TerminalCommandPart, TerminalContentPart, TerminalState, TerminalUnclassifiedPart, + ToolCallAuthRequiredState, ToolCallCancellationReason, ToolCallCancelledState, + ToolCallCompletedState, ToolCallConfirmationReason, ToolCallContributor, + ToolCallPendingConfirmationState, ToolCallPendingResultConfirmationState, ToolCallResponsePart, + ToolCallRunningState, ToolCallState, ToolCallStatus, ToolCallStreamingState, ToolInput, Turn, + TurnState, }; /// What happened when an action was applied. @@ -496,36 +497,71 @@ fn container_children_mut(c: &mut Customization) -> Option<&mut Vec bool { + match enablement.first() { + Some(CustomizationEnablement::Global { enabled }) => *enabled, + Some(CustomizationEnablement::Workspace { enabled, .. }) => *enabled, + Some(CustomizationEnablement::Session { enabled }) => *enabled, + None => true, + } +} + +fn apply_container_enablement(c: &mut Customization, enablement: &[CustomizationEnablement]) { + let enabled = effective_enablement(enablement); + let provenance = (!enablement.is_empty()).then(|| enablement.to_vec()); match c { - Customization::Plugin(p) => p.enabled = enabled, - Customization::Directory(d) => d.enabled = enabled, - Customization::McpServer(m) => m.enabled = enabled, + Customization::Plugin(p) => { + p.enablement = provenance; + } + Customization::Directory(d) => { + d.enabled = enabled; + } + Customization::McpServer(m) => { + m.enablement = provenance; + } Customization::Unknown(_) => {} } } -fn set_child_enabled(c: &mut ChildCustomization, enabled: bool) { +fn apply_child_enablement(c: &mut ChildCustomization, enablement: &[CustomizationEnablement]) { + let enabled = effective_enablement(enablement); + let provenance = (!enablement.is_empty()).then(|| enablement.to_vec()); match c { - ChildCustomization::Agent(x) => x.enabled = Some(enabled), - ChildCustomization::Skill(x) => x.enabled = Some(enabled), - ChildCustomization::Prompt(x) => x.enabled = Some(enabled), - ChildCustomization::Rule(x) => x.enabled = Some(enabled), - ChildCustomization::Hook(x) => x.enabled = Some(enabled), - ChildCustomization::McpServer(x) => x.enabled = enabled, + ChildCustomization::Agent(x) => { + x.enabled = Some(enabled); + } + ChildCustomization::Skill(x) => { + x.enabled = Some(enabled); + } + ChildCustomization::Prompt(x) => { + x.enabled = Some(enabled); + } + ChildCustomization::Rule(x) => { + x.enabled = Some(enabled); + } + ChildCustomization::Hook(x) => { + x.enabled = Some(enabled); + } + ChildCustomization::McpServer(x) => { + x.enablement = provenance; + } ChildCustomization::Unknown(_) => {} } } -fn apply_toggle(list: &mut [Customization], id: &str, enabled: bool) -> bool { +fn apply_toggle( + list: &mut [Customization], + id: &str, + enablement: &[CustomizationEnablement], +) -> bool { if let Some(container) = list.iter_mut().find(|c| customization_id(c) == Some(id)) { - set_container_enabled(container, enabled); + apply_container_enablement(container, enablement); return true; } for container in list.iter_mut() { if let Some(children) = container_children_mut(container) { if let Some(child) = children.iter_mut().find(|c| child_id_of(c) == Some(id)) { - set_child_enabled(child, enabled); + apply_child_enablement(child, enablement); return true; } } @@ -841,7 +877,7 @@ pub fn apply_action_to_session(state: &mut SessionState, action: &StateAction) - let Some(list) = state.customizations.as_mut() else { return ReduceOutcome::NoOp; }; - if apply_toggle(list, &a.id, a.enabled) { + if apply_toggle(list, &a.id, &a.enablement) { ReduceOutcome::Applied } else { ReduceOutcome::NoOp diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift index da101cfd4..f439fcb23 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift @@ -1354,19 +1354,19 @@ public struct SessionCustomizationsChangedAction: Codable, Sendable { public struct SessionCustomizationToggledAction: Codable, Sendable { public var type: ActionType - /// The id of the container or child to toggle. + /// The id of the container or child to update. public var id: String - /// Whether to enable or disable the targeted customization. - public var enabled: Bool + /// Explicit enablement decisions, replacing the previous list entirely. + public var enablement: [CustomizationEnablement] public init( type: ActionType, id: String, - enabled: Bool + enablement: [CustomizationEnablement] ) { self.type = type self.id = id - self.enabled = enabled + self.enablement = enablement } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift index 7ec7dd9b2..2f61d18a0 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift @@ -294,6 +294,13 @@ public enum CustomizationType: String, Codable, Sendable { case mcpServer = "mcpServer" } +/// Scope at which customization enablement is decided. +public enum CustomizationEnablementKind: String, Codable, Sendable { + case global = "global" + case workspace = "workspace" + case session = "session" +} + /// Discriminant values for {@link CustomizationLoadState}. public enum CustomizationLoadStatus: String, Codable, Sendable { case loading = "loading" @@ -3510,8 +3517,6 @@ public struct PluginCustomization: Codable, Sendable { /// protocol; producers and consumers agree on its contents /// out-of-band. public var meta: [String: AnyCodable]? - /// Whether this container is currently enabled. - public var enabled: Bool /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. public var clientId: String? @@ -3525,6 +3530,8 @@ public struct PluginCustomization: Codable, Sendable { /// nothing. public var children: [ChildCustomization]? public var type: CustomizationType + /// Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + public var enablement: [CustomizationEnablement]? /// Version of the plugin, sourced from the /// [Open Plugins](https://open-plugins.com/) manifest's optional /// `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -3540,11 +3547,11 @@ public struct PluginCustomization: Codable, Sendable { case icons case range case meta = "_meta" - case enabled case clientId case load case children case type + case enablement case version } @@ -3555,11 +3562,11 @@ public struct PluginCustomization: Codable, Sendable { icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, - enabled: Bool, clientId: String? = nil, load: CustomizationLoadState? = nil, children: [ChildCustomization]? = nil, type: CustomizationType, + enablement: [CustomizationEnablement]? = nil, version: String? = nil ) { self.id = id @@ -3568,11 +3575,11 @@ public struct PluginCustomization: Codable, Sendable { self.icons = icons self.range = range self.meta = meta - self.enabled = enabled self.clientId = clientId self.load = load self.children = children self.type = type + self.enablement = enablement self.version = version } } @@ -3605,8 +3612,6 @@ public struct ClientPluginCustomization: Codable, Sendable { /// protocol; producers and consumers agree on its contents /// out-of-band. public var meta: [String: AnyCodable]? - /// Whether this container is currently enabled. - public var enabled: Bool /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. public var clientId: String? @@ -3620,6 +3625,8 @@ public struct ClientPluginCustomization: Codable, Sendable { /// nothing. public var children: [ChildCustomization]? public var type: CustomizationType + /// Explicit enablement decisions. See {@link McpServerCustomization.enablement}. + public var enablement: [CustomizationEnablement]? /// Version of the plugin, sourced from the /// [Open Plugins](https://open-plugins.com/) manifest's optional /// `version` field (semver, e.g. `"1.2.0"`). Absent when the manifest @@ -3629,6 +3636,15 @@ public struct ClientPluginCustomization: Codable, Sendable { public var version: String? /// Opaque version token used by the host to detect changes. public var nonce: String? + /// Explicit enablement decisions for children this plugin contributes, + /// keyed by child name (for MCP servers, the server name as it appears in + /// the bundled `.mcp.json`). + /// + /// Bundled children are discovered by the host rather than published by the + /// client, so the client cannot attach `enablement` to them directly. This + /// carries the client's global decision for each one; the host applies it + /// under the child's durable key. + public var childEnablement: [String: [CustomizationEnablement]]? enum CodingKeys: String, CodingKey { case id @@ -3637,13 +3653,14 @@ public struct ClientPluginCustomization: Codable, Sendable { case icons case range case meta = "_meta" - case enabled case clientId case load case children case type + case enablement case version case nonce + case childEnablement } public init( @@ -3653,13 +3670,14 @@ public struct ClientPluginCustomization: Codable, Sendable { icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, - enabled: Bool, clientId: String? = nil, load: CustomizationLoadState? = nil, children: [ChildCustomization]? = nil, type: CustomizationType, + enablement: [CustomizationEnablement]? = nil, version: String? = nil, - nonce: String? = nil + nonce: String? = nil, + childEnablement: [String: [CustomizationEnablement]]? = nil ) { self.id = id self.uri = uri @@ -3667,13 +3685,14 @@ public struct ClientPluginCustomization: Codable, Sendable { self.icons = icons self.range = range self.meta = meta - self.enabled = enabled self.clientId = clientId self.load = load self.children = children self.type = type + self.enablement = enablement self.version = version self.nonce = nonce + self.childEnablement = childEnablement } } @@ -3705,8 +3724,6 @@ public struct DirectoryCustomization: Codable, Sendable { /// protocol; producers and consumers agree on its contents /// out-of-band. public var meta: [String: AnyCodable]? - /// Whether this container is currently enabled. - public var enabled: Bool /// `clientId` of the client that contributed this container. Absent for /// server-originated entries. public var clientId: String? @@ -3720,6 +3737,8 @@ public struct DirectoryCustomization: Codable, Sendable { /// nothing. public var children: [ChildCustomization]? public var type: CustomizationType + /// Whether this container is currently enabled. + public var enabled: Bool /// Which child customization type this directory holds. public var contents: CustomizationType /// Whether clients may write into this directory. @@ -3732,11 +3751,11 @@ public struct DirectoryCustomization: Codable, Sendable { case icons case range case meta = "_meta" - case enabled case clientId case load case children case type + case enabled case contents case writable } @@ -3748,11 +3767,11 @@ public struct DirectoryCustomization: Codable, Sendable { icons: [Icon]? = nil, range: TextRange? = nil, meta: [String: AnyCodable]? = nil, - enabled: Bool, clientId: String? = nil, load: CustomizationLoadState? = nil, children: [ChildCustomization]? = nil, type: CustomizationType, + enabled: Bool, contents: CustomizationType, writable: Bool ) { @@ -3762,11 +3781,11 @@ public struct DirectoryCustomization: Codable, Sendable { self.icons = icons self.range = range self.meta = meta - self.enabled = enabled self.clientId = clientId self.load = load self.children = children self.type = type + self.enabled = enabled self.contents = contents self.writable = writable } @@ -3805,9 +3824,10 @@ public struct AgentCustomization: Codable, Sendable { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -3917,9 +3937,10 @@ public struct SkillCustomization: Codable, Sendable { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -4011,9 +4032,10 @@ public struct PromptCustomization: Codable, Sendable { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -4090,9 +4112,10 @@ public struct RuleCustomization: Codable, Sendable { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -4182,9 +4205,10 @@ public struct HookCustomization: Codable, Sendable { /// turned off on its own. /// /// This flag is independent of the parent container's: the **effective** - /// enabled state of a child is - /// `container.enabled && (child.enabled ?? true)`, so a disabled container - /// disables every child regardless of each child's own flag. + /// enabled state of a plugin child is the plugin's derived enabled value and + /// `(child.enabled ?? true)`, so a disabled plugin disables every child + /// regardless of each child's own flag. A directory child instead uses the + /// directory's `enabled` value and its own flag. /// /// A child is turned on or off by id with /// {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -4252,8 +4276,25 @@ public struct McpServerCustomization: Codable, Sendable { /// out-of-band. public var meta: [String: AnyCodable]? public var type: CustomizationType - /// Whether this MCP server is currently enabled. - public var enabled: Bool + /// Explicit enablement decisions for this customization, one entry per scope + /// that has one. This is a wire contract: producers MUST publish entries + /// sorted by descending specificity (Session, Workspace, then Global). + /// The agent host emits at most one Workspace entry, for the session's primary + /// working directory. Consumers MAY treat + /// `enablement[0]` as the decisive decision and + /// `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + /// absent or empty array means no explicit decision exists, so the + /// customization is enabled by default. + /// + /// Flows in both directions. A client publishes this alongside a customization + /// to assert its global decision, which is authoritative for the Global scope; + /// a client always includes its global entry, even when enabled. The host + /// publishes the fully resolved set across all scopes, and consumers derive + /// the effective enabled value from that set. + public var enablement: [CustomizationEnablement]? + /// Whether the client explicitly bundled this server and owns its Global + /// enablement decision. + public var isClientBundled: Bool? /// Current lifecycle state of the MCP server. public var state: McpServerState /// An `mcp://`-protocol channel the client uses to side-channel traffic @@ -4284,7 +4325,8 @@ public struct McpServerCustomization: Codable, Sendable { case range case meta = "_meta" case type - case enabled + case enablement + case isClientBundled case state case channel case mcpApp @@ -4298,7 +4340,8 @@ public struct McpServerCustomization: Codable, Sendable { range: TextRange? = nil, meta: [String: AnyCodable]? = nil, type: CustomizationType, - enabled: Bool, + enablement: [CustomizationEnablement]? = nil, + isClientBundled: Bool? = nil, state: McpServerState, channel: String? = nil, mcpApp: McpServerCustomizationApps? = nil @@ -4310,7 +4353,8 @@ public struct McpServerCustomization: Codable, Sendable { self.range = range self.meta = meta self.type = type - self.enabled = enabled + self.enablement = enablement + self.isClientBundled = isClientBundled self.state = state self.channel = channel self.mcpApp = mcpApp @@ -5227,6 +5271,70 @@ public struct ResourceChange: Codable, Sendable { } } +// MARK: - Customization Enablement Union + +/// A single explicit customization enablement decision. +public enum CustomizationEnablement: Codable, Sendable { + case global(CustomizationEnablementGlobal) + case workspace(CustomizationEnablementWorkspace) + case session(CustomizationEnablementSession) + + private enum DiscriminantKey: String, CodingKey { + case kind + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + switch try container.decode(String.self, forKey: .kind) { + case "global": + self = .global(try CustomizationEnablementGlobal(from: decoder)) + case "workspace": + self = .workspace(try CustomizationEnablementWorkspace(from: decoder)) + case "session": + self = .session(try CustomizationEnablementSession(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown CustomizationEnablement kind") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .global(let value): try value.encode(to: encoder) + case .workspace(let value): try value.encode(to: encoder) + case .session(let value): try value.encode(to: encoder) + } + } +} + +public struct CustomizationEnablementGlobal: Codable, Sendable { + public var kind: String = "global" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct CustomizationEnablementWorkspace: Codable, Sendable { + public var kind: String = "workspace" + public var uri: URI + public var enabled: Bool + + public init(uri: URI, enabled: Bool) { + self.uri = uri + self.enabled = enabled + } +} + +public struct CustomizationEnablementSession: Codable, Sendable { + public var kind: String = "session" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + // MARK: - Tool Input /// Raw tool input represented inline or by content reference. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift index e3e10ef65..9032fd1e8 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/NativeReducer.swift @@ -216,16 +216,27 @@ func setCustomizationChildren(_ c: inout Customization, _ children: [ChildCustom } } -func setCustomizationEnabled(_ c: inout Customization, _ enabled: Bool) { +func effectiveEnablement(_ enablement: [CustomizationEnablement]) -> Bool { + guard let decision = enablement.first else { return true } + switch decision { + case .global(let value): return value.enabled + case .workspace(let value): return value.enabled + case .session(let value): return value.enabled + } +} + +func applyCustomizationEnablement(_ c: inout Customization, _ enablement: [CustomizationEnablement]) { + let enabled = effectiveEnablement(enablement) + let provenance = enablement.isEmpty ? nil : enablement switch c { case .plugin(var p): - p.enabled = enabled + p.enablement = provenance c = .plugin(p) case .directory(var d): d.enabled = enabled c = .directory(d) case .mcpServer(var m): - m.enabled = enabled + m.enablement = provenance c = .mcpServer(m) // Unknown/future customization: opaque payload, nothing to mutate. case .unknown: @@ -233,7 +244,9 @@ func setCustomizationEnabled(_ c: inout Customization, _ enabled: Bool) { } } -func setChildCustomizationEnabled(_ c: inout ChildCustomization, _ enabled: Bool) { +func applyChildCustomizationEnablement(_ c: inout ChildCustomization, _ enablement: [CustomizationEnablement]) { + let enabled = effectiveEnablement(enablement) + let provenance = enablement.isEmpty ? nil : enablement switch c { case .agent(var x): x.enabled = enabled @@ -251,7 +264,7 @@ func setChildCustomizationEnabled(_ c: inout ChildCustomization, _ enabled: Bool x.enabled = enabled c = .hook(x) case .mcpServer(var x): - x.enabled = enabled + x.enablement = provenance c = .mcpServer(x) // Unknown/future child customization: opaque payload, nothing to mutate. case .unknown: @@ -259,11 +272,11 @@ func setChildCustomizationEnabled(_ c: inout ChildCustomization, _ enabled: Bool } } -func toggleCustomization(in list: inout [Customization], id: String, enabled: Bool) -> Bool { +func toggleCustomization(in list: inout [Customization], id: String, enablement: [CustomizationEnablement]) -> Bool { for i in list.indices { if customizationId(list[i]) == id { var entry = list[i] - setCustomizationEnabled(&entry, enabled) + applyCustomizationEnablement(&entry, enablement) list[i] = entry return true } @@ -273,7 +286,7 @@ func toggleCustomization(in list: inout [Customization], id: String, enabled: Bo guard var children = customizationChildren(container) else { continue } guard let childIdx = children.firstIndex(where: { childId($0) == id }) else { continue } var child = children[childIdx] - setChildCustomizationEnabled(&child, enabled) + applyChildCustomizationEnablement(&child, enablement) children[childIdx] = child setCustomizationChildren(&container, children) list[containerIdx] = container diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift index 4dfa1c2c7..9e46edc9e 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift @@ -817,7 +817,7 @@ public func sessionReducer(state: SessionState, action: StateAction) -> SessionS case .sessionCustomizationToggled(let a): guard var list = state.customizations else { return state } - guard toggleCustomization(in: &list, id: a.id, enabled: a.enabled) else { return state } + guard toggleCustomization(in: &list, id: a.id, enablement: a.enablement) else { return state } var next = state next.customizations = list return next diff --git a/docs/.changes/20260804-scoped-customization-enablement.json b/docs/.changes/20260804-scoped-customization-enablement.json new file mode 100644 index 000000000..74cb1816a --- /dev/null +++ b/docs/.changes/20260804-scoped-customization-enablement.json @@ -0,0 +1,4 @@ +{ + "type": "changed", + "message": "`Customization` enablement now carries scoped host-published provenance, and `session/customizationToggled` replaces its complete decision set." +} diff --git a/docs/.changes/20260812-client-bundled-customization-enablement.json b/docs/.changes/20260812-client-bundled-customization-enablement.json new file mode 100644 index 000000000..60115facc --- /dev/null +++ b/docs/.changes/20260812-client-bundled-customization-enablement.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`McpServerCustomization` now exposes client-bundled enablement metadata, and `ClientPluginCustomization` can publish child enablement decisions." +} diff --git a/docs/guide/actions.md b/docs/guide/actions.md index b863ff52e..db0ad6a88 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -125,7 +125,7 @@ See [Elicitation](/guide/elicitation) for the request lifecycle. | Type | Client-dispatchable? | When | |---|---|---| | `session/customizationsChanged` | No | Server replaced the session's top-level customization list (full replacement) | -| `session/customizationToggled` | **Yes** | Client toggled a container or child customization on or off by id | +| `session/customizationToggled` | **Yes** | Client replaced a customization's explicit enablement decisions by id | | `session/customizationUpdated` | No | Server upserted a top-level container (plugin or directory) by id (full-entry replacement, including children) | | `session/customizationRemoved` | No | Server removed a customization by id (containers cascade to children) | @@ -193,7 +193,7 @@ The client applies the action **optimistically** to its local state before sendi | `chat/pendingMessageSet` | Stores a steering or queued message (upsert); if queued and idle, auto-starts a turn | | `chat/pendingMessageRemoved` | Cancels a pending message before it is consumed | | `chat/queuedMessagesReordered` | Reorders queued messages; unknown IDs ignored, unmentioned messages kept at end | -| `session/customizationToggled` | Toggles a container or child customization on or off by id | +| `session/customizationToggled` | Replaces a customization's explicit enablement decisions by id | | `session/isReadChanged` | Marks the session as read or unread | | `session/isArchivedChanged` | Archives or unarchives the session | diff --git a/docs/guide/customizations.md b/docs/guide/customizations.md index 4b51c1a6b..872ddea16 100644 --- a/docs/guide/customizations.md +++ b/docs/guide/customizations.md @@ -5,7 +5,7 @@ Customizations extend agent sessions with additional capabilities — agents, sk - **Top-level entries are typically containers**: a `PluginCustomization` (an [Open Plugins](https://open-plugins.com/) package) or a `DirectoryCustomization` (a directory the host watches on disk). The host MAY also surface a bare `McpServerCustomization` at the top level (for example, a globally-configured MCP server that isn't bundled in a plugin). - **Other children live inside a container**: `AgentCustomization`, `SkillCustomization`, `PromptCustomization`, `RuleCustomization`, `HookCustomization`, `McpServerCustomization`. MCP servers can therefore appear in either position. -The agent host is authoritative on the effective tree. Clients publish plugins, the host expands them into children, and the host owns disk-backed directories and bare top-level MCP servers. +The agent host is authoritative on the effective tree and its enablement. Clients publish plugins, the host expands them into children, and the host owns disk-backed directories and bare top-level MCP servers. For MCP-specific behaviour (server lifecycle, authentication, App support), see [MCP Servers](/guide/mcp). @@ -57,7 +57,7 @@ PluginCustomization { uri: URI // plugin URL or marketplace id name: string icons?: Icon[] - enabled: boolean + enablement?: CustomizationEnablement[] // explicit scoped decisions clientId?: string // set when published by a client load?: CustomizationLoadState // host-reported parse/load state children?: ChildCustomization[] @@ -108,7 +108,7 @@ stateDiagram-v2 ## Children -Every child carries the same base fields (`id`, `uri`, `name`, optional `icons`) plus an `enabled` flag — optional for the five leaf children (absent means enabled) and always present on an `McpServerCustomization`. Children are leaf nodes — no further nesting — and their parent is implied by which container holds them in its `children` array. A child's `enabled` is independent of its container's. Children have no `clientId`: client provenance lives on the container since clients can only contribute containers, not individual children. +Every child carries the same base fields (`id`, `uri`, `name`, optional `icons`). The five leaf children carry an optional `enabled` flag (absent means enabled), while `McpServerCustomization` carries explicit scoped `enablement` decisions. Children are leaf nodes — no further nesting — and their parent is implied by which container holds them in its `children` array. A child's `enabled` is independent of its container's. Children have no `clientId`: client provenance lives on the container since clients can only contribute containers, not individual children. Each child type carries optional metadata sourced from its [Open Plugins](https://open-plugins.com/plugin-builders/specification.md) component definition (typically the file's YAML frontmatter): @@ -118,7 +118,7 @@ SkillCustomization { type: 'skill'; description?, disableModelInvoc PromptCustomization { type: 'prompt'; description? } RuleCustomization { type: 'rule'; description?, alwaysApply?, globs? } // covers "instruction" formats too HookCustomization { type: 'hook'; event?, matcher? } -McpServerCustomization { type: 'mcpServer'; enabled, state, channel?, mcpApp? } // see /guide/mcp +McpServerCustomization { type: 'mcpServer'; enablement?, isClientBundled?, state, channel?, mcpApp? } // see /guide/mcp ``` Agents and skills carry a symmetric invocation matrix. `disableModelInvocation` removes the entry from the agent's automatic choices — a custom agent it won't auto-delegate to, or a skill it won't auto-invoke — while leaving it available for the user to pick. `disableUserInvocation` does the reverse: the entry stays available for the agent to invoke but is hidden from user-facing pickers and slash-commands. Both are absent/`false` by default (invocable by either party), and they are independent, so an entry can be agent-only, user-only, both, or neither. @@ -133,19 +133,60 @@ state.customizations .filter(c => c.type === CustomizationType.Agent) ``` +## Enablement + +Plugins and MCP servers may carry an `enablement` array of explicit decisions: + +```typescript +CustomizationEnablement = + | { kind: 'global'; enabled: boolean } + | { kind: 'workspace'; uri: URI; enabled: boolean } + | { kind: 'session'; enabled: boolean } +``` + +Producers MUST publish the entries in descending specificity: session, +workspace, then global. The host emits at most one workspace decision, for the +session's primary working directory. Consumers MAY use `enablement[0]` as the +decisive decision, with `enablement?.[0]?.enabled ?? true` as the effective +value. An absent or empty array has no explicit decision and means enabled by +default. + +For MCP servers, enablement flows both ways. A client publishing a server +includes its global decision (even when enabled), and the host publishes the +fully resolved decisions across all scopes. Client-published plugins can supply +global decisions for their discovered children through +`ClientPluginCustomization.childEnablement`, keyed by child name. + +`DirectoryCustomization` and the five leaf child kinds retain their plain +`enabled` fields. A plugin's effective value is derived from its `enablement`; +a plugin child is effectively enabled when that derived value and +`(child.enabled ?? true)` are both true. A directory child instead uses +`directory.enabled && (child.enabled ?? true)`. + ## Toggling -Any client can enable or disable any customization by dispatching `session/customizationToggled` with that entry's `id`: +Any client can request an enablement update with +`session/customizationToggled`. It carries the complete set of explicit +decisions for the entry: ```typescript { type: 'session/customizationToggled' id: string // any customization id - enabled: boolean + enablement: CustomizationEnablement[] } ``` -Both containers and children carry an `enabled` flag. The reducer matches `id` against every top-level customization first — plugins, directories, and bare top-level MCP servers — then against the children inside every container, and sets that entry's `enabled`. A child's effective state is `container.enabled && (child.enabled ?? true)`, so disabling a container disables all of its children regardless of each child's own flag, and a child toggle only takes effect while its container is enabled. The action is a no-op if no customization has that id. +The action replaces the entry's array wholesale rather than merging a single +scope. A caller changing one scope must include every decision it intends to +preserve. The reducer matches `id` against every top-level customization first +— plugins, directories, and bare top-level MCP servers — then against the +children inside every container. For plugins and MCP servers it replaces the +matched entry's `enablement`; for directories and leaf children it updates the +plain `enabled` value from the first decision. An empty array clears explicit +provenance from plugins and MCP servers and restores the default enabled value +for directories and leaf children. The action is a no-op if no customization +has that id. ```mermaid sequenceDiagram @@ -154,7 +195,7 @@ sequenceDiagram Note over Server: customizations: [Plugin A (enabled), Plugin B (enabled)] - Client->>Server: customizationToggled (id: plugin-a, enabled: false) + Client->>Server: customizationToggled (id: plugin-a, enablement: [session: false]) Server->>Client: action echoed Note over Server: customizations: [Plugin A (disabled), Plugin B (enabled)] ``` @@ -195,7 +236,7 @@ dispatch({ id: 'client-plugin-1', uri: 'virtual://my-client/workspace-skills', name: 'Workspace Skills', - enabled: true, + enablement: [{ kind: 'global', enabled: true }], nonce: 'sha256:...', }, ], @@ -423,7 +464,7 @@ sequenceDiagram Note over Client,Server: 3. Client disables Plugin A - Client->>Server: customizationToggled (id: plugin-a, enabled: false) + Client->>Server: customizationToggled (id: plugin-a, enablement: [session: false]) Server->>Client: action echoed Note over Client,Server: 4. Active client disconnects diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 50be30de5..a8e0be7f0 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -2,7 +2,7 @@ [Model Context Protocol](https://modelcontextprotocol.io/) servers are surfaced in AHP as a [`McpServerCustomization`](/reference/session#mcpservercustomization) — a customization that represents one running (or registered) MCP server within a session. AHP intentionally does **not** re-spec MCP. It exposes: -- Enough state for clients to render the server (name, icon, enabled flag, runtime status). +- Enough state for clients to render the server (name, icon, scoped enablement, runtime status). - Enough state for clients to drive authentication when the server demands it. - An optional [`mcp://` side-channel](/specification/mcp-channel) the client can use to talk to the upstream server when it needs to render an [MCP App](#mcp-apps). @@ -33,14 +33,18 @@ McpServerCustomization { name: string icons?: Icon[] range?: TextRange // span inside `uri` for inline declarations - enabled: boolean // user-toggleable (see Customizations guide) + enablement?: CustomizationEnablement[] // user-toggleable (see Customizations guide) + isClientBundled?: boolean // client owns the Global enablement decision state: McpServerState // discriminated union — see below channel?: URI // optional mcp:// side-channel mcpApp?: McpServerCustomizationApps } ``` -`enabled` follows the same model as any other container — it's toggled with `session/customizationToggled`. Disabling a server signals the host to stop it; the host then transitions the runtime through `stopped` and removes it from the session (or leaves it as `stopped` until removal, host's choice). +`enablement` is toggled with `session/customizationToggled`; its first entry +is the effective decision. Disabling a server signals the host to stop it; the +host then transitions the runtime through `stopped` and removes it from the +session (or leaves it as `stopped` until removal, host's choice). Clients can also ask the host to manage the server process without changing the customization's enabled intent: @@ -49,7 +53,7 @@ Clients can also ask the host to manage the server process without changing the ## Runtime status -`state` is a [discriminated union on `kind`](/reference/session#mcpserverstatus). It is the host's view of the server's lifecycle, separate from `enabled` (which is the user's intent). +`state` is a [discriminated union on `kind`](/reference/session#mcpserverstatus). It is the host's view of the server's lifecycle, separate from `enablement` (which records the user's scoped intent). ```mermaid stateDiagram-v2 diff --git a/schema/actions.schema.json b/schema/actions.schema.json index fffbb4603..d0731e6f5 100644 --- a/schema/actions.schema.json +++ b/schema/actions.schema.json @@ -495,24 +495,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client updated a customization's enablement decisions.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry). Plugins\nand MCP servers retain the matched entry's explicit decisions; other\nentries update their `enabled` flag. Disabling a plugin still disables all\nof its children — the effective state of a plugin child is the plugin's\nderived enabled value and `(child.enabled ?? true)` — so toggling a child\nonly matters while its plugin is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions, replacing the previous list entirely." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { @@ -3648,10 +3651,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3671,8 +3670,7 @@ "required": [ "id", "uri", - "name", - "enabled" + "name" ] }, "PluginCustomization": { @@ -3707,10 +3705,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3729,6 +3723,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -3738,7 +3739,6 @@ "id", "uri", "name", - "enabled", "type" ] }, @@ -3774,10 +3774,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3796,6 +3792,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -3803,13 +3806,22 @@ "nonce": { "type": "string", "description": "Opaque version token used by the host to detect changes." + }, + "childEnablement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + } + }, + "description": "Explicit enablement decisions for children this plugin contributes,\nkeyed by child name (for MCP servers, the server name as it appears in\nthe bundled `.mcp.json`).\n\nBundled children are discovered by the host rather than published by the\nclient, so the client cannot attach `enablement` to them directly. This\ncarries the client's global decision for each one; the host applies it\nunder the child's durable key." } }, "required": [ "id", "uri", "name", - "enabled", "type" ] }, @@ -3845,10 +3857,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3867,6 +3875,10 @@ "type": { "const": "directory" }, + "enabled": { + "type": "boolean", + "description": "Whether this container is currently enabled." + }, "contents": { "$ref": "#/$defs/ChildCustomizationType", "description": "Which child customization type this directory holds." @@ -3880,15 +3892,15 @@ "id", "uri", "name", - "enabled", "type", + "enabled", "contents", "writable" ] }, "ChildCustomizationBase": { "type": "object", - "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase: it always carries an explicit {@link McpServerCustomization.enabled}\nbecause it can appear as a top-level customization too.", + "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase because it can appear as a top-level customization too.", "properties": { "id": { "type": "string", @@ -3920,7 +3932,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." } }, "required": [ @@ -3963,7 +3975,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "agent" @@ -4033,7 +4045,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "skill" @@ -4092,7 +4104,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "prompt" @@ -4143,7 +4155,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "rule" @@ -4205,7 +4217,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "hook" @@ -4253,9 +4265,16 @@ "type": { "const": "mcpServer" }, - "enabled": { + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nFlows in both directions. A client publishes this alongside a customization\nto assert its global decision, which is authoritative for the Global scope;\na client always includes its global entry, even when enabled. The host\npublishes the fully resolved set across all scopes, and consumers derive\nthe effective enabled value from that set." + }, + "isClientBundled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether the client explicitly bundled this server and owns its Global\nenablement decision." }, "state": { "$ref": "#/$defs/McpServerState", @@ -4275,7 +4294,6 @@ "uri", "name", "type", - "enabled", "state" ] }, @@ -7127,6 +7145,60 @@ ], "description": "One outstanding piece of input a session is blocked on, aggregated across all\nchats in {@link SessionState.inputNeeded}.\n\nEach entry is self-sufficient: it carries the owning\n{@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed\nto construct the response, so a client can answer by dispatching the ordinary\n`chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`,\n`chat/toolCallComplete`, …) to that chat's channel **without having subscribed\nto the chat** — except {@link SessionToolAuthenticationRequest}, which is\nresolved via the `authenticate` command instead. The host removes the entry\nwith `session/inputNeededRemoved` once the underlying request resolves." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/schema/commands.schema.json b/schema/commands.schema.json index e65cb0f59..e1911e570 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -2957,10 +2957,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -2980,8 +2976,7 @@ "required": [ "id", "uri", - "name", - "enabled" + "name" ] }, "PluginCustomization": { @@ -3016,10 +3011,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3038,6 +3029,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -3047,7 +3045,6 @@ "id", "uri", "name", - "enabled", "type" ] }, @@ -3083,10 +3080,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3105,6 +3098,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -3112,13 +3112,22 @@ "nonce": { "type": "string", "description": "Opaque version token used by the host to detect changes." + }, + "childEnablement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + } + }, + "description": "Explicit enablement decisions for children this plugin contributes,\nkeyed by child name (for MCP servers, the server name as it appears in\nthe bundled `.mcp.json`).\n\nBundled children are discovered by the host rather than published by the\nclient, so the client cannot attach `enablement` to them directly. This\ncarries the client's global decision for each one; the host applies it\nunder the child's durable key." } }, "required": [ "id", "uri", "name", - "enabled", "type" ] }, @@ -3154,10 +3163,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -3176,6 +3181,10 @@ "type": { "const": "directory" }, + "enabled": { + "type": "boolean", + "description": "Whether this container is currently enabled." + }, "contents": { "$ref": "#/$defs/ChildCustomizationType", "description": "Which child customization type this directory holds." @@ -3189,15 +3198,15 @@ "id", "uri", "name", - "enabled", "type", + "enabled", "contents", "writable" ] }, "ChildCustomizationBase": { "type": "object", - "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase: it always carries an explicit {@link McpServerCustomization.enabled}\nbecause it can appear as a top-level customization too.", + "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase because it can appear as a top-level customization too.", "properties": { "id": { "type": "string", @@ -3229,7 +3238,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." } }, "required": [ @@ -3272,7 +3281,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "agent" @@ -3342,7 +3351,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "skill" @@ -3401,7 +3410,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "prompt" @@ -3452,7 +3461,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "rule" @@ -3514,7 +3523,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "hook" @@ -3562,9 +3571,16 @@ "type": { "const": "mcpServer" }, - "enabled": { + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nFlows in both directions. A client publishes this alongside a customization\nto assert its global decision, which is authoritative for the Global scope;\na client always includes its global entry, even when enabled. The host\npublishes the fully resolved set across all scopes, and consumers derive\nthe effective enabled value from that set." + }, + "isClientBundled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether the client explicitly bundled this server and owns its Global\nenablement decision." }, "state": { "$ref": "#/$defs/McpServerState", @@ -3584,7 +3600,6 @@ "uri", "name", "type", - "enabled", "state" ] }, @@ -6873,24 +6888,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client updated a customization's enablement decisions.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry). Plugins\nand MCP servers retain the matched entry's explicit decisions; other\nentries update their `enabled` flag. Disabling a plugin still disables all\nof its children — the effective state of a plugin child is the plugin's\nderived enabled value and `(child.enabled ?? true)` — so toggling a child\nonly matters while its plugin is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions, replacing the previous list entirely." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { @@ -9031,6 +9049,60 @@ ], "description": "Child customizations that live inside a {@link PluginCustomization} or\n{@link DirectoryCustomization}." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/schema/errors.schema.json b/schema/errors.schema.json index b1b9745fb..faf6b0288 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -1553,10 +1553,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1576,8 +1572,7 @@ "required": [ "id", "uri", - "name", - "enabled" + "name" ] }, "PluginCustomization": { @@ -1612,10 +1607,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1634,6 +1625,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1643,7 +1641,6 @@ "id", "uri", "name", - "enabled", "type" ] }, @@ -1679,10 +1676,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1701,6 +1694,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1708,13 +1708,22 @@ "nonce": { "type": "string", "description": "Opaque version token used by the host to detect changes." + }, + "childEnablement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + } + }, + "description": "Explicit enablement decisions for children this plugin contributes,\nkeyed by child name (for MCP servers, the server name as it appears in\nthe bundled `.mcp.json`).\n\nBundled children are discovered by the host rather than published by the\nclient, so the client cannot attach `enablement` to them directly. This\ncarries the client's global decision for each one; the host applies it\nunder the child's durable key." } }, "required": [ "id", "uri", "name", - "enabled", "type" ] }, @@ -1750,10 +1759,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1772,6 +1777,10 @@ "type": { "const": "directory" }, + "enabled": { + "type": "boolean", + "description": "Whether this container is currently enabled." + }, "contents": { "$ref": "#/$defs/ChildCustomizationType", "description": "Which child customization type this directory holds." @@ -1785,15 +1794,15 @@ "id", "uri", "name", - "enabled", "type", + "enabled", "contents", "writable" ] }, "ChildCustomizationBase": { "type": "object", - "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase: it always carries an explicit {@link McpServerCustomization.enabled}\nbecause it can appear as a top-level customization too.", + "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase because it can appear as a top-level customization too.", "properties": { "id": { "type": "string", @@ -1825,7 +1834,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." } }, "required": [ @@ -1868,7 +1877,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "agent" @@ -1938,7 +1947,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "skill" @@ -1997,7 +2006,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "prompt" @@ -2048,7 +2057,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "rule" @@ -2110,7 +2119,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "hook" @@ -2158,9 +2167,16 @@ "type": { "const": "mcpServer" }, - "enabled": { + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nFlows in both directions. A client publishes this alongside a customization\nto assert its global decision, which is authoritative for the Global scope;\na client always includes its global entry, even when enabled. The host\npublishes the fully resolved set across all scopes, and consumers derive\nthe effective enabled value from that set." + }, + "isClientBundled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether the client explicitly bundled this server and owns its Global\nenablement decision." }, "state": { "$ref": "#/$defs/McpServerState", @@ -2180,7 +2196,6 @@ "uri", "name", "type", - "enabled", "state" ] }, @@ -6631,6 +6646,60 @@ ], "description": "Child customizations that live inside a {@link PluginCustomization} or\n{@link DirectoryCustomization}." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { @@ -7831,24 +7900,27 @@ }, "SessionCustomizationToggledAction": { "type": "object", - "description": "A client toggled a customization on or off.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry), and\nsets the matched entry's `enabled` flag. Disabling a container still\ndisables all of its children — the effective state of a child is\n`container.enabled && (child.enabled ?? true)` — so toggling a child\nonly matters while its container is enabled. Is a no-op when no\ncustomization has the given `id`.", + "description": "A client updated a customization's enablement decisions.\n\nMatches `id` against every top-level customization first — a plugin or\ndirectory container, or a bare top-level MCP server — then against the\nchildren inside each container (a skill, agent, or other entry). Plugins\nand MCP servers retain the matched entry's explicit decisions; other\nentries update their `enabled` flag. Disabling a plugin still disables all\nof its children — the effective state of a plugin child is the plugin's\nderived enabled value and `(child.enabled ?? true)` — so toggling a child\nonly matters while its plugin is enabled. Is a no-op when no\ncustomization has the given `id`.\n\nThe `enablement` array completely replaces all explicit decisions. A caller\nchanging one scope must include every decision it intends to preserve.", "properties": { "type": { "const": "session/customizationToggled" }, "id": { "type": "string", - "description": "The id of the container or child to toggle." + "description": "The id of the container or child to update." }, - "enabled": { - "type": "boolean", - "description": "Whether to enable or disable the targeted customization." + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions, replacing the previous list entirely." } }, "required": [ "type", "id", - "enabled" + "enablement" ] }, "SessionCustomizationUpdatedAction": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index 1c977584a..19ab77673 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -1716,10 +1716,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1739,8 +1735,7 @@ "required": [ "id", "uri", - "name", - "enabled" + "name" ] }, "PluginCustomization": { @@ -1775,10 +1770,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1797,6 +1788,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1806,7 +1804,6 @@ "id", "uri", "name", - "enabled", "type" ] }, @@ -1842,10 +1839,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1864,6 +1857,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1871,13 +1871,22 @@ "nonce": { "type": "string", "description": "Opaque version token used by the host to detect changes." + }, + "childEnablement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + } + }, + "description": "Explicit enablement decisions for children this plugin contributes,\nkeyed by child name (for MCP servers, the server name as it appears in\nthe bundled `.mcp.json`).\n\nBundled children are discovered by the host rather than published by the\nclient, so the client cannot attach `enablement` to them directly. This\ncarries the client's global decision for each one; the host applies it\nunder the child's durable key." } }, "required": [ "id", "uri", "name", - "enabled", "type" ] }, @@ -1913,10 +1922,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1935,6 +1940,10 @@ "type": { "const": "directory" }, + "enabled": { + "type": "boolean", + "description": "Whether this container is currently enabled." + }, "contents": { "$ref": "#/$defs/ChildCustomizationType", "description": "Which child customization type this directory holds." @@ -1948,15 +1957,15 @@ "id", "uri", "name", - "enabled", "type", + "enabled", "contents", "writable" ] }, "ChildCustomizationBase": { "type": "object", - "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase: it always carries an explicit {@link McpServerCustomization.enabled}\nbecause it can appear as a top-level customization too.", + "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase because it can appear as a top-level customization too.", "properties": { "id": { "type": "string", @@ -1988,7 +1997,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." } }, "required": [ @@ -2031,7 +2040,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "agent" @@ -2101,7 +2110,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "skill" @@ -2160,7 +2169,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "prompt" @@ -2211,7 +2220,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "rule" @@ -2273,7 +2282,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "hook" @@ -2321,9 +2330,16 @@ "type": { "const": "mcpServer" }, - "enabled": { + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nFlows in both directions. A client publishes this alongside a customization\nto assert its global decision, which is authoritative for the Global scope;\na client always includes its global entry, even when enabled. The host\npublishes the fully resolved set across all scopes, and consumers derive\nthe effective enabled value from that set." + }, + "isClientBundled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether the client explicitly bundled this server and owns its Global\nenablement decision." }, "state": { "$ref": "#/$defs/McpServerState", @@ -2343,7 +2359,6 @@ "uri", "name", "type", - "enabled", "state" ] }, @@ -5309,6 +5324,60 @@ ], "description": "Child customizations that live inside a {@link PluginCustomization} or\n{@link DirectoryCustomization}." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/schema/state.schema.json b/schema/state.schema.json index d594f6b42..be1850022 100644 --- a/schema/state.schema.json +++ b/schema/state.schema.json @@ -1464,10 +1464,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1487,8 +1483,7 @@ "required": [ "id", "uri", - "name", - "enabled" + "name" ] }, "PluginCustomization": { @@ -1523,10 +1518,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1545,6 +1536,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1554,7 +1552,6 @@ "id", "uri", "name", - "enabled", "type" ] }, @@ -1590,10 +1587,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1612,6 +1605,13 @@ "type": { "const": "plugin" }, + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions. See {@link McpServerCustomization.enablement}." + }, "version": { "type": "string", "description": "Version of the plugin, sourced from the\n[Open Plugins](https://open-plugins.com/) manifest's optional\n`version` field (semver, e.g. `\"1.2.0\"`). Absent when the manifest\ndeclares no version — the field is optional there — or the source\nhas no version concept. Provenance / display only: the host neither\nparses nor enforces it." @@ -1619,13 +1619,22 @@ "nonce": { "type": "string", "description": "Opaque version token used by the host to detect changes." + }, + "childEnablement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + } + }, + "description": "Explicit enablement decisions for children this plugin contributes,\nkeyed by child name (for MCP servers, the server name as it appears in\nthe bundled `.mcp.json`).\n\nBundled children are discovered by the host rather than published by the\nclient, so the client cannot attach `enablement` to them directly. This\ncarries the client's global decision for each one; the host applies it\nunder the child's durable key." } }, "required": [ "id", "uri", "name", - "enabled", "type" ] }, @@ -1661,10 +1670,6 @@ "additionalProperties": {}, "description": "Additional provider-specific metadata for this customization.\n\nMirrors the MCP `_meta` convention. Optional and opaque to the\nprotocol; producers and consumers agree on its contents\nout-of-band." }, - "enabled": { - "type": "boolean", - "description": "Whether this container is currently enabled." - }, "clientId": { "type": "string", "description": "`clientId` of the client that contributed this container. Absent for\nserver-originated entries." @@ -1683,6 +1688,10 @@ "type": { "const": "directory" }, + "enabled": { + "type": "boolean", + "description": "Whether this container is currently enabled." + }, "contents": { "$ref": "#/$defs/ChildCustomizationType", "description": "Which child customization type this directory holds." @@ -1696,15 +1705,15 @@ "id", "uri", "name", - "enabled", "type", + "enabled", "contents", "writable" ] }, "ChildCustomizationBase": { "type": "object", - "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase: it always carries an explicit {@link McpServerCustomization.enabled}\nbecause it can appear as a top-level customization too.", + "description": "Fields shared by the leaf child customizations that live inside a\ncontainer — {@link AgentCustomization}, {@link SkillCustomization},\n{@link PromptCustomization}, {@link RuleCustomization}, and\n{@link HookCustomization}.\n\n{@link McpServerCustomization} is also a child but does not extend this\nbase because it can appear as a top-level customization too.", "properties": { "id": { "type": "string", @@ -1736,7 +1745,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." } }, "required": [ @@ -1779,7 +1788,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "agent" @@ -1849,7 +1858,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "skill" @@ -1908,7 +1917,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "prompt" @@ -1959,7 +1968,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "rule" @@ -2021,7 +2030,7 @@ }, "enabled": { "type": "boolean", - "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a child is\n`container.enabled && (child.enabled ?? true)`, so a disabled container\ndisables every child regardless of each child's own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." + "description": "Whether this child is individually enabled. Absent means enabled, so a\nproducer only needs to set it to surface a child that exists but is\nturned off on its own.\n\nThis flag is independent of the parent container's: the **effective**\nenabled state of a plugin child is the plugin's derived enabled value and\n`(child.enabled ?? true)`, so a disabled plugin disables every child\nregardless of each child's own flag. A directory child instead uses the\ndirectory's `enabled` value and its own flag.\n\nA child is turned on or off by id with\n{@link SessionCustomizationToggledAction | `session/customizationToggled`}." }, "type": { "const": "hook" @@ -2069,9 +2078,16 @@ "type": { "const": "mcpServer" }, - "enabled": { + "enablement": { + "type": "array", + "items": { + "$ref": "#/$defs/CustomizationEnablement" + }, + "description": "Explicit enablement decisions for this customization, one entry per scope\nthat has one. This is a wire contract: producers MUST publish entries\nsorted by descending specificity (Session, Workspace, then Global).\nThe agent host emits at most one Workspace entry, for the session's primary\nworking directory. Consumers MAY treat\n`enablement[0]` as the decisive decision and\n`enablement?.[0]?.enabled ?? true` as the effective enabled value. An\nabsent or empty array means no explicit decision exists, so the\ncustomization is enabled by default.\n\nFlows in both directions. A client publishes this alongside a customization\nto assert its global decision, which is authoritative for the Global scope;\na client always includes its global entry, even when enabled. The host\npublishes the fully resolved set across all scopes, and consumers derive\nthe effective enabled value from that set." + }, + "isClientBundled": { "type": "boolean", - "description": "Whether this MCP server is currently enabled." + "description": "Whether the client explicitly bundled this server and owns its Global\nenablement decision." }, "state": { "$ref": "#/$defs/McpServerState", @@ -2091,7 +2107,6 @@ "uri", "name", "type", - "enabled", "state" ] }, @@ -4943,6 +4958,60 @@ ], "description": "One outstanding piece of input a session is blocked on, aggregated across all\nchats in {@link SessionState.inputNeeded}.\n\nEach entry is self-sufficient: it carries the owning\n{@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed\nto construct the response, so a client can answer by dispatching the ordinary\n`chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`,\n`chat/toolCallComplete`, …) to that chat's channel **without having subscribed\nto the chat** — except {@link SessionToolAuthenticationRequest}, which is\nresolved via the `authenticate` command instead. The host removes the entry\nwith `session/inputNeededRemoved` once the underlying request resolves." }, + "CustomizationEnablement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "global" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "workspace" + }, + "uri": { + "$ref": "#/$defs/URI" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "uri", + "enabled" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "const": "session" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "kind", + "enabled" + ] + } + ], + "description": "A single explicit enablement decision." + }, "ChildCustomizationType": { "oneOf": [ { diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 34e8eabd1..63b508b23 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -695,7 +695,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1315,6 +1315,11 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ───────────────────────────────────────'); + lines.push(''); + lines.push(generateCustomizationEnablementGo()); + lines.push(''); + lines.push(generateToolInput()); lines.push(''); @@ -1610,6 +1615,74 @@ const CHAT_SOURCE_UNION: UnionConfig = { ], }; +function generateCustomizationEnablementGo(): string { + return `// CustomizationEnablement is a single explicit customization enablement decision. +type CustomizationEnablement struct { +\tValue isCustomizationEnablement +} + +type isCustomizationEnablement interface{ isCustomizationEnablement() } + +type CustomizationEnablementGlobal struct { +\tKind string \`json:"kind"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementGlobal) isCustomizationEnablement() {} + +type CustomizationEnablementWorkspace struct { +\tKind string \`json:"kind"\` +\tURI URI \`json:"uri"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementWorkspace) isCustomizationEnablement() {} + +type CustomizationEnablementSession struct { +\tKind string \`json:"kind"\` +\tEnabled bool \`json:"enabled"\` +} + +func (*CustomizationEnablementSession) isCustomizationEnablement() {} + +func (e *CustomizationEnablement) UnmarshalJSON(data []byte) error { +\tdisc, _, err := readDiscriminator(data, "kind") +\tif err != nil { +\t\treturn err +\t} +\tswitch disc { +\tcase "global": +\t\tvar value CustomizationEnablementGlobal +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tcase "workspace": +\t\tvar value CustomizationEnablementWorkspace +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tcase "session": +\t\tvar value CustomizationEnablementSession +\t\tif err := json.Unmarshal(data, &value); err != nil { +\t\t\treturn err +\t\t} +\t\te.Value = &value +\tdefault: +\t\treturn &json.UnmarshalTypeError{Value: "CustomizationEnablement"} +\t} +\treturn nil +} + +func (e CustomizationEnablement) MarshalJSON() ([]byte, error) { +\tif e.Value == nil { +\t\treturn []byte("null"), nil +\t} +\treturn json.Marshal(e.Value) +}`; +} + function generateChangesetOperationTargetGo(): string { return `// ChangesetOperationTarget identifies the file or range a // ChangesetOperation should act on. @@ -2095,6 +2168,7 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', 'JsonRpcErrorCode', 'ChangesetOperationTarget', + 'CustomizationEnablement', ]); const missing = [...imported].filter((n) => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index 81a192deb..2c2f17cb0 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -900,7 +900,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1256,6 +1256,11 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ─────────────────────────────────────'); + lines.push(''); + lines.push(generateCustomizationEnablementKotlin()); + lines.push(''); + lines.push('// ─── Tool Input ──────────────────────────────────────────────────────────────'); lines.push(''); lines.push(generateToolInput()); @@ -1600,6 +1605,76 @@ const CHAT_SOURCE_UNION: UnionConfig = { ], }; +function generateCustomizationEnablementKotlin(): string { + return `/** + * A single explicit customization enablement decision. + */ +@Serializable(with = CustomizationEnablementSerializer::class) +sealed interface CustomizationEnablement { + @JvmInline value class Global(val value: CustomizationEnablementGlobal) : CustomizationEnablement + @JvmInline value class Workspace(val value: CustomizationEnablementWorkspace) : CustomizationEnablement + @JvmInline value class Session(val value: CustomizationEnablementSession) : CustomizationEnablement +} + +@Serializable +data class CustomizationEnablementGlobal( + val enabled: Boolean, + val kind: String = "global", +) + +@Serializable +data class CustomizationEnablementWorkspace( + val uri: URI, + val enabled: Boolean, + val kind: String = "workspace", +) + +@Serializable +data class CustomizationEnablementSession( + val enabled: Boolean, + val kind: String = "session", +) + +internal object CustomizationEnablementSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CustomizationEnablement") + + override fun deserialize(decoder: Decoder): CustomizationEnablement { + val input = decoder as? JsonDecoder + ?: error("CustomizationEnablement can only be deserialized from JSON") + val element = input.decodeJsonElement() + val obj = element as? JsonObject + ?: error("Expected JsonObject for CustomizationEnablement") + return when ((obj["kind"] as? JsonPrimitive)?.contentOrNull) { + "global" -> CustomizationEnablement.Global( + input.json.decodeFromJsonElement(CustomizationEnablementGlobal.serializer(), element), + ) + "workspace" -> CustomizationEnablement.Workspace( + input.json.decodeFromJsonElement(CustomizationEnablementWorkspace.serializer(), element), + ) + "session" -> CustomizationEnablement.Session( + input.json.decodeFromJsonElement(CustomizationEnablementSession.serializer(), element), + ) + else -> error("Unknown CustomizationEnablement kind") + } + } + + override fun serialize(encoder: Encoder, value: CustomizationEnablement) { + val output = encoder as? JsonEncoder + ?: error("CustomizationEnablement can only be serialized to JSON") + val element: JsonElement = when (value) { + is CustomizationEnablement.Global -> + output.json.encodeToJsonElement(CustomizationEnablementGlobal.serializer(), value.value) + is CustomizationEnablement.Workspace -> + output.json.encodeToJsonElement(CustomizationEnablementWorkspace.serializer(), value.value) + is CustomizationEnablement.Session -> + output.json.encodeToJsonElement(CustomizationEnablementSession.serializer(), value.value) + } + output.encodeJsonElement(element) + } +}`; +} + /** * ChangesetOperationTarget — TS discriminated union over `{ kind: "resource" }` * and `{ kind: "range" }`. The variant structs are inline-only in TS (not @@ -2120,6 +2195,7 @@ function checkExhaustiveness(project: Project): void { 'ForkChatSource', // generateFixedChatSourceBranchKotlin() 'SideChatSource', // generateFixedChatSourceBranchKotlin() 'ChangesetOperationTarget', // generateChangesetOperationTargetKotlin() + 'CustomizationEnablement', // generateCustomizationEnablementKotlin() ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index 11d46c18e..9efbe80d3 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -657,7 +657,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1151,6 +1151,10 @@ function generateStateFile(project: Project): string { } } + lines.push('// ─── Customization Enablement Union ───────────────────────────────────────\n'); + lines.push(generateCustomizationEnablementRust()); + lines.push(''); + lines.push(generateToolInput()); lines.push(''); @@ -1330,7 +1334,7 @@ pub struct ${scope}ToolCallConfirmedAction { function generateActionsFile(project: Project): string { const lines: string[] = [GENERATED_HEADER]; lines.push('#[allow(unused_imports)]'); - lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); + lines.push('use crate::state::{AgentInfo, AgentSelection, Annotation, AnnotationEntry, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ChatInteractivity, ChatOrigin, ConfirmationOption, ContentRef, Customization, CustomizationEnablement, ErrorInfo, McpAuthRequirement, McpServerState, ModelSelection, ResponsePart, SessionActiveClient, SessionInputRequest, SideChatSelection, TerminalClaim, TerminalInfo, TextRange, ToolCallContributor, ToolCallResult, ToolCallRiskAssessment, ToolCallConfirmationReason, ToolCallCancellationReason, ToolDefinition, ToolInput, ToolResultContent, UsageInfo, Message, PendingMessageKind, Turn, ChangesetStatus, ChangesetFile, ChangesetOperation, ChangesetOperationStatus, Changeset, ChatSummary};'); lines.push(''); // ActionType enum @@ -1538,6 +1542,27 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateCustomizationEnablementRust(): string { + return `/// A single explicit customization enablement decision. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum CustomizationEnablement { + #[serde(rename = "global")] + Global { + enabled: bool, + }, + #[serde(rename = "workspace")] + Workspace { + uri: Uri, + enabled: bool, + }, + #[serde(rename = "session")] + Session { + enabled: bool, + }, +}`; +} + function generateSubscribeParamsImplRust(): string { return `impl SubscribeParams { /// Create subscribe params with default delivery behavior. @@ -1927,6 +1952,7 @@ function checkExhaustiveness(project: Project): void { 'AhpErrorCodeWithData', 'JsonRpcErrorCode', 'ChangesetOperationTarget', + 'CustomizationEnablement', ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 6b486893e..1893f4e5d 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -609,7 +609,7 @@ const STATE_ENUMS = [ 'ToolCallRiskAssessmentStatus', 'ToolCallCancellationReason', 'ConfirmationOptionKind', 'ToolCallContributorKind', - 'ToolResultContentType', 'CustomizationType', 'CustomizationLoadStatus', 'TerminalClaimKind', + 'ToolResultContentType', 'CustomizationType', 'CustomizationEnablementKind', 'CustomizationLoadStatus', 'TerminalClaimKind', 'McpServerStatus', 'McpAuthRequiredReason', 'ChangesetStatus', 'ChangesetOperationStatus', 'ChangesetOperationScope', 'ResourceChangeType', ]; @@ -1151,6 +1151,10 @@ function generateStateFile(project: Project): string { } } + lines.push('// MARK: - Customization Enablement Union\n'); + lines.push(generateCustomizationEnablementSwift()); + lines.push(''); + lines.push('// MARK: - Tool Input\n'); lines.push(generateToolInput()); lines.push(''); @@ -1559,6 +1563,70 @@ function generateCommandsFile(project: Project): string { return lines.join('\n'); } +function generateCustomizationEnablementSwift(): string { + return `/// A single explicit customization enablement decision. +public enum CustomizationEnablement: Codable, Sendable { + case global(CustomizationEnablementGlobal) + case workspace(CustomizationEnablementWorkspace) + case session(CustomizationEnablementSession) + + private enum DiscriminantKey: String, CodingKey { + case kind + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DiscriminantKey.self) + switch try container.decode(String.self, forKey: .kind) { + case "global": + self = .global(try CustomizationEnablementGlobal(from: decoder)) + case "workspace": + self = .workspace(try CustomizationEnablementWorkspace(from: decoder)) + case "session": + self = .session(try CustomizationEnablementSession(from: decoder)) + default: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown CustomizationEnablement kind") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .global(let value): try value.encode(to: encoder) + case .workspace(let value): try value.encode(to: encoder) + case .session(let value): try value.encode(to: encoder) + } + } +} + +public struct CustomizationEnablementGlobal: Codable, Sendable { + public var kind: String = "global" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +} + +public struct CustomizationEnablementWorkspace: Codable, Sendable { + public var kind: String = "workspace" + public var uri: URI + public var enabled: Bool + + public init(uri: URI, enabled: Bool) { + self.uri = uri + self.enabled = enabled + } +} + +public struct CustomizationEnablementSession: Codable, Sendable { + public var kind: String = "session" + public var enabled: Bool + + public init(enabled: Bool) { + self.enabled = enabled + } +}`; +} + function generateChangesetOperationTargetSwift(): string { return `/// Identifies the file or range a \`ChangesetOperation\` should act on. public enum ChangesetOperationTarget: Codable, Sendable { @@ -2139,6 +2207,7 @@ function checkExhaustiveness(project: Project): void { 'ForkChatSource', // generateFixedChatSourceBranchSwift() 'SideChatSource', // generateFixedChatSourceBranchSwift() 'ChangesetOperationTarget', // TS discriminated union; consumers should add a Swift case-iterable enum + 'CustomizationEnablement', // generateCustomizationEnablementSwift() ]); const missing = [...imported].filter(n => !coveredByLists.has(n) && !knownSpecial.has(n)); diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts index 5ba4ef058..97a7c33d5 100644 --- a/types/channels-session/actions.ts +++ b/types/channels-session/actions.ts @@ -11,6 +11,7 @@ import type { SessionActiveClient, SessionInputRequest, Customization, + CustomizationEnablement, McpServerState, } from './state.js'; import type { URI } from '../common/state.js'; @@ -359,27 +360,31 @@ export interface SessionCustomizationsChangedAction { } /** - * A client toggled a customization on or off. + * A client updated a customization's enablement decisions. * * Matches `id` against every top-level customization first — a plugin or * directory container, or a bare top-level MCP server — then against the - * children inside each container (a skill, agent, or other entry), and - * sets the matched entry's `enabled` flag. Disabling a container still - * disables all of its children — the effective state of a child is - * `container.enabled && (child.enabled ?? true)` — so toggling a child - * only matters while its container is enabled. Is a no-op when no + * children inside each container (a skill, agent, or other entry). Plugins + * and MCP servers retain the matched entry's explicit decisions; other + * entries update their `enabled` flag. Disabling a plugin still disables all + * of its children — the effective state of a plugin child is the plugin's + * derived enabled value and `(child.enabled ?? true)` — so toggling a child + * only matters while its plugin is enabled. Is a no-op when no * customization has the given `id`. * + * The `enablement` array completely replaces all explicit decisions. A caller + * changing one scope must include every decision it intends to preserve. + * * @category Session Actions * @version 1 * @clientDispatchable */ export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; - /** The id of the container or child to toggle. */ + /** The id of the container or child to update. */ id: string; - /** Whether to enable or disable the targeted customization. */ - enabled: boolean; + /** Explicit enablement decisions, replacing the previous list entirely. */ + enablement: CustomizationEnablement[]; } /** diff --git a/types/channels-session/reducer.ts b/types/channels-session/reducer.ts index c62517b28..277a3dd31 100644 --- a/types/channels-session/reducer.ts +++ b/types/channels-session/reducer.ts @@ -8,6 +8,9 @@ import { ActionType } from '../common/actions.js'; import type { SessionState, SessionInputRequest, + ChildCustomization, + Customization, + CustomizationEnablement, McpServerCustomization, } from './state.js'; import { @@ -105,6 +108,27 @@ function updateMcpServerCustomization( return { ...state, customizations: updated }; } +/** + * Replaces explicit decisions for plugins and MCP servers; other customizations + * retain their legacy `enabled` field, derived from the incoming decisions. + */ +function applyCustomizationEnablement(customization: Customization, enablement: readonly CustomizationEnablement[]): Customization; +function applyCustomizationEnablement(customization: ChildCustomization, enablement: readonly CustomizationEnablement[]): ChildCustomization; +function applyCustomizationEnablement(customization: Customization | ChildCustomization, enablement: readonly CustomizationEnablement[]): Customization | ChildCustomization { + switch (customization.type) { + case CustomizationType.Plugin: + case CustomizationType.McpServer: { + if (enablement.length > 0) { + return { ...customization, enablement: [...enablement] }; + } + const { enablement: _enablement, ...withoutEnablement } = customization; + return withoutEnablement; + } + default: + return { ...customization, enabled: enablement[0]?.enabled ?? true }; + } +} + // ─── Session Reducer ───────────────────────────────────────────────────────── /** @@ -308,7 +332,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: const topIdx = list.findIndex(c => c.id === action.id); if (topIdx >= 0) { const updated = list.slice(); - updated[topIdx] = { ...list[topIdx], enabled: action.enabled }; + updated[topIdx] = applyCustomizationEnablement(list[topIdx], action.enablement); return { ...state, customizations: updated }; } for (let i = 0; i < list.length; i++) { @@ -325,7 +349,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: continue; } const newChildren = children.slice(); - newChildren[childIdx] = { ...children[childIdx], enabled: action.enabled }; + newChildren[childIdx] = applyCustomizationEnablement(children[childIdx], action.enablement); const updated = list.slice(); updated[i] = { ...container, children: newChildren }; return { ...state, customizations: updated }; diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts index e26fecfc7..e10c1d221 100644 --- a/types/channels-session/state.ts +++ b/types/channels-session/state.ts @@ -645,6 +645,23 @@ export const enum CustomizationType { McpServer = 'mcpServer', } +/** + * Scope at which customization enablement is decided. + * + * @category Customization Types + */ +export const enum CustomizationEnablementKind { + Global = 'global', + Workspace = 'workspace', + Session = 'session', +} + +/** A single explicit enablement decision. */ +export type CustomizationEnablement = + | { kind: CustomizationEnablementKind.Global; enabled: boolean } + | { kind: CustomizationEnablementKind.Workspace; uri: URI; enabled: boolean } + | { kind: CustomizationEnablementKind.Session; enabled: boolean }; + /** * Customization types that appear as children of a * {@link PluginCustomization} or {@link DirectoryCustomization}. @@ -772,8 +789,6 @@ export type CustomizationLoadState = * @category Customization Types */ interface ContainerCustomizationBase extends CustomizationBase { - /** Whether this container is currently enabled. */ - enabled: boolean; /** * `clientId` of the client that contributed this container. Absent for * server-originated entries. @@ -801,6 +816,8 @@ interface ContainerCustomizationBase extends CustomizationBase { */ export interface PluginCustomization extends ContainerCustomizationBase { type: CustomizationType.Plugin; + /** Explicit enablement decisions. See {@link McpServerCustomization.enablement}. */ + enablement?: CustomizationEnablement[]; /** * Version of the plugin, sourced from the * [Open Plugins](https://open-plugins.com/) manifest's optional @@ -828,6 +845,17 @@ export interface PluginCustomization extends ContainerCustomizationBase { export interface ClientPluginCustomization extends PluginCustomization { /** Opaque version token used by the host to detect changes. */ nonce?: string; + /** + * Explicit enablement decisions for children this plugin contributes, + * keyed by child name (for MCP servers, the server name as it appears in + * the bundled `.mcp.json`). + * + * Bundled children are discovered by the host rather than published by the + * client, so the client cannot attach `enablement` to them directly. This + * carries the client's global decision for each one; the host applies it + * under the child's durable key. + */ + childEnablement?: Record; } /** @@ -845,6 +873,8 @@ export interface ClientPluginCustomization extends PluginCustomization { */ export interface DirectoryCustomization extends ContainerCustomizationBase { type: CustomizationType.Directory; + /** Whether this container is currently enabled. */ + enabled: boolean; /** Which child customization type this directory holds. */ contents: ChildCustomizationType; /** Whether clients may write into this directory. */ @@ -858,8 +888,7 @@ export interface DirectoryCustomization extends ContainerCustomizationBase { * {@link HookCustomization}. * * {@link McpServerCustomization} is also a child but does not extend this - * base: it always carries an explicit {@link McpServerCustomization.enabled} - * because it can appear as a top-level customization too. + * base because it can appear as a top-level customization too. * * @category Customization Types */ @@ -870,9 +899,10 @@ interface ChildCustomizationBase extends CustomizationBase { * turned off on its own. * * This flag is independent of the parent container's: the **effective** - * enabled state of a child is - * `container.enabled && (child.enabled ?? true)`, so a disabled container - * disables every child regardless of each child's own flag. + * enabled state of a plugin child is the plugin's derived enabled value and + * `(child.enabled ?? true)`, so a disabled plugin disables every child + * regardless of each child's own flag. A directory child instead uses the + * directory's `enabled` value and its own flag. * * A child is turned on or off by id with * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. @@ -1024,9 +1054,28 @@ export interface HookCustomization extends ChildCustomizationBase { export interface McpServerCustomization extends CustomizationBase { type: CustomizationType.McpServer; /** - * Whether this MCP server is currently enabled. + * Explicit enablement decisions for this customization, one entry per scope + * that has one. This is a wire contract: producers MUST publish entries + * sorted by descending specificity (Session, Workspace, then Global). + * The agent host emits at most one Workspace entry, for the session's primary + * working directory. Consumers MAY treat + * `enablement[0]` as the decisive decision and + * `enablement?.[0]?.enabled ?? true` as the effective enabled value. An + * absent or empty array means no explicit decision exists, so the + * customization is enabled by default. + * + * Flows in both directions. A client publishes this alongside a customization + * to assert its global decision, which is authoritative for the Global scope; + * a client always includes its global entry, even when enabled. The host + * publishes the fully resolved set across all scopes, and consumers derive + * the effective enabled value from that set. */ - enabled: boolean; + enablement?: CustomizationEnablement[]; + /** + * Whether the client explicitly bundled this server and owns its Global + * enablement decision. + */ + isClientBundled?: boolean; /** * Current lifecycle state of the MCP server. */ diff --git a/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json b/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json index e29bc4e02..768a141f0 100644 --- a/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json +++ b/types/test-cases/reducers/058-session-customizationschanged-replaces-entire-list.json @@ -17,15 +17,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": false, "clientId": "client-1" } ] @@ -41,15 +39,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": false, "clientId": "client-1" } ], diff --git a/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json b/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json index c23d5faf2..092ed658a 100644 --- a/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json +++ b/types/test-cases/reducers/059-session-customizationschanged-replaces-existing-customizations.json @@ -11,8 +11,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], @@ -26,8 +25,7 @@ "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": false + "name": "Plugin B" } ] } @@ -42,8 +40,7 @@ "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": false + "name": "Plugin B" } ], "activeClients": [], diff --git a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json index dfdcb10fc..7d39e7ba3 100644 --- a/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json +++ b/types/test-cases/reducers/060-session-customizationtoggled-toggles-by-id.json @@ -11,15 +11,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": true + "name": "Plugin B" } ], "activeClients": [], @@ -29,7 +27,21 @@ { "type": "session/customizationToggled", "id": "plugin-a", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + }, + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": true + }, + { + "kind": "global", + "enabled": true + } + ] } ], "expected": { @@ -43,14 +55,27 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + }, + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": true + }, + { + "kind": "global", + "enabled": true + } + ] }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": true + "name": "Plugin B" } ], "activeClients": [], diff --git a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json index e46cb7d13..23445c10f 100644 --- a/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json +++ b/types/test-cases/reducers/061-session-customizationtoggled-is-no-op-for-unknown-id.json @@ -11,8 +11,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], @@ -22,7 +21,12 @@ { "type": "session/customizationToggled", "id": "plugin-unknown", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { @@ -35,8 +39,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], diff --git a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json index fa303b4f0..98a48cf1b 100644 --- a/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json +++ b/types/test-cases/reducers/062-session-customizationtoggled-is-no-op-when-customizations-undefined.json @@ -13,7 +13,12 @@ { "type": "session/customizationToggled", "id": "plugin-a", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { diff --git a/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json b/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json index cbe1886e4..11e70945c 100644 --- a/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json +++ b/types/test-cases/reducers/137-session-customizationupdated-replaces-existing-container.json @@ -12,7 +12,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "load": { "kind": "loading" } @@ -22,7 +21,6 @@ "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": true, "load": { "kind": "loaded" } @@ -39,7 +37,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "load": { "kind": "error", "message": "Failed to load" @@ -58,7 +55,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "load": { "kind": "error", "message": "Failed to load" @@ -69,7 +65,6 @@ "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": true, "load": { "kind": "loaded" } diff --git a/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json b/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json index 3007df29c..f558733fb 100644 --- a/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json +++ b/types/test-cases/reducers/138-session-customizationupdated-appends-unknown-id.json @@ -11,8 +11,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], @@ -26,7 +25,6 @@ "id": "plugin-new", "uri": "https://plugins.example/new", "name": "New Plugin", - "enabled": true, "load": { "kind": "loading" } @@ -43,15 +41,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-new", "uri": "https://plugins.example/new", "name": "New Plugin", - "enabled": true, "load": { "kind": "loading" } diff --git a/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json b/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json index 50807563b..8110de197 100644 --- a/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json +++ b/types/test-cases/reducers/152-session-customizationremoved-removes-container-and-children.json @@ -12,7 +12,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -26,8 +25,7 @@ "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": true + "name": "Plugin B" } ], "activeClients": [], @@ -49,8 +47,7 @@ "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", - "name": "Plugin B", - "enabled": true + "name": "Plugin B" } ], "activeClients": [], diff --git a/types/test-cases/reducers/153-session-customizationremoved-removes-child.json b/types/test-cases/reducers/153-session-customizationremoved-removes-child.json index b0c269d31..63ceca282 100644 --- a/types/test-cases/reducers/153-session-customizationremoved-removes-child.json +++ b/types/test-cases/reducers/153-session-customizationremoved-removes-child.json @@ -12,7 +12,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -49,7 +48,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", diff --git a/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json b/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json index 737f2ff37..7a8e7e485 100644 --- a/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json +++ b/types/test-cases/reducers/154-session-customizationremoved-noop-unknown-id.json @@ -11,8 +11,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], @@ -34,8 +33,7 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" } ], "activeClients": [], diff --git a/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json b/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json index 05dec62a7..ac61e7c6e 100644 --- a/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json +++ b/types/test-cases/reducers/159-session-mcpserverstatechanged-upserts-top-level-server.json @@ -14,7 +14,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "starting" } @@ -44,7 +43,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "ready" }, diff --git a/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json b/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json index 09dcba162..9d5e18a61 100644 --- a/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json +++ b/types/test-cases/reducers/160-session-mcpserverstatechanged-upserts-container-child.json @@ -14,7 +14,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -27,7 +26,6 @@ "id": "mcp-child", "uri": "https://plugins.example/a#mcp/search", "name": "Search", - "enabled": true, "state": { "kind": "starting" } @@ -59,7 +57,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -72,7 +69,6 @@ "id": "mcp-child", "uri": "https://plugins.example/a#mcp/search", "name": "Search", - "enabled": true, "state": { "kind": "ready" }, diff --git a/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json b/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json index 4fcaf4fd3..998c6f9e2 100644 --- a/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json +++ b/types/test-cases/reducers/161-session-mcpserverstatechanged-noop-unknown-id.json @@ -14,7 +14,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "ready" }, @@ -44,7 +43,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "ready" }, diff --git a/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json b/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json index 66fe919b3..225d7024d 100644 --- a/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json +++ b/types/test-cases/reducers/162-session-mcpserverstatechanged-noop-non-mcp-id.json @@ -14,7 +14,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -57,7 +56,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", diff --git a/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json b/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json index 839c8155d..ca9856b13 100644 --- a/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json +++ b/types/test-cases/reducers/225-session-customizationtoggled-toggles-child-by-id.json @@ -12,7 +12,6 @@ "id": "mcp-top", "uri": "https://mcp.example/server", "name": "Top-level Server", - "enabled": true, "state": { "kind": "ready" } @@ -22,7 +21,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -47,7 +45,12 @@ { "type": "session/customizationToggled", "id": "skill-1", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { @@ -61,7 +64,6 @@ "id": "mcp-top", "uri": "https://mcp.example/server", "name": "Top-level Server", - "enabled": true, "state": { "kind": "ready" } @@ -71,7 +73,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", diff --git a/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json b/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json index 549241466..0edff6148 100644 --- a/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json +++ b/types/test-cases/reducers/226-session-customizationtoggled-is-no-op-for-unknown-child-id.json @@ -12,7 +12,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -30,7 +29,12 @@ { "type": "session/customizationToggled", "id": "does-not-exist", - "enabled": false + "enablement": [ + { + "kind": "session", + "enabled": false + } + ] } ], "expected": { @@ -44,7 +48,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", diff --git a/types/test-cases/reducers/235-session-mcpserverstartrequested-starts-top-level-server.json b/types/test-cases/reducers/235-session-mcpserverstartrequested-starts-top-level-server.json index 4832c0635..190725ae3 100644 --- a/types/test-cases/reducers/235-session-mcpserverstartrequested-starts-top-level-server.json +++ b/types/test-cases/reducers/235-session-mcpserverstartrequested-starts-top-level-server.json @@ -14,7 +14,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "ready" }, @@ -41,7 +40,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "starting" }, diff --git a/types/test-cases/reducers/236-session-mcpserverstoprequested-stops-auth-required-server.json b/types/test-cases/reducers/236-session-mcpserverstoprequested-stops-auth-required-server.json index c6a50f29c..8863c6336 100644 --- a/types/test-cases/reducers/236-session-mcpserverstoprequested-stops-auth-required-server.json +++ b/types/test-cases/reducers/236-session-mcpserverstoprequested-stops-auth-required-server.json @@ -14,7 +14,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "authRequired", "reason": "required", @@ -47,7 +46,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "stopped" }, diff --git a/types/test-cases/reducers/237-session-mcpserverstoprequested-stops-container-child.json b/types/test-cases/reducers/237-session-mcpserverstoprequested-stops-container-child.json index 6604aa753..d67bc95a9 100644 --- a/types/test-cases/reducers/237-session-mcpserverstoprequested-stops-container-child.json +++ b/types/test-cases/reducers/237-session-mcpserverstoprequested-stops-container-child.json @@ -14,7 +14,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -27,7 +26,6 @@ "id": "mcp-child", "uri": "https://plugins.example/a#mcp/search", "name": "Search", - "enabled": true, "state": { "kind": "ready" }, @@ -56,7 +54,6 @@ "id": "plugin-a", "uri": "https://plugins.example/a", "name": "Plugin A", - "enabled": true, "children": [ { "type": "skill", @@ -69,7 +66,6 @@ "id": "mcp-child", "uri": "https://plugins.example/a#mcp/search", "name": "Search", - "enabled": true, "state": { "kind": "stopped" }, diff --git a/types/test-cases/reducers/239-session-mcpserverstartrequested-no-op-unknown-id.json b/types/test-cases/reducers/239-session-mcpserverstartrequested-no-op-unknown-id.json index f0c05949b..840d16fc5 100644 --- a/types/test-cases/reducers/239-session-mcpserverstartrequested-no-op-unknown-id.json +++ b/types/test-cases/reducers/239-session-mcpserverstartrequested-no-op-unknown-id.json @@ -13,15 +13,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": true, "children": [ { "type": "skill", @@ -36,7 +34,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "stopped" } @@ -61,15 +58,13 @@ "type": "plugin", "id": "plugin-a", "uri": "https://plugins.example/a", - "name": "Plugin A", - "enabled": true + "name": "Plugin A" }, { "type": "plugin", "id": "plugin-b", "uri": "https://plugins.example/b", "name": "Plugin B", - "enabled": true, "children": [ { "type": "skill", @@ -84,7 +79,6 @@ "id": "mcp-1", "uri": "file:///workspace/.mcp/servers.json", "name": "Filesystem", - "enabled": true, "state": { "kind": "stopped" } diff --git a/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json b/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json new file mode 100644 index 000000000..118619f78 --- /dev/null +++ b/types/test-cases/reducers/263-session-customizationtoggled-clears-enablement.json @@ -0,0 +1,60 @@ +{ + "description": "session/customizationToggled clears enablement and restores the default", + "reducer": "session", + "initial": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "creating", + "customizations": [ + { + "type": "mcpServer", + "id": "server-a", + "uri": "file:///workspace/.vscode/mcp.json", + "name": "Server A", + "enablement": [ + { + "kind": "workspace", + "uri": "file:///workspace", + "enabled": false + }, + { + "kind": "global", + "enabled": true + } + ], + "state": { + "kind": "stopped" + } + } + ], + "activeClients": [], + "chats": [] + }, + "actions": [ + { + "type": "session/customizationToggled", + "id": "server-a", + "enablement": [] + } + ], + "expected": { + "provider": "copilot", + "title": "Test Session", + "status": 1, + "lifecycle": "creating", + "customizations": [ + { + "type": "mcpServer", + "id": "server-a", + "uri": "file:///workspace/.vscode/mcp.json", + "name": "Server A", + "state": { + "kind": "stopped" + } + } + ], + "activeClients": [], + "chats": [] + } +} diff --git a/types/test-cases/round-trips/027-agent-customization-model-and-tools.json b/types/test-cases/round-trips/027-agent-customization-model-and-tools.json index 6bef7c15a..168e69c25 100644 --- a/types/test-cases/round-trips/027-agent-customization-model-and-tools.json +++ b/types/test-cases/round-trips/027-agent-customization-model-and-tools.json @@ -8,7 +8,6 @@ "id": "plugin-1", "uri": "https://example.com/plugins/coding", "name": "Coding Plugin", - "enabled": true, "children": [ { "type": "agent", @@ -27,7 +26,6 @@ "id": "plugin-1", "uri": "https://example.com/plugins/coding", "name": "Coding Plugin", - "enabled": true, "children": [ { "type": "agent",