Let the coordinator manage skills, MCP servers and tools, behind a confirm card

- Coordinator tools in its own chat (super admin only): list_extensions,
  set_extension_access, delete_extension, and update_skill /
  update_mcp_server / update_custom_tool for what can't change where
  anything is sent (on/off, names, arguments, left-out tools)
- Adding a skill, MCP server or tool, or changing a skill's words, a
  server's URL or headers or a tool's kind or config, goes through
  propose_skill / propose_mcp_server / propose_custom_tool: stored in
  extension_proposals with the bot's background check, shown as a
  [proposal:ID] chip that opens a card with Confirm and Reject
- The card's facts are worked out by the app from what Confirm saves: the
  host, every {{env.KEY}} and whether it's saved, the config; for a skill
  its links, hosts, tool names, mentions of secrets and a diff against
  the saved runbook
- Confirming decides once and checks again; a server or tool missing a key
  is saved switched off, and the outcome is posted to the bot in chat
- Never from a bot: stdio servers, a URL whose host a template decides,
  envs in a prompt tool, a key's value

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 06:08:09 +01:00
co-authored by Claude Opus 5.5
parent e6c278198b
commit fefc295d15
77 changed files with 3307 additions and 12 deletions
@@ -0,0 +1,122 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import redactSecrets from "@/src/utils/redact-secrets";
import grabAllEnvs from "../envs/grab-all-envs";
import grabCustomTool from "../extensions/grab-custom-tool";
import grabMcpServer from "../extensions/grab-mcp-server";
import saveCustomTool from "../extensions/save-custom-tool";
import saveMcpServer from "../extensions/save-mcp-server";
import grabSkill from "../extensions/grab-skill";
import saveSkill from "../extensions/save-skill";
import syncMcpServer from "../extensions/sync-mcp-server";
import extensionEnvUses from "@/src/utils/extension-env-uses";
import checkProposalData from "./check-proposal-data";
import claimExtensionProposal from "./claim-extension-proposal";
import grabExtensionProposal from "./grab-extension-proposal";
import grabProposalEnvs from "./grab-proposal-envs";
type Params = {
user_id: number | string;
proposal_id: number | string;
};
/**
* The user confirmed a proposal from its card: claims it (once, and only
* while it's pending and not expired), checks it again, then saves it
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
* that isn't saved yet is saved switched off, and the outcome names the
* keys to add. A remote server that's on is connected and its tools
* listed. Whatever happens is recorded on the proposal as its `outcome`.
*/
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
const proposal = await grabExtensionProposal({ proposal_id, user_id });
await claimExtensionProposal({ proposal, status: "confirmed" });
try {
if (proposal.kind == "skill") {
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
const result_id = await saveSkill({
user_id,
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
});
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""
}`;
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
values: [outcome, result_id, Date.now(), proposal.id],
});
return proposal;
}
checkProposalData(proposal);
const { sends } = extensionEnvUses(proposal);
const missing = [
...new Set(
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
),
];
const keys_note = missing.length
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
: "";
let outcome = "";
let result_id = 0;
if (proposal.kind == "mcp") {
if (proposal.extension_id) {
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
}
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
result_id = await saveMcpServer({ user_id, data });
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
outcome = `Saved as MCP server #${result_id}.${keys_note}${
!sync
? missing.length
? ""
: " It's off, so it wasn't connected."
: sync.ok
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
: ` It didn't connect: ${sync.error}`
}`;
} else {
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
result_id = await saveCustomTool({ user_id, data });
outcome = `Saved as tool #${result_id}.${keys_note}`;
}
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
values: [outcome, result_id, Date.now(), proposal.id],
});
} catch (error: unknown) {
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
const message = redactSecrets({
text: error instanceof Error ? error.message : String(error),
secrets,
}).slice(0, 2000);
await BunSQLite.sql({
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
});
}
return proposal;
}