@ateam-ai/mcp 0.3.56 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +340 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.3.56",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.ariekogan/ateam-mcp",
5
5
  "description": "A-Team MCP Server — build, validate, and deploy multi-agent solutions from any AI environment",
6
6
  "type": "module",
package/src/tools.js CHANGED
@@ -83,6 +83,89 @@ function _resolveDottedField(obj, dottedPath) {
83
83
  return { parent, leaf: parts[parts.length - 1] };
84
84
  }
85
85
 
86
+ // ─── Protected array fields (v0.4.0 sibling-loss guard) ─────────────
87
+ //
88
+ // Historically, `ateam_patch(target:"solution", updates:{ linked_skills:["only-one"] })`
89
+ // silently REPLACED the whole linked_skills array — wiping every other
90
+ // skill wired into the solution. Same footgun on ui_plugins, handoffs,
91
+ // grants, connectors, etc. The Solution Builder skill hit this in the
92
+ // wild and wiped a tenant's whole solution.
93
+ //
94
+ // From v0.4.0, ateam_patch REFUSES a bare array-replace on any of these
95
+ // fields unless the caller explicitly opts in via one of:
96
+ // updates: { _replace: true, linked_skills: [...] } // object-level
97
+ // updates: { linked_skills: [...], linked_skills_replace: true } // field-level
98
+ // To ADD or REMOVE without opt-in, use the _push / _delete / _update
99
+ // suffix pattern that has always been the correct form.
100
+ const SOLUTION_ARRAY_FIELDS = new Set([
101
+ 'linked_skills',
102
+ 'ui_plugins',
103
+ 'platform_connectors',
104
+ 'handoffs',
105
+ 'grants',
106
+ 'triggers',
107
+ 'notification_routes',
108
+ 'channels',
109
+ 'actor_types',
110
+ 'admin_roles',
111
+ 'plugins',
112
+ 'security_contracts',
113
+ 'connectors',
114
+ 'skills',
115
+ ]);
116
+
117
+ const SKILL_ARRAY_FIELDS = new Set([
118
+ 'tools',
119
+ 'connectors',
120
+ 'handoffs',
121
+ 'scenarios',
122
+ 'triggers',
123
+ 'notification_routes',
124
+ 'plugins',
125
+ 'bootstrap_tools',
126
+ ]);
127
+
128
+ // Returns { ok:false, ... } if the write would REPLACE a protected array
129
+ // without an explicit opt-in; returns null if the write is safe to proceed.
130
+ function _guardArrayReplace({ target, key, value, current, updates }) {
131
+ const knownFields = target === 'skill' ? SKILL_ARRAY_FIELDS : SOLUTION_ARRAY_FIELDS;
132
+ if (!knownFields.has(key)) return null;
133
+ if (!Array.isArray(value)) return null;
134
+ const currentArr = Array.isArray(current) ? current : [];
135
+ if (currentArr.length === 0) return null; // nothing to lose
136
+ if (updates && updates._replace === true) return null;
137
+ if (updates && updates[key + '_replace'] === true) return null;
138
+
139
+ // Compute what would be dropped so the error message is actionable.
140
+ const keyOf = (item) => (item && typeof item === 'object') ? (item.id ?? item.name ?? JSON.stringify(item)) : item;
141
+ const newKeys = new Set(value.map(keyOf));
142
+ const dropped = currentArr.map(keyOf).filter(k => !newKeys.has(k));
143
+ const kept = currentArr.map(keyOf).filter(k => newKeys.has(k));
144
+ const wouldAdd = value.map(keyOf).filter(k => !currentArr.map(keyOf).includes(k));
145
+
146
+ return {
147
+ ok: false,
148
+ phase: 'patch',
149
+ error:
150
+ `⚠️ REFUSED: bare-array replace on ${target}.${key} would drop ${dropped.length} sibling item(s): ` +
151
+ `[${dropped.slice(0, 8).join(', ')}${dropped.length > 8 ? ', ...' : ''}]. ` +
152
+ `This footgun wiped a whole solution in the wild — v0.4.0 refuses it by default. ` +
153
+ `\n\nWhat to do instead:` +
154
+ `\n • To ADD items: updates: { "${key}_push": ${JSON.stringify(wouldAdd)} }` +
155
+ (dropped.length ? `\n • To REMOVE items: updates: { "${key}_delete": ${JSON.stringify(dropped)} }` : '') +
156
+ `\n • To FULLY REPLACE (rare): updates: { "${key}": [...], "${key}_replace": true }` +
157
+ `\n • To replace many arrays in one call: updates: { _replace: true, "${key}": [...] }`,
158
+ dropped_ids: dropped,
159
+ kept_ids: kept,
160
+ would_add: wouldAdd,
161
+ safe_alternatives: {
162
+ push: { [`${key}_push`]: wouldAdd },
163
+ ...(dropped.length && { delete: { [`${key}_delete`]: dropped } }),
164
+ force_replace: { [key]: value, [`${key}_replace`]: true },
165
+ },
166
+ };
167
+ }
168
+
86
169
  // ─── Tool definitions ───────────────────────────────────────────────
87
170
 
88
171
  export const tools = [
@@ -131,15 +214,15 @@ export const tools = [
131
214
  name: "ateam_get_spec",
132
215
  core: true,
133
216
  description:
134
- "Get the A-Team specification — schemas, validation rules, system tools, agent guides, and templates. Start here after bootstrap to understand how to build skills and solutions. Use 'section' to get just one part of the skill spec (much smaller than the full spec). Use 'search' to find specific fields or concepts across the spec.\n\nWhen designing a persona that orchestrates logic via run_python_script (the Python-as-orchestrator pattern), also fetch topic='python_helpers' — that returns the adas.* helper namespace reference. Skills designed without knowing about adas.* produce 5-10x larger / brittler scripts.",
217
+ "Get the A-Team specification — schemas, validation rules, system tools, agent guides, and templates. Start here after bootstrap to understand how to build skills and solutions. Use 'section' to get just one part of the skill spec (much smaller than the full spec). Use 'search' to find specific fields or concepts across the spec.\n\nWhen designing a persona that orchestrates logic via run_python_script (the Python-as-orchestrator pattern), also fetch topic='python_helpers' — that returns the adas.* helper namespace reference. Skills designed without knowing about adas.* produce 5-10x larger / brittler scripts.\n\nWhen wiring widgets (UI plugins) into a solution, fetch topic='widgets' — that returns the widget spec (catalog model, how_to_use blocks, opener_call shape, persona phrasing rules, binding semantics) so you can declare `ui_plugins` correctly. For the live catalog of widgets actually available in a deployed tenant, use ateam_get_widget_catalog instead.",
135
218
  inputSchema: {
136
219
  type: "object",
137
220
  properties: {
138
221
  topic: {
139
222
  type: "string",
140
- enum: ["overview", "skill", "solution", "enums", "connector-multi-user", "python_helpers"],
223
+ enum: ["overview", "skill", "solution", "enums", "connector-multi-user", "python_helpers", "widgets"],
141
224
  description:
142
- "What to fetch: 'overview' = API overview + endpoints, 'skill' = full skill spec, 'solution' = full solution spec, 'enums' = all enum values, 'connector-multi-user' = multi-user connector guide, 'python_helpers' = adas.* helper namespace for run_python_script orchestration (read this when designing personas that read state → call tools → checkpoint → status; without it, scripts hand-roll JSON parsing and tool delegation = 5-10x larger and brittler).",
225
+ "What to fetch: 'overview' = API overview + endpoints, 'skill' = full skill spec, 'solution' = full solution spec, 'enums' = all enum values, 'connector-multi-user' = multi-user connector guide, 'python_helpers' = adas.* helper namespace for run_python_script orchestration (read this when designing personas that read state → call tools → checkpoint → status; without it, scripts hand-roll JSON parsing and tool delegation = 5-10x larger and brittler), 'widgets' = widget (UI plugin) spec: catalog model, how_to_use block shape (solution.json snippet + opener_call + persona_phrasing + binding_notes), and rules for declaring ui_plugins. Pair with ateam_get_widget_catalog for the live per-tenant inventory.",
143
226
  },
144
227
  section: {
145
228
  type: "string",
@@ -428,24 +511,27 @@ export const tools = [
428
511
  core: true,
429
512
  description:
430
513
  "Surgically update ANY field in a skill or solution definition, redeploy, and optionally re-test — all in one step.\n\n" +
431
- "SUPPORTED OPERATIONS:\n" +
514
+ "⚠️ MERGE-BY-DEFAULT (v0.4.0) — Arrays are protected from silent replace. Bare array writes on solution.linked_skills / ui_plugins / platform_connectors / handoffs / grants / triggers (etc.) and skill.tools / connectors / handoffs / scenarios are REFUSED to prevent sibling loss. Add or remove items with the _push / _delete / _update suffixes; opt into a full-array replace only when you really mean it.\n\n" +
515
+ "OPERATIONS (safe by construction):\n" +
432
516
  "1. Scalar (dot notation): { \"problem.statement\": \"new value\", \"role.persona\": \"You are...\" }\n" +
433
517
  "2. Deep nested: { \"intents.thresholds.accept\": 0.9, \"policy.escalation.enabled\": true }\n" +
434
- "3. Array push: { \"tools_push\": [{ name: \"new_tool\", description: \"...\" }] }\n" +
435
- "4. Array delete: { \"tools_delete\": [\"tool_name\"] }\n" +
436
- "5. Array update: { \"tools_update\": [{ name: \"existing_tool\", description: \"updated\" }] }\n" +
437
- "6. Replace whole section: { \"role\": { persona: \"...\", goals: [...] } }\n\n" +
438
- "EXAMPLES:\n" +
439
- "- Change persona (full replace): updates: { \"role.persona\": \"You are a friendly assistant\" }\n" +
440
- "- Append to persona (don't replace): updates: { \"persona_append\": \"\\n\\nALWAYS respond in 2 sentences.\" }\n" +
518
+ "3. Array APPEND: { \"tools_push\": [{ name: \"new_tool\", description: \"...\" }] }\n" +
519
+ "4. Array REMOVE: { \"tools_delete\": [\"tool_name\"] }\n" +
520
+ "5. Array MODIFY-ONE: { \"tools_update\": [{ name: \"existing_tool\", description: \"updated\" }] }\n" +
521
+ "6. Full-array REPLACE (opt-in): { \"linked_skills\": [...], \"linked_skills_replace\": true } — or { _replace: true, ... } to opt every array in this call.\n\n" +
522
+ "SOLUTION-LEVEL EXAMPLES (target='solution'):\n" +
523
+ "- ADD a skill to the solution: updates: { \"linked_skills_push\": [\"my-new-skill\"] } NOT { linked_skills: [\"my-new-skill\"] } (that would REFUSE — it drops your other skills)\n" +
524
+ "- REMOVE a skill: updates: { \"linked_skills_delete\": [\"old-skill\"] }\n" +
525
+ "- ADD a UI plugin: updates: { \"ui_plugins_push\": [{ id: \"mcp:conn:panel\", ... }] }\n" +
526
+ "- ADD a handoff: updates: { \"handoffs_push\": [{ id: \"h1\", ... }] }\n\n" +
527
+ "SKILL-LEVEL EXAMPLES (target='skill' + skill_id):\n" +
528
+ "- Change persona: updates: { \"role.persona\": \"You are a friendly assistant\" }\n" +
529
+ "- Append to persona: updates: { \"persona_append\": \"\\n\\nALWAYS respond in 2 sentences.\" }\n" +
441
530
  "- Add a guardrail: updates: { \"policy.guardrails.never_push\": [\"Never share passwords\"] }\n" +
442
- "- Update problem: updates: { \"problem.statement\": \"...\", \"problem.goals\": [\"goal1\"] }\n" +
443
531
  "- Add a tool: updates: { \"tools_push\": [{ name: \"conn.tool\", description: \"...\", inputs: [...], output: {...} }] }\n" +
444
532
  "- Change intent: updates: { \"intents.supported_update\": [{ id: \"i1\", description: \"new desc\" }] }\n" +
445
- "- Force redeploy: updates: { \"_force_redeploy\": true }\n" +
446
- "- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" }\n" +
447
- " If the skill doesn't exist yet, a default scaffold is created and the updates are applied on top. The skill is automatically added to the solution topology.\n\n" +
448
- "Use target='skill' + skill_id for skill fields. Use target='solution' for solution-level fields (linked_skills, platform_connectors, ui_plugins).",
533
+ "- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" } — auto-scaffolded and added to solution topology.\n\n" +
534
+ "PREVIEW BEFORE WRITING: pass dry_run:true to see the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids) without applying. Use this before any destructive-looking edit.",
449
535
  inputSchema: {
450
536
  type: "object",
451
537
  properties: {
@@ -473,6 +559,10 @@ export const tools = [
473
559
  type: "string",
474
560
  description: "Optional: re-test the skill after patching. Requires skill_id.",
475
561
  },
562
+ dry_run: {
563
+ type: "boolean",
564
+ description: "If true, apply the patch in memory and return the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids, would_write_bytes) WITHOUT writing to GitHub or redeploying. Preview a change before committing to it.",
565
+ },
476
566
  },
477
567
  required: ["solution_id", "target", "updates"],
478
568
  },
@@ -516,7 +606,9 @@ export const tools = [
516
606
  name: "ateam_delete_solution",
517
607
  core: true,
518
608
  description:
519
- "Delete a deployed solution and all its skills from A-Team. Use with caution this removes the solution from both the Skill Builder and A-Team Core. Useful for cleaning up test solutions or starting fresh.",
609
+ "⚠️ IRREVERSIBLE kills Mongo state, running MCP processes, and Builder FS for the whole solution and every skill. " +
610
+ "REQUIRES `confirm:true` AND `confirm_solution_id` echoing the solution id you're destroying (defeats typos and hallucinated ids). " +
611
+ "RECOVERY: the GitHub repo is untouched; `ateam_github_pull` rebuilds the solution from `main`. Prefer that over re-deploying from memory.",
520
612
  inputSchema: {
521
613
  type: "object",
522
614
  properties: {
@@ -524,15 +616,24 @@ export const tools = [
524
616
  type: "string",
525
617
  description: "The solution ID to delete",
526
618
  },
619
+ confirm: {
620
+ type: "boolean",
621
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
622
+ },
623
+ confirm_solution_id: {
624
+ type: "string",
625
+ description: "REQUIRED. Must exactly equal `solution_id`. This defeats typos and hallucinated ids — you can't wipe a solution you couldn't spell.",
626
+ },
527
627
  },
528
- required: ["solution_id"],
628
+ required: ["solution_id", "confirm", "confirm_solution_id"],
529
629
  },
530
630
  },
531
631
  {
532
632
  name: "ateam_delete_skill",
533
633
  core: true,
534
634
  description:
535
- "Delete a single skill from a deployed solution. Removes the skill from A-Team Core (kills the running MCP process, unregisters from skill registry, deletes from Mongo), removes the skill from solution.skills[] and solution.linked_skills, and deletes the skill's files from Builder FS. Use this to drop a skill without tearing down the whole solution.",
635
+ "⚠️ IRREVERSIBLE in Core + Builder FS kills the running MCP process, unregisters from skill registry, deletes the Mongo record, drops from solution.skills[] and solution.linked_skills, and removes the skill's files from Builder FS. " +
636
+ "REQUIRES `confirm:true`. RECOVERY: the skill still lives in GitHub — `ateam_github_pull` rebuilds the whole solution (no per-skill restore path).",
536
637
  inputSchema: {
537
638
  type: "object",
538
639
  properties: {
@@ -544,15 +645,22 @@ export const tools = [
544
645
  type: "string",
545
646
  description: "The skill ID to remove (e.g. 'linkedin-agent')",
546
647
  },
648
+ confirm: {
649
+ type: "boolean",
650
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
651
+ },
547
652
  },
548
- required: ["solution_id", "skill_id"],
653
+ required: ["solution_id", "skill_id", "confirm"],
549
654
  },
550
655
  },
551
656
  {
552
657
  name: "ateam_delete_connector",
553
658
  core: true,
554
659
  description:
555
- "Remove a connector from a deployed solution. Stops and deletes it from A-Team Core, removes references from the solution definition (grants, platform_connectors) and skill definitions (connectors array), and cleans up mcp-store files.",
660
+ "⚠️ CASCADING any skill whose engine.bootstrap_tools or tools[] name a tool from this connector will FAIL its next execution. " +
661
+ "Stops and deletes the connector from A-Team Core; drops references from the solution definition (grants, platform_connectors, ui_plugins ids starting `mcp:<connector-id>:*`) and skill definitions (connectors array); cleans up mcp-store files. " +
662
+ "GitHub source is preserved — a follow-up `ateam_build_and_run(github:true)` can resurrect. " +
663
+ "REQUIRES `confirm:true`.",
556
664
  inputSchema: {
557
665
  type: "object",
558
666
  properties: {
@@ -564,8 +672,12 @@ export const tools = [
564
672
  type: "string",
565
673
  description: "The connector ID to remove (e.g. 'device-mock-mcp')",
566
674
  },
675
+ confirm: {
676
+ type: "boolean",
677
+ description: "REQUIRED. Must be exactly true. A missing/false value refuses the call with a recovery hint.",
678
+ },
567
679
  },
568
- required: ["solution_id", "connector_id"],
680
+ required: ["solution_id", "connector_id", "confirm"],
569
681
  },
570
682
  },
571
683
 
@@ -993,6 +1105,45 @@ export const tools = [
993
1105
  required: ["job_id"],
994
1106
  },
995
1107
  },
1108
+ {
1109
+ name: "ateam_get_widget_catalog",
1110
+ core: true,
1111
+ description:
1112
+ "Get the live catalog of widgets (UI plugins) available in this tenant's solution. Returns platform-bundled + solution-bundled + skill-declared widgets, each with a paste-ready how_to_use block (solution.json snippet + opener_call + persona_phrasing + binding_notes).\n\n" +
1113
+ "Use this when wiring widgets into a skill or solution — the how_to_use block is designed to be copied verbatim into the solution.json ui_plugins[] entry and into the persona's opener phrasing, so you don't have to hand-roll either. The catalog reflects what is actually deployed in the tenant right now, not the abstract spec (for the spec itself, use ateam_get_spec topic='widgets').\n\n" +
1114
+ "Origins:\n" +
1115
+ " • 'platform' = widgets bundled with the platform (always available).\n" +
1116
+ " • 'solution' = widgets bundled with this tenant's solution.\n" +
1117
+ " • 'skill' = widgets declared by a specific skill in the solution.\n\n" +
1118
+ "Auth: forwards your authed api_key to Core (no master-secret involvement). Tenant scope is pinned by the key itself.",
1119
+ inputSchema: {
1120
+ type: "object",
1121
+ properties: {
1122
+ solution_id: {
1123
+ type: "string",
1124
+ description: "Optional. The solution to query. Defaults to the tenant's current solution.",
1125
+ },
1126
+ origin: {
1127
+ type: "string",
1128
+ enum: ["all", "platform", "solution", "skill"],
1129
+ description:
1130
+ "Optional. Filter by widget origin. 'all' (default) returns everything. 'platform' = platform-bundled only. 'solution' = solution-bundled only. 'skill' = skill-declared only.",
1131
+ },
1132
+ include_unused: {
1133
+ type: "boolean",
1134
+ description:
1135
+ "Optional. If true, includes widgets that are available but not currently referenced by any skill or ui_plugins entry. Default false (only widgets actually wired into the solution).",
1136
+ },
1137
+ format: {
1138
+ type: "string",
1139
+ enum: ["summary", "full"],
1140
+ description:
1141
+ "Optional. 'full' (default) returns each widget with its paste-ready how_to_use block (solution.json snippet, opener_call, persona_phrasing, binding_notes). 'summary' returns just id/name/origin/description for a quick overview.",
1142
+ },
1143
+ },
1144
+ required: [],
1145
+ },
1146
+ },
996
1147
  {
997
1148
  name: "ateam_test_abort",
998
1149
  core: true,
@@ -1512,6 +1663,7 @@ const SPEC_PATHS = {
1512
1663
  enums: "/spec/enums",
1513
1664
  "connector-multi-user": "/spec/multi-user-connector",
1514
1665
  python_helpers: "/spec/python_helpers",
1666
+ widgets: "/spec/widgets",
1515
1667
  };
1516
1668
 
1517
1669
  const EXAMPLE_PATHS = {
@@ -1554,6 +1706,7 @@ const TENANT_TOOLS = new Set([
1554
1706
  "ateam_test_status",
1555
1707
  "ateam_test_abort",
1556
1708
  "ateam_get_chain",
1709
+ "ateam_get_widget_catalog",
1557
1710
  "ateam_get_connector_source",
1558
1711
  "ateam_get_metrics",
1559
1712
  "ateam_diff",
@@ -2494,9 +2647,10 @@ const handlers = {
2494
2647
  // Updates → Redeploys → Optionally tests
2495
2648
  // One call replaces: ateam_update + ateam_redeploy
2496
2649
 
2497
- ateam_patch: async ({ solution_id, target, skill_id, updates, test_message }, sid) => {
2650
+ ateam_patch: async ({ solution_id, target, skill_id, updates, test_message, dry_run }, sid) => {
2498
2651
  const phases = [];
2499
2652
  let isNewSkill = false;
2653
+ const _diff = { arrays_merged: [], arrays_replaced: [], scalars_changed: [], sections_replaced: [] };
2500
2654
 
2501
2655
  // GitHub-first patch: read from GitHub → apply patch → write back → redeploy
2502
2656
  // This ensures GitHub stays the single source of truth.
@@ -2528,7 +2682,7 @@ const handlers = {
2528
2682
  role: { name: "", persona: "", goals: [], limitations: [], communication_style: { tone: "professional", verbosity: "concise" } },
2529
2683
  intents: { supported: [], thresholds: { accept: 0.8, clarify: 0.5, reject: 0.5 }, out_of_domain: { action: "redirect", message: "" } },
2530
2684
  tools: [],
2531
- policy: { guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
2685
+ policy: { access: { requires_roles: [] }, guardrails: { never: [], always: [] }, approvals: [], workflows: [], escalation: { enabled: false, conditions: [], target: "" } },
2532
2686
  engine: { rv2: { max_iterations: 10, iteration_timeout_ms: 120000, allow_parallel_tools: false, on_max_iterations: "ask_user" }, hlr: { enabled: true, critic: { enabled: true, check_interval: 3, strictness: "medium" }, reflection: { enabled: true, depth: "shallow" }, replanning: { enabled: true, max_replans: 3 } }, autonomy: { level: "supervised" }, finalization_gate: { enabled: true, max_retries: 2 } },
2533
2687
  access_policy: { rules: [{ tools: ["*"], effect: "allow" }] },
2534
2688
  grant_mappings: [],
@@ -2615,9 +2769,30 @@ const handlers = {
2615
2769
  else arr.push(upd);
2616
2770
  }
2617
2771
  parent[leaf] = arr;
2772
+ } else if (key === "_replace" || key.endsWith("_replace")) {
2773
+ // Escape-hatch flags handled by the guard — skip them here so they
2774
+ // don't get written into the patched object as literal fields.
2775
+ continue;
2618
2776
  } else if (key.includes(".")) {
2619
2777
  // Dot notation: "role.persona", "intents.thresholds.accept"
2620
2778
  const parts = key.split(".");
2779
+ // Sibling-loss guard: if the leaf resolves to an existing non-empty
2780
+ // array and the incoming value is also an array, refuse the replace
2781
+ // unless the caller opted in. (Dot-notation is how many agents
2782
+ // accidentally hit this — e.g. updates:{ "linked_skills": ["one"] }
2783
+ // on target='solution'.)
2784
+ const leafKey = parts[parts.length - 1];
2785
+ let cursor = patched;
2786
+ for (let i = 0; i < parts.length - 1; i++) {
2787
+ if (!cursor || typeof cursor[parts[i]] !== 'object') { cursor = null; break; }
2788
+ cursor = cursor[parts[i]];
2789
+ }
2790
+ const currentLeaf = cursor && Object.prototype.hasOwnProperty.call(cursor, leafKey) ? cursor[leafKey] : undefined;
2791
+ const guardErr = _guardArrayReplace({ target, key: leafKey, value, current: currentLeaf, updates });
2792
+ if (guardErr) return guardErr;
2793
+ if (Array.isArray(value) && Array.isArray(currentLeaf)) _diff.arrays_replaced.push(key);
2794
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
2795
+ else _diff.scalars_changed.push(key);
2621
2796
  let obj = patched;
2622
2797
  for (let i = 0; i < parts.length - 1; i++) {
2623
2798
  if (!obj[parts[i]] || typeof obj[parts[i]] !== "object") obj[parts[i]] = {};
@@ -2625,7 +2800,14 @@ const handlers = {
2625
2800
  }
2626
2801
  obj[parts[parts.length - 1]] = value;
2627
2802
  } else {
2628
- // Direct field replacement
2803
+ // Direct top-level field replacement. Sibling-loss guard: if this
2804
+ // names a known array field and would drop items, refuse unless the
2805
+ // caller passed _replace:true (object-level) or <field>_replace:true.
2806
+ const guardErr = _guardArrayReplace({ target, key, value, current: patched[key], updates });
2807
+ if (guardErr) return guardErr;
2808
+ if (Array.isArray(value) && Array.isArray(patched[key])) _diff.arrays_replaced.push(key);
2809
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) _diff.sections_replaced.push(key);
2810
+ else _diff.scalars_changed.push(key);
2629
2811
  patched[key] = value;
2630
2812
  }
2631
2813
  }
@@ -2634,6 +2816,23 @@ const handlers = {
2634
2816
  return { ok: false, phase: "patch", error: `Failed to apply patch: ${err.message}` };
2635
2817
  }
2636
2818
 
2819
+ // Dry-run: return diff + would-be after-state without writing to GitHub
2820
+ // or redeploying. Lets an agent preview any destructive-looking edit.
2821
+ if (dry_run) {
2822
+ return {
2823
+ ok: true,
2824
+ dry_run: true,
2825
+ target,
2826
+ solution_id,
2827
+ skill_id,
2828
+ phases,
2829
+ _diff,
2830
+ after_state: patched,
2831
+ would_write_bytes: JSON.stringify(patched, null, 2).length,
2832
+ hint: "No changes applied. Remove dry_run:true to commit + redeploy.",
2833
+ };
2834
+ }
2835
+
2637
2836
  // Phase 3: Write patched version back to GitHub
2638
2837
  try {
2639
2838
  const patchKeys = Object.keys(updates || {});
@@ -3097,6 +3296,84 @@ const handlers = {
3097
3296
  return data;
3098
3297
  },
3099
3298
 
3299
+ ateam_get_widget_catalog: async ({ origin, format }, sid) => {
3300
+ // Wraps Core's existing GET /api/ui-plugins (merged tenant plugin list)
3301
+ // and enriches each entry with the documentation/how-to-use layer.
3302
+ // Filtering by origin and the summary/full format projection happen
3303
+ // client-side here — Core just returns the raw merged plugins[].
3304
+ const creds = getCredentials(sid);
3305
+ const apiKey = creds?.apiKey;
3306
+ if (!apiKey) throw new Error("No api_key in session — call ateam_auth(api_key) first.");
3307
+ const coreUrl = process.env.ADAS_CORE_URL || "http://adas-backend:4000";
3308
+ const res = await fetch(`${coreUrl}/api/ui-plugins`, {
3309
+ method: "GET",
3310
+ headers: { "x-api-key": apiKey, "X-ADAS-SERVICE": "ateam-mcp.get_widget_catalog" },
3311
+ signal: AbortSignal.timeout(15_000),
3312
+ });
3313
+ const text = await res.text();
3314
+ let data;
3315
+ try { data = JSON.parse(text); } catch { data = { ok: false, error: text.slice(0, 400) }; }
3316
+ if (!res.ok) {
3317
+ throw new Error(`Core /api/ui-plugins returned ${res.status}: ${data.error || JSON.stringify(data).slice(0, 200)}`);
3318
+ }
3319
+
3320
+ // Project each plugin into the catalog shape with how_to_use guidance.
3321
+ const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
3322
+ const wantSummary = format === "summary";
3323
+ const filterOrigin = origin && origin !== "all" ? origin : null;
3324
+
3325
+ const widgets = plugins.map((p) => {
3326
+ const id = p?.id || "";
3327
+ const shortId = id.split(":").pop() || id;
3328
+ // origin classification: platform vs solution vs skill
3329
+ const src = p?._source || "";
3330
+ const inferredOrigin = src === "mcp_introspection" ? "platform"
3331
+ : src === "skill_declared" ? "skill"
3332
+ : "solution";
3333
+ const opener = Array.isArray(p?.capabilities?.commands) && p.capabilities.commands.length > 0
3334
+ ? `ui.${shortId}.${p.capabilities.commands[0].name || "open"}({ /* args per input_schema */ })`
3335
+ : `sys.focusUiPlugin({ plugin_id: "${id}" })`;
3336
+ const entry = {
3337
+ id,
3338
+ name: p?.name,
3339
+ version: p?.version,
3340
+ description: p?.description,
3341
+ type: p?.type || "ui",
3342
+ origin: inferredOrigin,
3343
+ owned_by_connector: p?._connector_id,
3344
+ render: p?.render,
3345
+ surface: p?.surface,
3346
+ capabilities: p?.capabilities,
3347
+ channels: p?.channels,
3348
+ commands: p?.capabilities?.commands || p?.commands || [],
3349
+ uiActions: p?.uiActions,
3350
+ };
3351
+ if (!wantSummary) {
3352
+ entry.how_to_use = {
3353
+ solution_json_snippet: { id, name: p?.name, version: p?.version, render: p?.render },
3354
+ opener_call: opener,
3355
+ persona_phrasing: `When the user wants to view ${(p?.description || p?.name || shortId).toString().toLowerCase()}, call ${opener.split("(")[0]}.`,
3356
+ binding_notes: {
3357
+ commands_input_schemas: (p?.capabilities?.commands || []).map(c => ({ command: c.name, schema: c.input_schema })),
3358
+ deeplink_template: p?.uiActions?.deeplink || null,
3359
+ view_entity_kinds: p?.uiActions?.intents?.view_entity?.entity_kinds || null,
3360
+ host_auto_routes_intents: Object.keys(p?.uiActions?.intents || {}),
3361
+ },
3362
+ };
3363
+ }
3364
+ return entry;
3365
+ });
3366
+
3367
+ const filtered = filterOrigin ? widgets.filter(w => w.origin === filterOrigin) : widgets;
3368
+ const counts = {
3369
+ total: filtered.length,
3370
+ platform: filtered.filter(w => w.origin === "platform").length,
3371
+ solution: filtered.filter(w => w.origin === "solution").length,
3372
+ skill: filtered.filter(w => w.origin === "skill").length,
3373
+ };
3374
+ return { ok: true, generated_at: new Date().toISOString(), counts, widgets: filtered };
3375
+ },
3376
+
3100
3377
  ateam_test_abort: async ({ solution_id, skill_id, job_id }, sid) =>
3101
3378
  del(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid),
3102
3379
 
@@ -3245,14 +3522,46 @@ const handlers = {
3245
3522
  ateam_github_list_versions: async ({ solution_id }, sid) =>
3246
3523
  get(`/deploy/solutions/${solution_id}/versions/dev`, sid),
3247
3524
 
3248
- ateam_delete_solution: async ({ solution_id }, sid) =>
3249
- del(`/deploy/solutions/${solution_id}`, sid),
3525
+ ateam_delete_solution: async ({ solution_id, confirm, confirm_solution_id }, sid) => {
3526
+ if (confirm !== true) {
3527
+ return {
3528
+ ok: false,
3529
+ error: "⚠️ REFUSED: ateam_delete_solution requires confirm:true. This is irreversible in Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds from `main` if you already deleted by mistake.",
3530
+ recovery: "ateam_github_pull(solution_id, ref:'main')",
3531
+ };
3532
+ }
3533
+ if (confirm_solution_id !== solution_id) {
3534
+ return {
3535
+ ok: false,
3536
+ error: `⚠️ REFUSED: confirm_solution_id must exactly equal solution_id. Got confirm_solution_id="${confirm_solution_id}" but solution_id="${solution_id}". This check defeats typos and hallucinated ids — you should not be able to wipe a solution whose id you can't spell correctly.`,
3537
+ expected: solution_id,
3538
+ received: confirm_solution_id,
3539
+ };
3540
+ }
3541
+ return del(`/deploy/solutions/${solution_id}`, sid);
3542
+ },
3250
3543
 
3251
- ateam_delete_skill: async ({ solution_id, skill_id }, sid) =>
3252
- del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid),
3544
+ ateam_delete_skill: async ({ solution_id, skill_id, confirm }, sid) => {
3545
+ if (confirm !== true) {
3546
+ return {
3547
+ ok: false,
3548
+ error: `⚠️ REFUSED: ateam_delete_skill requires confirm:true. Kills the running MCP process and deletes the skill from Core + Builder FS. GitHub source is preserved — ateam_github_pull rebuilds the whole solution.`,
3549
+ recovery: "ateam_github_pull(solution_id, ref:'main') — no per-skill restore path",
3550
+ };
3551
+ }
3552
+ return del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid);
3553
+ },
3253
3554
 
3254
- ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
3255
- del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid),
3555
+ ateam_delete_connector: async ({ solution_id, connector_id, confirm }, sid) => {
3556
+ if (confirm !== true) {
3557
+ return {
3558
+ ok: false,
3559
+ error: `⚠️ REFUSED: ateam_delete_connector requires confirm:true. Cascading — any skill wired to this connector's tools will fail its next execution. GitHub source is preserved.`,
3560
+ recovery: "ateam_build_and_run(solution_id, github:true) can resurrect from GitHub",
3561
+ };
3562
+ }
3563
+ return del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid);
3564
+ },
3256
3565
 
3257
3566
  ateam_upload_connector: async ({ solution_id, connector_id, github, files, ref, replace }, sid) =>
3258
3567
  post(