Skip to main content

agentmux_srv/server/app_api/
skill.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxAPI handlers for the v1 standalone Skill primitive.
5//! skill.list / skill.get / skill.upsert / skill.delete / skill.bind / skill.unbind
6//!
7//! Plus the Memory-level catalog (skill.catalog.*): global skills only,
8//! window-scoped — no `agent_id`, no `check_s1` (mirrors bundle.* auth
9//! shape, since Memory has no agent connection context to gate on).
10//!
11//! `db_skills` is authoritatively `identity_store` as of Phase 2 of
12//! SPEC_DURABLE_BINDINGS_2026_09_10.md (§5.1/§5.4) — `mstore` stays the
13//! ref-table owner (`db_agent_skills_ref`/`db_bundle_skills_ref` are not
14//! promoted until Phase 3/6). Pure single-table reads/writes (`skill_get`,
15//! `skill_upsert_unique_global`) call directly on `identity_store`; methods
16//! that combine a ref table with the catalog now take `identity_store` as an
17//! explicit `catalog` parameter while still being called ON `mstore` (the
18//! ref-owning receiver).
19
20use super::*;
21
22pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
23    register_skill_list(engine, state);
24    register_skill_get(engine, state);
25    register_skill_upsert(engine, state);
26    register_skill_delete(engine, state);
27    register_skill_bind(engine, state);
28    register_skill_unbind(engine, state);
29    register_skill_catalog_list(engine, state);
30    register_skill_catalog_upsert(engine, state);
31    register_skill_catalog_delete(engine, state);
32    register_skill_catalog_bind(engine, state);
33    register_skill_catalog_list_for_agent(engine, state);
34    register_skill_catalog_unbind(engine, state);
35    register_skill_catalog_bind_to_bundle(engine, state);
36    register_skill_catalog_unbind_from_bundle(engine, state);
37    register_skill_catalog_list_for_bundle(engine, state);
38    register_skill_catalog_upsert_for_bundle(engine, state);
39}
40
41fn register_skill_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
42    let mstore = state.mstore.clone();
43    let identity_store = state.identity_store.clone();
44    engine.register_typed(
45        COMMAND_SKILL_LIST,
46        move |req: SkillAgentScopeData, ctx| {
47            let mstore = mstore.clone();
48            let identity_store = identity_store.clone();
49            async move {
50                check_s1(&mstore, &ctx, &req.agent_id)?;
51                let skills = mstore.skill_list(&identity_store, &req.agent_id)
52                    .map_err(|e| format!("skill.list: {e}"))?;
53                Ok(skills)
54            }
55        },
56    );
57}
58
59fn register_skill_get(engine: &Arc<WshRpcEngine>, state: &AppState) {
60    let mstore = state.mstore.clone();
61    let identity_store = state.identity_store.clone();
62    engine.register_typed(
63        COMMAND_SKILL_GET,
64        move |req: SkillAgentItemData, ctx| {
65            let mstore = mstore.clone();
66            let identity_store = identity_store.clone();
67            async move {
68                check_s1(&mstore, &ctx, &req.agent_id)?;
69                if !mstore.skill_is_accessible_to(&identity_store, &req.agent_id, &req.id)
70                    .map_err(|e| format!("skill.get: {e}"))?
71                {
72                    return Err("FORBIDDEN: skill not accessible to this agent".to_string());
73                }
74                let skill = identity_store.skill_get(&req.id)
75                    .map_err(|e| format!("skill.get: {e}"))?;
76                Ok(skill)
77            }
78        },
79    );
80}
81
82fn register_skill_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
83    let mstore = state.mstore.clone();
84    let identity_store = state.identity_store.clone();
85    let broker = state.broker.clone();
86    engine.register_typed(
87        COMMAND_SKILL_UPSERT,
88        move |req: CommandSkillUpsertData, ctx| {
89            let mstore = mstore.clone();
90            let identity_store = identity_store.clone();
91            let broker = broker.clone();
92            async move {
93                check_s1(&mstore, &ctx, &req.agent_id)?;
94
95                let now = now_ms();
96                // created_at is preserved across updates so the response never
97                // reports a timestamp the DB won't persist (ON CONFLICT keeps
98                // the original). For a new row it defaults to `now`.
99                let (id, created_at) = if req.id.is_empty() {
100                    (uuid::Uuid::new_v4().to_string(), now)
101                } else {
102                    if !mstore.skill_is_bound_to(&req.agent_id, &req.id)
103                        .map_err(|e| format!("skill.upsert: {e}"))?
104                    {
105                        return Err("FORBIDDEN: skill not bound to this agent".to_string());
106                    }
107                    let existing = identity_store.skill_get(&req.id)
108                        .map_err(|e| format!("skill.upsert: {e}"))?;
109                    match existing {
110                        Some(s) if s.is_global => {
111                            return Err("FORBIDDEN: cannot mutate a global skill".to_string());
112                        }
113                        Some(s) => (req.id.clone(), s.created_at),
114                        None => (req.id.clone(), now),
115                    }
116                };
117
118                let skill = crate::backend::storage::Skill {
119                    id: id.clone(),
120                    name: req.name,
121                    trigger: req.trigger,
122                    skill_type: req.skill_type,
123                    description: req.description,
124                    content: req.content,
125                    is_global: false,
126                    created_at,
127                    updated_at: now,
128                };
129                // No longer a single transaction across the catalog insert and
130                // the ref-bind (Phase 2 of SPEC_DURABLE_BINDINGS_2026_09_10.md
131                // §5.4, accepted interim gap) — see
132                // `Store::managed_upsert_unique`'s doc comment.
133                mstore.skill_upsert_unique(&identity_store, &req.agent_id, &skill, req.id.is_empty())
134                    .map_err(|e| format!("skill.upsert: {e}"))?;
135                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
136                Ok(skill)
137            }
138        },
139    );
140}
141
142fn register_skill_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
143    let mstore = state.mstore.clone();
144    let identity_store = state.identity_store.clone();
145    let broker = state.broker.clone();
146    engine.register_typed(
147        COMMAND_SKILL_DELETE,
148        move |req: SkillAgentItemData, ctx| {
149            let mstore = mstore.clone();
150            let identity_store = identity_store.clone();
151            let broker = broker.clone();
152            async move {
153                check_s1(&mstore, &ctx, &req.agent_id)?;
154                if !mstore.skill_is_bound_to(&req.agent_id, &req.id)
155                    .map_err(|e| format!("skill.delete: {e}"))?
156                {
157                    return Err("FORBIDDEN: skill not bound to this agent".to_string());
158                }
159                if let Some(existing) = identity_store.skill_get(&req.id)
160                    .map_err(|e| format!("skill.delete: {e}"))?
161                {
162                    if existing.is_global {
163                        return Err("FORBIDDEN: cannot delete a global skill".to_string());
164                    }
165                }
166                let deleted = mstore.skill_delete(&identity_store, &req.id)
167                    .map_err(|e| format!("skill.delete: {e}"))?;
168                if deleted {
169                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
170                }
171                Ok(SkillDeleteResult { deleted })
172            }
173        },
174    );
175}
176
177fn register_skill_bind(engine: &Arc<WshRpcEngine>, state: &AppState) {
178    let mstore = state.mstore.clone();
179    let identity_store = state.identity_store.clone();
180    let broker = state.broker.clone();
181    engine.register_typed(
182        COMMAND_SKILL_BIND,
183        move |req: SkillAgentBindingData, ctx| {
184            let mstore = mstore.clone();
185            let identity_store = identity_store.clone();
186            let broker = broker.clone();
187            async move {
188                check_s1(&mstore, &ctx, &req.agent_id)?;
189                // Only global skills (or ones already bound) may be bound, so an
190                // agent can't bootstrap read access to another agent's private skill.
191                match identity_store.skill_get(&req.skill_id)
192                    .map_err(|e| format!("skill.bind: {e}"))?
193                {
194                    None => return Err("skill.bind: skill not found".to_string()),
195                    Some(s) if !s.is_global => {
196                        if !mstore.skill_is_bound_to(&req.agent_id, &req.skill_id)
197                            .map_err(|e| format!("skill.bind: {e}"))?
198                        {
199                            return Err("FORBIDDEN: can only bind global skills".to_string());
200                        }
201                    }
202                    Some(_) => {}
203                }
204                mstore.skill_bind(&identity_store, &req.agent_id, &req.skill_id)
205                    .map_err(|e| format!("skill.bind: {e}"))?;
206                // An agent binding a skill to itself over its own authenticated
207                // connection should reach an already-open Stash Skills tab for
208                // that agent too — same reactivity as the catalog-tier bind.
209                // reagentx P2 on PR #2329.
210                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
211                Ok(SkillBindResult { bound: true })
212            }
213        },
214    );
215}
216
217fn register_skill_unbind(engine: &Arc<WshRpcEngine>, state: &AppState) {
218    let mstore = state.mstore.clone();
219    let broker = state.broker.clone();
220    engine.register_typed(
221        COMMAND_SKILL_UNBIND,
222        move |req: SkillAgentBindingData, ctx| {
223            let mstore = mstore.clone();
224            let broker = broker.clone();
225            async move {
226                check_s1(&mstore, &ctx, &req.agent_id)?;
227                let unbound = mstore.skill_unbind(&req.agent_id, &req.skill_id)
228                    .map_err(|e| format!("skill.unbind: {e}"))?;
229                if unbound {
230                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
231                }
232                Ok(SkillUnbindResult { unbound })
233            }
234        },
235    );
236}
237
238fn register_skill_catalog_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
239    let mstore = state.mstore.clone();
240    let identity_store = state.identity_store.clone();
241    engine.register_typed(
242        COMMAND_SKILL_CATALOG_LIST,
243        move |_req: SkillCatalogListData, _ctx| {
244            let mstore = mstore.clone();
245            let identity_store = identity_store.clone();
246            async move {
247                let skills = mstore.skill_list_global(&identity_store)
248                    .map_err(|e| format!("skill.catalog.list: {e}"))?;
249                Ok(skills)
250            }
251        },
252    );
253}
254
255fn register_skill_catalog_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
256    let identity_store = state.identity_store.clone();
257    let broker = state.broker.clone();
258    engine.register_typed(
259        COMMAND_SKILL_CATALOG_UPSERT,
260        move |req: CommandSkillCatalogUpsertData, _ctx| {
261            let identity_store = identity_store.clone();
262            let broker = broker.clone();
263            async move {
264
265                let now = now_ms();
266                let (id, created_at) = if req.id.is_empty() {
267                    (uuid::Uuid::new_v4().to_string(), now)
268                } else {
269                    let existing = identity_store.skill_get(&req.id)
270                        .map_err(|e| format!("skill.catalog.upsert: {e}"))?;
271                    match existing {
272                        // No agent_id/check_s1 here to verify ownership of a private
273                        // skill, so the catalog surface may only create new global
274                        // rows or edit ones that are ALREADY global — never promote
275                        // a private skill into the catalog by supplying its id.
276                        Some(s) if !s.is_global => {
277                            return Err("FORBIDDEN: cannot promote a private skill via the catalog".to_string());
278                        }
279                        Some(s) => (req.id.clone(), s.created_at),
280                        None => (req.id.clone(), now),
281                    }
282                };
283
284                let skill = crate::backend::storage::Skill {
285                    id: id.clone(),
286                    name: req.name,
287                    trigger: req.trigger,
288                    skill_type: req.skill_type,
289                    description: req.description,
290                    content: req.content,
291                    is_global: true,
292                    created_at,
293                    updated_at: now,
294                };
295                // Global-scoped uniqueness (not skill_upsert_unique's
296                // per-agent check) — same defense-in-depth as the mcp.catalog
297                // fix, reagent P1 on #1948. Pure single-table op — called
298                // directly on identity_store.
299                identity_store.skill_upsert_unique_global(&skill)
300                    .map_err(|e| format!("skill.catalog.upsert: {e}"))?;
301                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
302                Ok(skill)
303            }
304        },
305    );
306}
307
308// Catalog-tier sibling of register_skill_bind (above) — same DB write and
309// "only global skills may be bound" safety check, but no check_s1: the
310// Memory's WebSocket never authenticates as an agent (see this file's
311// module doc comment), so a check_s1-gated bind can never be satisfied from
312// that caller. skill.bind itself is left untouched — it may still be the
313// intended surface for an agent binding a skill to itself over its own
314// authenticated connection, and loosening its gate would silently widen
315// what any authenticated agent connection can do (bind arbitrary skills to
316// *other* agent ids), which nobody asked for.
317// See docs/reports/REPORT_ARMORY_SKILLS_MARKDOWN_AND_BIND_BUG_2026_07_27.md §2.4.
318fn register_skill_catalog_bind(engine: &Arc<WshRpcEngine>, state: &AppState) {
319    let mstore = state.mstore.clone();
320    let identity_store = state.identity_store.clone();
321    let broker = state.broker.clone();
322    engine.register_typed(
323        COMMAND_SKILL_CATALOG_BIND,
324        move |req: SkillAgentBindingData, _ctx| {
325            let mstore = mstore.clone();
326            let identity_store = identity_store.clone();
327            let broker = broker.clone();
328            async move {
329                // Only global skills (or ones already bound) may be bound, so the
330                // catalog surface can't be used to bootstrap read access to
331                // another agent's private skill — same rule as skill.bind.
332                match identity_store.skill_get(&req.skill_id)
333                    .map_err(|e| format!("skill.catalog.bind: {e}"))?
334                {
335                    None => return Err("skill.catalog.bind: skill not found".to_string()),
336                    Some(s) if !s.is_global => {
337                        if !mstore.skill_is_bound_to(&req.agent_id, &req.skill_id)
338                            .map_err(|e| format!("skill.catalog.bind: {e}"))?
339                        {
340                            return Err("FORBIDDEN: can only bind global skills".to_string());
341                        }
342                    }
343                    Some(_) => {}
344                }
345                mstore.skill_bind(&identity_store, &req.agent_id, &req.skill_id)
346                    .map_err(|e| format!("skill.catalog.bind: {e}"))?;
347                // Lets any other open Stash/Memory view for this agent pick up
348                // the new binding without a manual refresh — skill.bind (the
349                // check_s1 agent-self-service path) intentionally left alone.
350                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
351                Ok(SkillBindResult { bound: true })
352            }
353        },
354    );
355}
356
357/// Catalog-tier sibling of `skill.list` (above) — GLOBAL SKILLS ONLY, each
358/// annotated with `bound_to_agent` for the caller-supplied `agent_id`, no
359/// `check_s1`. AgentStashModal's Skills tab (the per-agent Stash view) runs
360/// over the dashboard's connection, which is never agent-authenticated, so
361/// `skill.list`'s gate can never be satisfied from that caller — the exact
362/// same reasoning as `skill.catalog.bind` above. Deliberately does NOT
363/// reuse `skill.list`'s full computation (global + this agent's own
364/// private skills): with no `check_s1`, `agent_id` is unverified, and a
365/// private skill's `content`/`description`/`trigger` can carry sensitive
366/// agent-authored material — returning it for an arbitrary caller-chosen
367/// `agent_id` would be an IDOR into every agent's private skills. See
368/// `Store::skill_list_global_for_agent`'s doc comment and
369/// docs/reports/REPORT_ARMORY_ARCHITECTURE_AND_NAMING_REVIEW_2026_07_23.md §2.2.
370fn register_skill_catalog_list_for_agent(engine: &Arc<WshRpcEngine>, state: &AppState) {
371    let mstore = state.mstore.clone();
372    let identity_store = state.identity_store.clone();
373    engine.register_typed(
374        COMMAND_SKILL_CATALOG_LIST_FOR_AGENT,
375        move |req: SkillAgentScopeData, _ctx| {
376            let mstore = mstore.clone();
377            let identity_store = identity_store.clone();
378            async move {
379                let skills = mstore.skill_list_global_for_agent(&identity_store, &req.agent_id)
380                    .map_err(|e| format!("skill.catalog.list_for_agent: {e}"))?;
381                Ok(skills)
382            }
383        },
384    );
385}
386
387/// Catalog-tier sibling of `skill.unbind` (above) — same DB write, no
388/// `check_s1`. Restricted to global rows only, matching the guard
389/// `register_skill_catalog_bind`/`register_skill_catalog_delete` already
390/// apply in this file: any window connection can sever any agent's binding
391/// to a *global* skill (an intentional, pre-existing trust boundary — bind
392/// and delete already have the same or larger blast radius), but a private
393/// row's own binding must not be touchable via this no-`check_s1` surface,
394/// even though `skill_id` is never exposed by any no-`check_s1` command
395/// today — defense in depth over relying solely on UUID secrecy.
396/// reagentx P1 on PR #2329 (round 2).
397fn register_skill_catalog_unbind(engine: &Arc<WshRpcEngine>, state: &AppState) {
398    let mstore = state.mstore.clone();
399    let identity_store = state.identity_store.clone();
400    let broker = state.broker.clone();
401    engine.register_typed(
402        COMMAND_SKILL_CATALOG_UNBIND,
403        move |req: SkillAgentBindingData, _ctx| {
404            let mstore = mstore.clone();
405            let identity_store = identity_store.clone();
406            let broker = broker.clone();
407            async move {
408                match identity_store.skill_get(&req.skill_id)
409                    .map_err(|e| format!("skill.catalog.unbind: {e}"))?
410                {
411                    None => return Err("skill.catalog.unbind: skill not found".to_string()),
412                    Some(s) if !s.is_global => {
413                        return Err("FORBIDDEN: can only unbind global skills".to_string());
414                    }
415                    Some(_) => {}
416                }
417                let unbound = mstore.skill_unbind(&req.agent_id, &req.skill_id)
418                    .map_err(|e| format!("skill.catalog.unbind: {e}"))?;
419                if unbound {
420                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
421                }
422                Ok(SkillUnbindResult { unbound })
423            }
424        },
425    );
426}
427
428// Bundle-scoped sibling of register_skill_catalog_bind (above) — same DB
429// write and "only global skills may be bound" safety check, keyed by
430// bundle_id via bundle_skill_bind instead of agent_id/skill_bind.
431// Composable model v2, docs/specs/SPEC_BUNDLE_AS_CONTAINER_V2_2026_08_17.md
432// (GH issue #2024 item 3).
433fn register_skill_catalog_bind_to_bundle(engine: &Arc<WshRpcEngine>, state: &AppState) {
434    let mstore = state.mstore.clone();
435    let id_store = state.id_store.clone();
436    let identity_store = state.identity_store.clone();
437    let broker = state.broker.clone();
438    engine.register_typed(
439        COMMAND_SKILL_CATALOG_BIND_TO_BUNDLE,
440        move |req: SkillBundleBindingData, _ctx| {
441            let mstore = mstore.clone();
442            let id_store = id_store.clone();
443            let identity_store = identity_store.clone();
444            let broker = broker.clone();
445            async move {
446                // Only global skills (or ones already bound) may be bound, so
447                // this surface can't be used to bootstrap read access to
448                // another entity's private skill — same rule as
449                // skill.catalog.bind.
450                match identity_store.skill_get(&req.skill_id)
451                    .map_err(|e| format!("skill.catalog.bind_to_bundle: {e}"))?
452                {
453                    None => return Err("skill.catalog.bind_to_bundle: skill not found".to_string()),
454                    Some(s) if !s.is_global => {
455                        if !mstore.bundle_skill_is_accessible_to(&identity_store, &req.bundle_id, &req.skill_id)
456                            .map_err(|e| format!("skill.catalog.bind_to_bundle: {e}"))?
457                        {
458                            return Err("FORBIDDEN: can only bind global skills to a bundle".to_string());
459                        }
460                    }
461                    Some(_) => {}
462                }
463                mstore.bundle_skill_bind(&identity_store, &id_store, &req.bundle_id, &req.skill_id)
464                    .map_err(|e| format!("skill.catalog.bind_to_bundle: {e}"))?;
465                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
466                Ok(SkillBindResult { bound: true })
467            }
468        },
469    );
470}
471
472/// Bundle-scoped sibling of `skill.list` — GLOBAL SKILLS ONLY plus this
473/// bundle's own referenced skills, each annotated with `bound_to_bundle`.
474/// No `check_s1` (a bundle has no agent identity to gate on). Deliberately
475/// restricted to global + bundle-bound rows only, same IDOR reasoning as
476/// `skill_list_global_for_agent`'s doc comment — see `bundle_skill_list`'s
477/// own implementation.
478fn register_skill_catalog_list_for_bundle(engine: &Arc<WshRpcEngine>, state: &AppState) {
479    let mstore = state.mstore.clone();
480    let identity_store = state.identity_store.clone();
481    engine.register_typed(
482        COMMAND_SKILL_CATALOG_LIST_FOR_BUNDLE,
483        move |req: SkillBundleScopeData, _ctx| {
484            let mstore = mstore.clone();
485            let identity_store = identity_store.clone();
486            async move {
487                let skills = mstore.bundle_skill_list(&identity_store, &req.bundle_id)
488                    .map_err(|e| format!("skill.catalog.list_for_bundle: {e}"))?;
489                Ok(skills)
490            }
491        },
492    );
493}
494
495/// Creates a NEW, PRIVATE skill scoped directly to a bundle (never global)
496/// — the bundle-level analog of `skill.upsert` (agent-scoped). See
497/// `mcp.rs`'s `register_mcp_catalog_upsert_for_bundle` for the full
498/// reasoning (reagentx P1 review on PR #2639).
499fn register_skill_catalog_upsert_for_bundle(engine: &Arc<WshRpcEngine>, state: &AppState) {
500    let mstore = state.mstore.clone();
501    let id_store = state.id_store.clone();
502    let identity_store = state.identity_store.clone();
503    let broker = state.broker.clone();
504    engine.register_typed(
505        COMMAND_SKILL_CATALOG_UPSERT_FOR_BUNDLE,
506        move |req: CommandSkillCatalogUpsertForBundleData, _ctx| {
507            let mstore = mstore.clone();
508            let id_store = id_store.clone();
509            let identity_store = identity_store.clone();
510            let broker = broker.clone();
511            async move {
512
513                let now = now_ms();
514                let (id, created_at) = if req.id.is_empty() {
515                    (uuid::Uuid::new_v4().to_string(), now)
516                } else {
517                    if !mstore.bundle_skill_is_bound_to(&req.bundle_id, &req.id)
518                        .map_err(|e| format!("skill.catalog.upsert_for_bundle: {e}"))?
519                    {
520                        return Err("FORBIDDEN: skill not bound to this bundle".to_string());
521                    }
522                    let existing = identity_store.skill_get(&req.id)
523                        .map_err(|e| format!("skill.catalog.upsert_for_bundle: {e}"))?;
524                    match existing {
525                        Some(s) if s.is_global => {
526                            return Err("FORBIDDEN: cannot mutate a global skill".to_string());
527                        }
528                        Some(s) => (req.id.clone(), s.created_at),
529                        None => (req.id.clone(), now),
530                    }
531                };
532
533                let skill = crate::backend::storage::Skill {
534                    id: id.clone(),
535                    name: req.name,
536                    trigger: req.trigger,
537                    skill_type: req.skill_type,
538                    description: req.description,
539                    content: req.content,
540                    is_global: false,
541                    created_at,
542                    updated_at: now,
543                };
544                mstore.bundle_skill_upsert_unique(&identity_store, &id_store, &req.bundle_id, &skill, req.id.is_empty())
545                    .map_err(|e| format!("skill.catalog.upsert_for_bundle: {e}"))?;
546                broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
547                Ok(skill)
548            }
549        },
550    );
551}
552
553// Bundle-scoped sibling of register_skill_catalog_unbind (above). See
554// mcp.rs's register_mcp_catalog_unbind_from_bundle for why this does NOT
555// restrict to global-only, unlike its agent-level sibling.
556fn register_skill_catalog_unbind_from_bundle(engine: &Arc<WshRpcEngine>, state: &AppState) {
557    let mstore = state.mstore.clone();
558    let broker = state.broker.clone();
559    engine.register_typed(
560        COMMAND_SKILL_CATALOG_UNBIND_FROM_BUNDLE,
561        move |req: SkillBundleBindingData, _ctx| {
562            let mstore = mstore.clone();
563            let broker = broker.clone();
564            async move {
565                let unbound = mstore.bundle_skill_unbind(&req.bundle_id, &req.skill_id)
566                    .map_err(|e| format!("skill.catalog.unbind_from_bundle: {e}"))?;
567                if unbound {
568                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
569                }
570                Ok(SkillUnbindResult { unbound })
571            }
572        },
573    );
574}
575
576fn register_skill_catalog_delete(engine: &Arc<WshRpcEngine>, state: &AppState) {
577    let mstore = state.mstore.clone();
578    let identity_store = state.identity_store.clone();
579    let broker = state.broker.clone();
580    engine.register_typed(
581        COMMAND_SKILL_CATALOG_DELETE,
582        move |req: SkillCatalogItemData, _ctx| {
583            let mstore = mstore.clone();
584            let identity_store = identity_store.clone();
585            let broker = broker.clone();
586            async move {
587                if let Some(existing) = identity_store.skill_get(&req.id)
588                    .map_err(|e| format!("skill.catalog.delete: {e}"))?
589                {
590                    if !existing.is_global {
591                        return Err("FORBIDDEN: cannot delete a private skill via the catalog".to_string());
592                    }
593                }
594                let deleted = mstore.skill_delete(&identity_store, &req.id)
595                    .map_err(|e| format!("skill.catalog.delete: {e}"))?;
596                if deleted {
597                    broker.publish_event::<crate::backend::mps_events::SkillsChanged>(&(), vec![], 0);
598                }
599                Ok(SkillDeleteResult { deleted })
600            }
601        },
602    );
603}